Files
owlry-plugins/docs/superpowers/plans/2026-03-26-converter-plugin.md

56 KiB

Converter Plugin Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a unit and currency conversion plugin for owlry with natural speech input (102F to C, 50 kg in lb, 100 eur to usd).

Architecture: Dynamic plugin following the calculator pattern. Query parser extracts number + unit + optional target. Conversion engine uses factor-based math with a base unit per category. Currency rates fetched from ECB daily XML feed and cached locally.

Tech Stack: Rust, owlry-plugin-api (ABI-stable), reqwest (ECB fetch), serde/serde_json (cache), dirs (XDG paths)

Spec: docs/superpowers/specs/2026-03-26-converter-plugin-design.md


File Structure

owlry-plugins/crates/owlry-plugin-converter/
├── Cargo.toml          # Crate config, cdylib output
└── src/
    ├── lib.rs          # Plugin vtable, query dispatch, result formatting
    ├── parser.rs       # Parse "102F to C" into (number, from_unit, to_unit)
    ├── units.rs        # Unit definitions, categories, conversion factors, convert()
    └── currency.rs     # ECB XML fetch, rate cache, currency unit definitions

Task 1: Crate scaffold and plugin vtable

Files:

  • Create: crates/owlry-plugin-converter/Cargo.toml

  • Create: crates/owlry-plugin-converter/src/lib.rs

  • Modify: Cargo.toml (workspace root — add member)

  • Step 1: Create Cargo.toml

[package]
name = "owlry-plugin-converter"
version = "1.0.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Unit and currency conversion plugin for owlry"
keywords = ["owlry", "plugin", "converter", "units", "currency"]
categories = ["science"]

[lib]
crate-type = ["cdylib"]

[dependencies]
owlry-plugin-api = { git = "https://somegit.dev/Owlibou/owlry.git", tag = "plugin-api-v1.0.0" }
abi_stable = "0.11"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.13", features = ["blocking"] }
dirs = "5"
  • Step 2: Add to workspace

In root Cargo.toml, add "crates/owlry-plugin-converter" to the members list.

  • Step 3: Create src/lib.rs with plugin vtable and stub modules
//! Converter Plugin for Owlry
//!
//! A dynamic provider that converts between units and currencies.
//! Supports queries prefixed with `>` or auto-detected.
//!
//! Examples:
//! - `> 100 F to C` → 37.78 °C
//! - `50 kg in lb` → 110.23 lb
//! - `100 eur to usd` → 108.32 USD

mod currency;
mod parser;
mod units;

use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
    API_VERSION, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind,
    ProviderPosition, owlry_plugin,
};

const PLUGIN_ID: &str = "converter";
const PLUGIN_NAME: &str = "Converter";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Convert between units and currencies";

const PROVIDER_ID: &str = "converter";
const PROVIDER_NAME: &str = "Converter";
const PROVIDER_PREFIX: &str = ">";
const PROVIDER_ICON: &str = "edit-find-replace";
const PROVIDER_TYPE_ID: &str = "conv";

struct ConverterState;

extern "C" fn plugin_info() -> PluginInfo {
    PluginInfo {
        id: RString::from(PLUGIN_ID),
        name: RString::from(PLUGIN_NAME),
        version: RString::from(PLUGIN_VERSION),
        description: RString::from(PLUGIN_DESCRIPTION),
        api_version: API_VERSION,
    }
}

extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
    vec![ProviderInfo {
        id: RString::from(PROVIDER_ID),
        name: RString::from(PROVIDER_NAME),
        prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
        icon: RString::from(PROVIDER_ICON),
        provider_type: ProviderKind::Dynamic,
        type_id: RString::from(PROVIDER_TYPE_ID),
        position: ProviderPosition::Normal,
        priority: 9000,
    }]
    .into()
}

extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
    let state = Box::new(ConverterState);
    ProviderHandle::from_box(state)
}

extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
    RVec::new()
}

extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
    let query_str = query.as_str().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 RVec::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| {
            PluginItem::new(
                format!("conv:{}:{}:{}", parsed.from_unit, r.target_symbol, r.value),
                r.display_value.clone(),
                format!("sh -c 'echo -n \"{}\" | wl-copy'", r.raw_value),
            )
            .with_description(format!(
                "{} {} = {} {}",
                format_number(parsed.value),
                parsed.from_symbol,
                r.display_value,
                r.target_symbol,
            ))
            .with_icon(PROVIDER_ICON)
        })
        .collect::<Vec<_>>()
        .into()
}

extern "C" fn provider_drop(handle: ProviderHandle) {
    if !handle.ptr.is_null() {
        unsafe {
            handle.drop_as::<ConverterState>();
        }
    }
}

owlry_plugin! {
    info: plugin_info,
    providers: plugin_providers,
    init: provider_init,
    refresh: provider_refresh,
    query: provider_query,
    drop: provider_drop,
}

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()
    }
}

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()
}
  • Step 4: Create stub src/parser.rs
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> {
    None // stub
}
  • Step 5: Create stub src/units.rs
