lemma/parser/
units.rs

1//! Unit resolution - maps unit strings to Lemma unit types
2
3use crate::error::LemmaError;
4use crate::semantic::*;
5use rust_decimal::Decimal;
6
7/// Resolve a unit string and value to a LiteralValue
8pub fn resolve_unit(value: Decimal, unit_str: &str) -> Result<LiteralValue, LemmaError> {
9    let unit_lower = unit_str.to_lowercase();
10
11    // Try each unit category in order and wrap in NumericUnit
12    if let Some(unit) = try_parse_mass_unit(&unit_lower) {
13        return Ok(LiteralValue::Unit(NumericUnit::Mass(value, unit)));
14    }
15
16    if let Some(unit) = try_parse_length_unit(&unit_lower) {
17        return Ok(LiteralValue::Unit(NumericUnit::Length(value, unit)));
18    }
19
20    if let Some(unit) = try_parse_volume_unit(&unit_lower) {
21        return Ok(LiteralValue::Unit(NumericUnit::Volume(value, unit)));
22    }
23
24    if let Some(unit) = try_parse_duration_unit(&unit_lower) {
25        return Ok(LiteralValue::Unit(NumericUnit::Duration(value, unit)));
26    }
27
28    if let Some(unit) = try_parse_temperature_unit(&unit_lower) {
29        return Ok(LiteralValue::Unit(NumericUnit::Temperature(value, unit)));
30    }
31
32    if let Some(unit) = try_parse_power_unit(&unit_lower) {
33        return Ok(LiteralValue::Unit(NumericUnit::Power(value, unit)));
34    }
35
36    if let Some(unit) = try_parse_force_unit(&unit_lower) {
37        return Ok(LiteralValue::Unit(NumericUnit::Force(value, unit)));
38    }
39
40    if let Some(unit) = try_parse_pressure_unit(&unit_lower) {
41        return Ok(LiteralValue::Unit(NumericUnit::Pressure(value, unit)));
42    }
43
44    if let Some(unit) = try_parse_energy_unit(&unit_lower) {
45        return Ok(LiteralValue::Unit(NumericUnit::Energy(value, unit)));
46    }
47
48    if let Some(unit) = try_parse_frequency_unit(&unit_lower) {
49        return Ok(LiteralValue::Unit(NumericUnit::Frequency(value, unit)));
50    }
51
52    if let Some(unit) = try_parse_data_size_unit(&unit_lower) {
53        return Ok(LiteralValue::Unit(NumericUnit::Data(value, unit)));
54    }
55
56    if let Some(currency) = try_parse_money_unit(&unit_lower) {
57        return Ok(LiteralValue::Unit(NumericUnit::Money(value, currency)));
58    }
59
60    // Unit not recognized
61    let suggestion = find_closest_unit(&unit_lower);
62    Err(LemmaError::Engine(format!(
63        "Unknown unit: '{}'. {}",
64        unit_str, suggestion
65    )))
66}
67
68// Mass Units
69fn try_parse_mass_unit(s: &str) -> Option<MassUnit> {
70    match s {
71        "kilogram" | "kilograms" => Some(MassUnit::Kilogram),
72        "gram" | "grams" => Some(MassUnit::Gram),
73        "milligram" | "milligrams" => Some(MassUnit::Milligram),
74        "ton" | "tons" | "tonne" | "tonnes" => Some(MassUnit::Ton),
75        "pound" | "pounds" => Some(MassUnit::Pound),
76        "ounce" | "ounces" => Some(MassUnit::Ounce),
77        _ => None,
78    }
79}
80
81// Length Units
82fn try_parse_length_unit(s: &str) -> Option<LengthUnit> {
83    match s {
84        "kilometer" | "kilometers" | "kilometre" | "kilometres" => Some(LengthUnit::Kilometer),
85        "mile" | "miles" => Some(LengthUnit::Mile),
86        "nautical_mile" | "nautical_miles" | "nauticalmile" | "nauticalmiles" => {
87            Some(LengthUnit::NauticalMile)
88        }
89        "meter" | "meters" | "metre" | "metres" => Some(LengthUnit::Meter),
90        "decimeter" | "decimeters" | "decimetre" | "decimetres" => Some(LengthUnit::Decimeter),
91        "centimeter" | "centimeters" | "centimetre" | "centimetres" => Some(LengthUnit::Centimeter),
92        "millimeter" | "millimeters" | "millimetre" | "millimetres" => Some(LengthUnit::Millimeter),
93        "yard" | "yards" => Some(LengthUnit::Yard),
94        "foot" | "feet" => Some(LengthUnit::Foot),
95        "inch" | "inches" => Some(LengthUnit::Inch),
96        _ => None,
97    }
98}
99
100// Volume Units
101fn try_parse_volume_unit(s: &str) -> Option<VolumeUnit> {
102    match s {
103        "cubic_meter" | "cubic_meters" | "cubic_metre" | "cubic_metres" | "cubicmeter"
104        | "cubicmeters" | "cubicmetre" | "cubicmetres" => Some(VolumeUnit::CubicMeter),
105        "cubic_centimeter" | "cubic_centimeters" | "cubic_centimetre" | "cubic_centimetres"
106        | "cubiccentimeter" | "cubiccentimeters" => Some(VolumeUnit::CubicCentimeter),
107        "liter" | "liters" | "litre" | "litres" => Some(VolumeUnit::Liter),
108        "deciliter" | "deciliters" | "decilitre" | "decilitres" => Some(VolumeUnit::Deciliter),
109        "centiliter" | "centiliters" | "centilitre" | "centilitres" => Some(VolumeUnit::Centiliter),
110        "milliliter" | "milliliters" | "millilitre" | "millilitres" => Some(VolumeUnit::Milliliter),
111        "gallon" | "gallons" => Some(VolumeUnit::Gallon),
112        "quart" | "quarts" => Some(VolumeUnit::Quart),
113        "pint" | "pints" => Some(VolumeUnit::Pint),
114        "fluid_ounce" | "fluid_ounces" | "fluidounce" | "fluidounces" => {
115            Some(VolumeUnit::FluidOunce)
116        }
117        _ => None,
118    }
119}
120
121// Duration Units
122fn try_parse_duration_unit(s: &str) -> Option<DurationUnit> {
123    match s {
124        "year" | "years" => Some(DurationUnit::Year),
125        "month" | "months" => Some(DurationUnit::Month),
126        "week" | "weeks" => Some(DurationUnit::Week),
127        "day" | "days" => Some(DurationUnit::Day),
128        "hour" | "hours" => Some(DurationUnit::Hour),
129        "minute" | "minutes" => Some(DurationUnit::Minute),
130        "second" | "seconds" => Some(DurationUnit::Second),
131        "millisecond" | "milliseconds" => Some(DurationUnit::Millisecond),
132        "microsecond" | "microseconds" => Some(DurationUnit::Microsecond),
133        _ => None,
134    }
135}
136
137// Duration conversion constants (all relative to seconds)
138const SECONDS_PER_MINUTE: i32 = 60;
139const SECONDS_PER_HOUR: i32 = 3600; // 60 * 60
140const SECONDS_PER_DAY: i32 = 86400; // 24 * 60 * 60
141const SECONDS_PER_WEEK: i32 = 604800; // 7 * 24 * 60 * 60
142const MILLISECONDS_PER_SECOND: i32 = 1000;
143const MICROSECONDS_PER_SECOND: i32 = 1000000;
144
145/// Convert a time-based duration value to seconds.
146///
147/// Note: Month and Year are calendar units that depend on specific dates
148/// (e.g., February has 28 or 29 days), so they cannot be converted to fixed
149/// second values and must be handled separately using chrono's date arithmetic.
150pub(crate) fn duration_to_seconds(value: Decimal, unit: &DurationUnit) -> Decimal {
151    match unit {
152        DurationUnit::Microsecond => value / Decimal::from(MICROSECONDS_PER_SECOND),
153        DurationUnit::Millisecond => value / Decimal::from(MILLISECONDS_PER_SECOND),
154        DurationUnit::Second => value,
155        DurationUnit::Minute => value * Decimal::from(SECONDS_PER_MINUTE),
156        DurationUnit::Hour => value * Decimal::from(SECONDS_PER_HOUR),
157        DurationUnit::Day => value * Decimal::from(SECONDS_PER_DAY),
158        DurationUnit::Week => value * Decimal::from(SECONDS_PER_WEEK),
159        DurationUnit::Month | DurationUnit::Year => {
160            // Calendar units should be rejected before reaching here
161            // This should never happen if validation works correctly, but we return a sentinel
162            // value rather than panicking. The actual error should be caught in convert_duration().
163            Decimal::ZERO
164        }
165    }
166}
167
168fn try_parse_temperature_unit(s: &str) -> Option<TemperatureUnit> {
169    match s {
170        "celsius" => Some(TemperatureUnit::Celsius),
171        "fahrenheit" => Some(TemperatureUnit::Fahrenheit),
172        "kelvin" => Some(TemperatureUnit::Kelvin),
173        _ => None,
174    }
175}
176
177// Power Units
178fn try_parse_power_unit(s: &str) -> Option<PowerUnit> {
179    match s {
180        "megawatt" | "megawatts" => Some(PowerUnit::Megawatt),
181        "kilowatt" | "kilowatts" => Some(PowerUnit::Kilowatt),
182        "watt" | "watts" => Some(PowerUnit::Watt),
183        "milliwatt" | "milliwatts" => Some(PowerUnit::Milliwatt),
184        "horsepower" => Some(PowerUnit::Horsepower),
185        _ => None,
186    }
187}
188
189// Force Units
190fn try_parse_force_unit(s: &str) -> Option<ForceUnit> {
191    match s {
192        "newton" | "newtons" => Some(ForceUnit::Newton),
193        "kilonewton" | "kilonewtons" => Some(ForceUnit::Kilonewton),
194        "lbf" | "poundforce" => Some(ForceUnit::Lbf),
195        _ => None,
196    }
197}
198
199// Pressure Units
200fn try_parse_pressure_unit(s: &str) -> Option<PressureUnit> {
201    match s {
202        "megapascal" | "megapascals" => Some(PressureUnit::Megapascal),
203        "kilopascal" | "kilopascals" => Some(PressureUnit::Kilopascal),
204        "pascal" | "pascals" => Some(PressureUnit::Pascal),
205        "atmosphere" | "atmospheres" => Some(PressureUnit::Atmosphere),
206        "bar" => Some(PressureUnit::Bar),
207        "psi" => Some(PressureUnit::Psi),
208        "torr" => Some(PressureUnit::Torr),
209        "mmhg" => Some(PressureUnit::Mmhg),
210        _ => None,
211    }
212}
213
214// Energy Units
215fn try_parse_energy_unit(s: &str) -> Option<EnergyUnit> {
216    match s {
217        "megajoule" | "megajoules" => Some(EnergyUnit::Megajoule),
218        "kilojoule" | "kilojoules" => Some(EnergyUnit::Kilojoule),
219        "joule" | "joules" => Some(EnergyUnit::Joule),
220        "kilowatthour" | "kilowatthours" => Some(EnergyUnit::Kilowatthour),
221        "watthour" | "watthours" => Some(EnergyUnit::Watthour),
222        "kilocalorie" | "kilocalories" => Some(EnergyUnit::Kilocalorie),
223        "calorie" | "calories" => Some(EnergyUnit::Calorie),
224        "btu" => Some(EnergyUnit::Btu),
225        _ => None,
226    }
227}
228
229// Frequency Units
230fn try_parse_frequency_unit(s: &str) -> Option<FrequencyUnit> {
231    match s {
232        "hertz" => Some(FrequencyUnit::Hertz),
233        "kilohertz" => Some(FrequencyUnit::Kilohertz),
234        "megahertz" => Some(FrequencyUnit::Megahertz),
235        "gigahertz" => Some(FrequencyUnit::Gigahertz),
236        _ => None,
237    }
238}
239
240// Data Size Units
241fn try_parse_data_size_unit(s: &str) -> Option<DataUnit> {
242    match s {
243        "petabyte" | "petabytes" => Some(DataUnit::Petabyte),
244        "terabyte" | "terabytes" => Some(DataUnit::Terabyte),
245        "gigabyte" | "gigabytes" => Some(DataUnit::Gigabyte),
246        "megabyte" | "megabytes" => Some(DataUnit::Megabyte),
247        "kilobyte" | "kilobytes" => Some(DataUnit::Kilobyte),
248        "byte" | "bytes" => Some(DataUnit::Byte),
249        "tebibyte" | "tebibytes" => Some(DataUnit::Tebibyte),
250        "gibibyte" | "gibibytes" => Some(DataUnit::Gibibyte),
251        "mebibyte" | "mebibytes" => Some(DataUnit::Mebibyte),
252        "kibibyte" | "kibibytes" => Some(DataUnit::Kibibyte),
253        _ => None,
254    }
255}
256
257// Money Units (ISO 4217 3-character currency codes only)
258fn try_parse_money_unit(s: &str) -> Option<MoneyUnit> {
259    match s {
260        "eur" => Some(MoneyUnit::Eur),
261        "usd" => Some(MoneyUnit::Usd),
262        "gbp" => Some(MoneyUnit::Gbp),
263        "jpy" => Some(MoneyUnit::Jpy),
264        "cny" => Some(MoneyUnit::Cny),
265        "chf" => Some(MoneyUnit::Chf),
266        "cad" => Some(MoneyUnit::Cad),
267        "aud" => Some(MoneyUnit::Aud),
268        "inr" => Some(MoneyUnit::Inr),
269        _ => None,
270    }
271}
272
273/// Resolve a unit conversion target (for "in" expressions)
274pub fn resolve_conversion_target(unit_str: &str) -> Result<ConversionTarget, LemmaError> {
275    let unit_lower = unit_str.to_lowercase();
276
277    // Handle "percentage" conversion
278    if unit_lower == "percentage" || unit_lower == "percent" {
279        return Ok(ConversionTarget::Percentage);
280    }
281
282    // Try each unit category
283    if let Some(unit) = try_parse_mass_unit(&unit_lower) {
284        return Ok(ConversionTarget::Mass(unit));
285    }
286
287    if let Some(unit) = try_parse_length_unit(&unit_lower) {
288        return Ok(ConversionTarget::Length(unit));
289    }
290
291    if let Some(unit) = try_parse_volume_unit(&unit_lower) {
292        return Ok(ConversionTarget::Volume(unit));
293    }
294
295    if let Some(unit) = try_parse_duration_unit(&unit_lower) {
296        return Ok(ConversionTarget::Duration(unit));
297    }
298
299    if let Some(unit) = try_parse_temperature_unit(&unit_lower) {
300        return Ok(ConversionTarget::Temperature(unit));
301    }
302
303    if let Some(unit) = try_parse_power_unit(&unit_lower) {
304        return Ok(ConversionTarget::Power(unit));
305    }
306
307    if let Some(unit) = try_parse_force_unit(&unit_lower) {
308        return Ok(ConversionTarget::Force(unit));
309    }
310
311    if let Some(unit) = try_parse_pressure_unit(&unit_lower) {
312        return Ok(ConversionTarget::Pressure(unit));
313    }
314
315    if let Some(unit) = try_parse_energy_unit(&unit_lower) {
316        return Ok(ConversionTarget::Energy(unit));
317    }
318
319    if let Some(unit) = try_parse_frequency_unit(&unit_lower) {
320        return Ok(ConversionTarget::Frequency(unit));
321    }
322
323    if let Some(unit) = try_parse_data_size_unit(&unit_lower) {
324        return Ok(ConversionTarget::Data(unit));
325    }
326
327    if let Some(unit) = try_parse_money_unit(&unit_lower) {
328        return Ok(ConversionTarget::Money(unit));
329    }
330
331    // Conversion target not recognized
332    let suggestion = find_closest_unit(&unit_lower);
333    Err(LemmaError::Engine(format!(
334        "Unknown conversion target unit: '{}'. {}",
335        unit_str, suggestion
336    )))
337}
338
339/// Find the closest matching unit to provide a helpful suggestion
340fn find_closest_unit(s: &str) -> String {
341    // Common typos and alternatives
342    let suggestions: Vec<(&str, &str)> = vec![
343        ("kilometer", "kilometers"),
344        ("kilometre", "kilometers"),
345        ("metre", "meters"),
346        ("kilogramme", "kilograms"),
347        ("gramme", "grams"),
348        ("litre", "liters"),
349        ("sec", "seconds"),
350        ("min", "minutes"),
351        ("hr", "hours"),
352    ];
353
354    for (typo, correct) in &suggestions {
355        if s == *typo || s.starts_with(typo) {
356            return format!("Did you mean '{}'?", correct);
357        }
358    }
359
360    // Check for common abbreviation issues
361    if s.len() <= 3 {
362        return "Try using the full unit name (e.g., 'kilometers' instead of 'km')".to_string();
363    }
364
365    "Check the unit name spelling".to_string()
366}