feat(core): add built-in converter provider

This commit is contained in:
2026-03-28 12:14:31 +01:00
parent 99d38a66b8
commit 81626c33dd
5 changed files with 1676 additions and 0 deletions

View File

@@ -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<Option<CurrencyRates>> = Mutex::new(None);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrencyRates {
pub date: String,
pub rates: HashMap<String, f64>,
}
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<CurrencyRates> {
// 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<PathBuf> {
let cache_dir = dirs::cache_dir()?.join("owlry");
Some(cache_dir.join("ecb_rates.json"))
}
fn load_cache() -> Option<CurrencyRates> {
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<CurrencyRates> {
let response = reqwest::blocking::get(ECB_URL).ok()?;
let body = response.text().ok()?;
parse_ecb_xml(&body)
}
fn parse_ecb_xml(xml: &str) -> Option<CurrencyRates> {
let mut rates = HashMap::new();
let mut date = String::new();
for line in xml.lines() {
let trimmed = line.trim();
// Extract date: <Cube time='2026-03-26'>
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: <Cube currency='USD' rate='1.0832'/>
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<String> {
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#"<?xml version="1.0" encoding="UTF-8"?>
<gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01" xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
<gesmes:subject>Reference rates</gesmes:subject>
<Cube>
<Cube time='2026-03-26'>
<Cube currency='USD' rate='1.0832'/>
<Cube currency='JPY' rate='161.94'/>
<Cube currency='GBP' rate='0.83450'/>
</Cube>
</Cube>
</gesmes:Envelope>"#;
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);
}
}

View File

@@ -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<LaunchItem> {
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<LaunchItem> {
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");
}
}

View File

@@ -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<String>,
}
pub fn parse_conversion(input: &str) -> Option<ParsedQuery> {
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");
}
}

View File

@@ -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<Vec<UnitDef>> = LazyLock::new(build_unit_table);
static ALIAS_MAP: LazyLock<HashMap<String, usize>> = 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<HashMap<Category, Vec<&'static str>>> = 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!["", "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<ConversionResult> {
// 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<ConversionResult> {
// 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<ConversionResult> {
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<ConversionResult> {
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<UnitDef> {
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: "",
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);
}
}

View File

@@ -2,6 +2,7 @@
mod application;
mod command;
pub(crate) mod calculator;
pub(crate) mod converter;
pub(crate) mod system;
// Native plugin bridge