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            unreachable!("Calendar units (month/year) should be handled by date arithmetic")
161        }
162    }
163}
164
165fn try_parse_temperature_unit(s: &str) -> Option<TemperatureUnit> {
166    match s {
167        "celsius" => Some(TemperatureUnit::Celsius),
168        "fahrenheit" => Some(TemperatureUnit::Fahrenheit),
169        "kelvin" => Some(TemperatureUnit::Kelvin),
170        _ => None,
171    }
172}
173
174// Power Units
175fn try_parse_power_unit(s: &str) -> Option<PowerUnit> {
176    match s {
177        "megawatt" | "megawatts" => Some(PowerUnit::Megawatt),
178        "kilowatt" | "kilowatts" => Some(PowerUnit::Kilowatt),
179        "watt" | "watts" => Some(PowerUnit::Watt),
180        "milliwatt" | "milliwatts" => Some(PowerUnit::Milliwatt),
181        "horsepower" => Some(PowerUnit::Horsepower),
182        _ => None,
183    }
184}
185
186// Force Units
187fn try_parse_force_unit(s: &str) -> Option<ForceUnit> {
188    match s {
189        "newton" | "newtons" => Some(ForceUnit::Newton),
190        "kilonewton" | "kilonewtons" => Some(ForceUnit::Kilonewton),
191        "lbf" | "poundforce" => Some(ForceUnit::Lbf),
192        _ => None,
193    }
194}
195
196// Pressure Units
197fn try_parse_pressure_unit(s: &str) -> Option<PressureUnit> {
198    match s {
199        "megapascal" | "megapascals" => Some(PressureUnit::Megapascal),
200        "kilopascal" | "kilopascals" => Some(PressureUnit::Kilopascal),
201        "pascal" | "pascals" => Some(PressureUnit::Pascal),
202        "atmosphere" | "atmospheres" => Some(PressureUnit::Atmosphere),
203        "bar" => Some(PressureUnit::Bar),
204        "psi" => Some(PressureUnit::Psi),
205        "torr" => Some(PressureUnit::Torr),
206        "mmhg" => Some(PressureUnit::Mmhg),
207        _ => None,
208    }
209}
210
211// Energy Units
212fn try_parse_energy_unit(s: &str) -> Option<EnergyUnit> {
213    match s {
214        "megajoule" | "megajoules" => Some(EnergyUnit::Megajoule),
215        "kilojoule" | "kilojoules" => Some(EnergyUnit::Kilojoule),
216        "joule" | "joules" => Some(EnergyUnit::Joule),
217        "kilowatthour" | "kilowatthours" => Some(EnergyUnit::Kilowatthour),
218        "watthour" | "watthours" => Some(EnergyUnit::Watthour),
219        "kilocalorie" | "kilocalories" => Some(EnergyUnit::Kilocalorie),
220        "calorie" | "calories" => Some(EnergyUnit::Calorie),
221        "btu" => Some(EnergyUnit::Btu),
222        _ => None,
223    }
224}
225
226// Frequency Units
227fn try_parse_frequency_unit(s: &str) -> Option<FrequencyUnit> {
228    match s {
229        "hertz" => Some(FrequencyUnit::Hertz),
230        "kilohertz" => Some(FrequencyUnit::Kilohertz),
231        "megahertz" => Some(FrequencyUnit::Megahertz),
232        "gigahertz" => Some(FrequencyUnit::Gigahertz),
233        _ => None,
234    }
235}
236
237// Data Size Units
238fn try_parse_data_size_unit(s: &str) -> Option<DataUnit> {
239    match s {
240        "petabyte" | "petabytes" => Some(DataUnit::Petabyte),
241        "terabyte" | "terabytes" => Some(DataUnit::Terabyte),
242        "gigabyte" | "gigabytes" => Some(DataUnit::Gigabyte),
243        "megabyte" | "megabytes" => Some(DataUnit::Megabyte),
244        "kilobyte" | "kilobytes" => Some(DataUnit::Kilobyte),
245        "byte" | "bytes" => Some(DataUnit::Byte),
246        "tebibyte" | "tebibytes" => Some(DataUnit::Tebibyte),
247        "gibibyte" | "gibibytes" => Some(DataUnit::Gibibyte),
248        "mebibyte" | "mebibytes" => Some(DataUnit::Mebibyte),
249        "kibibyte" | "kibibytes" => Some(DataUnit::Kibibyte),
250        _ => None,
251    }
252}
253
254// Money Units (ISO 4217 3-character currency codes only)
255fn try_parse_money_unit(s: &str) -> Option<MoneyUnit> {
256    match s {
257        "eur" => Some(MoneyUnit::Eur),
258        "usd" => Some(MoneyUnit::Usd),
259        "gbp" => Some(MoneyUnit::Gbp),
260        "jpy" => Some(MoneyUnit::Jpy),
261        "cny" => Some(MoneyUnit::Cny),
262        "chf" => Some(MoneyUnit::Chf),
263        "cad" => Some(MoneyUnit::Cad),
264        "aud" => Some(MoneyUnit::Aud),
265        "inr" => Some(MoneyUnit::Inr),
266        _ => None,
267    }
268}
269
270/// Resolve a unit conversion target (for "in" expressions)
271pub fn resolve_conversion_target(unit_str: &str) -> Result<ConversionTarget, LemmaError> {
272    let unit_lower = unit_str.to_lowercase();
273
274    // Handle "percentage" conversion
275    if unit_lower == "percentage" || unit_lower == "percent" {
276        return Ok(ConversionTarget::Percentage);
277    }
278
279    // Try each unit category
280    if let Some(unit) = try_parse_mass_unit(&unit_lower) {
281        return Ok(ConversionTarget::Mass(unit));
282    }
283
284    if let Some(unit) = try_parse_length_unit(&unit_lower) {
285        return Ok(ConversionTarget::Length(unit));
286    }
287
288    if let Some(unit) = try_parse_volume_unit(&unit_lower) {
289        return Ok(ConversionTarget::Volume(unit));
290    }
291
292    if let Some(unit) = try_parse_duration_unit(&unit_lower) {
293        return Ok(ConversionTarget::Duration(unit));
294    }
295
296    if let Some(unit) = try_parse_temperature_unit(&unit_lower) {
297        return Ok(ConversionTarget::Temperature(unit));
298    }
299
300    if let Some(unit) = try_parse_power_unit(&unit_lower) {
301        return Ok(ConversionTarget::Power(unit));
302    }
303
304    if let Some(unit) = try_parse_force_unit(&unit_lower) {
305        return Ok(ConversionTarget::Force(unit));
306    }
307
308    if let Some(unit) = try_parse_pressure_unit(&unit_lower) {
309        return Ok(ConversionTarget::Pressure(unit));
310    }
311
312    if let Some(unit) = try_parse_energy_unit(&unit_lower) {
313        return Ok(ConversionTarget::Energy(unit));
314    }
315
316    if let Some(unit) = try_parse_frequency_unit(&unit_lower) {
317        return Ok(ConversionTarget::Frequency(unit));
318    }
319
320    if let Some(unit) = try_parse_data_size_unit(&unit_lower) {
321        return Ok(ConversionTarget::Data(unit));
322    }
323
324    if let Some(unit) = try_parse_money_unit(&unit_lower) {
325        return Ok(ConversionTarget::Money(unit));
326    }
327
328    // Conversion target not recognized
329    let suggestion = find_closest_unit(&unit_lower);
330    Err(LemmaError::Engine(format!(
331        "Unknown conversion target unit: '{}'. {}",
332        unit_str, suggestion
333    )))
334}
335
336/// Find the closest matching unit to provide a helpful suggestion
337fn find_closest_unit(s: &str) -> String {
338    // Common typos and alternatives
339    let suggestions: Vec<(&str, &str)> = vec![
340        ("kilometer", "kilometers"),
341        ("kilometre", "kilometers"),
342        ("metre", "meters"),
343        ("kilogramme", "kilograms"),
344        ("gramme", "grams"),
345        ("litre", "liters"),
346        ("sec", "seconds"),
347        ("min", "minutes"),
348        ("hr", "hours"),
349    ];
350
351    for (typo, correct) in &suggestions {
352        if s == *typo || s.starts_with(typo) {
353            return format!("Did you mean '{}'?", correct);
354        }
355    }
356
357    // Check for common abbreviation issues
358    if s.len() <= 3 {
359        return "Try using the full unit name (e.g., 'kilometers' instead of 'km')".to_string();
360    }
361
362    "Check the unit name spelling".to_string()
363}