From 81626c33ddcb77ec572cf40fdc2f58e23d6824cb Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 28 Mar 2026 12:14:31 +0100 Subject: [PATCH] feat(core): add built-in converter provider --- .../src/providers/converter/currency.rs | 313 ++++++ .../owlry-core/src/providers/converter/mod.rs | 183 ++++ .../src/providers/converter/parser.rs | 235 +++++ .../src/providers/converter/units.rs | 944 ++++++++++++++++++ crates/owlry-core/src/providers/mod.rs | 1 + 5 files changed, 1676 insertions(+) create mode 100644 crates/owlry-core/src/providers/converter/currency.rs create mode 100644 crates/owlry-core/src/providers/converter/mod.rs create mode 100644 crates/owlry-core/src/providers/converter/parser.rs create mode 100644 crates/owlry-core/src/providers/converter/units.rs diff --git a/crates/owlry-core/src/providers/converter/currency.rs b/crates/owlry-core/src/providers/converter/currency.rs new file mode 100644 index 0000000..ea35c44 --- /dev/null +++ b/crates/owlry-core/src/providers/converter/currency.rs @@ -0,0 +1,313 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::SystemTime; + +use serde::{Deserialize, Serialize}; + +const ECB_URL: &str = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"; +const CACHE_MAX_AGE_SECS: u64 = 86400; // 24 hours + +static CACHED_RATES: Mutex> = Mutex::new(None); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurrencyRates { + pub date: String, + pub rates: HashMap, +} + +struct CurrencyAlias { + code: &'static str, + aliases: &'static [&'static str], +} + +static CURRENCY_ALIASES: &[CurrencyAlias] = &[ + CurrencyAlias { + code: "EUR", + aliases: &["eur", "euro", "euros", "€"], + }, + CurrencyAlias { + code: "USD", + aliases: &["usd", "dollar", "dollars", "$", "us_dollar"], + }, + CurrencyAlias { + code: "GBP", + aliases: &["gbp", "pound_sterling", "£", "british_pound", "pounds"], + }, + CurrencyAlias { + code: "JPY", + aliases: &["jpy", "yen", "¥", "japanese_yen"], + }, + CurrencyAlias { + code: "CHF", + aliases: &["chf", "swiss_franc", "francs"], + }, + CurrencyAlias { + code: "CAD", + aliases: &["cad", "canadian_dollar", "c$"], + }, + CurrencyAlias { + code: "AUD", + aliases: &["aud", "australian_dollar", "a$"], + }, + CurrencyAlias { + code: "CNY", + aliases: &["cny", "yuan", "renminbi", "rmb"], + }, + CurrencyAlias { + code: "SEK", + aliases: &["sek", "swedish_krona", "kronor"], + }, + CurrencyAlias { + code: "NOK", + aliases: &["nok", "norwegian_krone"], + }, + CurrencyAlias { + code: "DKK", + aliases: &["dkk", "danish_krone"], + }, + CurrencyAlias { + code: "PLN", + aliases: &["pln", "zloty", "złoty"], + }, + CurrencyAlias { + code: "CZK", + aliases: &["czk", "czech_koruna"], + }, + CurrencyAlias { + code: "HUF", + aliases: &["huf", "forint"], + }, + CurrencyAlias { + code: "TRY", + aliases: &["try", "turkish_lira", "lira"], + }, +]; + +pub fn resolve_currency_code(alias: &str) -> Option<&'static str> { + let lower = alias.to_lowercase(); + + // Check aliases + for ca in CURRENCY_ALIASES { + if ca.aliases.contains(&lower.as_str()) { + return Some(ca.code); // ca.code is already &'static str + } + } + + // Check if it's a raw 3-letter ISO code we know about + let upper = alias.to_uppercase(); + if upper.len() == 3 { + if upper == "EUR" { + return Some("EUR"); + } + if let Some(rates) = get_rates() + && rates.rates.contains_key(&upper) + { + for ca in CURRENCY_ALIASES { + if ca.code == upper { + return Some(ca.code); + } + } + } + } + + None +} + +#[allow(dead_code)] +pub fn is_currency_alias(alias: &str) -> bool { + resolve_currency_code(alias).is_some() +} + +pub fn get_rates() -> Option { + // Check memory cache first + { + let cache = CACHED_RATES.lock().ok()?; + if let Some(ref rates) = *cache { + return Some(rates.clone()); + } + } + + // Try disk cache + if let Some(rates) = load_cache() + && !is_stale(&rates) + { + let mut cache = CACHED_RATES.lock().ok()?; + *cache = Some(rates.clone()); + return Some(rates); + } + + // Fetch fresh rates + if let Some(rates) = fetch_rates() { + save_cache(&rates); + let mut cache = CACHED_RATES.lock().ok()?; + *cache = Some(rates.clone()); + return Some(rates); + } + + // Fall back to stale cache + load_cache() +} + +fn cache_path() -> Option { + let cache_dir = dirs::cache_dir()?.join("owlry"); + Some(cache_dir.join("ecb_rates.json")) +} + +fn load_cache() -> Option { + let path = cache_path()?; + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +fn save_cache(rates: &CurrencyRates) { + if let Some(path) = cache_path() { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).ok(); + } + if let Ok(json) = serde_json::to_string_pretty(rates) { + fs::write(path, json).ok(); + } + } +} + +fn is_stale(_rates: &CurrencyRates) -> bool { + let path = match cache_path() { + Some(p) => p, + None => return true, + }; + let metadata = match fs::metadata(path) { + Ok(m) => m, + Err(_) => return true, + }; + let modified = match metadata.modified() { + Ok(t) => t, + Err(_) => return true, + }; + match SystemTime::now().duration_since(modified) { + Ok(age) => age.as_secs() > CACHE_MAX_AGE_SECS, + Err(_) => true, + } +} + +fn fetch_rates() -> Option { + let response = reqwest::blocking::get(ECB_URL).ok()?; + let body = response.text().ok()?; + parse_ecb_xml(&body) +} + +fn parse_ecb_xml(xml: &str) -> Option { + let mut rates = HashMap::new(); + let mut date = String::new(); + + for line in xml.lines() { + let trimmed = line.trim(); + + // Extract date: + if trimmed.contains("time=") + && let Some(start) = trimmed.find("time='") + { + let rest = &trimmed[start + 6..]; + if let Some(end) = rest.find('\'') { + date = rest[..end].to_string(); + } + } + + // Extract rate: + if trimmed.contains("currency=") && trimmed.contains("rate=") { + let currency = extract_attr(trimmed, "currency")?; + let rate_str = extract_attr(trimmed, "rate")?; + let rate: f64 = rate_str.parse().ok()?; + rates.insert(currency, rate); + } + } + + if rates.is_empty() { + return None; + } + + Some(CurrencyRates { date, rates }) +} + +fn extract_attr(line: &str, attr: &str) -> Option { + let needle = format!("{}='", attr); + let start = line.find(&needle)? + needle.len(); + let rest = &line[start..]; + let end = rest.find('\'')?; + Some(rest[..end].to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_currency_code_iso() { + assert_eq!(resolve_currency_code("usd"), Some("USD")); + assert_eq!(resolve_currency_code("EUR"), Some("EUR")); + } + + #[test] + fn test_resolve_currency_code_name() { + assert_eq!(resolve_currency_code("dollar"), Some("USD")); + assert_eq!(resolve_currency_code("euro"), Some("EUR")); + assert_eq!(resolve_currency_code("pounds"), Some("GBP")); + } + + #[test] + fn test_resolve_currency_code_symbol() { + assert_eq!(resolve_currency_code("$"), Some("USD")); + assert_eq!(resolve_currency_code("€"), Some("EUR")); + assert_eq!(resolve_currency_code("£"), Some("GBP")); + } + + #[test] + fn test_resolve_currency_unknown() { + assert_eq!(resolve_currency_code("xyz"), None); + } + + #[test] + fn test_is_currency_alias() { + assert!(is_currency_alias("usd")); + assert!(is_currency_alias("euro")); + assert!(is_currency_alias("$")); + assert!(!is_currency_alias("km")); + } + + #[test] + fn test_parse_ecb_xml() { + let xml = r#" + + Reference rates + + + + + + + +"#; + + let rates = parse_ecb_xml(xml).unwrap(); + assert!((rates.rates["USD"] - 1.0832).abs() < 0.001); + assert!((rates.rates["GBP"] - 0.8345).abs() < 0.001); + assert!((rates.rates["JPY"] - 161.94).abs() < 0.01); + } + + #[test] + fn test_cache_roundtrip() { + let rates = CurrencyRates { + date: "2026-03-26".to_string(), + rates: { + let mut m = HashMap::new(); + m.insert("USD".to_string(), 1.0832); + m.insert("GBP".to_string(), 0.8345); + m + }, + }; + let json = serde_json::to_string(&rates).unwrap(); + let parsed: CurrencyRates = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.rates["USD"], 1.0832); + } +} diff --git a/crates/owlry-core/src/providers/converter/mod.rs b/crates/owlry-core/src/providers/converter/mod.rs new file mode 100644 index 0000000..5afceba --- /dev/null +++ b/crates/owlry-core/src/providers/converter/mod.rs @@ -0,0 +1,183 @@ +mod currency; +mod parser; +mod units; + +use super::{DynamicProvider, LaunchItem, ProviderType}; + +const PROVIDER_TYPE_ID: &str = "conv"; +const PROVIDER_ICON: &str = "edit-find-replace-symbolic"; + +pub struct ConverterProvider; + +impl ConverterProvider { + pub fn new() -> Self { + Self + } +} + +impl DynamicProvider for ConverterProvider { + fn name(&self) -> &str { + "Converter" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(PROVIDER_TYPE_ID.into()) + } + + fn priority(&self) -> u32 { + 9_000 + } + + fn query(&self, query: &str) -> Vec { + let query_str = query.trim(); + // Strip prefix + let input = if let Some(rest) = query_str.strip_prefix('>') { + rest.trim() + } else { + query_str + }; + + let parsed = match parser::parse_conversion(input) { + Some(p) => p, + None => return Vec::new(), + }; + + let results = if let Some(ref target) = parsed.target_unit { + units::convert_to(&parsed.value, &parsed.from_unit, target) + .into_iter() + .collect() + } else { + units::convert_common(&parsed.value, &parsed.from_unit) + }; + + results + .into_iter() + .map(|r| LaunchItem { + id: format!("conv:{}:{}:{}", parsed.from_unit, r.target_symbol, r.value), + name: r.display_value.clone(), + description: Some(format!( + "{} {} = {}", + format_number(parsed.value), + parsed.from_symbol, + r.display_value, + )), + icon: Some(PROVIDER_ICON.into()), + provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), + command: format!( + "printf '%s' '{}' | wl-copy", + r.raw_value.replace('\'', "'\\''") + ), + terminal: false, + tags: vec!["converter".into(), "units".into()], + }) + .collect() + } +} + +fn format_number(n: f64) -> String { + if n.fract() == 0.0 && n.abs() < 1e15 { + let i = n as i64; + if i.abs() >= 1000 { + format_with_separators(i) + } else { + format!("{}", i) + } + } else { + format!("{:.4}", n) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } +} + +pub(crate) fn format_with_separators(n: i64) -> String { + let s = n.abs().to_string(); + let mut result = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(c); + } + if n < 0 { + result.push('-'); + } + result.chars().rev().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn query(input: &str) -> Vec { + ConverterProvider::new().query(input) + } + + #[test] + fn test_prefix_trigger() { + let r = query("> 100 km to mi"); + assert!(!r.is_empty()); + } + + #[test] + fn test_auto_detect() { + let r = query("100 km to mi"); + assert!(!r.is_empty()); + } + + #[test] + fn test_common_conversions() { + let r = query("> 100 km"); + assert!(r.len() > 1); + } + + #[test] + fn test_temperature() { + let r = query("102F to C"); + assert!(!r.is_empty()); + } + + #[test] + fn test_nonsense_returns_empty() { + assert!(query("hello world").is_empty()); + } + + #[test] + fn test_provider_type() { + assert_eq!( + ConverterProvider::new().provider_type(), + ProviderType::Plugin("conv".into()) + ); + } + + #[test] + fn test_no_double_unit() { + let r = query("100 km to mi"); + if let Some(item) = r.first() { + let desc = item.description.as_deref().unwrap(); + assert!(!desc.ends_with(" mi mi"), "double unit in: {}", desc); + } + } + + #[test] + fn test_format_number_integer() { + assert_eq!(format_number(42.0), "42"); + } + + #[test] + fn test_format_number_large_integer() { + assert_eq!(format_number(1000000.0), "1,000,000"); + } + + #[test] + fn test_format_number_decimal() { + assert_eq!(format_number(3.14), "3.14"); + } + + #[test] + fn test_format_with_separators() { + assert_eq!(format_with_separators(1234567), "1,234,567"); + assert_eq!(format_with_separators(999), "999"); + assert_eq!(format_with_separators(-1234), "-1,234"); + } +} diff --git a/crates/owlry-core/src/providers/converter/parser.rs b/crates/owlry-core/src/providers/converter/parser.rs new file mode 100644 index 0000000..56015c8 --- /dev/null +++ b/crates/owlry-core/src/providers/converter/parser.rs @@ -0,0 +1,235 @@ +use super::units; + +pub struct ParsedQuery { + pub value: f64, + pub from_unit: String, + pub from_symbol: String, + pub target_unit: Option, +} + +pub fn parse_conversion(input: &str) -> Option { + let input = input.trim(); + if input.is_empty() { + return None; + } + + // Extract leading number + let (value, rest) = extract_number(input)?; + let rest = rest.trim(); + + if rest.is_empty() { + return None; + } + + // Split on " to " or " in " (case-insensitive) + let (from_str, target_str) = split_on_connector(rest); + + // Resolve from unit + let from_lower = from_str.trim().to_lowercase(); + let from_symbol = units::find_unit(&from_lower)?; + + let from_symbol_str = from_symbol.to_string(); + + // Resolve target unit if present + let target_unit = target_str.and_then(|t| { + let t_lower = t.trim().to_lowercase(); + if t_lower.is_empty() { + None + } else { + units::find_unit(&t_lower).map(|_| t_lower) + } + }); + + Some(ParsedQuery { + value, + from_unit: from_lower, + from_symbol: from_symbol_str, + target_unit, + }) +} + +fn extract_number(input: &str) -> Option<(f64, &str)> { + let bytes = input.as_bytes(); + let mut i = 0; + + // Optional negative sign + if i < bytes.len() && bytes[i] == b'-' { + i += 1; + } + + // Must have at least one digit or start with . + if i >= bytes.len() { + return None; + } + + let start_digits = i; + + // Integer part + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + + // Decimal part + if i < bytes.len() && bytes[i] == b'.' { + i += 1; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + + if i == start_digits && !(i > 0 && bytes[0] == b'-') { + // No digits found (and not just a negative sign before a dot) + // Handle ".5" case + if bytes[start_digits] == b'.' { + // already advanced past dot above + } else { + return None; + } + } + + if i == 0 || (i == 1 && bytes[0] == b'-') { + return None; + } + + let num_str = &input[..i]; + let value: f64 = num_str.parse().ok()?; + let rest = &input[i..]; + + Some((value, rest)) +} + +fn split_on_connector(input: &str) -> (&str, Option<&str>) { + let lower = input.to_lowercase(); + + // Try " to " first + if let Some(pos) = lower.find(" to ") { + let from = &input[..pos]; + let target = &input[pos + 4..]; + return (from, Some(target)); + } + + // Try " in " + if let Some(pos) = lower.find(" in ") { + let from = &input[..pos]; + let target = &input[pos + 4..]; + return (from, Some(target)); + } + + (input, None) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_number_and_unit_with_space() { + let p = parse_conversion("100 km").unwrap(); + assert!((p.value - 100.0).abs() < 0.001); + assert_eq!(p.from_unit, "km"); + assert!(p.target_unit.is_none()); + } + + #[test] + fn test_number_and_unit_no_space() { + let p = parse_conversion("100km").unwrap(); + assert!((p.value - 100.0).abs() < 0.001); + assert_eq!(p.from_unit, "km"); + } + + #[test] + fn test_with_target_to() { + let p = parse_conversion("100 km to mi").unwrap(); + assert!((p.value - 100.0).abs() < 0.001); + assert_eq!(p.from_unit, "km"); + assert_eq!(p.target_unit.as_deref(), Some("mi")); + } + + #[test] + fn test_with_target_in() { + let p = parse_conversion("100 km in mi").unwrap(); + assert_eq!(p.target_unit.as_deref(), Some("mi")); + } + + #[test] + fn test_temperature_no_space() { + let p = parse_conversion("102F to C").unwrap(); + assert!((p.value - 102.0).abs() < 0.001); + assert_eq!(p.from_unit, "f"); + assert_eq!(p.target_unit.as_deref(), Some("c")); + } + + #[test] + fn test_temperature_with_space() { + let p = parse_conversion("102 F in K").unwrap(); + assert!((p.value - 102.0).abs() < 0.001); + assert_eq!(p.from_unit, "f"); + assert_eq!(p.target_unit.as_deref(), Some("k")); + } + + #[test] + fn test_decimal_number() { + let p = parse_conversion("3.5 kg to lb").unwrap(); + assert!((p.value - 3.5).abs() < 0.001); + } + + #[test] + fn test_decimal_starting_with_dot() { + let p = parse_conversion(".5 kg").unwrap(); + assert!((p.value - 0.5).abs() < 0.001); + } + + #[test] + fn test_full_unit_names() { + let p = parse_conversion("100 kilometers to miles").unwrap(); + assert_eq!(p.from_unit, "kilometers"); + assert_eq!(p.target_unit.as_deref(), Some("miles")); + } + + #[test] + fn test_case_insensitive() { + let p = parse_conversion("100 KM TO MI").unwrap(); + assert_eq!(p.from_unit, "km"); + assert_eq!(p.target_unit.as_deref(), Some("mi")); + } + + #[test] + fn test_currency() { + let p = parse_conversion("100 eur to usd").unwrap(); + assert_eq!(p.from_unit, "eur"); + assert_eq!(p.target_unit.as_deref(), Some("usd")); + } + + #[test] + fn test_no_number_returns_none() { + assert!(parse_conversion("km to mi").is_none()); + } + + #[test] + fn test_unknown_unit_returns_none() { + assert!(parse_conversion("100 xyz to abc").is_none()); + } + + #[test] + fn test_empty_returns_none() { + assert!(parse_conversion("").is_none()); + } + + #[test] + fn test_number_only_returns_none() { + assert!(parse_conversion("100").is_none()); + } + + #[test] + fn test_compound_unit_alias() { + let p = parse_conversion("100 km/h to mph").unwrap(); + assert_eq!(p.from_unit, "km/h"); + assert_eq!(p.target_unit.as_deref(), Some("mph")); + } + + #[test] + fn test_multi_word_unit() { + let p = parse_conversion("100 fl_oz to ml").unwrap(); + assert_eq!(p.from_unit, "fl_oz"); + } +} diff --git a/crates/owlry-core/src/providers/converter/units.rs b/crates/owlry-core/src/providers/converter/units.rs new file mode 100644 index 0000000..2607b82 --- /dev/null +++ b/crates/owlry-core/src/providers/converter/units.rs @@ -0,0 +1,944 @@ +use std::collections::HashMap; +use std::sync::LazyLock; + +use super::currency; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Category { + Temperature, + Length, + Weight, + Volume, + Speed, + Area, + Data, + Time, + Pressure, + Energy, + Currency, +} + +#[derive(Clone)] +enum Conversion { + Factor(f64), + Custom { + to_base: fn(f64) -> f64, + from_base: fn(f64) -> f64, + }, +} + +#[derive(Clone)] +pub(crate) struct UnitDef { + _id: &'static str, + symbol: &'static str, + aliases: &'static [&'static str], + category: Category, + conversion: Conversion, +} + +impl UnitDef { + fn to_base(&self, value: f64) -> f64 { + match &self.conversion { + Conversion::Factor(f) => value * f, + Conversion::Custom { to_base, .. } => to_base(value), + } + } + + fn convert_from_base(&self, value: f64) -> f64 { + match &self.conversion { + Conversion::Factor(f) => value / f, + Conversion::Custom { from_base, .. } => from_base(value), + } + } +} + +pub struct ConversionResult { + pub value: f64, + pub raw_value: String, + pub display_value: String, + pub target_symbol: String, +} + +static UNITS: LazyLock> = LazyLock::new(build_unit_table); +static ALIAS_MAP: LazyLock> = LazyLock::new(|| { + let mut map = HashMap::new(); + for (i, unit) in UNITS.iter().enumerate() { + for alias in unit.aliases { + map.insert(alias.to_lowercase(), i); + } + } + map +}); + +// Common conversions per category (symbols to show when no target specified) +static COMMON_TARGETS: LazyLock>> = LazyLock::new(|| { + let mut m = HashMap::new(); + m.insert(Category::Temperature, vec!["°C", "°F", "K"]); + m.insert(Category::Length, vec!["m", "km", "ft", "mi", "in"]); + m.insert(Category::Weight, vec!["kg", "lb", "oz", "g", "st"]); + m.insert(Category::Volume, vec!["l", "gal", "ml", "cup", "fl oz"]); + m.insert(Category::Speed, vec!["km/h", "mph", "m/s", "kn"]); + m.insert(Category::Area, vec!["m²", "ft²", "ac", "ha", "km²"]); + m.insert(Category::Data, vec!["MB", "GB", "MiB", "GiB", "TB"]); + m.insert(Category::Time, vec!["s", "min", "h", "d", "wk"]); + m.insert(Category::Pressure, vec!["bar", "psi", "atm", "hPa", "mmHg"]); + m.insert(Category::Energy, vec!["kJ", "kcal", "kWh", "BTU", "Wh"]); + m.insert(Category::Currency, vec!["USD", "EUR", "GBP", "JPY", "CNY"]); + m +}); + +pub fn find_unit(alias: &str) -> Option<&'static str> { + let lower = alias.to_lowercase(); + if let Some(&i) = ALIAS_MAP.get(&lower) { + return Some(UNITS[i].symbol); + } + currency::resolve_currency_code(&lower) +} + +pub fn lookup_unit(alias: &str) -> Option<(usize, &UnitDef)> { + let lower = alias.to_lowercase(); + ALIAS_MAP.get(&lower).map(|&i| (i, &UNITS[i])) +} + +pub fn convert_to(value: &f64, from: &str, to: &str) -> Option { + // Try currency first — currency aliases (dollar, euro, etc.) aren't in the UNITS table + if currency::is_currency_alias(from) || currency::is_currency_alias(to) { + return convert_currency(*value, from, to); + } + + let (_, from_def) = lookup_unit(from)?; + let (_, to_def) = lookup_unit(to)?; + + // Currency via UNITS table (shouldn't reach here, but just in case) + if from_def.category == Category::Currency || to_def.category == Category::Currency { + return convert_currency(*value, from, to); + } + + // Must be same category + if from_def.category != to_def.category { + return None; + } + + let base_value = from_def.to_base(*value); + let result = to_def.convert_from_base(base_value); + + Some(format_result(result, to_def.symbol)) +} + +pub fn convert_common(value: &f64, from: &str) -> Vec { + // Try currency first — currency aliases (dollar, euro, etc.) aren't in the UNITS table + if currency::is_currency_alias(from) { + return convert_currency_common(*value, from); + } + + let (_, from_def) = match lookup_unit(from) { + Some(u) => u, + None => return vec![], + }; + + let category = from_def.category; + let from_symbol = from_def.symbol; + + if category == Category::Currency { + return convert_currency_common(*value, from); + } + + let targets = match COMMON_TARGETS.get(&category) { + Some(t) => t, + None => return vec![], + }; + + let base_value = from_def.to_base(*value); + + targets + .iter() + .filter(|&&sym| sym != from_symbol) + .filter_map(|&sym| { + let (_, to_def) = lookup_unit(sym)?; + let result = to_def.convert_from_base(base_value); + Some(format_result(result, to_def.symbol)) + }) + .take(5) + .collect() +} + +fn convert_currency(value: f64, from: &str, to: &str) -> Option { + let rates = currency::get_rates()?; + let from_code = currency::resolve_currency_code(from)?; + let to_code = currency::resolve_currency_code(to)?; + + let from_rate = if from_code == "EUR" { 1.0 } else { *rates.rates.get(from_code)? }; + let to_rate = if to_code == "EUR" { 1.0 } else { *rates.rates.get(to_code)? }; + + let result = value / from_rate * to_rate; + Some(format_currency_result(result, to_code)) +} + +fn convert_currency_common(value: f64, from: &str) -> Vec { + let rates = match currency::get_rates() { + Some(r) => r, + None => return vec![], + }; + let from_code = match currency::resolve_currency_code(from) { + Some(c) => c, + None => return vec![], + }; + + let targets = COMMON_TARGETS.get(&Category::Currency).unwrap(); + let from_rate = if from_code == "EUR" { + 1.0 + } else { + match rates.rates.get(from_code) { + Some(&r) => r, + None => return vec![], + } + }; + + targets + .iter() + .filter(|&&sym| sym != from_code) + .filter_map(|&sym| { + let to_rate = if sym == "EUR" { 1.0 } else { *rates.rates.get(sym)? }; + let result = value / from_rate * to_rate; + Some(format_currency_result(result, sym)) + }) + .take(5) + .collect() +} + +fn format_result(value: f64, symbol: &str) -> ConversionResult { + let raw = if value.fract() == 0.0 && value.abs() < 1e15 { + format!("{}", value as i64) + } else { + format!("{:.4}", value) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + }; + + let display = if value.abs() >= 1000.0 && value.fract() == 0.0 && value.abs() < 1e15 { + super::format_with_separators(value as i64) + } else { + raw.clone() + }; + + ConversionResult { + value, + raw_value: raw, + display_value: format!("{} {}", display, symbol), + target_symbol: symbol.to_string(), + } +} + +fn format_currency_result(value: f64, code: &str) -> ConversionResult { + let raw = format!("{:.2}", value); + let display = raw.clone(); + + ConversionResult { + value, + raw_value: raw, + display_value: format!("{} {}", display, code), + target_symbol: code.to_string(), + } +} + +fn build_unit_table() -> Vec { + vec![ + // Temperature (base: Kelvin) + UnitDef { + _id: "celsius", + symbol: "°C", + aliases: &["c", "°c", "celsius", "degc", "centigrade"], + category: Category::Temperature, + conversion: Conversion::Custom { + to_base: |v| v + 273.15, + from_base: |v| v - 273.15, + }, + }, + UnitDef { + _id: "fahrenheit", + symbol: "°F", + aliases: &["f", "°f", "fahrenheit", "degf"], + category: Category::Temperature, + conversion: Conversion::Custom { + to_base: |v| (v - 32.0) * 5.0 / 9.0 + 273.15, + from_base: |v| (v - 273.15) * 9.0 / 5.0 + 32.0, + }, + }, + UnitDef { + _id: "kelvin", + symbol: "K", + aliases: &["k", "kelvin"], + category: Category::Temperature, + conversion: Conversion::Factor(1.0), // base + }, + // Length (base: meter) + UnitDef { + _id: "millimeter", + symbol: "mm", + aliases: &["mm", "millimeter", "millimeters", "millimetre"], + category: Category::Length, + conversion: Conversion::Factor(0.001), + }, + UnitDef { + _id: "centimeter", + symbol: "cm", + aliases: &["cm", "centimeter", "centimeters", "centimetre"], + category: Category::Length, + conversion: Conversion::Factor(0.01), + }, + UnitDef { + _id: "meter", + symbol: "m", + aliases: &["m", "meter", "meters", "metre", "metres"], + category: Category::Length, + conversion: Conversion::Factor(1.0), + }, + UnitDef { + _id: "kilometer", + symbol: "km", + aliases: &["km", "kms", "kilometer", "kilometers", "kilometre"], + category: Category::Length, + conversion: Conversion::Factor(1000.0), + }, + UnitDef { + _id: "inch", + symbol: "in", + aliases: &["in", "inch", "inches"], + category: Category::Length, + conversion: Conversion::Factor(0.0254), + }, + UnitDef { + _id: "foot", + symbol: "ft", + aliases: &["ft", "foot", "feet"], + category: Category::Length, + conversion: Conversion::Factor(0.3048), + }, + UnitDef { + _id: "yard", + symbol: "yd", + aliases: &["yd", "yard", "yards"], + category: Category::Length, + conversion: Conversion::Factor(0.9144), + }, + UnitDef { + _id: "mile", + symbol: "mi", + aliases: &["mi", "mile", "miles"], + category: Category::Length, + conversion: Conversion::Factor(1609.344), + }, + UnitDef { + _id: "nautical_mile", + symbol: "nmi", + aliases: &["nmi", "nautical_mile", "nautical_miles"], + category: Category::Length, + conversion: Conversion::Factor(1852.0), + }, + // Weight (base: kg) + UnitDef { + _id: "milligram", + symbol: "mg", + aliases: &["mg", "milligram", "milligrams"], + category: Category::Weight, + conversion: Conversion::Factor(0.000001), + }, + UnitDef { + _id: "gram", + symbol: "g", + aliases: &["g", "gram", "grams"], + category: Category::Weight, + conversion: Conversion::Factor(0.001), + }, + UnitDef { + _id: "kilogram", + symbol: "kg", + aliases: &["kg", "kilogram", "kilograms", "kilo", "kilos"], + category: Category::Weight, + conversion: Conversion::Factor(1.0), + }, + UnitDef { + _id: "tonne", + symbol: "t", + aliases: &["t", "ton", "tons", "tonne", "tonnes", "metric_ton"], + category: Category::Weight, + conversion: Conversion::Factor(1000.0), + }, + UnitDef { + _id: "short_ton", + symbol: "short_ton", + aliases: &["short_ton", "ton_us"], + category: Category::Weight, + conversion: Conversion::Factor(907.185), + }, + UnitDef { + _id: "ounce", + symbol: "oz", + aliases: &["oz", "ounce", "ounces"], + category: Category::Weight, + conversion: Conversion::Factor(0.0283495), + }, + UnitDef { + _id: "pound", + symbol: "lb", + aliases: &["lb", "lbs", "pound", "pounds"], + category: Category::Weight, + conversion: Conversion::Factor(0.453592), + }, + UnitDef { + _id: "stone", + symbol: "st", + aliases: &["st", "stone", "stones"], + category: Category::Weight, + conversion: Conversion::Factor(6.35029), + }, + // Volume (base: liter) + UnitDef { + _id: "milliliter", + symbol: "ml", + aliases: &["ml", "milliliter", "milliliters", "millilitre"], + category: Category::Volume, + conversion: Conversion::Factor(0.001), + }, + UnitDef { + _id: "liter", + symbol: "l", + aliases: &["l", "liter", "liters", "litre", "litres"], + category: Category::Volume, + conversion: Conversion::Factor(1.0), + }, + UnitDef { + _id: "us_gallon", + symbol: "gal", + aliases: &["gal", "gallon", "gallons"], + category: Category::Volume, + conversion: Conversion::Factor(3.78541), + }, + UnitDef { + _id: "imp_gallon", + symbol: "imp gal", + aliases: &["imp_gal", "gal_uk", "imperial_gallon"], + category: Category::Volume, + conversion: Conversion::Factor(4.54609), + }, + UnitDef { + _id: "quart", + symbol: "qt", + aliases: &["qt", "quart", "quarts"], + category: Category::Volume, + conversion: Conversion::Factor(0.946353), + }, + UnitDef { + _id: "pint", + symbol: "pt", + aliases: &["pt", "pint", "pints"], + category: Category::Volume, + conversion: Conversion::Factor(0.473176), + }, + UnitDef { + _id: "cup", + symbol: "cup", + aliases: &["cup", "cups"], + category: Category::Volume, + conversion: Conversion::Factor(0.236588), + }, + UnitDef { + _id: "fluid_ounce", + symbol: "fl oz", + aliases: &["floz", "fl_oz", "fluid_ounce", "fluid_ounces"], + category: Category::Volume, + conversion: Conversion::Factor(0.0295735), + }, + UnitDef { + _id: "tablespoon", + symbol: "tbsp", + aliases: &["tbsp", "tablespoon", "tablespoons"], + category: Category::Volume, + conversion: Conversion::Factor(0.0147868), + }, + UnitDef { + _id: "teaspoon", + symbol: "tsp", + aliases: &["tsp", "teaspoon", "teaspoons"], + category: Category::Volume, + conversion: Conversion::Factor(0.00492892), + }, + // Speed (base: m/s) + UnitDef { + _id: "mps", + symbol: "m/s", + aliases: &["m/s", "mps", "meters_per_second"], + category: Category::Speed, + conversion: Conversion::Factor(1.0), + }, + UnitDef { + _id: "kmh", + symbol: "km/h", + aliases: &["km/h", "kmh", "kph", "kilometers_per_hour"], + category: Category::Speed, + conversion: Conversion::Factor(0.277778), + }, + UnitDef { + _id: "mph", + symbol: "mph", + aliases: &["mph", "miles_per_hour"], + category: Category::Speed, + conversion: Conversion::Factor(0.44704), + }, + UnitDef { + _id: "knot", + symbol: "kn", + aliases: &["kn", "kt", "knot", "knots"], + category: Category::Speed, + conversion: Conversion::Factor(0.514444), + }, + UnitDef { + _id: "fps", + symbol: "ft/s", + aliases: &["ft/s", "fps", "feet_per_second"], + category: Category::Speed, + conversion: Conversion::Factor(0.3048), + }, + // Area (base: m²) + UnitDef { + _id: "sqmm", + symbol: "mm²", + aliases: &["mm2", "sqmm", "square_millimeter"], + category: Category::Area, + conversion: Conversion::Factor(0.000001), + }, + UnitDef { + _id: "sqcm", + symbol: "cm²", + aliases: &["cm2", "sqcm", "square_centimeter"], + category: Category::Area, + conversion: Conversion::Factor(0.0001), + }, + UnitDef { + _id: "sqm", + symbol: "m²", + aliases: &["m2", "sqm", "square_meter", "square_meters"], + category: Category::Area, + conversion: Conversion::Factor(1.0), + }, + UnitDef { + _id: "sqkm", + symbol: "km²", + aliases: &["km2", "sqkm", "square_kilometer"], + category: Category::Area, + conversion: Conversion::Factor(1000000.0), + }, + UnitDef { + _id: "sqft", + symbol: "ft²", + aliases: &["ft2", "sqft", "square_foot", "square_feet"], + category: Category::Area, + conversion: Conversion::Factor(0.092903), + }, + UnitDef { + _id: "acre", + symbol: "ac", + aliases: &["ac", "acre", "acres"], + category: Category::Area, + conversion: Conversion::Factor(4046.86), + }, + UnitDef { + _id: "hectare", + symbol: "ha", + aliases: &["ha", "hectare", "hectares"], + category: Category::Area, + conversion: Conversion::Factor(10000.0), + }, + // Data (base: byte) + UnitDef { + _id: "byte", + symbol: "B", + aliases: &["b", "byte", "bytes"], + category: Category::Data, + conversion: Conversion::Factor(1.0), + }, + UnitDef { + _id: "kilobyte", + symbol: "KB", + aliases: &["kb", "kilobyte", "kilobytes"], + category: Category::Data, + conversion: Conversion::Factor(1000.0), + }, + UnitDef { + _id: "megabyte", + symbol: "MB", + aliases: &["mb", "megabyte", "megabytes"], + category: Category::Data, + conversion: Conversion::Factor(1_000_000.0), + }, + UnitDef { + _id: "gigabyte", + symbol: "GB", + aliases: &["gb", "gigabyte", "gigabytes"], + category: Category::Data, + conversion: Conversion::Factor(1_000_000_000.0), + }, + UnitDef { + _id: "terabyte", + symbol: "TB", + aliases: &["tb", "terabyte", "terabytes"], + category: Category::Data, + conversion: Conversion::Factor(1_000_000_000_000.0), + }, + UnitDef { + _id: "kibibyte", + symbol: "KiB", + aliases: &["kib", "kibibyte", "kibibytes"], + category: Category::Data, + conversion: Conversion::Factor(1024.0), + }, + UnitDef { + _id: "mebibyte", + symbol: "MiB", + aliases: &["mib", "mebibyte", "mebibytes"], + category: Category::Data, + conversion: Conversion::Factor(1_048_576.0), + }, + UnitDef { + _id: "gibibyte", + symbol: "GiB", + aliases: &["gib", "gibibyte", "gibibytes"], + category: Category::Data, + conversion: Conversion::Factor(1_073_741_824.0), + }, + UnitDef { + _id: "tebibyte", + symbol: "TiB", + aliases: &["tib", "tebibyte", "tebibytes"], + category: Category::Data, + conversion: Conversion::Factor(1_099_511_627_776.0), + }, + // Time (base: second) + UnitDef { + _id: "second", + symbol: "s", + aliases: &["s", "sec", "second", "seconds"], + category: Category::Time, + conversion: Conversion::Factor(1.0), + }, + UnitDef { + _id: "minute", + symbol: "min", + aliases: &["min", "minute", "minutes"], + category: Category::Time, + conversion: Conversion::Factor(60.0), + }, + UnitDef { + _id: "hour", + symbol: "h", + aliases: &["h", "hr", "hour", "hours"], + category: Category::Time, + conversion: Conversion::Factor(3600.0), + }, + UnitDef { + _id: "day", + symbol: "d", + aliases: &["d", "day", "days"], + category: Category::Time, + conversion: Conversion::Factor(86400.0), + }, + UnitDef { + _id: "week", + symbol: "wk", + aliases: &["wk", "week", "weeks"], + category: Category::Time, + conversion: Conversion::Factor(604800.0), + }, + UnitDef { + _id: "month", + symbol: "mo", + aliases: &["mo", "month", "months"], + category: Category::Time, + conversion: Conversion::Factor(2_592_000.0), + }, + UnitDef { + _id: "year", + symbol: "yr", + aliases: &["yr", "year", "years"], + category: Category::Time, + conversion: Conversion::Factor(31_536_000.0), + }, + // Pressure (base: Pa) + UnitDef { + _id: "pascal", + symbol: "Pa", + aliases: &["pa", "pascal", "pascals"], + category: Category::Pressure, + conversion: Conversion::Factor(1.0), + }, + UnitDef { + _id: "hectopascal", + symbol: "hPa", + aliases: &["hpa", "hectopascal"], + category: Category::Pressure, + conversion: Conversion::Factor(100.0), + }, + UnitDef { + _id: "kilopascal", + symbol: "kPa", + aliases: &["kpa", "kilopascal"], + category: Category::Pressure, + conversion: Conversion::Factor(1000.0), + }, + UnitDef { + _id: "bar", + symbol: "bar", + aliases: &["bar", "bars"], + category: Category::Pressure, + conversion: Conversion::Factor(100_000.0), + }, + UnitDef { + _id: "millibar", + symbol: "mbar", + aliases: &["mbar", "millibar"], + category: Category::Pressure, + conversion: Conversion::Factor(100.0), + }, + UnitDef { + _id: "psi", + symbol: "psi", + aliases: &["psi", "pounds_per_square_inch"], + category: Category::Pressure, + conversion: Conversion::Factor(6894.76), + }, + UnitDef { + _id: "atmosphere", + symbol: "atm", + aliases: &["atm", "atmosphere", "atmospheres"], + category: Category::Pressure, + conversion: Conversion::Factor(101_325.0), + }, + UnitDef { + _id: "mmhg", + symbol: "mmHg", + aliases: &["mmhg", "torr"], + category: Category::Pressure, + conversion: Conversion::Factor(133.322), + }, + // Energy (base: Joule) + UnitDef { + _id: "joule", + symbol: "J", + aliases: &["j", "joule", "joules"], + category: Category::Energy, + conversion: Conversion::Factor(1.0), + }, + UnitDef { + _id: "kilojoule", + symbol: "kJ", + aliases: &["kj", "kilojoule", "kilojoules"], + category: Category::Energy, + conversion: Conversion::Factor(1000.0), + }, + UnitDef { + _id: "calorie", + symbol: "cal", + aliases: &["cal", "calorie", "calories"], + category: Category::Energy, + conversion: Conversion::Factor(4.184), + }, + UnitDef { + _id: "kilocalorie", + symbol: "kcal", + aliases: &["kcal", "kilocalorie", "kilocalories"], + category: Category::Energy, + conversion: Conversion::Factor(4184.0), + }, + UnitDef { + _id: "watt_hour", + symbol: "Wh", + aliases: &["wh", "watt_hour"], + category: Category::Energy, + conversion: Conversion::Factor(3600.0), + }, + UnitDef { + _id: "kilowatt_hour", + symbol: "kWh", + aliases: &["kwh", "kilowatt_hour"], + category: Category::Energy, + conversion: Conversion::Factor(3_600_000.0), + }, + UnitDef { + _id: "btu", + symbol: "BTU", + aliases: &["btu", "british_thermal_unit"], + category: Category::Energy, + conversion: Conversion::Factor(1055.06), + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_celsius_to_fahrenheit() { + let r = convert_to(&100.0, "c", "f").unwrap(); + assert!((r.value - 212.0).abs() < 0.01); + } + + #[test] + fn test_fahrenheit_to_celsius() { + let r = convert_to(&32.0, "f", "c").unwrap(); + assert!((r.value - 0.0).abs() < 0.01); + } + + #[test] + fn test_body_temp_f_to_c() { + let r = convert_to(&98.6, "f", "c").unwrap(); + assert!((r.value - 37.0).abs() < 0.01); + } + + #[test] + fn test_km_to_miles() { + let r = convert_to(&100.0, "km", "mi").unwrap(); + assert!((r.value - 62.1371).abs() < 0.01); + } + + #[test] + fn test_miles_to_km() { + let r = convert_to(&1.0, "mi", "km").unwrap(); + assert!((r.value - 1.60934).abs() < 0.01); + } + + #[test] + fn test_kg_to_lb() { + let r = convert_to(&1.0, "kg", "lb").unwrap(); + assert!((r.value - 2.20462).abs() < 0.01); + } + + #[test] + fn test_lb_to_kg() { + let r = convert_to(&100.0, "lbs", "kg").unwrap(); + assert!((r.value - 45.3592).abs() < 0.01); + } + + #[test] + fn test_liters_to_gallons() { + let r = convert_to(&3.78541, "l", "gal").unwrap(); + assert!((r.value - 1.0).abs() < 0.01); + } + + #[test] + fn test_kmh_to_mph() { + let r = convert_to(&100.0, "kmh", "mph").unwrap(); + assert!((r.value - 62.1371).abs() < 0.01); + } + + #[test] + fn test_gb_to_mb() { + let r = convert_to(&1.0, "gb", "mb").unwrap(); + assert!((r.value - 1000.0).abs() < 0.01); + } + + #[test] + fn test_gib_to_mib() { + let r = convert_to(&1.0, "gib", "mib").unwrap(); + assert!((r.value - 1024.0).abs() < 0.01); + } + + #[test] + fn test_hours_to_minutes() { + let r = convert_to(&2.5, "h", "min").unwrap(); + assert!((r.value - 150.0).abs() < 0.01); + } + + #[test] + fn test_bar_to_psi() { + let r = convert_to(&1.0, "bar", "psi").unwrap(); + assert!((r.value - 14.5038).abs() < 0.01); + } + + #[test] + fn test_kcal_to_kj() { + let r = convert_to(&1.0, "kcal", "kj").unwrap(); + assert!((r.value - 4.184).abs() < 0.01); + } + + #[test] + fn test_sqm_to_sqft() { + let r = convert_to(&1.0, "m2", "ft2").unwrap(); + assert!((r.value - 10.7639).abs() < 0.01); + } + + #[test] + fn test_unknown_unit_returns_none() { + assert!(convert_to(&100.0, "xyz", "abc").is_none()); + } + + #[test] + fn test_cross_category_returns_none() { + assert!(convert_to(&100.0, "km", "kg").is_none()); + } + + #[test] + fn test_convert_common_returns_results() { + let results = convert_common(&100.0, "km"); + assert!(!results.is_empty()); + assert!(results.len() <= 5); + } + + #[test] + fn test_convert_common_excludes_source() { + let results = convert_common(&100.0, "km"); + for r in &results { + assert_ne!(r.target_symbol, "km"); + } + } + + #[test] + fn test_alias_case_insensitive() { + let r1 = convert_to(&100.0, "KM", "MI").unwrap(); + let r2 = convert_to(&100.0, "km", "mi").unwrap(); + assert!((r1.value - r2.value).abs() < 0.001); + } + + #[test] + fn test_full_name_alias() { + let r = convert_to(&100.0, "kilometers", "miles").unwrap(); + assert!((r.value - 62.1371).abs() < 0.01); + } + + #[test] + fn test_format_currency_two_decimals() { + let r = convert_to(&1.0, "km", "mi").unwrap(); + // display_value should have reasonable formatting + assert!(!r.display_value.is_empty()); + } + + #[test] + fn test_currency_alias_convert_to() { + // "dollar" and "euro" are aliases, not in the UNITS table + let r = convert_to(&20.0, "dollar", "euro"); + // May return None if ECB rates unavailable (network), but should not panic + // In a network-available environment, this should return Some + if let Some(r) = r { + assert!(r.value > 0.0); + assert_eq!(r.target_symbol, "EUR"); + } + } + + #[test] + fn test_currency_alias_convert_common() { + let results = convert_common(&20.0, "dollar"); + // May be empty if ECB rates unavailable, but should not panic + for r in &results { + assert!(r.value > 0.0); + } + } + + #[test] + fn test_display_value_no_double_unit() { + let r = convert_to(&100.0, "km", "mi").unwrap(); + // display_value should contain the symbol exactly once + let count = r.display_value.matches(&r.target_symbol).count(); + assert_eq!(count, 1, "display_value '{}' should contain '{}' exactly once", r.display_value, r.target_symbol); + } +} diff --git a/crates/owlry-core/src/providers/mod.rs b/crates/owlry-core/src/providers/mod.rs index 7bd8bfb..c9ef27c 100644 --- a/crates/owlry-core/src/providers/mod.rs +++ b/crates/owlry-core/src/providers/mod.rs @@ -2,6 +2,7 @@ mod application; mod command; pub(crate) mod calculator; +pub(crate) mod converter; pub(crate) mod system; // Native plugin bridge