pub struct ConversionResult {
    pub value: f64,
    pub raw_value: String,
    pub display_value: String,
    pub target_symbol: String,
}

pub fn convert_to(_value: &f64, _from: &str, _to: &str) -> Option<ConversionResult> {
    None // stub
}

pub fn convert_common(_value: &f64, _from: &str) -> Vec<ConversionResult> {
    vec![] // stub
}
  • Step 6: Create stub src/currency.rs
pub struct CurrencyRates {
    pub rates: std::collections::HashMap<String, f64>,
}

pub fn get_rates() -> Option<CurrencyRates> {
    None // stub
}
  • Step 7: Verify it compiles

Run: cargo build -p owlry-plugin-converter

Expected: compiles, produces .so in target/debug/

  • Step 8: Commit
git add Cargo.toml crates/owlry-plugin-converter/
git commit -m "feat(converter): scaffold plugin crate with vtable"

Task 2: Unit definitions and conversion engine

Files:

  • Create: crates/owlry-plugin-converter/src/units.rs (replace stub)

  • Step 1: Write tests for unit conversion

Add to bottom of src/units.rs:

#[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());
    }
}
  • Step 2: Run tests to verify they fail

Run: cargo test -p owlry-plugin-converter

Expected: compilation or assertion failures (stub returns None).

  • Step 3: Implement the full unit definitions and conversion engine

Replace src/units.rs with the complete implementation:

use std::collections::HashMap;
use std::sync::LazyLock;

use crate::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)]
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 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!["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();
    ALIAS_MAP.get(&lower).map(|&i| UNITS[i].symbol)
}

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> {
    let (_, from_def) = lookup_unit(from)?;
    let (_, to_def) = lookup_unit(to)?;

    // Currency needs special handling
    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.from_base(base_value);

    Some(format_result(result, to_def.symbol))
}

pub fn convert_common(value: &f64, from: &str) -> Vec<ConversionResult> {
    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.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 {
        crate::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
        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) },
    ]
}
  • Step 4: Run tests

Run: cargo test -p owlry-plugin-converter

Expected: all unit conversion tests pass. Currency tests will be skipped (stub returns None).

  • Step 5: Commit
git add crates/owlry-plugin-converter/src/units.rs
git commit -m "feat(converter): implement unit definitions and conversion engine"

Task 3: Query parser

Files:

  • Create: crates/owlry-plugin-converter/src/parser.rs (replace stub)

  • Step 1: Write parser tests

Add to bottom of src/parser.rs:

#[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");
    }
}
  • Step 2: Run tests to verify they fail

Run: cargo test -p owlry-plugin-converter -- parser

Expected: fails (stub returns None).

  • Step 3: Implement the parser

Replace src/parser.rs:

use crate::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");
    }
}
  • Step 4: Run tests

Run: cargo test -p owlry-plugin-converter -- parser

Expected: all parser tests pass.

  • Step 5: Commit
git add crates/owlry-plugin-converter/src/parser.rs
git commit -m "feat(converter): implement natural speech query parser"

Task 4: Currency module (ECB rates)

Files:

  • Create: crates/owlry-plugin-converter/src/currency.rs (replace stub)

  • Step 1: Write currency tests

Add to bottom of src/currency.rs:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_resolve_currency_code_iso() {
        assert_eq!(resolve_currency_code("usd"), Some("USD".to_string()));
        assert_eq!(resolve_currency_code("EUR"), Some("EUR".to_string()));
    }

    #[test]
    fn test_resolve_currency_code_name() {
        assert_eq!(resolve_currency_code("dollar"), Some("USD".to_string()));
        assert_eq!(resolve_currency_code("euro"), Some("EUR".to_string()));
        assert_eq!(resolve_currency_code("pounds"), Some("GBP".to_string()));
    }

    #[test]
    fn test_resolve_currency_code_symbol() {
        assert_eq!(resolve_currency_code("$"), Some("USD".to_string()));
        assert_eq!(resolve_currency_code("€"), Some("EUR".to_string()));
        assert_eq!(resolve_currency_code("£"), Some("GBP".to_string()));
    }

    #[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);
    }
}
  • Step 2: Run tests to verify they fail

Run: cargo test -p owlry-plugin-converter -- currency

Expected: fails (stub).

  • Step 3: Implement currency module

