diff --git a/CLAUDE.md b/CLAUDE.md index a27ed8c..3a0f9da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `cargo test` - Run tests with rstest framework - `cargo check` - Fast compile check - `cargo clippy` - Run linter +- `cargo fmt` - Format the code - `cargo doc --no-deps` - Generate documentation +Always run cargo check, test, fmt and clipy before committing. + ### Bacon Integration (preferred for continuous development) Uses `bacon.toml` configuration: - `bacon` or `bacon check` - Continuous cargo check @@ -91,4 +94,4 @@ Communication via tokio channels with priority queues (high/low priority from Ap ### Testing - Uses `rstest` framework for unit tests -- Integration tests should consume real external resources (following user's global CLAUDE.md) \ No newline at end of file +- Integration tests should consume real external resources (following user's global CLAUDE.md) diff --git a/Cargo.lock b/Cargo.lock index 001dbe1..0de85dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -241,6 +258,15 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -485,6 +511,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -553,6 +589,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "eyre" version = "0.6.12" @@ -754,6 +796,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.2", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -1491,6 +1543,42 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -1505,6 +1593,18 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags", "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", ] [[package]] @@ -3265,6 +3365,7 @@ dependencies = [ name = "webex-tui" version = "0.8.0" dependencies = [ + "arboard", "base64", "chrono", "clap", @@ -3273,7 +3374,7 @@ dependencies = [ "dirs", "enum-iterator", "fuzzy-matcher", - "gethostname", + "gethostname 0.5.0", "html2text", "itertools 0.14.0", "log", @@ -3681,6 +3782,23 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname 1.1.0", + "rustix 1.1.2", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 139ad24..6b6d29e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ repository = "https://github.com/sgrimee/webex-tui" version = "0.8.0" [dependencies] +arboard = { version = "3.6", default-features = false } base64 = "0.22" chrono = "0.4" clap = { version = "4", features = ["cargo"] } diff --git a/justfile b/justfile index 780194f..b2e4ffe 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,20 @@ # Justfile for webex-tui development +# List all available targets +default: + @just --list + +# Run comprehensive quality checks (check, clippy, test, fmt) +check-all: + just check + just lint + just test + just fmt + +# Compile check +check: + cargo check + # Build the project build: cargo build diff --git a/src/app/actions.rs b/src/app/actions.rs index 2c60cc4..0175dae 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -48,6 +48,7 @@ pub(crate) enum Action { ToggleRoomSelection, SelectAllVisibleRooms, ClearRoomSelections, + CopyMessage, DeleteSelectedRooms, UnselectMessage, } @@ -94,6 +95,7 @@ impl Action { Action::ToggleRoomSelection => &[Key::Char(' ')], Action::SelectAllVisibleRooms => &[Key::Char('A')], Action::ClearRoomSelections => &[Key::Char('C')], + Action::CopyMessage => &[Key::Char('y')], Action::DeleteSelectedRooms => &[Key::Char('X')], Action::UnselectMessage => &[Key::Esc], } @@ -142,10 +144,11 @@ impl Display for Action { Action::ToggleRoomSelection => "Toggle room selection", Action::SelectAllVisibleRooms => "Select all visible rooms", Action::ClearRoomSelections => "Clear room selections", + Action::CopyMessage => "Copy message", Action::DeleteSelectedRooms => "Delete selected rooms", Action::UnselectMessage => "Unselect message", }; - write!(f, "{}", str) + write!(f, "{str}") } } @@ -197,7 +200,7 @@ impl From> for Actions { .map(Action::to_string) .collect::>() .join(", "); - format!("Conflict key {} with actions {}", key, actions) + format!("Conflict key {key} with actions {actions}") }) .collect::>(); if !errors.is_empty() { diff --git a/src/app/cache/mod.rs b/src/app/cache/mod.rs index bf059ba..61f5852 100644 --- a/src/app/cache/mod.rs +++ b/src/app/cache/mod.rs @@ -197,14 +197,17 @@ impl Cache { .iter() .filter_map(|room| { let room_and_team_title = self.room_and_team_title(&room.id).ok()?; - let search_text = format!("{} {}", + let search_text = format!( + "{} {}", room_and_team_title.room_title, room_and_team_title.team_name.unwrap_or_default() ); - matcher.fuzzy_match(&search_text, query).map(|score| (room, score)) + matcher + .fuzzy_match(&search_text, query) + .map(|score| (room, score)) }) .collect(); - + // Sort by score (highest first) scored_rooms.sort_by(|a, b| b.1.cmp(&a.1)); scored_rooms.into_iter() diff --git a/src/app/cache/rooms.rs b/src/app/cache/rooms.rs index 2712e90..147efc8 100644 --- a/src/app/cache/rooms.rs +++ b/src/app/cache/rooms.rs @@ -70,7 +70,7 @@ impl Rooms { /// Mark a room as unread. pub(crate) fn mark_unread(&mut self, id: &RoomId) { - debug!("Marking room {} unread", id); + debug!("Marking room {id} unread"); for room in &mut self.sorted_rooms { if room.id == *id { room.unread = true; @@ -81,7 +81,7 @@ impl Rooms { /// Mark a room as read. pub(crate) fn mark_read(&mut self, id: &RoomId) { - debug!("Marking room {} read", id); + debug!("Marking room {id} read"); for room in &mut self.sorted_rooms { if room.id == *id { room.unread = false; @@ -108,7 +108,7 @@ impl Rooms { /// Remove a room completely pub(crate) fn remove_room(&mut self, id: &RoomId) { - debug!("Removing room {}", id); + debug!("Removing room {id}"); self.sorted_rooms.retain(|room| room.id != *id); self.requested_rooms.remove(id); } @@ -117,7 +117,6 @@ impl Rooms { pub(crate) fn sorted_rooms(&self) -> &Vec { &self.sorted_rooms } - } #[cfg(test)] diff --git a/src/app/callbacks.rs b/src/app/callbacks.rs index 9eaeb7c..4af3ef3 100644 --- a/src/app/callbacks.rs +++ b/src/app/callbacks.rs @@ -44,7 +44,7 @@ impl App<'_> { self.cb_messages_received_in_room(&room_id, &messages, update_unread) } None => { - error!("Received message without room id: {:#?}", msg); + error!("Received message without room id: {msg:#?}"); } } } @@ -58,11 +58,7 @@ impl App<'_> { messages: &[Message], update_unread: bool, ) { - debug!( - "Received {} messages in room {}", - messages.len(), - room_id.to_string() - ); + debug!("Received {} messages in room {}", messages.len(), room_id); // keep track of the selected message, if any let selected_message_id = self .state @@ -92,7 +88,7 @@ impl App<'_> { self.state.cache.rooms.mark_unread(room_id); } if let Err(err) = self.state.cache.add_message(msg) { - error!("Error adding received message to store: {}", err); + error!("Error adding received message to store: {err}"); } } } @@ -180,10 +176,7 @@ impl App<'_> { // If the webex_room has a team_id, and the team is not already requested, request it and add it to the list of requested teams. if let Some(team_id) = team_id { if !self.state.cache.teams.exists_or_requested(&team_id) { - debug!( - "Requesting team {} identified by room: {}", - team_id, room_title - ); + debug!("Requesting team {team_id} identified by room: {room_title}"); self.state.cache.teams.add_requested(team_id.clone()); self.dispatch_to_teams(AppCmdEvent::UpdateTeam(team_id), &Priority::Low); } @@ -207,7 +200,7 @@ impl App<'_> { // We don't know which message was deleted, so we wipe the room and request the messages again. match self.state.cache.wipe_messages_in_room(room_id) { Ok(num_msg_deleted) => { - debug!("Deleted {} messages in room {}", num_msg_deleted, room_id); + debug!("Deleted {num_msg_deleted} messages in room {room_id}"); let num_msg_deleted: u32 = num_msg_deleted.try_into().unwrap_or(100); self.dispatch_to_teams( AppCmdEvent::ListMessagesInRoom(room_id.to_string(), None, num_msg_deleted), @@ -215,7 +208,7 @@ impl App<'_> { ); } Err(err) => { - error!("Error deleting messages in room {}: {}", room_id, err); + error!("Error deleting messages in room {room_id}: {err}"); } } } @@ -227,10 +220,10 @@ impl App<'_> { /// Callback when a room is left. pub(crate) fn cb_space_left(&mut self, room_id: &RoomId) { - debug!("Callback: space left for room ID: {}", room_id); + debug!("Callback: space left for room ID: {room_id}"); if let Some(room) = self.state.cache.rooms.room_with_id(room_id) { if let Some(title) = &room.title { - debug!("Removing room from cache: {}", title); + debug!("Removing room from cache: {title}"); } } self.state.cache.remove_room(room_id); diff --git a/src/app/messages_list.rs b/src/app/messages_list.rs index cc27d9b..581b807 100644 --- a/src/app/messages_list.rs +++ b/src/app/messages_list.rs @@ -41,7 +41,6 @@ impl MessagesList { self.table_state.select(Some(index)); } } - /// Selects the next message in the list and updates the table_state. pub(crate) fn select_next_message(&mut self) { diff --git a/src/app/mod.rs b/src/app/mod.rs index d8cf953..73bfc4a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -74,10 +74,10 @@ impl App<'_> { /// the corresponding action otherwise pub(crate) async fn process_key_event(&mut self, key_event: KeyEvent) -> AppReturn { if self.state.message_editor.is_composing() { - trace!("Keyevent: {:?}", key_event); + trace!("Keyevent: {key_event:?}"); self.process_editing_key(key_event) } else if self.state.active_pane() == &Some(ActivePane::Search) { - trace!("Search keyevent: {:?}", key_event); + trace!("Search keyevent: {key_event:?}"); self.process_search_key(key_event) } else { self.do_action(Key::from(key_event)) @@ -87,16 +87,16 @@ impl App<'_> { /// Handle a user action (non-editing mode) fn do_action(&mut self, key: crate::inputs::key::Key) -> AppReturn { if let Some(action) = self.state.actions.find(key) { - debug!("Run action [{:?}]", action); + debug!("Run action [{action:?}]"); match action { Action::DeleteMessage => { if let Err(e) = self.delete_selected_message() { - error!("Could not delete message: {}", e); + error!("Could not delete message: {e}"); }; } Action::DumpRoomContentToFile => { if let Err(e) = self.dump_room_content_to_file() { - error!("Could not dump room content to file: {}", e); + error!("Could not dump room content to file: {e}"); }; } Action::ComposeNewMessage => { @@ -106,7 +106,7 @@ impl App<'_> { } Action::EditSelectedMessage => { if let Err(e) = self.edit_selected_message() { - error!("Could not respond to message: {}", e); + error!("Could not respond to message: {e}"); }; self.state.message_editor.set_is_composing(true); self.state.set_active_pane(Some(ActivePane::Compose)); @@ -128,14 +128,10 @@ impl App<'_> { self.state.log_state.transition(TuiWidgetEvent::RightKey); } Action::LogPageDown => { - self.state - .log_state - .transition(TuiWidgetEvent::NextPageKey); + self.state.log_state.transition(TuiWidgetEvent::NextPageKey); } Action::LogPageUp => { - self.state - .log_state - .transition(TuiWidgetEvent::PrevPageKey); + self.state.log_state.transition(TuiWidgetEvent::PrevPageKey); } Action::LogReduceCapturedOneLevel => { self.state.log_state.transition(TuiWidgetEvent::MinusKey); @@ -174,14 +170,14 @@ impl App<'_> { Action::Quit => return AppReturn::Exit, Action::RespondMessage => { if let Err(e) = self.respond_to_selected_message() { - error!("Could not respond to message: {}", e); + error!("Could not respond to message: {e}"); }; self.state.message_editor.set_is_composing(true); self.state.set_active_pane(Some(ActivePane::Compose)); } Action::SendMessage => { if let Err(e) = self.send_message_buffer() { - error!("Could not send message: {}", e); + error!("Could not send message: {e}"); }; } Action::ToggleDebug => { @@ -233,33 +229,45 @@ impl App<'_> { } Action::ToggleRoomSelection => { // Collect room info first to avoid borrowing conflicts - let room_ids: Vec<_> = self.state.visible_rooms().map(|r| r.id.clone()).collect(); - let visible_rooms: Vec<_> = room_ids.iter() + let room_ids: Vec<_> = + self.state.visible_rooms().map(|r| r.id.clone()).collect(); + let visible_rooms: Vec<_> = room_ids + .iter() .filter_map(|id| self.state.cache.rooms.room_with_id(id)) .collect(); - self.state.rooms_list.toggle_current_room_selection(&visible_rooms); + self.state + .rooms_list + .toggle_current_room_selection(&visible_rooms); } Action::SelectAllVisibleRooms => { - // Collect room info first to avoid borrowing conflicts - let room_ids: Vec<_> = self.state.visible_rooms().map(|r| r.id.clone()).collect(); - let visible_rooms: Vec<_> = room_ids.iter() + // Collect room info first to avoid borrowing conflicts + let room_ids: Vec<_> = + self.state.visible_rooms().map(|r| r.id.clone()).collect(); + let visible_rooms: Vec<_> = room_ids + .iter() .filter_map(|id| self.state.cache.rooms.room_with_id(id)) .collect(); - self.state.rooms_list.select_all_visible_rooms(&visible_rooms); + self.state + .rooms_list + .select_all_visible_rooms(&visible_rooms); } Action::ClearRoomSelections => { self.state.rooms_list.clear_room_selections(); } + Action::CopyMessage => { + if let Err(e) = self.copy_selected_message() { + error!("Could not copy message: {e}"); + }; + } Action::DeleteSelectedRooms => { debug!("User triggered DeleteSelectedRooms action"); if let Err(e) = self.delete_selected_rooms() { - error!("Could not delete selected rooms: {}", e); + error!("Could not delete selected rooms: {e}"); } } - } } else { - warn!("No action associated with {} in this mode", key); + warn!("No action associated with {key} in this mode"); // If the key actually corresponds to an action, it needs to be added to the list of active actions too. } AppReturn::Continue @@ -277,7 +285,7 @@ impl App<'_> { Key::AltEnter => self.state.message_editor.insert_newline(), Key::Enter => { if let Err(e) = self.send_message_buffer() { - error!("Could not send message: {}", e); + error!("Could not send message: {e}"); }; } _ => _ = self.state.message_editor.input(Input::from(key_event)), @@ -324,8 +332,11 @@ impl App<'_> { } Key::Char(c) => { // Add character to search query - let query = self.state.rooms_list.search_query() - .map(|q| format!("{}{}", q, c)) + let query = self + .state + .rooms_list + .search_query() + .map(|q| format!("{q}{c}")) .unwrap_or_else(|| c.to_string()); self.state.rooms_list.set_search_query(Some(query)); // Reset selection when search changes @@ -416,6 +427,44 @@ impl App<'_> { Ok(()) } + /// Extracts copyable text content from a message in priority order: text > markdown > html (converted) + fn extract_message_content(message: &Message) -> Result { + match (&message.text, &message.markdown, &message.html) { + (Some(text), _, _) => Ok(text.clone()), + (None, Some(md), _) => Ok(md.clone()), + (None, None, Some(html)) => { + // Use html2text to convert HTML to plain text + html2text::from_read(html.as_bytes(), usize::MAX) + .map_err(|e| { + warn!("Failed to convert HTML to plain text: {e}"); + eyre!("Failed to convert HTML to plain text: {e}") + }) + .or_else(|_| { + warn!("HTML conversion failed, using raw HTML as fallback"); + Ok(html.clone()) + }) + } + _ => Err(eyre!("Message has no content")), + } + } + + /// Copies the selected message content to clipboard + fn copy_selected_message(&mut self) -> Result<()> { + use arboard::Clipboard; + + let message = self.state.selected_message()?; + let content = Self::extract_message_content(message)?; + + let mut clipboard = + Clipboard::new().map_err(|e| eyre!("Failed to access clipboard: {}", e))?; + clipboard + .set_text(content) + .map_err(|e| eyre!("Failed to set clipboard content: {}", e))?; + + info!("Message copied to clipboard"); + Ok(()) + } + /// Prepares the message editor to respond to the selected message fn respond_to_selected_message(&mut self) -> Result<()> { let message = self.state.selected_message()?; @@ -475,7 +524,7 @@ impl App<'_> { Priority::Low => &self.app_to_teams_tx_low, }; if let Err(e) = tx.send(action) { - error!("Error from dispatch {}", e); + error!("Error from dispatch {e}"); }; } @@ -555,8 +604,8 @@ impl App<'_> { /// Delete all selected rooms by leaving them fn delete_selected_rooms(&mut self) -> Result<()> { let selected_room_ids = self.state.rooms_list.selected_room_ids(); - debug!("Selected room IDs for deletion: {:?}", selected_room_ids); - + debug!("Selected room IDs for deletion: {selected_room_ids:?}"); + if selected_room_ids.is_empty() { debug!("No rooms selected for deletion"); return Err(eyre!("No rooms selected for deletion")); @@ -564,11 +613,11 @@ impl App<'_> { // Send leave room commands for all selected rooms for room_id in selected_room_ids.clone() { - debug!("Sending leave room command for room: {}", room_id); + debug!("Sending leave room command for room: {room_id}"); // Also log the room title if available if let Some(room) = self.state.cache.rooms.room_with_id(&room_id) { if let Some(title) = &room.title { - debug!("Room title: {}", title); + debug!("Room title: {title}"); } } self.dispatch_to_teams(AppCmdEvent::LeaveRoom(room_id), &Priority::High); @@ -576,7 +625,133 @@ impl App<'_> { // Clear selections after sending the commands self.state.rooms_list.clear_room_selections(); - debug!("Cleared room selections, sent {} leave commands", selected_room_ids.len()); + debug!( + "Cleared room selections, sent {} leave commands", + selected_room_ids.len() + ); Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use webex::Message; + + /// Helper function to create a test message with specified content + fn make_message( + id: &str, + text: Option, + markdown: Option, + html: Option, + ) -> Message { + Message { + id: Some(id.to_string()), + text, + markdown, + html, + created: Some(chrono::Utc::now().to_string()), + ..Default::default() + } + } + + #[test] + fn test_extract_message_content_prioritizes_text() { + let message = make_message( + "msg1", + Some("Plain text content".to_string()), + Some("**Markdown** content".to_string()), + Some("

