//! Units of measure for rate × quantity lines. Mirrors src/lib/units.ts: the ids below are stored in //! invoices and presets, and any other text that passes `validate` is a custom unit stored as typed. /// Built-in unit ids with the ERPNext UOM each one maps to by default. Whole-number UOMs ("Nos", "Set") /// reject fractional quantities, so units that are usually fractional map to UOMs that allow them. pub const KNOWN: &[(&str, &str)] = &[ ("unit", "Nos"), ("piece", "Nos"), ("set", "Set"), ("session", "Nos"), ("second", "Second"), ("minute", "Minute"), ("hour", "Hour"), ("day", "Day"), ("week", "Week"), ("month", "Month"), ("km", "Kilometer"), ("m", "Meter"), ("sqft", "Square Foot"), ("sqm", "Square Meter"), ("kg", "Kg"), ("litre", "Litre"), ]; /// Longest custom unit; it lands in a measured PDF column. pub const MAX_CUSTOM_LEN: usize = 12; pub fn is_known(unit: &str) -> bool { KNOWN.iter().any(|(id, _)| *id == unit) } /// The ERPNext UOM that suits a built-in unit. pub fn default_uom(unit: &str) -> Option<&'static str> { KNOWN .iter() .find(|(id, _)| *id == unit) .map(|(_, uom)| *uom) } fn custom_problem(text: &str) -> Option { let t = text.trim(); if t.is_empty() { return Some("a unit is required".into()); } if t.chars().count() > MAX_CUSTOM_LEN { return Some(format!( "a custom unit has at most {MAX_CUSTOM_LEN} characters" )); } let mut chars = t.chars(); let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphanumeric()); if !first_ok || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '/' | '-')) { return Some("a unit may use letters, digits, spaces, . / and - only".into()); } None } /// The unit as it is stored: trimmed, and a built-in id in lower case. An empty value is the generic unit. pub fn normalize(unit: &str) -> String { let t = unit.trim(); if t.is_empty() { return "unit".to_string(); } let lower = t.to_ascii_lowercase(); if is_known(&lower) { lower } else { t.to_string() } } /// Checks a unit that is about to be stored. Errors read as `Unit "x": `. pub fn validate(unit: &str) -> Result<(), String> { if unit.trim().is_empty() { return Err("A unit is required".to_string()); } if is_known(&normalize(unit)) { return Ok(()); } match custom_problem(unit) { None => Ok(()), Some(p) => Err(format!("Unit \"{}\": {p}", unit.trim())), } } #[cfg(test)] mod tests { use super::*; #[test] fn known_units_are_accepted_in_any_case() { for (id, _) in KNOWN { assert!(validate(id).is_ok(), "{id}"); } assert_eq!(normalize(" HOUR "), "hour"); assert_eq!(normalize(""), "unit"); } #[test] fn custom_units_follow_the_same_rules_as_the_ui() { assert!(validate("bundle").is_ok()); assert!(validate("sq.ft/day").is_ok()); assert_eq!(normalize(" bundle "), "bundle"); assert!(validate("a".repeat(13).as_str()).is_err()); assert!(validate("₹").is_err()); assert!(validate("-x").is_err()); assert!(validate("").is_err()); assert!(validate(" ").is_err()); assert!(validate("bad;drop").is_err()); } #[test] fn the_original_five_ids_are_unchanged() { for id in ["second", "minute", "hour", "session", "unit"] { assert!(is_known(id), "{id}"); } } #[test] fn fractional_units_do_not_map_to_whole_number_uoms() { for id in [ "second", "minute", "hour", "day", "week", "month", "km", "m", "sqft", "sqm", "kg", "litre", ] { assert_ne!(default_uom(id), Some("Nos"), "{id}"); } } }