Replace src/currency.rs:

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<String> {
    let lower = alias.to_lowercase();

    // Check aliases
    for ca in CURRENCY_ALIASES {
        if ca.aliases.contains(&lower.as_str()) {
            return Some(ca.code.to_string());
        }
    }

    // Check if it's a raw 3-letter ISO code we know about
    let upper = alias.to_uppercase();
    if upper.len() == 3 {
        // EUR is always valid
        if upper == "EUR" {
            return Some(upper);
        }
        // Check if we have rates for it
        if let Some(rates) = get_rates() {
            if rates.rates.contains_key(&upper) {
                return Some(upper);
            }
        }
    }

    None
}

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() {
        if !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=") {
            if 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".to_string()));
        assert_eq!(resolve_currency_code("EUR"), Some("EUR".to_string()));
    }

    #[test]
    fn test_resolve_currency_code_name() {
        assert_eq!(resolve_currency_code("dollar"), Some("USD".to_string()));
        assert_eq!(resolve_currency_code("euro"), Some("EUR".to_string()));
        assert_eq!(resolve_currency_code("pounds"), Some("GBP".to_string()));
    }

    #[test]
    fn test_resolve_currency_code_symbol() {
        assert_eq!(resolve_currency_code("$"), Some("USD".to_string()));
        assert_eq!(resolve_currency_code("€"), Some("EUR".to_string()));
        assert_eq!(resolve_currency_code("£"), Some("GBP".to_string()));
    }

    #[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);
    }
}
  • Step 4: Register currency aliases with the unit system

In src/units.rs, the find_unit function needs to also check currency aliases. Update find_unit and lookup_unit to fall through to currency::is_currency_alias:

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);
    }
    // Check currency
    currency::resolve_currency_code(&lower).map(|code| {
        // Return a leaked &'static str for the currency code
        // This is fine since there are a fixed number of currencies
        Box::leak(code.into_boxed_str()) as &'static str
    })
}

Note: The Box::leak approach is acceptable here because currency codes are a fixed, small set and won't accumulate over time. Alternatively, use a static HashMap of currency code → &'static str.

  • Step 5: Run all tests

Run: cargo test -p owlry-plugin-converter

Expected: all tests pass (unit + parser + currency).

  • Step 6: Commit
git add crates/owlry-plugin-converter/src/currency.rs crates/owlry-plugin-converter/src/units.rs
git commit -m "feat(converter): implement currency rates from ECB with caching"

Task 5: Integration and final wiring

Files:

  • Modify: crates/owlry-plugin-converter/src/lib.rs — finalize query dispatch

  • Step 1: Write integration tests

Add to src/lib.rs:

#[cfg(test)]
mod tests {
    use super::*;

    #[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");
    }

    #[test]
    fn test_provider_query_with_prefix() {
        // The query function should handle "> 100 km to mi"
        let result = provider_query(
            ProviderHandle { ptr: std::ptr::null_mut() },
            RStr::from("> 100 km to mi"),
        );
        assert!(!result.is_empty());
    }

    #[test]
    fn test_provider_query_auto_detect() {
        let result = provider_query(
            ProviderHandle { ptr: std::ptr::null_mut() },
            RStr::from("100 km to mi"),
        );
        assert!(!result.is_empty());
    }

    #[test]
    fn test_provider_query_no_target() {
        let result = provider_query(
            ProviderHandle { ptr: std::ptr::null_mut() },
            RStr::from("> 100 km"),
        );
        assert!(result.len() > 1); // Should show multiple common conversions
    }

    #[test]
    fn test_provider_query_nonsense() {
        let result = provider_query(
            ProviderHandle { ptr: std::ptr::null_mut() },
            RStr::from("hello world"),
        );
        assert!(result.is_empty());
    }

    #[test]
    fn test_provider_query_temperature() {
        let result = provider_query(
            ProviderHandle { ptr: std::ptr::null_mut() },
            RStr::from("102F to C"),
        );
        assert!(!result.is_empty());
        let name = result[0].name.as_str();
        assert!(name.contains("38"), "Expected ~38.89°C, got: {}", name);
    }
}
  • Step 2: Run tests

Run: cargo test -p owlry-plugin-converter

Expected: all tests pass.

  • Step 3: Build the plugin .so

Run: cargo build -p owlry-plugin-converter --release

Verify: ls target/release/libowlry_plugin_converter.so

  • Step 4: Commit
git add crates/owlry-plugin-converter/src/lib.rs
git commit -m "feat(converter): finalize integration and add integration tests"

Task 6: Update workspace and push

Files:

  • Modify: Cargo.toml (already done in Task 1)

  • Step 1: Run full workspace build and tests

cargo build --workspace
cargo test --workspace
cargo clippy --workspace

All must pass.

  • Step 2: Push to plugins repo
git push origin main
  • Step 3: Install locally and test
sudo install -Dm755 target/release/libowlry_plugin_converter.so /usr/lib/owlry/plugins/libowlry_plugin_converter.so

Then restart owlry-core and test:

  • > 100 F to C

  • 50 kg in lb

  • 100 km (should show common conversions)

  • 100 eur to usd (currency)

  • Step 4: Tag and push

git tag -a owlry-plugin-converter-v1.0.0 -m "owlry-plugin-converter v1.0.0"
git push origin --tags
  • Step 5: Commit final
git add -A
git commit -m "chore: finalize converter plugin v1.0.0"