HTML content

".to_string()), + ); + + let result = App::extract_message_content(&message).unwrap(); + assert_eq!(result, "Plain text content"); + } + + #[test] + fn test_extract_message_content_falls_back_to_markdown() { + let message = make_message( + "msg1", + None, + Some("**Markdown** content".to_string()), + Some("

HTML content

".to_string()), + ); + + let result = App::extract_message_content(&message).unwrap(); + assert_eq!(result, "**Markdown** content"); + } + + #[test] + fn test_extract_message_content_converts_html_to_text() { + let message = make_message( + "msg1", + None, + None, + Some("

Hello world!

".to_string()), + ); + + let result = App::extract_message_content(&message).unwrap(); + // html2text should convert HTML to plain text + assert!(result.contains("Hello")); + assert!(result.contains("world")); + // Should not contain HTML tags + assert!(!result.contains("

")); + assert!(!result.contains("")); + } + + #[test] + fn test_extract_message_content_handles_malformed_html() { + let message = make_message( + "msg1", + None, + None, + Some("

Malformed HTML without closing".to_string()), + ); + + // Should not panic and should return some content + let result = App::extract_message_content(&message); + assert!(result.is_ok()); + let content = result.unwrap(); + assert!(!content.is_empty()); + } + + #[test] + fn test_extract_message_content_fails_with_no_content() { + let message = make_message("msg1", None, None, None); + + let result = App::extract_message_content(&message); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Message has no content")); + } + + #[test] + fn test_extract_message_content_handles_empty_strings() { + // Test with empty text (should use it even if empty) + let message = make_message( + "msg1", + Some("".to_string()), + Some("Markdown content".to_string()), + None, + ); + + let result = App::extract_message_content(&message).unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn test_extract_message_content_trims_whitespace_from_html() { + let message = make_message( + "msg1", + None, + None, + Some("

Content with spaces

