diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd6b2e0..818a0e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,5 +56,5 @@ jobs: - name: cargo clippy run: cargo clippy --all-targets -# - name: cargo test -# run: cargo test --all-targets + - name: cargo test + run: cargo test --all-targets diff --git a/Cargo.lock b/Cargo.lock index 59d8cc8..2a3f8b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3876,6 +3876,7 @@ dependencies = [ "rfd", "serde", "serde_json", + "tempfile", "tokio", "toml 0.9.12+spec-1.1.0", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 7cb8af6..ff11df8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,3 +36,6 @@ toml = "0.9.8" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } tray-icon = "0.21.3" url = { version = "2.5.8", default-features = false } + +[dev-dependencies] +tempfile = "3.23.0" diff --git a/src/app.rs b/src/app.rs index 6892bdb..6e4d55c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -238,3 +238,63 @@ impl DebouncePolicy for Page { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + #[test] + fn page_display_labels_are_stable() { + assert_eq!(Page::Main.to_string(), "App search"); + assert_eq!(Page::FileSearch.to_string(), "File search"); + assert_eq!(Page::ClipboardHistory.to_string(), "Clipboard history"); + assert_eq!(Page::EmojiSearch.to_string(), "Emoji search"); + assert_eq!(Page::Settings.to_string(), "Settings"); + } + + #[test] + fn page_debounce_policy_matches_expected_pages() { + let config = Config { + debounce_delay: 123, + ..Config::default() + }; + + assert_eq!(Page::Main.debounce_delay(&config), None); + assert_eq!(Page::ClipboardHistory.debounce_delay(&config), None); + assert_eq!(Page::Settings.debounce_delay(&config), None); + assert_eq!( + Page::FileSearch.debounce_delay(&config), + Some(Duration::from_millis(123)) + ); + assert_eq!( + Page::EmojiSearch.debounce_delay(&config), + Some(Duration::from_millis(123)) + ); + } + + #[test] + fn mode_to_apps_adds_default_when_missing() { + let mut modes = HashMap::new(); + modes.insert("work".to_string(), "echo work".to_string()); + + let apps = modes.to_apps(); + + assert!(apps.iter().any(|app| app.search_name == "work")); + assert!(apps.iter().any(|app| app.search_name == "default")); + } + + #[test] + fn mode_to_apps_does_not_duplicate_default() { + let mut modes = HashMap::new(); + modes.insert("default".to_string(), "echo default".to_string()); + + let apps = modes.to_apps(); + let default_count = apps + .iter() + .filter(|app| app.search_name == "default") + .count(); + + assert_eq!(default_count, 1); + } +} diff --git a/src/app/tile.rs b/src/app/tile.rs index 7b131be..7f98e31 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -27,7 +27,7 @@ use objc2::rc::Retained; use objc2_app_kit::NSRunningApplication; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rayon::slice::ParallelSliceMut; -use tokio::io::AsyncBufReadExt; +use tokio::io::{AsyncBufReadExt, AsyncRead}; use tray_icon::TrayIcon; use std::collections::HashMap; @@ -139,6 +139,21 @@ impl AppIndex { } } +fn build_mdfind_args(query: &str, dirs: &[String], home_dir: &str) -> Option> { + assert!(query.len() < 1024, "Query too long."); + if query.len() < 2 { + return None; + } + + let mut args = vec!["-name".to_string(), query.to_string()]; + for dir in dirs { + args.push("-onlyin".to_string()); + args.push(dir.replace("~", home_dir)); + } + + Some(args) +} + /// This is the base window, and its a "Tile" /// Its fields are: /// - Theme ([`iced::Theme`]) @@ -372,8 +387,8 @@ fn handle_clipboard_history() -> impl futures::Stream { /// /// Returns when stdout reaches EOF, the receiver signals a new query, or /// max results are reached. Caller is responsible for process lifetime. -async fn read_mdfind_results( - stdout: tokio::process::ChildStdout, +async fn read_mdfind_results( + stdout: R, home_dir: &str, receiver: &mut tokio::sync::watch::Receiver<(String, Vec)>, output: &mut iced::futures::channel::mpsc::Sender, @@ -504,23 +519,11 @@ fn handle_file_search() -> impl futures::Stream { child = None; let (query, dirs) = receiver.borrow_and_update().clone(); - assert!(query.len() < 1024, "Query too long."); - if query.len() < 2 { + let Some(args) = build_mdfind_args(&query, &dirs, &home_dir) else { output.send(Message::FileSearchClear).await.ok(); continue; - } - - // The query is passed as a -name argument to mdfind. mdfind interprets - // this as a substring match on filenames — not as a glob or shell expression. - // Passed via args (not shell), so no shell injection risk. - // When dirs is empty, omit -onlyin so mdfind searches system-wide. - let mut args: Vec = vec!["-name".to_string(), query.clone()]; - for dir in &dirs { - let expanded = dir.replace("~", &home_dir); - args.push("-onlyin".to_string()); - args.push(expanded); - } + }; let mut command = tokio::process::Command::new("mdfind"); command.args(&args); @@ -571,6 +574,176 @@ fn handle_file_search() -> impl futures::Stream { }) } +#[cfg(test)] +#[allow(clippy::items_after_test_module)] +mod tests { + use super::*; + use crate::app::apps::{App, AppCommand}; + use crate::commands::Function; + use iced::futures::StreamExt; + use tokio::io::{AsyncWriteExt, duplex}; + + fn test_app(name: &str, ranking: i32) -> App { + App { + ranking, + open_command: AppCommand::Function(Function::OpenApp(format!( + "/Applications/{name}.app" + ))), + desc: "Application".to_string(), + icons: None, + display_name: name.to_string(), + search_name: name.to_lowercase(), + } + } + + #[test] + fn app_index_search_prefix_matches_prefix_and_word_boundaries() { + let index = AppIndex::from_apps(vec![ + test_app("Safari", 0), + App { + search_name: "visual studio code".to_string(), + display_name: "Visual Studio Code".to_string(), + ..test_app("Visual Studio Code", 0) + }, + App { + search_name: "signal-desktop".to_string(), + display_name: "Signal Desktop".to_string(), + ..test_app("Signal Desktop", 0) + }, + ]); + + let prefix_results: Vec<_> = index + .search_prefix("sa") + .map(|app| app.display_name.clone()) + .collect(); + let spaced_results: Vec<_> = index + .search_prefix("studio") + .map(|app| app.display_name.clone()) + .collect(); + let hyphen_results: Vec<_> = index + .search_prefix("desktop") + .map(|app| app.display_name.clone()) + .collect(); + + assert_eq!(prefix_results, vec!["Safari".to_string()]); + assert_eq!(spaced_results, vec!["Visual Studio Code".to_string()]); + assert_eq!(hyphen_results, vec!["Signal Desktop".to_string()]); + } + + #[test] + fn app_index_ranking_helpers_work() { + let mut index = AppIndex::from_apps(vec![ + test_app("Safari", 1), + test_app("Notes", -1), + test_app("Arc", 3), + test_app("Alfred", 3), + ]); + + index.update_ranking("safari"); + index.set_ranking("notes", -1); + + assert_eq!(index.get_rankings().get("safari"), Some(&2)); + assert_eq!(index.get_rankings().get("notes"), None); + + let top_ranked = index.top_ranked(2); + assert_eq!(top_ranked.len(), 2); + assert_eq!(top_ranked[0].display_name, "Alfred"); + assert_eq!(top_ranked[1].display_name, "Arc"); + + let favourites = index.get_favourites(); + assert_eq!(favourites.len(), 1); + assert_eq!(favourites[0].display_name, "Notes"); + } + + #[test] + fn build_mdfind_args_expands_home_and_omits_onlyin_for_empty_dirs() { + assert_eq!( + build_mdfind_args("ab", &[], "/Users/test").unwrap(), + vec!["-name".to_string(), "ab".to_string()] + ); + + assert_eq!( + build_mdfind_args( + "report", + &[String::from("~/Documents"), String::from("/tmp")], + "/Users/test" + ) + .unwrap(), + vec![ + "-name".to_string(), + "report".to_string(), + "-onlyin".to_string(), + "/Users/test/Documents".to_string(), + "-onlyin".to_string(), + "/tmp".to_string(), + ] + ); + } + + #[test] + fn build_mdfind_args_rejects_short_queries() { + assert!(build_mdfind_args("a", &[], "/Users/test").is_none()); + } + + #[tokio::test] + async fn read_mdfind_results_batches_and_limits_results() { + let total_lines = crate::app::FILE_SEARCH_BATCH_SIZE + 1; + let input = (0..=total_lines) + .map(|idx| format!("/Users/test/Documents/file-{idx}.txt\n")) + .collect::(); + + let (mut writer, reader) = duplex(16 * 1024); + writer.write_all(input.as_bytes()).await.unwrap(); + drop(writer); + + let (_notify_sender, mut receiver) = + tokio::sync::watch::channel((String::new(), Vec::::new())); + receiver.borrow_and_update(); + let (mut sender, mut out_receiver) = iced::futures::channel::mpsc::channel(16); + + let canceled = read_mdfind_results(reader, "/Users/test", &mut receiver, &mut sender).await; + assert!(!canceled); + + drop(sender); + + let mut batches = Vec::new(); + while let Some(message) = out_receiver.next().await { + if let Message::FileSearchResult(apps) = message { + batches.push(apps); + } + } + + assert_eq!(batches.len(), 2); + assert_eq!(batches[0].len() as u32, crate::app::FILE_SEARCH_BATCH_SIZE); + assert_eq!(batches[1].len(), 2); + assert_eq!(batches[0][0].desc, "~/Documents/file-0.txt"); + } + + #[tokio::test] + async fn read_mdfind_results_cancels_when_query_changes() { + let (notify_sender, mut receiver) = + tokio::sync::watch::channel((String::new(), Vec::::new())); + receiver.borrow_and_update(); + + let (mut out_sender, _out_receiver) = iced::futures::channel::mpsc::channel(4); + let (writer, reader) = duplex(1024); + + let task = tokio::spawn(async move { + read_mdfind_results(reader, "/Users/test", &mut receiver, &mut out_sender).await + }); + + // Keep the writer alive so the read loop waits on the watch channel. + let _writer = writer; + + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + notify_sender + .send((String::from("next"), Vec::::new())) + .unwrap(); + + assert!(task.await.unwrap()); + } +} + /// Handles the rx / receiver for sending and receiving messages fn handle_recipient() -> impl futures::Stream { stream::channel(100, async |mut output| { diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index f9dd999..cd345d2 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -53,6 +53,52 @@ fn extract_target(url: &Url) -> Option { .map(|(_, value)| value.into_owned()) } +#[derive(Debug, Clone, PartialEq)] +enum QueryAction { + OpenWebsite(String), + UnitConversions(Vec), + Calculation(Expr), + GoogleSearch(String), + ShellCommand(String), + ShowFavourites, + SwitchToPage(Page), +} + +fn classify_query_action(page: &Page, query: &str, query_lc: &str) -> Option { + match query_lc { + "cbhist" => return Some(QueryAction::SwitchToPage(Page::ClipboardHistory)), + "main" if *page != Page::Main => return Some(QueryAction::SwitchToPage(Page::Main)), + "fav" => return Some(QueryAction::ShowFavourites), + _ => {} + } + + if query_lc.starts_with('>') && *page == Page::Main { + return Some(QueryAction::ShellCommand( + query.strip_prefix('>').unwrap_or("").to_string(), + )); + } + + if is_valid_url(query) { + Some(QueryAction::OpenWebsite(query.to_string())) + } else if let Some(conversions) = unit_conversion::convert_query(query) { + Some(QueryAction::UnitConversions(conversions)) + } else if let Ok(expr) = Expr::from_str(query) { + Some(QueryAction::Calculation(expr)) + } else if query.ends_with('?') || query.split_whitespace().nth(2).is_some() { + Some(QueryAction::GoogleSearch(query.to_string())) + } else { + None + } +} + +fn message_for_open_command(command: &AppCommand) -> Message { + match command { + AppCommand::Function(func) => Message::RunFunction(func.clone()), + AppCommand::Message(msg) => msg.clone(), + AppCommand::Display => Message::ReturnFocus, + } +} + /// Handle the "elm" update pub fn handle_update(tile: &mut Tile, message: Message) -> Task { match message { @@ -935,18 +981,18 @@ fn open_result(tile: &mut Tile, id: usize) -> Task { let search_name = app.search_name.clone(); - match app.open_command { - AppCommand::Function(func) => { + match &app.open_command { + AppCommand::Function(_) => { info!("Updating ranking for: {search_name}"); tile.options.update_ranking(&search_name); - Task::done(Message::RunFunction(func)) + Task::done(message_for_open_command(&app.open_command)) } - AppCommand::Message(msg) => { + AppCommand::Message(_) => { info!("Updating ranking for: {search_name}"); tile.options.update_ranking(&search_name); - Task::done(msg) + Task::done(message_for_open_command(&app.open_command)) } - AppCommand::Display => Task::done(Message::ReturnFocus), + AppCommand::Display => Task::done(message_for_open_command(&app.open_command)), } } @@ -1043,35 +1089,43 @@ fn execute_query(tile: &mut Tile, id: Id) -> Task { }]; return single_item_resize_task(id); } - "cbhist" => { - task = task.chain(Task::done(Message::SwitchToPage(Page::ClipboardHistory))); - } - "main" => { - if tile.page != Page::Main { - task = task.chain(Task::done(Message::SwitchToPage(Page::Main))); - return Task::batch([zero_item_resize_task(id), task]); + _ => {} + } + + let deferred_action = if let Some(action) = + classify_query_action(&tile.page, &tile.query, &tile.query_lc) + { + match action { + QueryAction::SwitchToPage(page) => { + task = task.chain(Task::done(Message::SwitchToPage(page.clone()))); + if page == Page::Main { + return Task::batch([zero_item_resize_task(id), task]); + } + None } - } - "fav" => { - tile.results = tile.options.get_favourites(); - return resize_for_results_count(id, tile.results.len()); - } - query => 'a: { - if !query.starts_with(">") || tile.page != Page::Main { - break 'a; + QueryAction::ShowFavourites => { + tile.results = tile.options.get_favourites(); + return resize_for_results_count(id, tile.results.len()); } - let command = tile.query.strip_prefix(">").unwrap_or(""); - tile.results = vec![App { - ranking: 20, - open_command: AppCommand::Function(Function::RunShellCommand(command.to_string())), - display_name: format!("Shell Command: {}", command), - icons: None, - search_name: "".to_string(), - desc: "Shell Command".to_string(), - }]; - return single_item_resize_task(id); + QueryAction::ShellCommand(command) => { + tile.results = vec![App { + ranking: 20, + open_command: AppCommand::Function(Function::RunShellCommand(command.clone())), + display_name: format!("Shell Command: {}", command), + icons: None, + search_name: "".to_string(), + desc: "Shell Command".to_string(), + }]; + return single_item_resize_task(id); + } + QueryAction::OpenWebsite(_) + | QueryAction::UnitConversions(_) + | QueryAction::Calculation(_) + | QueryAction::GoogleSearch(_) => Some(action), } - } + } else { + None + }; match tile.page { Page::FileSearch => { @@ -1118,41 +1172,202 @@ fn execute_query(tile: &mut Tile, id: Id) -> Task { ])); } - if is_valid_url(&tile.query) { - tile.results.push(App { - ranking: 0, - open_command: AppCommand::Function(Function::OpenWebsite(tile.query.clone())), - desc: "Web Browsing".to_string(), - icons: None, - display_name: "Open Website: ".to_string() + &tile.query, - search_name: String::new(), - }); - } else if let Some(conversions) = unit_conversion::convert_query(&tile.query) { - tile.results = conversions - .into_iter() - .map(|conversion| conversion.to_app()) - .collect(); - return single_item_resize_task(id); - } else if let Ok(res) = Expr::from_str(&tile.query) { - tile.results.push(App { - ranking: 0, - open_command: AppCommand::Function(Function::Calculate(res.clone())), - desc: RUSTCAST_DESC_NAME.to_string(), - icons: None, - display_name: res.eval().map(|x| x.to_string()).unwrap_or("".to_string()), - search_name: "".to_string(), - }); - return single_item_resize_task(id); - } else if tile.query.ends_with("?") || tile.query.split_whitespace().nth(2).is_some() { - tile.results = vec![App { - ranking: 0, - open_command: AppCommand::Function(Function::GoogleSearch(tile.query.clone())), - icons: None, - desc: "Web Search".to_string(), - display_name: format!("Search for: {}", tile.query), - search_name: String::new(), - }]; - return single_item_resize_task(id); + if let Some(action) = deferred_action { + match action { + QueryAction::OpenWebsite(url) => { + tile.results.push(App { + ranking: 0, + open_command: AppCommand::Function(Function::OpenWebsite(url.clone())), + desc: "Web Browsing".to_string(), + icons: None, + display_name: "Open Website: ".to_string() + &url, + search_name: String::new(), + }); + } + QueryAction::UnitConversions(conversions) => { + tile.results = conversions + .into_iter() + .map(|conversion| conversion.to_app()) + .collect(); + return single_item_resize_task(id); + } + QueryAction::Calculation(res) => { + tile.results.push(App { + ranking: 0, + open_command: AppCommand::Function(Function::Calculate(res.clone())), + desc: RUSTCAST_DESC_NAME.to_string(), + icons: None, + display_name: res.eval().map(|x| x.to_string()).unwrap_or_default(), + search_name: "".to_string(), + }); + return single_item_resize_task(id); + } + QueryAction::GoogleSearch(query) => { + tile.results = vec![App { + ranking: 0, + open_command: AppCommand::Function(Function::GoogleSearch(query.clone())), + icons: None, + desc: "Web Search".to_string(), + display_name: format!("Search for: {}", query), + search_name: String::new(), + }]; + return single_item_resize_task(id); + } + QueryAction::SwitchToPage(_) + | QueryAction::ShowFavourites + | QueryAction::ShellCommand(_) => unreachable!(), + } } + task } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::tile::{AppIndex, Hotkeys}; + use crate::config::{Buffer, Theme}; + use crate::platform::macos::launching::Shortcut; + + fn test_app(search_name: &str, command: AppCommand, ranking: i32) -> App { + App { + ranking, + open_command: command, + desc: "Application".to_string(), + icons: None, + display_name: search_name.to_string(), + search_name: search_name.to_string(), + } + } + + fn test_tile(results: Vec) -> Tile { + Tile { + theme: iced::Theme::Dark, + focus_id: 0, + query: String::new(), + current_mode: "Default".to_string(), + update_available: false, + ranking: HashMap::new(), + query_lc: String::new(), + results, + options: AppIndex::from_apps(vec![ + test_app( + "openable", + AppCommand::Function(Function::OpenApp( + "/Applications/Openable.app".to_string(), + )), + 0, + ), + test_app( + "message", + AppCommand::Message(Message::SwitchToPage(Page::Settings)), + 0, + ), + test_app("display", AppCommand::Display, 0), + ]), + emoji_apps: AppIndex::empty(), + visible: true, + focused: true, + frontmost: None, + config: Config { + buffer_rules: Buffer { + clear_on_hide: true, + clear_on_enter: true, + }, + theme: Theme::default(), + ..Config::default() + }, + hotkeys: Hotkeys { + toggle: Shortcut::parse("alt+space").unwrap(), + clipboard_hotkey: Shortcut::parse("cmd+shift+c").unwrap(), + shells: HashMap::new(), + }, + clipboard_content: Vec::new(), + tray_icon: None, + sender: None, + page: Page::Main, + height: DEFAULT_WINDOW_HEIGHT, + file_search_sender: None, + debouncer: crate::debounce::Debouncer::new(10), + } + } + + #[test] + fn extract_target_reads_target_query_parameter() { + let url = Url::parse("rustcast://open?target=safari").unwrap(); + assert_eq!(extract_target(&url), Some("safari".to_string())); + } + + #[test] + fn classify_query_action_matches_special_cases() { + assert_eq!( + classify_query_action(&Page::Main, "example.com", "example.com"), + Some(QueryAction::OpenWebsite("example.com".to_string())) + ); + assert!(matches!( + classify_query_action(&Page::Main, "2 + 2", "2 + 2"), + Some(QueryAction::Calculation(_)) + )); + assert!(matches!( + classify_query_action(&Page::Main, "12 cm to in", "12 cm to in"), + Some(QueryAction::UnitConversions(_)) + )); + assert_eq!( + classify_query_action(&Page::Main, "find me something", "find me something"), + Some(QueryAction::GoogleSearch("find me something".to_string())) + ); + assert_eq!( + classify_query_action(&Page::Main, ">echo test", ">echo test"), + Some(QueryAction::ShellCommand("echo test".to_string())) + ); + assert_eq!( + classify_query_action(&Page::Main, "fav", "fav"), + Some(QueryAction::ShowFavourites) + ); + assert_eq!( + classify_query_action(&Page::Settings, "main", "main"), + Some(QueryAction::SwitchToPage(Page::Main)) + ); + } + + #[test] + fn message_for_open_command_maps_variants_without_side_effects() { + assert!(matches!( + message_for_open_command(&AppCommand::Function(Function::QuitAllApps)), + Message::RunFunction(Function::QuitAllApps) + )); + assert!(matches!( + message_for_open_command(&AppCommand::Message(Message::ReloadConfig)), + Message::ReloadConfig + )); + assert!(matches!( + message_for_open_command(&AppCommand::Display), + Message::ReturnFocus + )); + } + + #[test] + fn open_result_updates_ranking_only_for_actionable_results() { + let mut tile = test_tile(vec![ + test_app( + "openable", + AppCommand::Function(Function::OpenApp("/Applications/Openable.app".to_string())), + 0, + ), + test_app( + "message", + AppCommand::Message(Message::SwitchToPage(Page::Settings)), + 0, + ), + test_app("display", AppCommand::Display, 0), + ]); + + let _ = open_result(&mut tile, 0); + let _ = open_result(&mut tile, 1); + let _ = open_result(&mut tile, 2); + + assert_eq!(tile.options.get_rankings().get("openable"), Some(&1)); + assert_eq!(tile.options.get_rankings().get("message"), Some(&1)); + assert_eq!(tile.options.get_rankings().get("display"), None); + } +} diff --git a/src/calculator.rs b/src/calculator.rs index c1ba3ec..db9ec73 100644 --- a/src/calculator.rs +++ b/src/calculator.rs @@ -98,6 +98,7 @@ impl Expr { } } + #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Result { let mut p = Parser::new(s); let expr = p.parse_expr()?; @@ -386,3 +387,45 @@ impl<'a> Parser<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn eval(input: &str) -> f64 { + Expr::from_str(input).unwrap().eval().unwrap() + } + + fn approx_eq(left: f64, right: f64) { + assert!((left - right).abs() < 1e-9, "{left} != {right}"); + } + + #[test] + fn calculator_respects_precedence_and_parentheses() { + approx_eq(eval("2 + 3 * 4"), 14.0); + approx_eq(eval("2^(1+2)"), 8.0); + approx_eq(eval("-(3 + 4)"), -7.0); + } + + #[test] + fn calculator_supports_logs_and_unary_ops() { + approx_eq(eval("ln(2.718281828459045)"), 1.0); + approx_eq(eval("log(1000)"), 3.0); + approx_eq(eval("log(2, 8)"), 3.0); + approx_eq(eval("+5"), 5.0); + } + + #[test] + fn calculator_handles_scientific_notation() { + approx_eq(eval("1e2 + 5"), 105.0); + } + + #[test] + fn calculator_rejects_invalid_input() { + assert!(Expr::from_str("2 +").is_err()); + assert!(Expr::from_str("foo(1)").is_ok()); + assert!(Expr::from_str("foo(1)").unwrap().eval().is_none()); + assert!(Expr::from_str("log(1, 2, 3)").unwrap().eval().is_none()); + assert!(Expr::from_str(")").is_err()); + } +} diff --git a/src/clipboard.rs b/src/clipboard.rs index 4b0d225..bee6614 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -56,3 +56,32 @@ impl PartialEq for ClipBoardContentType { false } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clipboard_text_equality_is_content_based() { + assert_eq!( + ClipBoardContentType::Text("hello".to_string()), + ClipBoardContentType::Text("hello".to_string()) + ); + assert_ne!( + ClipBoardContentType::Text("hello".to_string()), + ClipBoardContentType::Text("world".to_string()) + ); + } + + #[test] + fn clipboard_to_app_truncates_and_uses_first_line_for_display() { + let item = + ClipBoardContentType::Text("abcdefghijklmnopqrstuvwxyz\nsecond line".to_string()); + + let app = item.to_app(); + + assert_eq!(app.display_name, "abcdefghijklmnopqrstuvwxy"); + assert_eq!(app.search_name, "abcdefghijklmnopqrstuvwxy"); + assert_eq!(app.desc, "Clipboard Item"); + } +} diff --git a/src/commands.rs b/src/commands.rs index a4d6d29..5c07dc8 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -142,3 +142,29 @@ pub fn path_to_app(absolute_path: &str, home_dir: &str) -> Option { search_name: filename.to_lowercase(), }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_to_app_rewrites_home_prefix_and_uses_filename() { + let app = path_to_app("/Users/test/Documents/report.pdf", "/Users/test").unwrap(); + + assert_eq!(app.display_name, "report.pdf"); + assert_eq!(app.search_name, "report.pdf"); + assert_eq!(app.desc, "~/Documents/report.pdf"); + assert!(matches!( + app.open_command, + AppCommand::Function(Function::OpenApp(path)) + if path == "/Users/test/Documents/report.pdf" + )); + } + + #[test] + fn path_to_app_rejects_empty_and_dotfile_paths() { + assert!(path_to_app("", "/Users/test").is_none()); + assert!(path_to_app("/Users/test/.env", "/Users/test").is_none()); + assert!(path_to_app(" ", "/Users/test").is_none()); + } +} diff --git a/src/config.rs b/src/config.rs index 41de249..1cc5e62 100644 --- a/src/config.rs +++ b/src/config.rs @@ -231,3 +231,27 @@ impl ToApp for Shelly { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_default_values_match_expected_defaults() { + let config = Config::default(); + + assert_eq!(config.toggle_hotkey, "ALT+SPACE"); + assert_eq!(config.clipboard_hotkey, "SUPER+SHIFT+C"); + assert_eq!(config.search_url, "https://duckduckgo.com/search?q=%s"); + assert_eq!(config.search_dirs, vec!["~".to_string()]); + assert_eq!(config.debounce_delay, 300); + assert_eq!(config.main_page, MainPage::Blank); + } + + #[test] + fn main_page_display_labels_are_stable() { + assert_eq!(MainPage::Blank.to_string(), "♥️ Rustcast"); + assert_eq!(MainPage::Favourites.to_string(), "Favourites"); + assert_eq!(MainPage::FrequentlyUsed.to_string(), "Frequently Used"); + } +} diff --git a/src/debounce.rs b/src/debounce.rs index 75e8cd8..f8bb6e0 100644 --- a/src/debounce.rs +++ b/src/debounce.rs @@ -41,3 +41,37 @@ pub trait DebouncePolicy { /// Returns Some(delay_ms) if this page should debounce, None otherwise fn debounce_delay(&self, config: &Config) -> Option; } + +#[cfg(test)] +mod tests { + use super::*; + use std::{thread, time::Duration}; + + #[test] + fn debouncer_becomes_ready_after_delay_and_clears_itself() { + let mut debouncer = Debouncer::new(10); + + assert!(!debouncer.is_ready()); + + debouncer.reset(); + assert!(!debouncer.is_ready()); + + thread::sleep(Duration::from_millis(15)); + assert!(debouncer.is_ready()); + assert!(!debouncer.is_ready()); + } + + #[test] + fn debouncer_reset_restarts_the_timer() { + let mut debouncer = Debouncer::new(20); + + debouncer.reset(); + thread::sleep(Duration::from_millis(10)); + debouncer.reset(); + thread::sleep(Duration::from_millis(10)); + assert!(!debouncer.is_ready()); + + thread::sleep(Duration::from_millis(15)); + assert!(debouncer.is_ready()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..745de89 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,13 @@ +#![deny(clippy::dbg_macro)] + +pub mod app; +pub mod calculator; +pub mod clipboard; +pub mod commands; +pub mod config; +pub mod debounce; +pub mod platform; +pub mod quit; +pub mod styles; +pub mod unit_conversion; +pub mod utils; diff --git a/src/main.rs b/src/main.rs index 82c8863..b0f2aa0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,8 @@ #![deny(clippy::dbg_macro)] -mod app; -mod calculator; -mod clipboard; -mod commands; -mod config; -mod debounce; -mod platform; -mod quit; -mod styles; -mod unit_conversion; -mod utils; - use std::{collections::HashMap, fs::OpenOptions, path::Path}; -use crate::{ +use rustcast::{ app::tile::{self, Hotkeys, Tile}, config::Config, platform::macos::{get_autostart_status, launching::Shortcut}, @@ -23,7 +11,7 @@ use crate::{ use log::info; use tracing_subscriber::{EnvFilter, Layer, util::SubscriberInitExt}; -use self::platform::set_activation_policy_accessory; +use rustcast::platform::set_activation_policy_accessory; fn main() -> iced::Result { set_activation_policy_accessory(); diff --git a/src/platform/cross.rs b/src/platform/cross.rs index c99036c..f51d5a9 100644 --- a/src/platform/cross.rs +++ b/src/platform/cross.rs @@ -1,7 +1,6 @@ use std::{ fs, path::{Path, PathBuf}, - process::exit, }; use log::{error, info}; @@ -38,128 +37,191 @@ pub(crate) fn get_installed_apps(store_icons: bool) -> Vec { /// Is a fallback from the method in [`crate::platform::macos::discovery::get_installed_apps`] /// /// the directories are defined in [`crate::app::tile::elm::new`] -fn discover_apps( - dir: impl AsRef, - store_icons: bool, -) -> impl IntoParallelIterator { +fn discover_apps(dir: impl AsRef, store_icons: bool) -> Vec { info!("Indexing apps started"); - let entries: Vec<_> = fs::read_dir(dir.as_ref()) - .unwrap_or_else(|x| { + let entries = match fs::read_dir(dir.as_ref()) { + Ok(entries) => entries.filter_map(Result::ok).collect::>(), + Err(error) => { error!( "Could not read directry: {} because of:\n{}", dir.as_ref().to_string_lossy(), - x + error ); - exit(1) - }) - .filter_map(|x| x.ok()) - .collect(); - - entries.into_par_iter().filter_map(move |x| { - let file_type = x.file_type().unwrap_or_else(|e| { - error!("Unable to map entries: {}", &e.to_string()); - exit(1) - }); - if !file_type.is_dir() { - return None; + return Vec::new(); } + }; - let file_name_os = x.file_name(); - let file_name = file_name_os.into_string().unwrap_or_else(|e| { - error!( - "Unable to get file name due to: {}", - e.to_str().unwrap_or("") - ); - exit(1) - }); + entries + .into_par_iter() + .filter_map(move |x| { + let file_type = match x.file_type() { + Ok(file_type) => file_type, + Err(error) => { + error!("Unable to map entries: {}", error); + return None; + } + }; + if !file_type.is_dir() { + return None; + } - if !file_name.ends_with(".app") { - return None; - } + let file_name_os = x.file_name(); + let file_name = file_name_os.to_string_lossy().to_string(); - let path = x.path(); - let path_str = path.to_str().map(|x| x.to_string()).unwrap_or_else(|| { - error!("Unable to get file_name"); - exit(1) - }); - - let icons = if store_icons { - match fs::read_to_string(format!("{}/Contents/Info.plist", path_str)).map(|content| { - let icon_line = content - .lines() - .scan(false, |expect_next, line| { - if *expect_next { - *expect_next = false; - // Return this line to the iterator - return Some(Some(line)); - } - - if line.trim() == "CFBundleIconFile" { - *expect_next = true; - } - - // For lines that are not the one after the key, return None to skip - Some(None) - }) - .flatten() // remove the Nones - .next() - .map(|x| { - x.trim() - .strip_prefix("") - .unwrap_or("") - .strip_suffix("") - .unwrap_or("") - }); - - handle_from_icns(Path::new(&format!( - "{}/Contents/Resources/{}", - path_str, - icon_line.unwrap_or("AppIcon.icns") - ))) - }) { - Ok(Some(a)) => Some(a), - _ => { - // Fallback method - let direntry = fs::read_dir(format!("{}/Contents/Resources", path_str)) - .into_iter() - .flatten() - .filter_map(|x| { - let file = x.ok()?; - let name = file.file_name(); - let file_name = name.to_str()?; - if file_name.ends_with(".icns") { - Some(file.path()) - } else { - None - } - }) - .collect::>(); - - if direntry.len() > 1 { - let icns_vec = direntry - .iter() - .filter(|x| x.ends_with("AppIcon.icns")) - .collect::>(); - handle_from_icns(icns_vec.first().unwrap_or(&&PathBuf::new())) - } else if !direntry.is_empty() { - handle_from_icns(direntry.first().unwrap_or(&PathBuf::new())) - } else { - None - } - } + if !file_name.ends_with(".app") { + return None; + } + + let path = x.path(); + let path_str = path.to_string_lossy().to_string(); + + let icons = if store_icons { + find_bundle_icon_path(&path).and_then(|icon_path| handle_from_icns(&icon_path)) + } else { + None + }; + + let name = file_name.strip_suffix(".app").unwrap().to_string(); + Some(App { + ranking: 0, + open_command: AppCommand::Function(Function::OpenApp(path_str)), + desc: "Application".to_string(), + icons, + search_name: name.to_lowercase(), + display_name: name, + }) + }) + .collect() +} + +fn plist_icon_name(contents: &str) -> Option { + contents + .lines() + .scan(false, |expect_next, line| { + if *expect_next { + *expect_next = false; + return Some(Some(line)); + } + + if line.trim() == "CFBundleIconFile" { + *expect_next = true; } - } else { - None - }; - - let name = file_name.strip_suffix(".app").unwrap().to_string(); - Some(App { - ranking: 0, - open_command: AppCommand::Function(Function::OpenApp(path_str)), - desc: "Application".to_string(), - icons, - search_name: name.to_lowercase(), - display_name: name, + + Some(None) }) - }) + .flatten() + .next() + .map(|line| { + line.trim() + .strip_prefix("") + .unwrap_or("") + .strip_suffix("") + .unwrap_or("") + .to_string() + }) + .filter(|line| !line.is_empty()) +} + +fn find_bundle_icon_path(bundle_path: &Path) -> Option { + let resources_dir = bundle_path.join("Contents/Resources"); + let plist_path = bundle_path.join("Contents/Info.plist"); + + if let Ok(contents) = fs::read_to_string(&plist_path) + && let Some(icon_name) = plist_icon_name(&contents) + { + let icon_path = resources_dir.join(icon_name); + if icon_path.exists() { + return Some(icon_path); + } + } + + let icns_files = fs::read_dir(resources_dir) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "icns")) + .collect::>(); + + if icns_files.len() > 1 { + icns_files + .iter() + .find(|path| path.file_name().is_some_and(|name| name == "AppIcon.icns")) + .cloned() + .or_else(|| icns_files.first().cloned()) + } else { + icns_files.first().cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn create_app_bundle(base: &Path, name: &str, plist: Option<&str>) -> PathBuf { + let bundle_path = base.join(format!("{name}.app")); + fs::create_dir_all(bundle_path.join("Contents/Resources")).unwrap(); + if let Some(plist) = plist { + fs::write(bundle_path.join("Contents/Info.plist"), plist).unwrap(); + } + bundle_path + } + + #[test] + fn plist_icon_name_extracts_icon_file() { + let plist = r#" + + CFBundleIconFile + Custom.icns + + "#; + + assert_eq!(plist_icon_name(plist), Some("Custom.icns".to_string())); + } + + #[test] + fn find_bundle_icon_path_prefers_plist_icon_then_appicon() { + let dir = tempdir().unwrap(); + let bundle = create_app_bundle( + dir.path(), + "Test", + Some( + r#" + CFBundleIconFile + Custom.icns + "#, + ), + ); + + let custom_icon = bundle.join("Contents/Resources/Custom.icns"); + let app_icon = bundle.join("Contents/Resources/AppIcon.icns"); + fs::write(&custom_icon, b"icon").unwrap(); + fs::write(&app_icon, b"fallback").unwrap(); + + assert_eq!(find_bundle_icon_path(&bundle), Some(custom_icon)); + } + + #[test] + fn find_bundle_icon_path_falls_back_to_appicon_when_needed() { + let dir = tempdir().unwrap(); + let bundle = create_app_bundle(dir.path(), "Test", None); + let app_icon = bundle.join("Contents/Resources/AppIcon.icns"); + fs::write(&app_icon, b"fallback").unwrap(); + + assert_eq!(find_bundle_icon_path(&bundle), Some(app_icon)); + } + + #[test] + fn discover_apps_skips_invalid_entries_instead_of_exiting() { + let dir = tempdir().unwrap(); + create_app_bundle(dir.path(), "Safari", None); + fs::create_dir_all(dir.path().join("NotAnApp")).unwrap(); + fs::write(dir.path().join("notes.txt"), b"ignore").unwrap(); + + let apps = discover_apps(dir.path(), false); + + assert_eq!(apps.len(), 1); + assert_eq!(apps[0].display_name, "Safari"); + assert_eq!(apps[0].search_name, "safari"); + } } diff --git a/src/platform/macos/discovery.rs b/src/platform/macos/discovery.rs index d0dce52..52bbe3e 100644 --- a/src/platform/macos/discovery.rs +++ b/src/platform/macos/discovery.rs @@ -175,34 +175,54 @@ static USER_APP_DIRECTORIES: LazyLock<&'static [&'static Path]> = LazyLock::new( Box::leak(Box::new([items[0], items[1], home_apps])) }); -/// Checks if an app path is in a trusted user-facing application directory. -fn is_in_user_app_directory(path: &Path) -> bool { - USER_APP_DIRECTORIES - .iter() +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct AppBundleMetadata { + bundle_display_name: Option, + bundle_name: Option, + application_category: Option, + background_only: bool, +} + +fn is_in_user_app_directory_with<'a>( + path: &Path, + directories: impl IntoIterator, +) -> bool { + directories + .into_iter() .any(|directory| path.starts_with(directory)) } -/// Extracts application metadata from a bundle URL. -/// -/// Queries the bundle's `Info.plist` for display name and icon, with the -/// following fallback chain for the app name: -/// 1. `CFBundleDisplayName` - localized display name -/// 2. `CFBundleName` - short bundle name -/// 3. File stem from path (e.g., "Safari" from "Safari.app") -/// -/// # Returns -/// -/// `Some(App)` if the bundle is valid and has a determinable name, `None` otherwise. -fn query_app(url: impl AsRef, store_icons: bool) -> Option { - let url = url.as_ref(); - let path = url.to_file_path()?; - if is_nested_inside_another_app(&path) || is_helper_location(&path) { - return None; +fn should_include_app(path: &Path, metadata: &AppBundleMetadata) -> bool { + should_include_app_with(path, metadata, USER_APP_DIRECTORIES.iter().copied()) +} + +fn should_include_app_with<'a>( + path: &Path, + metadata: &AppBundleMetadata, + directories: impl IntoIterator, +) -> bool { + if is_nested_inside_another_app(path) || is_helper_location(path) || metadata.background_only { + return false; } - let bundle = NSBundle::bundleWithURL(url)?; - let info = bundle.infoDictionary()?; + if !is_in_user_app_directory_with(path, directories) && metadata.application_category.is_none() + { + return false; + } + true +} + +fn select_app_name(path: &Path, metadata: &AppBundleMetadata) -> Option { + path.file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .or_else(|| metadata.bundle_display_name.clone()) + .or_else(|| metadata.bundle_name.clone()) +} + +fn extract_bundle_metadata( + info: &NSDictionary, +) -> AppBundleMetadata { let get_string = |key: &NSString| -> Option { info.objectForKey(key)? .downcast::() @@ -213,7 +233,6 @@ fn query_app(url: impl AsRef, store_icons: bool) -> Option { let is_truthy = |key: &NSString| -> bool { info.objectForKey(key) .map(|v| { - // Check for boolean true or string "1"/"YES" v.downcast_ref::().is_some_and(|n| n.boolValue()) || v.downcast_ref::().is_some_and(|s| { s.to_string() == "1" || s.to_string().eq_ignore_ascii_case("YES") @@ -222,34 +241,36 @@ fn query_app(url: impl AsRef, store_icons: bool) -> Option { .unwrap_or(false) }; - // Filter out background-only apps (daemons, agents, internal system apps) - if is_truthy(ns_string!("LSBackgroundOnly")) { - return None; + AppBundleMetadata { + bundle_display_name: get_string(ns_string!("CFBundleDisplayName")), + bundle_name: get_string(ns_string!("CFBundleName")), + application_category: get_string(ns_string!("LSApplicationCategoryType")), + background_only: is_truthy(ns_string!("LSBackgroundOnly")), } +} - // For apps outside trusted directories, require LSApplicationCategoryType to be set. - // This filters out internal system apps (SCIM, ShortcutsActions, etc.) while keeping - // user-facing apps like Finder that happen to live in /System/Library/CoreServices/. - if !is_in_user_app_directory(&path) - && get_string(ns_string!("LSApplicationCategoryType")).is_none() - { +/// Extracts application metadata from a bundle URL. +/// +/// Queries the bundle's `Info.plist` for display name and icon, with the +/// following fallback chain for the app name: +/// 1. `CFBundleDisplayName` - localized display name +/// 2. `CFBundleName` - short bundle name +/// 3. File stem from path (e.g., "Safari" from "Safari.app") +/// +/// # Returns +/// +/// `Some(App)` if the bundle is valid and has a determinable name, `None` otherwise. +fn query_app(url: impl AsRef, store_icons: bool) -> Option { + let url = url.as_ref(); + let path = url.to_file_path()?; + let bundle = NSBundle::bundleWithURL(url)?; + let info = bundle.infoDictionary()?; + let metadata = extract_bundle_metadata(&info); + if !should_include_app(&path, &metadata) { return None; } - let plist_name = get_string(ns_string!("CFBundleDisplayName")) - .or_else(|| get_string(ns_string!("CFBundleName"))); - - let file_path_name = path - .file_stem() - .map(|stem| stem.to_string_lossy().into_owned()); - - let name = if let Some(name) = file_path_name { - name - } else if let Some(name) = plist_name { - name - } else { - return None; - }; + let name = select_app_name(&path, &metadata)?; let icon = icon_of_path_ns(path.to_str().unwrap_or(&name)).unwrap_or(vec![]); let icons = if store_icons { @@ -398,3 +419,100 @@ pub fn icon_of_path_ns(path: &str) -> Option> { Some(png_data.to_vec()) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trusted_directory_detection_uses_prefixes() { + let dirs = [ + Path::new("/Applications"), + Path::new("/Users/test/Applications"), + ]; + + assert!(is_in_user_app_directory_with( + Path::new("/Applications/Safari.app"), + dirs.iter().copied() + )); + assert!(is_in_user_app_directory_with( + Path::new("/Users/test/Applications/Arc.app"), + dirs.iter().copied() + )); + assert!(!is_in_user_app_directory_with( + Path::new("/System/Library/CoreServices/Finder.app"), + dirs.iter().copied() + )); + } + + #[test] + fn app_path_filters_nested_and_helper_locations() { + assert!(is_nested_inside_another_app(Path::new( + "/Applications/Foo.app/Contents/Helpers/Bar.app" + ))); + assert!(is_helper_location(Path::new( + "/Applications/Foo.app/Contents/XPCServices/Bar.app" + ))); + assert!(!is_nested_inside_another_app(Path::new( + "/Applications/Foo.app" + ))); + } + + #[test] + fn include_rules_filter_background_and_uncategorized_internal_apps() { + let trusted_dirs = [Path::new("/Applications")]; + let background = AppBundleMetadata { + background_only: true, + ..AppBundleMetadata::default() + }; + let internal = AppBundleMetadata::default(); + let categorized = AppBundleMetadata { + application_category: Some("public.app-category.productivity".to_string()), + ..AppBundleMetadata::default() + }; + + assert!(!should_include_app_with( + Path::new("/Applications/Foo.app"), + &background, + trusted_dirs.iter().copied() + )); + assert!(!should_include_app_with( + Path::new("/System/Library/CoreServices/Finder.app"), + &internal, + trusted_dirs.iter().copied() + )); + assert!(should_include_app_with( + Path::new("/System/Library/CoreServices/Finder.app"), + &categorized, + trusted_dirs.iter().copied() + )); + } + + #[test] + fn app_name_selection_prefers_path_stem_then_bundle_fields() { + let metadata = AppBundleMetadata { + bundle_display_name: Some("Display Name".to_string()), + bundle_name: Some("Bundle Name".to_string()), + ..AppBundleMetadata::default() + }; + + assert_eq!( + select_app_name(Path::new("/Applications/Safari.app"), &metadata), + Some("Safari".to_string()) + ); + assert_eq!( + select_app_name(Path::new(""), &metadata), + Some("Display Name".to_string()) + ); + assert_eq!( + select_app_name( + Path::new(""), + &AppBundleMetadata { + bundle_name: Some("Bundle Name".to_string()), + ..AppBundleMetadata::default() + } + ), + Some("Bundle Name".to_string()) + ); + } +} diff --git a/src/unit_conversion.rs b/src/unit_conversion.rs index a86e17e..c96ca8e 100644 --- a/src/unit_conversion.rs +++ b/src/unit_conversion.rs @@ -17,7 +17,7 @@ pub enum UnitCategory { Temperature, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct UnitDef { pub name: &'static str, pub aliases: &'static [&'static str], @@ -26,7 +26,7 @@ pub struct UnitDef { pub offset: f64, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct ConversionResult { pub source_value: f64, pub source_unit: &'static UnitDef, @@ -414,3 +414,65 @@ fn to_base(value: f64, unit: &UnitDef) -> f64 { fn from_base(value: f64, unit: &UnitDef) -> f64 { value / unit.scale - unit.offset } + +#[cfg(test)] +mod tests { + use super::*; + + fn approx_eq(left: f64, right: f64) { + assert!((left - right).abs() < 1e-9, "{left} != {right}"); + } + + #[test] + fn format_number_trims_trailing_zeroes() { + assert_eq!(format_number(12.340000), "12.34"); + assert_eq!(format_number(7.0), "7"); + assert_eq!(format_number(0.0000000001), "0"); + } + + #[test] + fn parse_number_prefix_supports_signs_and_decimals() { + assert_eq!(parse_number_prefix(" -12.5 kg"), Some(("-12.5", " kg"))); + assert_eq!(parse_number_prefix("+0.75m"), Some(("+0.75", "m"))); + assert_eq!(parse_number_prefix("kg"), None); + } + + #[test] + fn convert_query_returns_specific_target_conversion() { + let results = convert_query("100 c to f").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].source_unit.name, "c"); + assert_eq!(results[0].target_unit.name, "f"); + approx_eq(results[0].target_value, 212.0); + } + + #[test] + fn convert_query_returns_all_units_for_category_without_target() { + let results = convert_query("1 m").unwrap(); + assert!( + results + .iter() + .all(|result| result.source_unit.category == UnitCategory::Length) + ); + assert!(results.iter().any(|result| result.target_unit.name == "km")); + assert!(results.iter().any(|result| result.target_unit.name == "ft")); + } + + #[test] + fn convert_query_rejects_category_mismatches_and_invalid_queries() { + assert!(convert_query("10 kg to m").is_none()); + assert!(convert_query("abc").is_none()); + assert!(convert_query("10").is_none()); + } + + #[test] + fn conversion_base_helpers_round_trip_temperature() { + let celsius = find_unit("c").unwrap(); + let kelvin = find_unit("k").unwrap(); + + let base = to_base(100.0, kelvin); + approx_eq(base, -173.15); + approx_eq(from_base(base, kelvin), 100.0); + approx_eq(from_base(to_base(0.0, celsius), celsius), 0.0); + } +} diff --git a/src/utils.rs b/src/utils.rs index cb59fd1..1128dfa 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -63,3 +63,22 @@ pub fn is_valid_url(s: &str) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use super::is_valid_url; + + #[test] + fn url_validation_accepts_supported_tlds() { + assert!(is_valid_url("example.com")); + assert!(is_valid_url("example.app")); + assert!(is_valid_url("openai.ai")); + } + + #[test] + fn url_validation_rejects_non_urls() { + assert!(!is_valid_url("localhost")); + assert!(!is_valid_url("not a url")); + assert!(!is_valid_url("example.invalidtld")); + } +}