diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..99aa10f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Build & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install protobuf compiler + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Build + run: cargo build --locked --verbose + + - name: Test + run: cargo test --locked --verbose diff --git a/Cargo.lock b/Cargo.lock index 7153341..64d600a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,17 +166,17 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "datacube" -version = "0.1.11" +version = "0.1.12" dependencies = [ "anyhow", "clap", "dirs 6.0.0", + "evalexpr", "freedesktop-desktop-entry", "freedesktop-icons", "futures", "fuzzy-matcher", "libc", - "meval", "notify", "prost", "prost-build", @@ -253,6 +253,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "evalexpr" +version = "13.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25929004897f2bbab309121a60400d36992f6d911d09baa6c172f6cc55706601" + [[package]] name = "fastrand" version = "2.4.1" @@ -271,12 +277,6 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -667,16 +667,6 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" -[[package]] -name = "meval" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9" -dependencies = [ - "fnv", - "nom", -] - [[package]] name = "mio" version = "1.2.1" @@ -695,12 +685,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "nom" -version = "1.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce" - [[package]] name = "notify" version = "8.2.0" diff --git a/Cargo.toml b/Cargo.toml index 394eef6..f6e0f17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "datacube" -version = "0.1.11" +version = "0.1.12" edition = "2021" authors = ["Hypercube Project"] description = "Data provider service for application launchers and desktop utilities" @@ -27,7 +27,7 @@ notify = "8" fuzzy-matcher = "0.3" # Math evaluation -meval = "0.2" +evalexpr = "13" # Serialization serde = { version = "1", features = ["derive"] } diff --git a/datacube.spec b/datacube.spec index 0354661..fb86ff0 100644 --- a/datacube.spec +++ b/datacube.spec @@ -1,7 +1,7 @@ %global crate datacube Name: %{crate} -Version: 0.1.11 +Version: 0.1.12 Release: 1%{?dist} Summary: Data provider service for application launchers and desktop utilities diff --git a/src/config.rs b/src/config.rs index 8146027..74603f7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -151,3 +151,50 @@ impl Config { config_dir.join("datacube").join("config.toml") } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_sane() { + let config = Config::default(); + assert_eq!(config.max_results, 50); + assert!(config.providers.applications.enabled); + assert!(config.providers.calculator.enabled); + assert_eq!(config.providers.calculator.prefix, "="); + assert!(config + .socket_path + .to_string_lossy() + .ends_with("datacube.sock")); + } + + #[test] + fn toml_round_trip() { + let config = Config::default(); + let serialized = toml::to_string(&config).expect("serialize"); + let parsed: Config = toml::from_str(&serialized).expect("deserialize"); + assert_eq!(parsed.max_results, config.max_results); + assert_eq!( + parsed.providers.calculator.prefix, + config.providers.calculator.prefix + ); + assert_eq!(parsed.socket_path, config.socket_path); + } + + #[test] + fn partial_config_uses_defaults() { + // Only override max_results; everything else should fall back to defaults. + let parsed: Config = toml::from_str("max_results = 7").expect("deserialize"); + assert_eq!(parsed.max_results, 7); + assert!(parsed.providers.applications.enabled); + assert_eq!(parsed.providers.calculator.prefix, "="); + } + + #[test] + fn empty_config_is_all_defaults() { + let parsed: Config = toml::from_str("").expect("deserialize"); + assert_eq!(parsed.max_results, 50); + assert!(parsed.providers.applications.enabled); + } +} diff --git a/src/providers/applications.rs b/src/providers/applications.rs index 245ed22..e47b90d 100644 --- a/src/providers/applications.rs +++ b/src/providers/applications.rs @@ -1105,3 +1105,208 @@ impl Provider for ApplicationsProvider { Box::pin(async move { result }) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + /// A self-cleaning temporary directory (avoids pulling in a dev-dependency). + struct TempDir { + path: PathBuf, + } + + impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("datacube-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn write(&self, name: &str, contents: &str) -> PathBuf { + let p = self.path.join(name); + fs::write(&p, contents).unwrap(); + p + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn make_entry(id: &str, name: &str) -> AppEntry { + AppEntry { + id: id.to_string(), + path: PathBuf::from(format!("/usr/share/applications/{id}.desktop")), + name: name.to_string(), + generic_name: None, + comment: None, + icon: "app-icon".to_string(), + icon_path: None, + keywords: Vec::new(), + terminal: false, + launch_count: 0, + source: AppSource::Native, + } + } + + /// Build a provider directly from a set of entries, bypassing the + /// filesystem scan and background loader. + fn provider_with(entries: Vec) -> ApplicationsProvider { + let map: HashMap = + entries.into_iter().map(|e| (e.id.clone(), e)).collect(); + ApplicationsProvider { + apps: Arc::new(RwLock::new(map)), + path_to_id: Arc::new(RwLock::new(HashMap::new())), + matcher: SkimMatcherV2::default(), + extra_dirs: Vec::new(), + watcher: None, + } + } + + #[test] + fn app_source_from_path() { + assert_eq!( + AppSource::from_path(Path::new( + "/var/lib/flatpak/exports/share/applications/org.x.desktop" + )), + AppSource::Flatpak + ); + assert_eq!( + AppSource::from_path(Path::new("/var/lib/snapd/desktop/applications/foo.desktop")), + AppSource::Snap + ); + assert_eq!( + AppSource::from_path(Path::new("/usr/share/applications/foo.desktop")), + AppSource::Native + ); + } + + #[test] + fn app_source_as_str() { + assert_eq!(AppSource::Native.as_str(), "native"); + assert_eq!(AppSource::Flatpak.as_str(), "flatpak"); + assert_eq!(AppSource::Snap.as_str(), "snap"); + } + + #[test] + fn detects_desktop_files() { + assert!(ApplicationsProvider::is_desktop_file(Path::new( + "/a/b/foo.desktop" + ))); + assert!(!ApplicationsProvider::is_desktop_file(Path::new( + "/a/b/foo.txt" + ))); + assert!(!ApplicationsProvider::is_desktop_file(Path::new( + "/a/b/foo" + ))); + } + + #[test] + fn parse_desktop_file_basic() { + let dir = TempDir::new(); + let path = dir.write( + "firefox.desktop", + "[Desktop Entry]\n\ + Type=Application\n\ + Name=Firefox\n\ + GenericName=Web Browser\n\ + Comment=Browse the web\n\ + Exec=/usr/bin/firefox\n\ + Icon=firefox\n\ + Keywords=internet;browser;\n\ + Terminal=false\n", + ); + + let entry = ApplicationsProvider::parse_desktop_file(&path).expect("should parse"); + assert_eq!(entry.id, "firefox"); + assert_eq!(entry.name, "Firefox"); + assert_eq!(entry.generic_name.as_deref(), Some("Web Browser")); + assert_eq!(entry.comment.as_deref(), Some("Browse the web")); + assert_eq!(entry.icon, "firefox"); + assert!(entry.keywords.iter().any(|k| k == "browser")); + assert!(!entry.terminal); + // Icon resolution is deferred - parse leaves it unset. + assert!(entry.icon_path.is_none()); + } + + #[test] + fn parse_desktop_file_skips_nodisplay_and_no_exec() { + let dir = TempDir::new(); + + let hidden = dir.write( + "hidden.desktop", + "[Desktop Entry]\nType=Application\nName=Hidden\nExec=/bin/true\nNoDisplay=true\n", + ); + assert!(ApplicationsProvider::parse_desktop_file(&hidden).is_none()); + + let no_exec = dir.write( + "noexec.desktop", + "[Desktop Entry]\nType=Application\nName=NoExec\n", + ); + assert!(ApplicationsProvider::parse_desktop_file(&no_exec).is_none()); + } + + #[test] + fn load_applications_into_reads_extra_dir() { + let dir = TempDir::new(); + let unique = "datacube-unit-test-app-xyz"; + dir.write( + &format!("{unique}.desktop"), + "[Desktop Entry]\nType=Application\nName=Datacube Unit Test App\nExec=/bin/true\nIcon=x\n", + ); + + let apps = Arc::new(RwLock::new(HashMap::new())); + let path_to_id = Arc::new(RwLock::new(HashMap::new())); + ApplicationsProvider::load_applications_into(&apps, &path_to_id, &[dir.path.clone()]); + + let guard = apps.read().unwrap(); + let entry = guard.get(unique).expect("temp app should be loaded"); + assert_eq!(entry.name, "Datacube Unit Test App"); + } + + #[test] + fn query_matches_by_name() { + let provider = provider_with(vec![ + make_entry("firefox", "Firefox"), + make_entry("gimp", "GIMP"), + make_entry("code", "Visual Studio Code"), + ]); + + let results = provider.query_impl("firefox", 10); + assert!(!results.is_empty()); + assert_eq!(results[0].text, "Firefox"); + assert_eq!(results[0].provider, "applications"); + } + + #[test] + fn query_matches_by_id() { + let mut entry = make_entry("org.mozilla.firefox", "Firefox"); + entry.source = AppSource::Flatpak; + let provider = provider_with(vec![entry, make_entry("gimp", "GIMP")]); + + let results = provider.query_impl("mozilla", 10); + assert_eq!(results.len(), 1); + assert_eq!(results[0].text, "Firefox"); + } + + #[test] + fn query_no_match_is_empty() { + let provider = provider_with(vec![make_entry("firefox", "Firefox")]); + assert!(provider.query_impl("zzzzzznotanapp", 10).is_empty()); + } + + #[test] + fn query_empty_returns_all_up_to_max() { + let provider = provider_with(vec![ + make_entry("a", "Alpha"), + make_entry("b", "Beta"), + make_entry("c", "Gamma"), + ]); + + assert_eq!(provider.query_impl("", 10).len(), 3); + assert_eq!(provider.query_impl("", 2).len(), 2); + } +} diff --git a/src/providers/calculator.rs b/src/providers/calculator.rs index 379a50e..592858c 100644 --- a/src/providers/calculator.rs +++ b/src/providers/calculator.rs @@ -1,7 +1,10 @@ //! Calculator provider - evaluates mathematical expressions use super::{Item, Provider}; -use meval::eval_str; +use evalexpr::{ + eval_with_context, ContextWithMutableFunctions, ContextWithMutableVariables, Function, + HashMapContext, Value, +}; use std::future::Future; use std::pin::Pin; use tracing::debug; @@ -20,24 +23,41 @@ impl CalculatorProvider { if expr.is_empty() { return vec![Item::new("Enter an expression (e.g., 2+2)", "calculator") - .with_subtext("Supports: +, -, *, /, ^, sqrt(), sin(), cos(), tan(), log(), etc.") + .with_subtext( + "Supports: +, -, *, /, ^, %, sqrt(), sin(), cos(), tan(), \ + log(), ln(), and constants pi, e", + ) .with_icon("accessories-calculator") .with_score(1.0)]; } + // evalexpr uses integer division for integer operands (5/2 == 2), which + // is surprising for a calculator. Coerce bare integer literals to floats + // so arithmetic behaves like a calculator (5/2 == 2.5). + let prepared = floatify_int_literals(expr); + let context = build_context(); + // Try to evaluate the expression - match eval_str(expr) { - Ok(result) => { - let result_str = format_result(result); - debug!("Calculator: {} = {}", expr, result_str); - - vec![Item::new(&result_str, "calculator") - .with_subtext(format!("{} =", expr)) - .with_icon("accessories-calculator") - .with_score(1.0) - .with_metadata("expression", expr) - .with_metadata("result", &result_str)] - } + match eval_with_context(&prepared, &context) { + Ok(value) => match format_value(&value) { + Some(result_str) => { + debug!("Calculator: {} = {}", expr, result_str); + + vec![Item::new(&result_str, "calculator") + .with_subtext(format!("{} =", expr)) + .with_icon("accessories-calculator") + .with_score(1.0) + .with_metadata("expression", expr) + .with_metadata("result", &result_str)] + } + None => { + debug!("Calculator: unsupported result type for '{}'", expr); + vec![Item::new("Invalid expression", "calculator") + .with_subtext("Error: unsupported result type") + .with_icon("dialog-error") + .with_score(0.5)] + } + }, Err(e) => { debug!("Calculator error for '{}': {}", expr, e); vec![Item::new("Invalid expression", "calculator") @@ -78,6 +98,143 @@ impl Provider for CalculatorProvider { } } +/// Build an evaluation context exposing common math functions and constants +/// under bare names (e.g. `sqrt`, `pi`) for a familiar calculator experience. +/// +/// evalexpr only ships these under a `math::` namespace and provides no math +/// constants, so we register the friendly names ourselves. +fn build_context() -> HashMapContext { + let mut ctx = HashMapContext::new(); + + // Constants + let _ = ctx.set_value("pi".into(), Value::Float(std::f64::consts::PI)); + let _ = ctx.set_value("e".into(), Value::Float(std::f64::consts::E)); + let _ = ctx.set_value("tau".into(), Value::Float(std::f64::consts::TAU)); + + // Unary f64 -> f64 functions + type UnaryFn = fn(f64) -> f64; + let unary: &[(&str, UnaryFn)] = &[ + ("sqrt", f64::sqrt), + ("cbrt", f64::cbrt), + ("sin", f64::sin), + ("cos", f64::cos), + ("tan", f64::tan), + ("asin", f64::asin), + ("acos", f64::acos), + ("atan", f64::atan), + ("sinh", f64::sinh), + ("cosh", f64::cosh), + ("tanh", f64::tanh), + ("ln", f64::ln), + ("log10", f64::log10), + ("log2", f64::log2), + ("exp", f64::exp), + ("abs", f64::abs), + ("floor", f64::floor), + ("ceil", f64::ceil), + ("round", f64::round), + ]; + for &(name, f) in unary { + let _ = ctx.set_function( + name.into(), + Function::new(move |arg| { + let x = arg.as_number()?; + Ok(Value::Float(f(x))) + }), + ); + } + + // log(x) is base-10; log(base, x) uses an explicit base. + let _ = ctx.set_function( + "log".into(), + Function::new(|arg| match arg.as_fixed_len_tuple(2) { + Ok(tuple) => { + let base: f64 = tuple[0].as_number()?; + let x: f64 = tuple[1].as_number()?; + Ok(Value::Float(x.log(base))) + } + Err(_) => { + let x: f64 = arg.as_number()?; + Ok(Value::Float(x.log10())) + } + }), + ); + + // pow(base, exp) + let _ = ctx.set_function( + "pow".into(), + Function::new(|arg| { + let tuple = arg.as_fixed_len_tuple(2)?; + let base: f64 = tuple[0].as_number()?; + let exp: f64 = tuple[1].as_number()?; + Ok(Value::Float(base.powf(exp))) + }), + ); + + ctx +} + +/// Convert an evaluation result into a display string. +/// Returns `None` for result types that have no meaningful textual form here +/// (empty value, tuples). +fn format_value(value: &Value) -> Option { + match value { + Value::Float(f) => Some(format_result(*f)), + Value::Int(i) => Some(i.to_string()), + Value::Boolean(b) => Some(b.to_string()), + Value::String(s) => Some(s.clone()), + _ => None, + } +} + +/// Append `.0` to standalone integer literals so evalexpr performs floating +/// point arithmetic (e.g. `5/2` -> `5.0/2.0` -> `2.5`). +/// +/// Digits that are part of an identifier (such as the `10` in `log10`) or that +/// already belong to a decimal literal (such as `3.14`) are left untouched. +fn floatify_int_literals(expr: &str) -> String { + let chars: Vec = expr.chars().collect(); + let mut out = String::with_capacity(expr.len()); + let mut i = 0; + + while i < chars.len() { + let c = chars[i]; + if !c.is_ascii_digit() { + out.push(c); + i += 1; + continue; + } + + // A digit run is a number literal (rather than part of an identifier or + // a decimal fraction) only if the preceding char is not alphanumeric, + // `_`, or `.`. + let is_number_literal = match chars.get(i.wrapping_sub(1)) { + _ if i == 0 => true, + Some(p) => !(p.is_alphanumeric() || *p == '_' || *p == '.'), + None => true, + }; + + // Consume the digit run. + let start = i; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + out.extend(&chars[start..i]); + + // Don't floatify if it's already a decimal (`3.14`) or is immediately + // followed by identifier characters (an unusual `2x`-style token). + let next = chars.get(i).copied(); + let followed_by_dot = next == Some('.'); + let followed_by_ident = next.map(|n| n.is_alphabetic() || n == '_').unwrap_or(false); + + if is_number_literal && !followed_by_dot && !followed_by_ident { + out.push_str(".0"); + } + } + + out +} + /// Format a floating point result nicely fn format_result(value: f64) -> String { if value.is_infinite() { @@ -104,6 +261,14 @@ fn format_result(value: f64) -> String { mod tests { use super::*; + fn eval(expr: &str) -> Option { + let prepared = floatify_int_literals(expr); + let context = build_context(); + evalexpr::eval_with_context(&prepared, &context) + .ok() + .and_then(|v| format_value(&v)) + } + #[test] fn test_format_result() { assert_eq!(format_result(42.0), "42"); @@ -111,4 +276,44 @@ mod tests { assert_eq!(format_result(f64::INFINITY), "Infinity"); assert_eq!(format_result(f64::NEG_INFINITY), "-Infinity"); } + + #[test] + fn test_floatify() { + assert_eq!(floatify_int_literals("5/2"), "5.0/2.0"); + assert_eq!(floatify_int_literals("2^10"), "2.0^10.0"); + assert_eq!(floatify_int_literals("3.14"), "3.14"); + assert_eq!(floatify_int_literals("log10(100)"), "log10(100.0)"); + assert_eq!(floatify_int_literals("abs(-5)"), "abs(-5.0)"); + assert_eq!(floatify_int_literals("pi"), "pi"); + } + + #[test] + fn test_basic_arithmetic() { + assert_eq!(eval("2+2").as_deref(), Some("4")); + assert_eq!(eval("5/2").as_deref(), Some("2.5")); + assert_eq!(eval("2*3.5").as_deref(), Some("7")); + assert_eq!(eval("(1+2)*3").as_deref(), Some("9")); + assert_eq!(eval("2^10").as_deref(), Some("1024")); + assert_eq!(eval("10%3").as_deref(), Some("1")); + } + + #[test] + fn test_functions_and_constants() { + assert_eq!(eval("sqrt(2)").as_deref(), Some("1.4142135624")); + assert_eq!(eval("abs(-5)").as_deref(), Some("5")); + assert_eq!(eval("log(100)").as_deref(), Some("2")); + assert_eq!(eval("log2(8)").as_deref(), Some("3")); + assert_eq!(eval("floor(3.7)").as_deref(), Some("3")); + // sin(pi) is ~0 + assert_eq!(eval("round(sin(pi))").as_deref(), Some("0")); + } + + #[test] + fn test_invalid() { + // Unbound functions / unparseable input yield no result. + assert_eq!(eval("notafunc(2)"), None); + assert_eq!(eval("2 +"), None); + // Float division by zero matches the previous (f64) behaviour. + assert_eq!(eval("1/0").as_deref(), Some("Infinity")); + } } diff --git a/src/providers/manager.rs b/src/providers/manager.rs index 33b02f9..bc688db 100644 --- a/src/providers/manager.rs +++ b/src/providers/manager.rs @@ -84,3 +84,125 @@ impl Default for ProviderManager { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::Item; + use std::future::Future; + use std::pin::Pin; + + /// A configurable provider for exercising the manager's routing/sorting. + struct MockProvider { + name: String, + prefix: Option, + /// (text, score) pairs returned for any query. + items: Vec<(&'static str, f32)>, + } + + impl Provider for MockProvider { + fn name(&self) -> &str { + &self.name + } + fn description(&self) -> &str { + "mock provider" + } + fn prefix(&self) -> Option<&str> { + self.prefix.as_deref() + } + fn query( + &self, + _query: &str, + _max_results: usize, + ) -> Pin> + Send + '_>> { + let name = self.name.clone(); + let items: Vec = self + .items + .iter() + .map(|(text, score)| Item::new(*text, name.clone()).with_score(*score)) + .collect(); + Box::pin(async move { items }) + } + } + + fn mock(name: &str, prefix: Option<&str>, items: Vec<(&'static str, f32)>) -> MockProvider { + MockProvider { + name: name.to_string(), + prefix: prefix.map(String::from), + items, + } + } + + #[tokio::test] + async fn registers_and_lists_providers() { + let manager = ProviderManager::new(); + manager.register(mock("alpha", None, vec![])).await; + manager.register(mock("beta", Some("="), vec![])).await; + + let providers = manager.list_providers().await; + assert_eq!(providers.len(), 2); + let names: Vec<_> = providers.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"alpha")); + assert!(names.contains(&"beta")); + } + + #[tokio::test] + async fn query_combines_and_sorts_by_score() { + let manager = ProviderManager::new(); + manager + .register(mock("a", None, vec![("low", 0.1), ("high", 0.9)])) + .await; + manager.register(mock("b", None, vec![("mid", 0.5)])).await; + + let items = manager.query("anything", 10, &[]).await; + let texts: Vec<_> = items.iter().map(|i| i.text.as_str()).collect(); + assert_eq!(texts, vec!["high", "mid", "low"]); + } + + #[tokio::test] + async fn query_truncates_to_max_results() { + let manager = ProviderManager::new(); + manager + .register(mock("a", None, vec![("x", 0.3), ("y", 0.2), ("z", 0.1)])) + .await; + + let items = manager.query("q", 2, &[]).await; + assert_eq!(items.len(), 2); + assert_eq!(items[0].text, "x"); + } + + #[tokio::test] + async fn explicit_provider_filter_is_respected() { + let manager = ProviderManager::new(); + manager + .register(mock("apps", None, vec![("app", 0.5)])) + .await; + manager + .register(mock("calc", Some("="), vec![("calc-result", 0.5)])) + .await; + + // Even without the prefix, an explicit provider request is honoured. + let items = manager.query("apps query", 10, &["calc".to_string()]).await; + assert_eq!(items.len(), 1); + assert_eq!(items[0].text, "calc-result"); + } + + #[tokio::test] + async fn prefix_provider_only_matches_with_prefix() { + let manager = ProviderManager::new(); + manager + .register(mock("apps", None, vec![("app", 0.5)])) + .await; + manager + .register(mock("calc", Some("="), vec![("calc-result", 0.9)])) + .await; + + // No prefix: calculator should not contribute. + let plain = manager.query("firefox", 10, &[]).await; + assert!(plain.iter().all(|i| i.text != "calc-result")); + + // With prefix: calculator is included. + let prefixed = manager.query("=2+2", 10, &[]).await; + assert!(prefixed.iter().any(|i| i.text == "calc-result")); + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index a1230f1..1f387a1 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -170,3 +170,73 @@ pub trait Provider: Send + Sync { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_builder_sets_fields() { + let item = Item::new("Firefox", "applications") + .with_subtext("Web Browser") + .with_icon("firefox") + .with_icon_path("/usr/share/icons/firefox.png") + .with_score(0.75) + .with_metadata("desktop_id", "firefox") + .with_source("native"); + + assert_eq!(item.text, "Firefox"); + assert_eq!(item.provider, "applications"); + assert_eq!(item.subtext, "Web Browser"); + assert_eq!(item.icon, "firefox"); + assert_eq!(item.icon_path, "/usr/share/icons/firefox.png"); + assert_eq!(item.score, 0.75); + assert_eq!( + item.metadata.get("desktop_id").map(String::as_str), + Some("firefox") + ); + assert_eq!(item.source, "native"); + assert!(!item.id.is_empty(), "id should be auto-generated"); + } + + #[test] + fn item_converts_to_proto() { + let item = Item::new("Calc", "calculator") + .with_subtext("2+2 =") + .with_score(1.0) + .with_metadata("result", "4"); + + let proto: crate::proto::Item = item.clone().into(); + assert_eq!(proto.text, item.text); + assert_eq!(proto.provider, item.provider); + assert_eq!(proto.subtext, item.subtext); + assert_eq!(proto.score, item.score); + assert_eq!(proto.metadata.get("result").map(String::as_str), Some("4")); + } + + #[test] + fn provider_info_converts_to_proto() { + let info = ProviderInfo { + name: "calculator".to_string(), + description: "Evaluate expressions".to_string(), + prefix: Some("=".to_string()), + enabled: true, + }; + let proto: crate::proto::ProviderInfo = info.into(); + assert_eq!(proto.name, "calculator"); + assert_eq!(proto.prefix, "="); + assert!(proto.enabled); + } + + #[test] + fn provider_info_none_prefix_becomes_empty() { + let info = ProviderInfo { + name: "applications".to_string(), + description: "Apps".to_string(), + prefix: None, + enabled: true, + }; + let proto: crate::proto::ProviderInfo = info.into(); + assert_eq!(proto.prefix, ""); + } +} diff --git a/src/server.rs b/src/server.rs index 574de8c..dffdd3b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -190,3 +190,112 @@ async fn handle_list_providers( Some((MessageType::ListProvidersResponse, response.encode_to_vec())) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::ListProvidersRequest; + use crate::providers::CalculatorProvider; + use std::time::Duration; + use tokio::net::UnixStream; + + async fn spawn_calculator_server() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("datacube-it-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let socket = dir.join("datacube.sock"); + + let mut config = Config::default(); + config.socket_path = socket.clone(); + // Keep the test hermetic: don't scan the host for applications. + config.providers.applications.enabled = false; + + let manager = ProviderManager::new(); + manager.register(CalculatorProvider::new()).await; + + let server = Server::new(config, manager); + tokio::spawn(async move { + let _ = server.run().await; + }); + + // Wait for the socket to be bound. + for _ in 0..200 { + if socket.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + socket + } + + async fn write_frame(stream: &mut UnixStream, msg_type: u8, body: &[u8]) { + let mut header = vec![msg_type]; + header.extend_from_slice(&(body.len() as u32).to_be_bytes()); + stream.write_all(&header).await.unwrap(); + stream.write_all(body).await.unwrap(); + stream.flush().await.unwrap(); + } + + async fn read_frame(stream: &mut UnixStream) -> (u8, Vec) { + let mut header = [0u8; 5]; + stream.read_exact(&mut header).await.unwrap(); + let len = u32::from_be_bytes([header[1], header[2], header[3], header[4]]) as usize; + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await.unwrap(); + (header[0], body) + } + + #[tokio::test] + async fn query_round_trip_over_socket() { + let socket = spawn_calculator_server().await; + let mut stream = UnixStream::connect(&socket).await.expect("connect"); + + let request = QueryRequest { + query: "=2+2".to_string(), + max_results: 10, + providers: vec![], + exact: false, + }; + write_frame( + &mut stream, + MessageType::Query as u8, + &request.encode_to_vec(), + ) + .await; + + let (msg_type, body) = read_frame(&mut stream).await; + assert_eq!(msg_type, MessageType::QueryResponse as u8); + + let response = QueryResponse::decode(body.as_slice()).unwrap(); + assert!( + !response.items.is_empty(), + "calculator should return a result" + ); + assert_eq!(response.items[0].text, "4"); + assert_eq!(response.items[0].provider, "calculator"); + assert!(!response.qid.is_empty()); + + let _ = std::fs::remove_dir_all(socket.parent().unwrap()); + } + + #[tokio::test] + async fn list_providers_over_socket() { + let socket = spawn_calculator_server().await; + let mut stream = UnixStream::connect(&socket).await.expect("connect"); + + let request = ListProvidersRequest {}; + write_frame( + &mut stream, + MessageType::ListProviders as u8, + &request.encode_to_vec(), + ) + .await; + + let (msg_type, body) = read_frame(&mut stream).await; + assert_eq!(msg_type, MessageType::ListProvidersResponse as u8); + + let response = ListProvidersResponse::decode(body.as_slice()).unwrap(); + assert!(response.providers.iter().any(|p| p.name == "calculator")); + + let _ = std::fs::remove_dir_all(socket.parent().unwrap()); + } +}