".to_string()), + ); + + let result = App::extract_message_content(&message).unwrap(); + // html2text should handle whitespace appropriately + assert!(result.contains("Content with spaces")); + } +} diff --git a/src/app/rooms_list.rs b/src/app/rooms_list.rs index ed74036..619a0f6 100644 --- a/src/app/rooms_list.rs +++ b/src/app/rooms_list.rs @@ -24,7 +24,7 @@ impl RoomsList { /// Does not update the active room. pub(crate) fn next_filter(&mut self, store: &Cache) { let new_mode = next_cycle(&self.filter); - debug!("Rooms list filter set to {:?}", new_mode); + debug!("Rooms list filter set to {new_mode:?}"); self.filter = new_mode; // Reset selection when we change filter let num_rooms = store @@ -40,7 +40,7 @@ impl RoomsList { /// Does not update the active room. pub(crate) fn previous_filter(&mut self, store: &Cache) { let new_mode = previous_cycle(&self.filter); - debug!("Rooms list mode set to {:?}", new_mode); + debug!("Rooms list mode set to {new_mode:?}"); self.filter = new_mode; // Reset selection when we change filter let num_rooms = store diff --git a/src/app/state.rs b/src/app/state.rs index c54d165..d7c380c 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -87,7 +87,11 @@ impl AppState<'_> { if let Some(query) = self.rooms_list.search_query() { if !query.trim().is_empty() { // Return search results (just the rooms, ignoring scores) - Box::new(self.cache.rooms_matching_search(query).map(|(room, _score)| room)) + Box::new( + self.cache + .rooms_matching_search(query) + .map(|(room, _score)| room), + ) } else { // Empty query - show all rooms with current filter Box::new(self.cache.rooms.rooms_filtered_by(self.rooms_list.filter())) @@ -147,7 +151,7 @@ impl AppState<'_> { /// according to what can be do in that pane. /// It also removes any message selection when switching to non Messages panes. pub(crate) fn set_active_pane(&mut self, active_pane: Option) { - debug!("Activating pane: {:?}", active_pane); + debug!("Activating pane: {active_pane:?}"); // Deselect messages when switching to non Messages panes match self.active_pane { Some(ActivePane::Messages) | Some(ActivePane::Compose) => (), @@ -182,6 +186,7 @@ impl AppState<'_> { actions.extend(vec![Action::NextMessage, Action::PreviousMessage]); } if self.messages_list.has_selection() { + actions.push(Action::CopyMessage); actions.push(Action::RespondMessage); actions.push(Action::UnselectMessage); if self.selected_message_is_from_me().unwrap_or_default() { @@ -370,7 +375,13 @@ impl Default for AppState<'_> { let log_state = TuiWidgetState::default(); log_state.transition(tui_logger::TuiWidgetEvent::HideKey); AppState { - actions: vec![Action::Quit, Action::ToggleHelp, Action::ToggleLogs, Action::ToggleRooms].into(), + actions: vec![ + Action::Quit, + Action::ToggleHelp, + Action::ToggleLogs, + Action::ToggleRooms, + ] + .into(), active_pane: None, cache: Cache::default(), theme: Theme::default(), diff --git a/src/config.rs b/src/config.rs index 95e68de..61cd162 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,8 +6,7 @@ use color_eyre::eyre::{eyre, Error, Result}; use log::{info, warn}; use serde::{Deserialize, Serialize}; use std::{ - env, - fs, + env, fs, io::{stdin, Write}, path::{Path, PathBuf}, }; @@ -47,7 +46,6 @@ pub(crate) struct UserConfig { /// Enable debug logging by default #[serde(default)] pub(crate) debug: bool, - } impl Default for UserConfig { @@ -132,10 +130,7 @@ impl ClientConfig { "Set the integration name to webex-tui", "Pick any icon", "Add a description of 10+ characters (for example sgrimee/webex-tui)", - &format!( - "Add `http://localhost:{}` to the Redirect URI(s)", - DEFAULT_PORT - ), + &format!("Add `http://localhost:{DEFAULT_PORT}` to the Redirect URI(s)"), "Under `Scopes`, check `spark:all`", "At the bottom, click `Add Integration`", "Enter the `Client ID` and `Client Secret` you will get below.", @@ -144,7 +139,7 @@ impl ClientConfig { let mut number = 1; for item in instructions.iter() { - println!(" {}. {}", number, item); + println!(" {number}. {item}"); number += 1; } @@ -165,7 +160,7 @@ impl ClientConfig { let content_yml = serde_yaml::to_string(&config_yml)?; let mut new_config = fs::File::create(&paths.config_file_path)?; - write!(new_config, "{}", content_yml)?; + write!(new_config, "{content_yml}")?; self.client_id = config_yml.client_id; self.client_secret = config_yml.client_secret; @@ -185,20 +180,19 @@ impl ClientConfig { const MAX_RETRIES: u8 = 5; let mut num_retries = 0; loop { - println!("\nEnter your {}: ", type_label); + println!("\nEnter your {type_label}: "); stdin().read_line(&mut client_key)?; client_key = client_key.trim().to_string(); match ClientConfig::validate_client_key(&client_key, expected_length) { Ok(_) => return Ok(client_key), Err(error_string) => { - println!("{}", error_string); + println!("{error_string}"); client_key.clear(); num_retries += 1; if num_retries == MAX_RETRIES { - return Err(Error::from(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Maximum retries ({}) exceeded.", MAX_RETRIES), - ))); + return Err(Error::from(std::io::Error::other(format!( + "Maximum retries ({MAX_RETRIES}) exceeded." + )))); } } }; @@ -233,8 +227,8 @@ impl UserConfig { pub(crate) fn load() -> Self { // Try environment variable first (nix can set this) if let Ok(config_path) = env::var("WEBEX_TUI_CONFIG") { - info!("Loading user config from WEBEX_TUI_CONFIG: {}", config_path); - if let Ok(config) = Self::load_from_file(&std::path::Path::new(&config_path)) { + info!("Loading user config from WEBEX_TUI_CONFIG: {config_path}"); + if let Ok(config) = Self::load_from_file(std::path::Path::new(&config_path)) { return config; } warn!("Failed to load config from WEBEX_TUI_CONFIG, trying default location"); @@ -242,7 +236,10 @@ impl UserConfig { // Try standard location if let Some(home) = dirs::home_dir() { - let config_path = home.join(CONFIG_DIR).join(APP_CONFIG_DIR).join(USER_FILE_NAME); + let config_path = home + .join(CONFIG_DIR) + .join(APP_CONFIG_DIR) + .join(USER_FILE_NAME); info!("Loading user config from: {}", config_path.display()); if let Ok(config) = Self::load_from_file(&config_path) { return config; @@ -266,12 +263,12 @@ impl UserConfig { pub(crate) fn save(&self) -> color_eyre::Result<()> { if let Some(home) = dirs::home_dir() { let config_dir = home.join(CONFIG_DIR).join(APP_CONFIG_DIR); - + // Create directories if they don't exist if !config_dir.exists() { fs::create_dir_all(&config_dir)?; } - + let config_path = config_dir.join(USER_FILE_NAME); let content = serde_yaml::to_string(self)?; fs::write(config_path, content)?; @@ -280,6 +277,4 @@ impl UserConfig { Err(eyre!("Could not determine home directory")) } } - - } diff --git a/src/inputs/handler.rs b/src/inputs/handler.rs index 88523c4..2a45ef6 100644 --- a/src/inputs/handler.rs +++ b/src/inputs/handler.rs @@ -42,12 +42,12 @@ impl EventHandler { crossterm::event::read().unwrap() { if let Err(err) = event_tx.send(Event::Input(key_event)).await { - error!("Could not send terminal event to main thread!, {}", err); + error!("Could not send terminal event to main thread!, {err}"); } } } if let Err(err) = event_tx.send(Event::Tick).await { - error!("Could not send tick to main thread!, {}", err); + error!("Could not send tick to main thread!, {err}"); } if event_stop_capture.load(Ordering::Relaxed) { break; diff --git a/src/inputs/key.rs b/src/inputs/key.rs index 9ce80a4..31e5245 100644 --- a/src/inputs/key.rs +++ b/src/inputs/key.rs @@ -72,7 +72,7 @@ impl Key { 10 => Key::F10, 11 => Key::F11, 12 => Key::F12, - _ => panic!("unknown function key: F{}", n), + _ => panic!("unknown function key: F{n}"), } } } @@ -81,14 +81,14 @@ impl Display for Key { fn fmt(&self, f: &mut Formatter) -> fmt::Result { match *self { Key::Alt(' ') => write!(f, ""), - Key::Alt(c) => write!(f, "", c), + Key::Alt(c) => write!(f, ""), Key::AltEnter => write!(f, ""), Key::Char(' ') => write!(f, ""), - Key::Char(c) => write!(f, "<{}>", c), + Key::Char(c) => write!(f, "<{c}>"), Key::Ctrl(' ') => write!(f, ""), - Key::Ctrl(c) => write!(f, "", c), + Key::Ctrl(c) => write!(f, ""), Key::ShiftTab => write!(f, ""), - _ => write!(f, "<{:?}>", self), + _ => write!(f, "<{self:?}>"), } } } diff --git a/src/logger.rs b/src/logger.rs index f13a15e..bd6551b 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -41,7 +41,7 @@ pub(crate) fn setup_logger( // set the log file if let Some(logfile) = logfile { let log_file = TuiLoggerFile::new(&logfile.to_string_lossy()); - let _ = tui_logger::set_log_file(log_file); + tui_logger::set_log_file(log_file); } } diff --git a/src/main.rs b/src/main.rs index 9199660..a8d6c96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,7 +82,7 @@ async fn main() -> Result<()> { if matches.get_flag("list-modules") { println!("Modules that can be traced:"); for module in logger::crate_modules().iter() { - println!(" {}", module); + println!(" {module}"); } return Ok(()); } @@ -102,21 +102,21 @@ async fn main() -> Result<()> { setup_logger(default_log_level, trace_modules, log_file_opt); // only for tui mode // Welcome message - println!("{}", BANNER); + println!("{BANNER}"); println!("Starting webex-tui, version {}.", env!("CARGO_PKG_VERSION")); // Read configurations let client_config = get_config()?; let user_config = UserConfig::load(); - + // Get port from client config (for OAuth redirect) let port = client_config.port.unwrap_or(DEFAULT_PORT); - + let credentials = ClientCredentials { client_id: client_config.client_id, client_secret: client_config.client_secret, }; - + // Load theme from user config let theme = load_theme(&user_config.theme); @@ -137,8 +137,10 @@ async fn main() -> Result<()> { tokio::sync::mpsc::unbounded_channel::(); // CLI args override config values let debug = matches.get_flag("debug") || user_config.debug; - let messages_to_load = *matches.get_one("messages").unwrap_or(&user_config.messages_to_load); - + let messages_to_load = *matches + .get_one("messages") + .unwrap_or(&user_config.messages_to_load); + let app = Arc::new(tokio::sync::Mutex::new(App::new( app_to_teams_tx_lowpri.clone(), app_to_teams_tx_highpri.clone(), diff --git a/src/teams/app_handler.rs b/src/teams/app_handler.rs index 4502478..0f58b4f 100644 --- a/src/teams/app_handler.rs +++ b/src/teams/app_handler.rs @@ -63,7 +63,7 @@ impl Teams<'_> { AppCmdEvent::LeaveRoom(room_id) => self.do_leave_room(&room_id).await, AppCmdEvent::WhoAmI() => self.get_me_user().await, } { - error!("Error handling app event: {}", error); + error!("Error handling app event: {error}"); } { self.app.lock().await.state.is_loading = false; @@ -85,7 +85,7 @@ impl Teams<'_> { let global_id = GlobalId::new(GlobalIdType::Message, id.to_owned()).unwrap(); match self.client.delete::(&global_id).await { Ok(_) => { - debug!("Deleted message with id: {:?}", global_id); + debug!("Deleted message with id: {global_id:?}"); Ok(()) } Err(e) => Err(eyre!("Could not delete message: {}", e)), @@ -97,7 +97,7 @@ impl Teams<'_> { match self.client.send_message(msg_to_send).await { Ok(msg) => { self.app.lock().await.cb_message_sent(&msg); - debug!("Sent message: {:?}", msg_to_send); + debug!("Sent message: {msg_to_send:?}"); Ok(()) } Err(e) => Err(eyre!("Error sending message: {}", e)), @@ -106,12 +106,12 @@ impl Teams<'_> { /// Gets the message with given id. async fn do_update_message(&self, msg_id: &MessageId) -> Result<()> { - debug!("Getting message with id: {:?}", msg_id); + debug!("Getting message with id: {msg_id:?}"); let global_id = GlobalId::new(GlobalIdType::Message, msg_id.to_owned()).unwrap(); match self.client.get::(&global_id).await { Ok(msg) => { self.app.lock().await.cb_message_received(&msg, false); - debug!("Updated message: {:?}", msg_id); + debug!("Updated message: {msg_id:?}"); Ok(()) } Err(e) => Err(eyre!("Error retrieving message: {}", e)), @@ -124,7 +124,7 @@ impl Teams<'_> { msg_id: &MessageId, room_id: &RoomId, ) -> Result<()> { - debug!("Getting children of message with id: {:?}", msg_id); + debug!("Getting children of message with id: {msg_id:?}"); let msg_gid = GlobalId::new(GlobalIdType::Message, msg_id.to_owned()).unwrap(); let mut params = MessageListParams::new(room_id); params.parent_id = Some(msg_gid.id()); @@ -165,7 +165,7 @@ impl Teams<'_> { }; match self.client.edit_message(&message_id, ¶ms).await { Ok(_) => { - debug!("Edited message with id: {:?}", message_id); + debug!("Edited message with id: {message_id:?}"); Ok(()) } Err(e) => Err(eyre!("Could not edit message: {}", e)), @@ -174,10 +174,7 @@ impl Teams<'_> { async fn do_refresh_room(&mut self, id: &RoomId) -> Result<()> { let global_id = GlobalId::new(GlobalIdType::Room, id.to_owned()).unwrap(); - debug!( - "Getting room with local id {} and global id: {:?}", - id, global_id - ); + debug!("Getting room with local id {id} and global id: {global_id:?}"); match self.client.get::(&global_id).await { Ok(webex_room) => { self.app.lock().await.cb_room_updated(webex_room); @@ -192,7 +189,7 @@ impl Teams<'_> { /// team details, so errors are silenced. async fn do_update_team(&self, team_id: &TeamId) -> Result<()> { let global_id = GlobalId::new(GlobalIdType::Team, team_id.to_owned()).unwrap(); - debug!("Getting team with global id: {:?}", global_id); + debug!("Getting team with global id: {global_id:?}"); if let Ok(webex_team) = self.client.get::(&global_id).await { self.app.lock().await.cb_team_updated(webex_team); }; @@ -238,13 +235,13 @@ impl Teams<'_> { before_id: Option, max: u32, ) -> Result<()> { - debug!("Getting messages in room {}", room_id); + debug!("Getting messages in room {room_id}"); let gid = GlobalId::new(GlobalIdType::Room, room_id.to_owned()).unwrap(); let mut params = MessageListParams::new(gid.id()); params.max = Some(max); let mut _msg_id = String::new(); if let Some(before_id) = before_id { - debug!("Only messages before {}", before_id); + debug!("Only messages before {before_id}"); _msg_id = before_id.to_string(); params.before_message = Some(&_msg_id); } @@ -262,7 +259,7 @@ impl Teams<'_> { } async fn do_update_person(&self, person_id: &PersonId) -> Result<()> { - debug!("Getting person with id: {}", person_id); + debug!("Getting person with id: {person_id}"); let global_id = GlobalId::new(GlobalIdType::Person, person_id.to_owned()).unwrap(); match self.client.get::(&global_id).await { Ok(webex_person) => { @@ -292,19 +289,24 @@ impl Teams<'_> { async fn do_leave_room(&mut self, room_id: &RoomId) -> Result<()> { let global_id = GlobalId::new(GlobalIdType::Room, room_id.to_owned()).unwrap(); debug!("=== STARTING LEAVE ROOM PROCESS ==="); - debug!("Leaving room with local id {} and global id: {:?}", room_id, global_id); - + debug!("Leaving room with local id {room_id} and global id: {global_id:?}"); + // Get room title for better logging let room_title = { let app = self.app.lock().await; - app.state.cache.rooms.room_with_id(room_id).and_then(|r| r.title.clone()).unwrap_or_else(|| "Unknown".to_string()) + app.state + .cache + .rooms + .room_with_id(room_id) + .and_then(|r| r.title.clone()) + .unwrap_or_else(|| "Unknown".to_string()) }; - debug!("Room title: {}", room_title); - + debug!("Room title: {room_title}"); + debug!("About to call client.leave_room..."); match self.client.leave_room(&global_id).await { Ok(_) => { - debug!("✅ Successfully left room: {} ({})", room_title, room_id); + debug!("✅ Successfully left room: {room_title} ({room_id})"); // Remove the room from the cache debug!("Calling cb_space_left callback..."); self.app.lock().await.cb_space_left(room_id); @@ -312,7 +314,7 @@ impl Teams<'_> { Ok(()) } Err(e) => { - error!("❌ Error leaving room {} ({}): {}", room_title, room_id, e); + error!("❌ Error leaving room {room_title} ({room_id}): {e}"); debug!("=== LEAVE ROOM PROCESS FAILED ==="); Err(eyre!("Error leaving room: {}", e)) } diff --git a/src/teams/auth.rs b/src/teams/auth.rs index a4ea57b..b743195 100644 --- a/src/teams/auth.rs +++ b/src/teams/auth.rs @@ -4,14 +4,14 @@ //! //! Inspired by `https://github.com/Nabushika/webexterm` -use super::token_cache::{load_token_cache, save_token_cache, clear_token_cache, TokenCache}; +use super::token_cache::{clear_token_cache, load_token_cache, save_token_cache, TokenCache}; use crate::ClientCredentials; use color_eyre::eyre::{eyre, Result}; use log::*; use oauth2::basic::BasicClient; use oauth2::url::Url; use oauth2::{ - AccessToken, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, + AccessToken, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenResponse, TokenUrl, }; @@ -19,11 +19,12 @@ use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::net::TcpStream; - - /// Get an integration token, using cached token if available and valid, /// otherwise falling back to browser authentication. -pub(crate) async fn get_integration_token_cached(credentials: ClientCredentials, port: u16) -> Result { +pub(crate) async fn get_integration_token_cached( + credentials: ClientCredentials, + port: u16, +) -> Result { // First, try to load and use cached token match load_token_cache() { Ok(cache) => { @@ -33,49 +34,54 @@ pub(crate) async fn get_integration_token_cached(credentials: ClientCredentials, } else { info!("Cached token expired or invalid, clearing cache"); if let Err(e) = clear_token_cache() { - warn!("Failed to clear expired token cache: {}", e); + warn!("Failed to clear expired token cache: {e}"); } } } Err(e) => { - debug!("No valid cached token found: {}", e); + debug!("No valid cached token found: {e}"); } } // No valid cached token, perform browser authentication info!("No valid cached token, starting browser authentication"); let token = get_integration_token_browser(credentials, port).await?; - + // Cache the new token for future use let cache = TokenCache::new(token.clone(), None, None); if let Err(e) = save_token_cache(&cache) { - warn!("Failed to cache authentication token: {}", e); + warn!("Failed to cache authentication token: {e}"); // Don't fail the authentication if caching fails } else { info!("Authentication token cached successfully"); } - + Ok(token) } /// Create and authorize a client with the given `ClientCredentials`. /// A browser is opened for user authentication. /// Returns a token, or an error if any authentication step fail. -async fn get_integration_token_browser(credentials: ClientCredentials, port: u16) -> Result { - let client = BasicClient::new( - ClientId::new(credentials.client_id.clone()), - ) - .set_client_secret(ClientSecret::new(credentials.client_secret.clone())) - .set_auth_uri(AuthUrl::new("https://webexapis.com/v1/authorize".to_string())?) - .set_token_uri(TokenUrl::new("https://webexapis.com/v1/access_token".to_string())?) - .set_redirect_uri(RedirectUrl::new(format!("http://localhost:{}", port))?); +async fn get_integration_token_browser( + credentials: ClientCredentials, + port: u16, +) -> Result { + let client = BasicClient::new(ClientId::new(credentials.client_id.clone())) + .set_client_secret(ClientSecret::new(credentials.client_secret.clone())) + .set_auth_uri(AuthUrl::new( + "https://webexapis.com/v1/authorize".to_string(), + )?) + .set_token_uri(TokenUrl::new( + "https://webexapis.com/v1/access_token".to_string(), + )?) + .set_redirect_uri(RedirectUrl::new(format!("http://localhost:{port}"))?); let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); // Use required scopes for webex-tui functionality let scopes = vec![ "spark:messages_read", - "spark:messages_write", + "spark:messages_write", "spark:rooms_read", "spark:rooms_write", "spark:memberships_read", @@ -88,23 +94,21 @@ async fn get_integration_token_browser(credentials: ClientCredentials, port: u16 let mut auth_url_builder = client .authorize_url(CsrfToken::new_random) .set_pkce_challenge(pkce_challenge); - + // Add all required scopes for scope in &scopes { auth_url_builder = auth_url_builder.add_scope(Scope::new(scope.to_string())); } - + let (auth_url, csrf_token) = auth_url_builder.url(); println!("Requesting authorization for required scopes..."); - debug!("Requesting scopes: {:?}", scopes); - + debug!("Requesting scopes: {scopes:?}"); + if webbrowser::open(auth_url.as_str()).is_err() { let msg = format!("We were unable to open a browser. You may quit with Ctrl+C and try again after setting -the BROWSER environment variable, or open the following url manually (on this computer):\n{}\n", - auth_url - ); - println!("{}", msg); +the BROWSER environment variable, or open the following url manually (on this computer):\n{auth_url}\n"); + println!("{msg}"); } let mut stream = await_authorization_callback(port).await?; @@ -133,18 +137,17 @@ the BROWSER environment variable, or open the following url manually (on this co Ok(token_result.access_token().clone()) } - - - - /// Backward compatibility alias for the cached authentication function -pub(crate) async fn get_integration_token(credentials: ClientCredentials, port: u16) -> Result { +pub(crate) async fn get_integration_token( + credentials: ClientCredentials, + port: u16, +) -> Result { get_integration_token_cached(credentials, port).await } /// Listen on local port for OAuth callback and return the TCP stream async fn await_authorization_callback(port: u16) -> Result { - let listener = TcpListener::bind(format!("127.0.0.1:{}", port))?; + let listener = TcpListener::bind(format!("127.0.0.1:{port}"))?; let stream = listener.incoming().flatten().next().unwrap(); Ok(stream) } @@ -161,12 +164,12 @@ fn parse_authorization_response(stream: &mut TcpStream) -> Result<(Authorization let url = Url::parse(&("http://localhost".to_string() + redirect_url)).unwrap(); // Debug: print all query parameters - debug!("OAuth redirect URL: {}", redirect_url); + debug!("OAuth redirect URL: {redirect_url}"); debug!("Parsed URL query parameters:"); for (key, value) in url.query_pairs() { - debug!(" {}: {}", key, value); + debug!(" {key}: {value}"); } - + let code_pair = url .query_pairs() .find(|pair| { @@ -175,9 +178,9 @@ fn parse_authorization_response(stream: &mut TcpStream) -> Result<(Authorization }) .ok_or_else(|| { let params: Vec = url.query_pairs() - .map(|(key, value)| format!("{}={}", key, value)) + .map(|(key, value)| format!("{key}={value}")) .collect(); - + // Check if this is a scope-related error let error_msg = url.query_pairs() .find(|(key, _)| key == "error_description") @@ -186,7 +189,7 @@ fn parse_authorization_response(stream: &mut TcpStream) -> Result<(Authorization .find(|(key, _)| key == "error") .map(|(_, value)| value.to_string())) .unwrap_or_else(|| "Unknown OAuth error".to_string()); - + if error_msg.contains("scope") || error_msg.contains("invalid_scope") { eyre!("OAuth scope error: {}. Your Webex integration must be configured with these exact scopes: spark:messages_read, spark:messages_write, spark:rooms_read, spark:rooms_write, spark:memberships_read, spark:memberships_write, spark:people_read, spark:kms. Please update your integration at https://developer.webex.com/my-apps", error_msg) } else { diff --git a/src/teams/mod.rs b/src/teams/mod.rs index b4e2bdf..0631aa6 100644 --- a/src/teams/mod.rs +++ b/src/teams/mod.rs @@ -69,7 +69,7 @@ impl<'a> Teams<'a> { warn!("Even stream closed, reopening."); break; } - error!("Error received from event stream: {}", e); + error!("Error received from event stream: {e}"); break; } } @@ -80,17 +80,17 @@ impl<'a> Teams<'a> { loop { tokio::select! { Some(app_event) = app_to_teams_rx_highpri.recv() => { - trace!("Got high priority app event: {:#?}", app_event); + trace!("Got high priority app event: {app_event:#?}"); self.handle_app_event(app_event).await; }, Some(webex_event) = wbx_stream_to_teams_rx.recv() => { - trace!("Got webex event: {:#?}", webex_event ); + trace!("Got webex event: {webex_event:#?}" ); if let Err(err) = self.handle_webex_event(webex_event).await { - error!("Error handling webex event: {}", err); + error!("Error handling webex event: {err}"); } }, Some(app_event) = app_to_teams_rx_lowpri.recv() => { - trace!("Got low priority app event: {:#?}", app_event); + trace!("Got low priority app event: {app_event:#?}"); self.handle_app_event(app_event).await; } } @@ -111,10 +111,7 @@ async fn initialize_event_stream(client: &Webex) -> WebexEventStream { break; } Err(e) => { - error!( - "Failed to start event stream, trying again in 1 minute: {}", - e - ); + error!("Failed to start event stream, trying again in 1 minute: {e}"); tokio::time::sleep(std::time::Duration::from_secs(60)).await; } }; diff --git a/src/teams/token_cache.rs b/src/teams/token_cache.rs index 886b6c0..c15b50d 100644 --- a/src/teams/token_cache.rs +++ b/src/teams/token_cache.rs @@ -79,16 +79,26 @@ impl TokenCache { /// Get the path to the token cache file fn get_cache_file_path() -> Result { + get_cache_file_path_impl(None) +} + +/// Internal implementation that supports custom suffixes for testing +fn get_cache_file_path_impl(suffix: Option<&str>) -> Result { let cache_dir = dirs::cache_dir() .or_else(|| dirs::home_dir().map(|home| home.join(".cache"))) .ok_or_else(|| eyre!("Could not determine cache directory"))?; let app_cache_dir = cache_dir.join("webex-tui"); - + // Ensure the cache directory exists fs::create_dir_all(&app_cache_dir)?; - Ok(app_cache_dir.join("tokens.json")) + let filename = match suffix { + Some(suffix) => format!("tokens-{suffix}.json"), + None => "tokens.json".to_string(), + }; + + Ok(app_cache_dir.join(filename)) } /// Set restrictive file permissions (user read/write only) @@ -114,23 +124,23 @@ fn set_secure_permissions(_path: &PathBuf) -> Result<()> { /// Save token cache to disk securely pub(crate) fn save_token_cache(cache: &TokenCache) -> Result<()> { let cache_path = get_cache_file_path()?; - - log::debug!("Saving token cache to: {:?}", cache_path); - + + log::debug!("Saving token cache to: {cache_path:?}"); + // Create the file with restrictive permissions from the start let file = OpenOptions::new() .create(true) .write(true) .truncate(true) .open(&cache_path)?; - + // Set secure permissions set_secure_permissions(&cache_path)?; - + // Write the cache data let writer = BufWriter::new(file); serde_json::to_writer_pretty(writer, cache)?; - + log::info!("Token cache saved successfully"); Ok(()) } @@ -138,18 +148,18 @@ pub(crate) fn save_token_cache(cache: &TokenCache) -> Result<()> { /// Load token cache from disk pub(crate) fn load_token_cache() -> Result { let cache_path = get_cache_file_path()?; - + if !cache_path.exists() { return Err(eyre!("Token cache file does not exist")); } - - log::debug!("Loading token cache from: {:?}", cache_path); - + + log::debug!("Loading token cache from: {cache_path:?}"); + let file = File::open(&cache_path)?; let reader = BufReader::new(file); - let cache: TokenCache = serde_json::from_reader(reader) - .map_err(|e| eyre!("Failed to parse token cache: {}", e))?; - + let cache: TokenCache = + serde_json::from_reader(reader).map_err(|e| eyre!("Failed to parse token cache: {}", e))?; + log::info!("Token cache loaded successfully"); Ok(cache) } @@ -157,12 +167,12 @@ pub(crate) fn load_token_cache() -> Result { /// Clear the token cache (e.g., on logout or authentication failure) pub(crate) fn clear_token_cache() -> Result<()> { let cache_path = get_cache_file_path()?; - + if cache_path.exists() { fs::remove_file(&cache_path)?; log::info!("Token cache cleared"); } - + Ok(()) } @@ -172,6 +182,53 @@ mod tests { use oauth2::{AccessToken, RefreshToken}; use std::time::Duration; + /// Test-specific function to save cache with a unique suffix + fn save_test_cache(cache: &TokenCache, test_suffix: &str) -> Result<()> { + let cache_path = get_cache_file_path_impl(Some(test_suffix))?; + + log::debug!("Saving test token cache to: {cache_path:?}"); + + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&cache_path)?; + + set_secure_permissions(&cache_path)?; + + let writer = BufWriter::new(file); + serde_json::to_writer_pretty(writer, cache)?; + + Ok(()) + } + + /// Test-specific function to load cache with a unique suffix + fn load_test_cache(test_suffix: &str) -> Result { + let cache_path = get_cache_file_path_impl(Some(test_suffix))?; + + if !cache_path.exists() { + return Err(eyre!("Token cache file does not exist")); + } + + let file = File::open(&cache_path)?; + let reader = BufReader::new(file); + let cache: TokenCache = serde_json::from_reader(reader) + .map_err(|e| eyre!("Failed to parse token cache: {}", e))?; + + Ok(cache) + } + + /// Test-specific function to clear cache with a unique suffix + fn clear_test_cache(test_suffix: &str) -> Result<()> { + let cache_path = get_cache_file_path_impl(Some(test_suffix))?; + + if cache_path.exists() { + fs::remove_file(&cache_path)?; + } + + Ok(()) + } + #[test] fn test_token_cache_creation() { let access_token = AccessToken::new("test_access_token".to_string()); @@ -188,13 +245,10 @@ mod tests { #[test] fn test_token_validity_with_expiration() { let access_token = AccessToken::new("test_token".to_string()); - + // Token that expires in 1 hour (should be valid) - let future_cache = TokenCache::new( - access_token.clone(), - None, - Some(Duration::from_secs(3600)) - ); + let future_cache = + TokenCache::new(access_token.clone(), None, Some(Duration::from_secs(3600))); assert!(future_cache.is_likely_valid()); // Simulate expired token @@ -203,7 +257,8 @@ mod tests { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() - .as_secs() - 100 // Expired 100 seconds ago + .as_secs() + - 100, // Expired 100 seconds ago ); assert!(!expired_cache.is_likely_valid()); } @@ -211,7 +266,7 @@ mod tests { #[test] fn test_token_validity_without_expiration() { let access_token = AccessToken::new("test_token".to_string()); - + // Recent token without expiration info (should be valid) let recent_cache = TokenCache::new(access_token.clone(), None, None); assert!(recent_cache.is_likely_valid()); @@ -221,25 +276,26 @@ mod tests { old_cache.cached_at = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() - .as_secs() - 24 * 60 * 60; // 24 hours ago + .as_secs() + - 24 * 60 * 60; // 24 hours ago assert!(!old_cache.is_likely_valid()); } #[test] fn test_cache_round_trip() { - // Test saving and loading a cache + // Test saving and loading a cache with unique suffix to avoid conflicts + let test_suffix = "round_trip"; let access_token = AccessToken::new("test_round_trip_token".to_string()); let original_cache = TokenCache::new(access_token, None, Some(Duration::from_secs(3600))); - // This test uses the real cache functions, so we need to be careful - // Clear any existing cache first - let _ = clear_token_cache(); + // Clear any existing test cache first + let _ = clear_test_cache(test_suffix); // Save the cache - save_token_cache(&original_cache).expect("Should save cache"); + save_test_cache(&original_cache, test_suffix).expect("Should save cache"); // Load the cache back - let loaded_cache = load_token_cache().expect("Should load cache"); + let loaded_cache = load_test_cache(test_suffix).expect("Should load cache"); // Verify they match assert_eq!(original_cache.access_token, loaded_cache.access_token); @@ -248,28 +304,29 @@ mod tests { assert!(loaded_cache.is_likely_valid()); // Clean up - let _ = clear_token_cache(); + let _ = clear_test_cache(test_suffix); } - #[test] + #[test] fn test_cache_clear() { - // Test clearing a cache + // Test clearing a cache with unique suffix to avoid conflicts + let test_suffix = "clear"; let access_token = AccessToken::new("test_clear_token".to_string()); let cache = TokenCache::new(access_token, None, None); - // Clear any existing cache first - let _ = clear_token_cache(); + // Clear any existing test cache first + let _ = clear_test_cache(test_suffix); // Save the cache - save_token_cache(&cache).expect("Should save cache"); + save_test_cache(&cache, test_suffix).expect("Should save cache"); // Verify it exists - assert!(load_token_cache().is_ok()); + assert!(load_test_cache(test_suffix).is_ok()); // Clear it - clear_token_cache().expect("Should clear cache"); + clear_test_cache(test_suffix).expect("Should clear cache"); // Verify it's gone - assert!(load_token_cache().is_err()); + assert!(load_test_cache(test_suffix).is_err()); } -} \ No newline at end of file +} diff --git a/src/teams/webex_handler.rs b/src/teams/webex_handler.rs index f6fae2d..3430374 100644 --- a/src/teams/webex_handler.rs +++ b/src/teams/webex_handler.rs @@ -37,7 +37,7 @@ impl Teams<'_> { trace!("Received unhandled highlight event."); } Unknown(s) => { - trace!("Received unhandled unknown event: {:#?}", s); + trace!("Received unhandled unknown event: {s:#?}"); } } Ok(()) @@ -62,13 +62,10 @@ impl Teams<'_> { e ) })?; - trace!( - "Received message posted/shared event with global id: {:#?}", - global_id - ); + trace!("Received message posted/shared event with global id: {global_id:#?}"); // The event doesn't contain the message content, go fetch it if let Ok(msg) = self.client.get::(&global_id).await { - trace!("Message: {:#?}", msg); + trace!("Message: {msg:#?}"); // add message and mark room as unread self.app.lock().await.cb_message_received(&msg, true); } @@ -81,10 +78,7 @@ impl Teams<'_> { e ) })?; - trace!( - "Received message deleted event with global id: {:#?}", - global_id - ); + trace!("Received message deleted event with global id: {global_id:#?}"); self.app .lock() .await @@ -110,8 +104,8 @@ impl Teams<'_> { })? .id() .to_string() as RoomId; - trace!("Received space event {:?} for room: {}", activity, room_id); - trace!("Space event: {:#?}", event); + trace!("Received space event {activity:?} for room: {room_id}"); + trace!("Space event: {event:#?}"); self.app.lock().await.cb_space_updated(&room_id); } Left => { @@ -122,11 +116,11 @@ impl Teams<'_> { })? .id() .to_string() as RoomId; - trace!("Received left space event for room: {}", room_id); + trace!("Received left space event for room: {room_id}"); self.app.lock().await.cb_space_left(&room_id); } _ => { - trace!("Unhandled space event: {:?}", activity); + trace!("Unhandled space event: {activity:?}"); } } Ok(()) diff --git a/src/theme/loader.rs b/src/theme/loader.rs index 9eee63f..10713b0 100644 --- a/src/theme/loader.rs +++ b/src/theme/loader.rs @@ -2,7 +2,7 @@ use super::models::Theme; use color_eyre::Result; -use log::{warn, debug, info}; +use log::{debug, info, warn}; use std::fs; use std::path::PathBuf; @@ -14,7 +14,7 @@ pub fn load_theme(theme_name: &str) -> Theme { theme } Err(e) => { - warn!("Failed to load theme '{}': {}. Using default theme.", theme_name, e); + warn!("Failed to load theme '{theme_name}': {e}. Using default theme."); Theme::default() } } @@ -23,61 +23,68 @@ pub fn load_theme(theme_name: &str) -> Theme { /// Attempt to load a theme file, returning errors for handling fn try_load_theme(theme_name: &str) -> Result { let theme_path = get_theme_path(theme_name)?; - + debug!("Loading theme from: {}", theme_path.display()); - + let content = fs::read_to_string(&theme_path)?; let mut theme: Theme = serde_yaml::from_str(&content)?; - + // Ensure the theme name matches (in case it's different in the file) if theme.name.is_empty() { theme.name = theme_name.to_string(); } - + // Validate user_colors array size to prevent memory issues if theme.user_colors.len() > 64 { - warn!("Theme '{}' has too many user_colors ({}), truncating to 64", theme.name, theme.user_colors.len()); + warn!( + "Theme '{}' has too many user_colors ({}), truncating to 64", + theme.name, + theme.user_colors.len() + ); theme.user_colors.truncate(64); } - + // If no user colors specified, use defaults if theme.user_colors.is_empty() { warn!("Theme '{}' has no user_colors, using defaults", theme.name); theme.user_colors = Theme::default().user_colors; } - + Ok(theme) } /// Get the path to a theme file fn get_theme_path(theme_name: &str) -> Result { - let home_dir = dirs::home_dir() - .ok_or_else(|| color_eyre::eyre::eyre!("Could not find home directory"))?; - + let home_dir = + dirs::home_dir().ok_or_else(|| color_eyre::eyre::eyre!("Could not find home directory"))?; + let theme_dir = home_dir.join(".config").join("webex-tui").join("themes"); - let theme_file = format!("{}.yml", theme_name); + let theme_file = format!("{theme_name}.yml"); let theme_path = theme_dir.join(theme_file); - + if !theme_path.exists() { - return Err(color_eyre::eyre::eyre!("Theme file not found: {}", theme_path.display())); + return Err(color_eyre::eyre::eyre!( + "Theme file not found: {}", + theme_path.display() + )); } - + Ok(theme_path) } /// Create the themes directory if it doesn't exist #[allow(dead_code)] pub fn ensure_themes_directory() -> Result { - let home_dir = dirs::home_dir() - .ok_or_else(|| color_eyre::eyre::eyre!("Could not find home directory"))?; - + let home_dir = + dirs::home_dir().ok_or_else(|| color_eyre::eyre::eyre!("Could not find home directory"))?; + let theme_dir = home_dir.join(".config").join("webex-tui").join("themes"); - + if !theme_dir.exists() { fs::create_dir_all(&theme_dir)?; debug!("Created themes directory: {}", theme_dir.display()); } - + Ok(theme_dir) } @@ -88,24 +95,24 @@ pub fn list_available_themes() -> Result> { Some(home) => home.join(".config").join("webex-tui").join("themes"), None => return Ok(vec![]), }; - + if !theme_dir.exists() { return Ok(vec![]); } - + let mut themes = Vec::new(); - + for entry in fs::read_dir(theme_dir)? { let entry = entry?; let path = entry.path(); - + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("yml") { if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { themes.push(stem.to_string()); } } } - + themes.sort(); Ok(themes) } @@ -114,7 +121,6 @@ pub fn list_available_themes() -> Result> { mod tests { use super::*; - #[test] fn test_load_default_theme() { let theme = load_theme("nonexistent"); @@ -131,4 +137,4 @@ mod tests { assert!(path.is_err()); } } -} \ No newline at end of file +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 96979e8..8e6a80b 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -3,12 +3,12 @@ //! This module provides configurable theming support through external YAML files. //! Themes define color palettes and semantic role assignments for UI elements. -pub mod models; pub mod loader; +pub mod models; pub mod parser; #[cfg(test)] mod tests; +pub use loader::load_theme; pub use models::Theme; -pub use loader::load_theme; \ No newline at end of file diff --git a/src/theme/models.rs b/src/theme/models.rs index 2794d64..6db5037 100644 --- a/src/theme/models.rs +++ b/src/theme/models.rs @@ -1,8 +1,8 @@ //! Theme data models +use super::parser::SerializableColor; use ratatui::style::Color; use serde::{Deserialize, Serialize}; -use super::parser::SerializableColor; /// Complete theme definition including palette, semantic roles, and user colors #[derive(Debug, Clone, Serialize, Deserialize)] @@ -103,26 +103,64 @@ pub struct Roles { impl Roles { // Convenience methods to get Color values for UI usage #[allow(dead_code)] - pub fn background(&self) -> Color { self.background.0 } + pub fn background(&self) -> Color { + self.background.0 + } #[allow(dead_code)] - pub fn text_primary(&self) -> Color { self.text_primary.0 } - pub fn text_muted(&self) -> Color { self.text_muted.0 } - pub fn accent(&self) -> Color { self.accent.0 } - pub fn selection_bg(&self) -> Color { self.selection_bg.0 } - pub fn selection_fg(&self) -> Color { self.selection_fg.0 } - pub fn border(&self) -> Color { self.border.0 } - pub fn border_active(&self) -> Color { self.border_active.0 } - pub fn title(&self) -> Color { self.title.0 } - pub fn hint(&self) -> Color { self.hint.0 } - pub fn room_unread(&self) -> Color { self.room_unread.0 } - pub fn room_team(&self) -> Color { self.room_team.0 } - pub fn msg_timestamp(&self) -> Color { self.msg_timestamp.0 } - pub fn log_error(&self) -> Color { self.log_error.0 } - pub fn log_warn(&self) -> Color { self.log_warn.0 } - pub fn log_info(&self) -> Color { self.log_info.0 } - pub fn log_debug(&self) -> Color { self.log_debug.0 } - pub fn log_trace(&self) -> Color { self.log_trace.0 } - pub fn compose_status(&self) -> Color { self.compose_status.0 } + pub fn text_primary(&self) -> Color { + self.text_primary.0 + } + pub fn text_muted(&self) -> Color { + self.text_muted.0 + } + pub fn accent(&self) -> Color { + self.accent.0 + } + pub fn selection_bg(&self) -> Color { + self.selection_bg.0 + } + pub fn selection_fg(&self) -> Color { + self.selection_fg.0 + } + pub fn border(&self) -> Color { + self.border.0 + } + pub fn border_active(&self) -> Color { + self.border_active.0 + } + pub fn title(&self) -> Color { + self.title.0 + } + pub fn hint(&self) -> Color { + self.hint.0 + } + pub fn room_unread(&self) -> Color { + self.room_unread.0 + } + pub fn room_team(&self) -> Color { + self.room_team.0 + } + pub fn msg_timestamp(&self) -> Color { + self.msg_timestamp.0 + } + pub fn log_error(&self) -> Color { + self.log_error.0 + } + pub fn log_warn(&self) -> Color { + self.log_warn.0 + } + pub fn log_info(&self) -> Color { + self.log_info.0 + } + pub fn log_debug(&self) -> Color { + self.log_debug.0 + } + pub fn log_trace(&self) -> Color { + self.log_trace.0 + } + pub fn compose_status(&self) -> Color { + self.compose_status.0 + } } impl Theme { @@ -193,43 +231,113 @@ impl Default for Roles { } // Default color functions for palette -fn default_black() -> SerializableColor { SerializableColor(Color::Black) } -fn default_red() -> SerializableColor { SerializableColor(Color::Red) } -fn default_green() -> SerializableColor { SerializableColor(Color::Green) } -fn default_yellow() -> SerializableColor { SerializableColor(Color::Yellow) } -fn default_blue() -> SerializableColor { SerializableColor(Color::Blue) } -fn default_magenta() -> SerializableColor { SerializableColor(Color::Magenta) } -fn default_cyan() -> SerializableColor { SerializableColor(Color::Cyan) } -fn default_white() -> SerializableColor { SerializableColor(Color::White) } -fn default_bright_black() -> SerializableColor { SerializableColor(Color::DarkGray) } -fn default_bright_red() -> SerializableColor { SerializableColor(Color::LightRed) } -fn default_bright_green() -> SerializableColor { SerializableColor(Color::LightGreen) } -fn default_bright_yellow() -> SerializableColor { SerializableColor(Color::LightYellow) } -fn default_bright_blue() -> SerializableColor { SerializableColor(Color::LightBlue) } -fn default_bright_magenta() -> SerializableColor { SerializableColor(Color::LightMagenta) } -fn default_bright_cyan() -> SerializableColor { SerializableColor(Color::LightCyan) } -fn default_bright_white() -> SerializableColor { SerializableColor(Color::Gray) } +fn default_black() -> SerializableColor { + SerializableColor(Color::Black) +} +fn default_red() -> SerializableColor { + SerializableColor(Color::Red) +} +fn default_green() -> SerializableColor { + SerializableColor(Color::Green) +} +fn default_yellow() -> SerializableColor { + SerializableColor(Color::Yellow) +} +fn default_blue() -> SerializableColor { + SerializableColor(Color::Blue) +} +fn default_magenta() -> SerializableColor { + SerializableColor(Color::Magenta) +} +fn default_cyan() -> SerializableColor { + SerializableColor(Color::Cyan) +} +fn default_white() -> SerializableColor { + SerializableColor(Color::White) +} +fn default_bright_black() -> SerializableColor { + SerializableColor(Color::DarkGray) +} +fn default_bright_red() -> SerializableColor { + SerializableColor(Color::LightRed) +} +fn default_bright_green() -> SerializableColor { + SerializableColor(Color::LightGreen) +} +fn default_bright_yellow() -> SerializableColor { + SerializableColor(Color::LightYellow) +} +fn default_bright_blue() -> SerializableColor { + SerializableColor(Color::LightBlue) +} +fn default_bright_magenta() -> SerializableColor { + SerializableColor(Color::LightMagenta) +} +fn default_bright_cyan() -> SerializableColor { + SerializableColor(Color::LightCyan) +} +fn default_bright_white() -> SerializableColor { + SerializableColor(Color::Gray) +} // Default color functions for roles - mapping current hardcoded colors -fn default_background() -> SerializableColor { SerializableColor(Color::Reset) } -fn default_text_primary() -> SerializableColor { SerializableColor(Color::Reset) } -fn default_text_muted() -> SerializableColor { SerializableColor(Color::Gray) } -fn default_accent() -> SerializableColor { SerializableColor(Color::LightCyan) } -fn default_selection_bg() -> SerializableColor { SerializableColor(Color::Yellow) } -fn default_selection_fg() -> SerializableColor { SerializableColor(Color::Black) } -fn default_border() -> SerializableColor { SerializableColor(Color::Reset) } -fn default_border_active() -> SerializableColor { SerializableColor(Color::Cyan) } -fn default_title() -> SerializableColor { SerializableColor(Color::LightCyan) } -fn default_hint() -> SerializableColor { SerializableColor(Color::Gray) } -fn default_room_unread() -> SerializableColor { SerializableColor(Color::LightBlue) } -fn default_room_team() -> SerializableColor { SerializableColor(Color::LightCyan) } -fn default_msg_timestamp() -> SerializableColor { SerializableColor(Color::Gray) } -fn default_log_error() -> SerializableColor { SerializableColor(Color::Red) } -fn default_log_warn() -> SerializableColor { SerializableColor(Color::Yellow) } -fn default_log_info() -> SerializableColor { SerializableColor(Color::Blue) } -fn default_log_debug() -> SerializableColor { SerializableColor(Color::Green) } -fn default_log_trace() -> SerializableColor { SerializableColor(Color::Gray) } -fn default_compose_status() -> SerializableColor { SerializableColor(Color::Yellow) } +fn default_background() -> SerializableColor { + SerializableColor(Color::Reset) +} +fn default_text_primary() -> SerializableColor { + SerializableColor(Color::Reset) +} +fn default_text_muted() -> SerializableColor { + SerializableColor(Color::Gray) +} +fn default_accent() -> SerializableColor { + SerializableColor(Color::LightCyan) +} +fn default_selection_bg() -> SerializableColor { + SerializableColor(Color::Yellow) +} +fn default_selection_fg() -> SerializableColor { + SerializableColor(Color::Black) +} +fn default_border() -> SerializableColor { + SerializableColor(Color::Reset) +} +fn default_border_active() -> SerializableColor { + SerializableColor(Color::Cyan) +} +fn default_title() -> SerializableColor { + SerializableColor(Color::LightCyan) +} +fn default_hint() -> SerializableColor { + SerializableColor(Color::Gray) +} +fn default_room_unread() -> SerializableColor { + SerializableColor(Color::LightBlue) +} +fn default_room_team() -> SerializableColor { + SerializableColor(Color::LightCyan) +} +fn default_msg_timestamp() -> SerializableColor { + SerializableColor(Color::Gray) +} +fn default_log_error() -> SerializableColor { + SerializableColor(Color::Red) +} +fn default_log_warn() -> SerializableColor { + SerializableColor(Color::Yellow) +} +fn default_log_info() -> SerializableColor { + SerializableColor(Color::Blue) +} +fn default_log_debug() -> SerializableColor { + SerializableColor(Color::Green) +} +fn default_log_trace() -> SerializableColor { + SerializableColor(Color::Gray) +} +fn default_compose_status() -> SerializableColor { + SerializableColor(Color::Yellow) +} fn default_user_colors() -> Vec { vec![ @@ -247,4 +355,4 @@ fn default_user_colors() -> Vec { SerializableColor(Color::LightMagenta), SerializableColor(Color::LightCyan), ] -} \ No newline at end of file +} diff --git a/src/theme/parser.rs b/src/theme/parser.rs index 530ea19..eb9dd20 100644 --- a/src/theme/parser.rs +++ b/src/theme/parser.rs @@ -1,8 +1,8 @@ //! Theme file parsing utilities use ratatui::style::Color; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::de::Error as DeError; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// Error type for color parsing failures #[derive(Debug, thiserror::Error)] @@ -19,12 +19,12 @@ pub enum ColorParseError { /// Parse a color from various string formats pub fn parse_color(input: &str) -> Result { let input = input.trim(); - + // Handle hex colors if input.starts_with('#') { return parse_hex_color(input); } - + // Handle named colors parse_named_color(input) } @@ -85,7 +85,7 @@ where { // Try to deserialize as string first let value = String::deserialize(deserializer)?; - parse_color(&value).map_err(|e| D::Error::custom(format!("Failed to parse color: {}", e))) + parse_color(&value).map_err(|e| D::Error::custom(format!("Failed to parse color: {e}"))) } /// Wrapper for Color that implements serde traits @@ -122,7 +122,7 @@ impl Serialize for SerializableColor { // Serialize Color as hex string when possible match self.0 { Color::Rgb(r, g, b) => { - let hex = format!("#{:02x}{:02x}{:02x}", r, g, b); + let hex = format!("#{r:02x}{g:02x}{b:02x}"); serializer.serialize_str(&hex) } Color::Reset => serializer.serialize_str("reset"), @@ -142,7 +142,7 @@ impl Serialize for SerializableColor { Color::LightMagenta => serializer.serialize_str("light_magenta"), Color::LightCyan => serializer.serialize_str("light_cyan"), Color::White => serializer.serialize_str("white"), - Color::Indexed(i) => serializer.serialize_str(&format!("indexed_{}", i)), + Color::Indexed(i) => serializer.serialize_str(&format!("indexed_{i}")), } } } @@ -178,4 +178,4 @@ mod tests { assert!(parse_color("invalid_color").is_err()); assert!(parse_color("").is_err()); } -} \ No newline at end of file +} diff --git a/src/theme/tests.rs b/src/theme/tests.rs index f6bcf07..3467c59 100644 --- a/src/theme/tests.rs +++ b/src/theme/tests.rs @@ -3,8 +3,8 @@ #[cfg(test)] mod tests { use super::super::load_theme; - use crate::theme::parser::parse_color; use crate::theme::models::Theme; + use crate::theme::parser::parse_color; use ratatui::style::Color; #[test] @@ -33,7 +33,7 @@ user_colors: - \"#ff5555\" - \"#50fa7b\" "; - + let theme: Theme = serde_yaml::from_str(yaml_content).unwrap(); assert_eq!(theme.name, "Dracula"); assert_eq!(theme.roles.accent(), Color::Rgb(189, 147, 249)); @@ -47,7 +47,7 @@ user_colors: roles: accent: \"#bd93f9\" "; - + let theme: Theme = serde_yaml::from_str(yaml_content).unwrap(); assert_eq!(theme.name, "Partial"); assert_eq!(theme.roles.accent(), Color::Rgb(189, 147, 249)); @@ -55,4 +55,4 @@ roles: assert_eq!(theme.roles.hint(), Color::Gray); assert!(!theme.user_colors().is_empty()); } -} \ No newline at end of file +} diff --git a/src/ui/logs.rs b/src/ui/logs.rs index 9b0c4c8..ed2da4d 100644 --- a/src/ui/logs.rs +++ b/src/ui/logs.rs @@ -28,6 +28,6 @@ pub(crate) fn draw_logs<'a>(state: &AppState) -> TuiLoggerSmartWidget<'a> { .output_file(true) .output_line(true) .state(&state.log_state) - .border_type(BorderType::Rounded) - .border_style(border_style) + .border_type(BorderType::Rounded) + .border_style(border_style) } diff --git a/src/ui/message_editor.rs b/src/ui/message_editor.rs index b9c317f..09102d9 100644 --- a/src/ui/message_editor.rs +++ b/src/ui/message_editor.rs @@ -54,7 +54,10 @@ pub(crate) fn draw_message_editor<'a>(state: &'a AppState<'a>) -> TextArea<'a> { } else { // Composing a new message vec![ - Span::styled("Type your new message.", Style::default().fg(state.theme.roles.compose_status())), + Span::styled( + "Type your new message.", + Style::default().fg(state.theme.roles.compose_status()), + ), hint, ] } diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 2f57ac6..c648f29 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -100,9 +100,10 @@ fn row_for_message<'a>(state: &AppState, msg: Message, width: u16) -> (Row<'a>, (None, Some(email)) => email, _ => String::from("Unknown"), }; - title_line - .spans - .push(Span::styled(sender, style_for_user(&msg.person_id, &state.theme.user_colors()))); + title_line.spans.push(Span::styled( + sender, + style_for_user(&msg.person_id, &state.theme.user_colors()), + )); // Add message timestamp title_line.spans.push(Span::from(" ")); @@ -112,9 +113,10 @@ fn row_for_message<'a>(state: &AppState, msg: Message, width: u16) -> (Row<'a>, } else if let Some(created) = &msg.created { stamp = human_timestamp(created); } - title_line - .spans - .push(Span::styled(stamp, Style::default().fg(state.theme.roles.msg_timestamp()))); + title_line.spans.push(Span::styled( + stamp, + Style::default().fg(state.theme.roles.msg_timestamp()), + )); // Add message id if state.debug { @@ -132,7 +134,8 @@ fn row_for_message<'a>(state: &AppState, msg: Message, width: u16) -> (Row<'a>, if state.debug { title_line.spans.push(Span::from(" (HTML)")); } - from_read(html.as_bytes(), text_width).unwrap_or_else(|_| String::from("Failed to parse HTML")) + from_read(html.as_bytes(), text_width) + .unwrap_or_else(|_| String::from("Failed to parse HTML")) } (_, Some(markdown), _) => { if state.debug { @@ -176,8 +179,10 @@ fn add_uuid_to_line(id: Option, line: &mut Line<'_>) { .and_then(|dec| String::from_utf8(dec).ok()) .map(|id| id.split('/').nth(4).unwrap().to_string()) { - line.spans - .push(Span::styled(format!(" [{}]", id), Style::default().fg(Color::DarkGray))) + line.spans.push(Span::styled( + format!(" [{id}]"), + Style::default().fg(Color::DarkGray), + )) } } @@ -195,10 +200,10 @@ pub(crate) fn draw_msg_table<'a>(state: &AppState, rect: &Rect) -> (Table<'a>, u .room_and_team_title(&room.id) .unwrap_or_default(); title_line = line_for_room_and_team_title( - ratt, - room.unread, + ratt, + room.unread, state.theme.roles.room_unread(), - state.theme.roles.room_team() + state.theme.roles.room_team(), ); // add the room id to the title if debug is enabled diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 586d4a9..92d9ffe 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -54,15 +54,15 @@ pub(crate) fn render(rect: &mut Frame, state: &mut AppState) { let mut body_constraints = vec![]; let mut rooms_column_index = None; let mut help_column_index = None; - + if state.show_rooms { body_constraints.push(Constraint::Length(ROOMS_LIST_WIDTH)); rooms_column_index = Some(body_constraints.len() - 1); } - + body_constraints.push(Constraint::Min(ACTIVE_ROOM_MIN_WIDTH)); let messages_column_index = body_constraints.len() - 1; - + if state.show_help { body_constraints.push(Constraint::Length(HELP_WIDTH)); help_column_index = Some(body_constraints.len() - 1); @@ -113,7 +113,11 @@ pub(crate) fn render(rect: &mut Frame, state: &mut AppState) { // Help if let Some(help_idx) = help_column_index { - let help = draw_help(&state.actions, state.theme.roles.accent(), state.theme.roles.text_muted()); + let help = draw_help( + &state.actions, + state.theme.roles.accent(), + state.theme.roles.text_muted(), + ); rect.render_widget(help, body_columns[help_idx]); } diff --git a/src/ui/rooms.rs b/src/ui/rooms.rs index 69814ab..53c4746 100644 --- a/src/ui/rooms.rs +++ b/src/ui/rooms.rs @@ -15,27 +15,33 @@ pub(crate) const ROOMS_LIST_WIDTH: u16 = 32; pub(crate) fn draw_rooms_table<'a>(state: &AppState) -> Table<'a> { // highlight pane if it is active or in search mode let border_style = match state.active_pane() { - Some(ActivePane::Rooms) | Some(ActivePane::Search) => Style::default().fg(state.theme.roles.border_active()), + Some(ActivePane::Rooms) | Some(ActivePane::Search) => { + Style::default().fg(state.theme.roles.border_active()) + } _ => Style::default().fg(state.theme.roles.border()), }; - + // Build title based on current mode let title = if let Some(query) = state.rooms_list.search_query() { let selected_count = state.rooms_list.selected_room_count(); if selected_count > 0 { - format!("Search: {} ({} selected)", query, selected_count) + format!("Search: {query} ({selected_count} selected)") } else { - format!("Search: {}", query) + format!("Search: {query}") } } else { let selected_count = state.rooms_list.selected_room_count(); if selected_count > 0 { - format!("Filter: {:?} ({} selected)", state.rooms_list.filter(), selected_count) + format!( + "Filter: {:?} ({} selected)", + state.rooms_list.filter(), + selected_count + ) } else { format!("Filter: {:?}", state.rooms_list.filter()) } }; - + let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) @@ -43,7 +49,11 @@ pub(crate) fn draw_rooms_table<'a>(state: &AppState) -> Table<'a> { .title({ let selected_count = state.rooms_list.selected_room_count(); if selected_count > 0 { - format!("Filter: {:?} ({} selected)", state.rooms_list.filter(), selected_count) + format!( + "Filter: {:?} ({} selected)", + state.rooms_list.filter(), + selected_count + ) } else { title } @@ -56,20 +66,20 @@ pub(crate) fn draw_rooms_table<'a>(state: &AppState) -> Table<'a> { .room_and_team_title(&room.id) .unwrap_or_default(); let line = line_for_room_and_team_title( - ratt, - room.unread, + ratt, + room.unread, state.theme.roles.room_unread(), - state.theme.roles.room_team() + state.theme.roles.room_team(), ); - + // Add selection indicator let selection_indicator = if state.rooms_list.is_room_selected(&room.id) { "☑ " } else { "☐ " }; - - let cell_content = format!("{}{}", selection_indicator, line); + + let cell_content = format!("{selection_indicator}{line}"); Row::new(vec![Cell::from(cell_content)]) }) .collect(); diff --git a/src/ui/style.rs b/src/ui/style.rs index 29aec19..133d180 100644 --- a/src/ui/style.rs +++ b/src/ui/style.rs @@ -9,7 +9,7 @@ use crate::app::cache::room_and_team_title::RoomAndTeamTitle; /// Returns a formatted ratatui line with the room title and team name if any. pub(crate) fn line_for_room_and_team_title<'a>( - ratt: RoomAndTeamTitle, + ratt: RoomAndTeamTitle, unread: bool, room_unread_color: Color, room_team_color: Color, @@ -26,7 +26,7 @@ pub(crate) fn line_for_room_and_team_title<'a>( .push(Span::styled(ratt.room_title.clone(), room_style)); if let Some(team_name) = ratt.team_name.clone() { line.spans.push(Span::styled( - format!(" ({})", team_name), + format!(" ({team_name})"), Style::default().fg(room_team_color), )); }