/// Parse Roman (classic or Excel variants) → number pub fn from_roman(s: &str) -> Result { if s.is_empty() { return Err("empty numeral".into()); } fn val(c: char) -> Option { Some(match c { 'I' => 1, 'V' => 5, 'X' => 10, 'L' => 50, 'C' => 100, 'D' => 500, 'M' => 1000, _ => return None, }) } // Accept the union of subtractive pairs used by the tables above (Excel-compatible). fn allowed_subtractive(a: char, b: char) -> bool { matches!( (a, b), // classic: ('I','V')|('I','X')|('X','L')|('X','C')|('C','D')|('C','M') // Excel forms: |('V','L')|('L','D')|('L','M') // VL, LD, LM |('X','D')|('X','M') // XD, XM |('V','M') // VM |('I','L')|('I','C')|('I','D')|('I','M') // IL, IC, ID, IM |('V','D')|('V','C') // VD, VC ) } let chars: Vec = s.chars().map(|c| c.to_ascii_uppercase()).collect(); let mut total = 0u32; let mut i = 0usize; // Repetition rules similar to classic Romans: // V, L, D cannot repeat; I, X, C, M max 3 in a row. let mut last_char: Option = None; let mut run_len = 0usize; while i < chars.len() { let c = chars[i]; let v = val(c).ok_or_else(|| format!("invalid character '{c}'"))?; if Some(c) == last_char { run_len += 1; match c { 'V' | 'L' | 'D' => return Err(format!("invalid repetition of '{c}'")), _ if run_len >= 3 => return Err(format!("invalid repetition of '{c}'")), _ => {} } } else { last_char = Some(c); run_len = 0; } if i + 1 < chars.len() { let c2 = chars[i + 1]; let v2 = val(c2).ok_or_else(|| format!("invalid character '{c2}'"))?; if v < v2 { if !allowed_subtractive(c, c2) { return Err(format!("invalid subtractive pair '{c}{c2}'")); } // Disallow stacked subtractives like IIV, XXL: if run_len > 0 { return Err(format!("malformed numeral near position {i}")); } total += v2 - v; i += 2; last_char = None; run_len = 0; continue; } } total += v; i += 1; } Ok(total) } /// Classic Roman (strict) encoder used as a base for all forms. fn to_roman(mut n: u32) -> Result { if !(1..=3999).contains(&n) { return Err("value out of range (must be 1..=3999)".into()); } const MAP: &[(u32, &str)] = &[ (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"), (50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"), ]; let mut out = String::with_capacity(15); for &(val, sym) in MAP { while n >= val { out.push_str(sym); n -= val; } if n == 0 { break; } } Ok(out) } /// Excel/Google Sheets compatible ROMAN(number, [form]) encoder. /// `form`: 0..=4 (0=Classic, 4=Simplified). pub fn to_roman_with_form(n: u32, form: i32) -> Result { let mut s = to_roman(n)?; if form == 0 { return Ok(s); } if !(0..=4).contains(&form) { return Err("form must be between 0 and 4".into()); } // Base rules (apply for all f >= 1) let base_rules: &[(&str, &str)] = &[ // C(D|M)XC -> L$1XL ("CDXC", "LDXL"), ("CMXC", "LMXL"), // C(D|M)L -> L$1 ("CDL", "LD"), ("CML", "LM"), // X(L|C)IX -> V$1IV ("XLIX", "VLIV"), ("XCIX", "VCIV"), // X(L|C)V -> V$1 ("XLV", "VL"), ("XCV", "VC"), ]; // Level 2 extra rules let lvl2_rules: &[(&str, &str)] = &[ // V(L|C)IV -> I$1 ("VLIV", "IL"), ("VCIV", "IC"), // L(D|M)XL -> X$1 ("LDXL", "XD"), ("LMXL", "XM"), // L(D|M)VL -> X$1V ("LDVL", "XDV"), ("LMVL", "XMV"), // L(D|M)IL -> X$1IX ("LDIL", "XDIX"), ("LMIL", "XMIX"), ]; // Level 3 extra rules let lvl3_rules: &[(&str, &str)] = &[ // X(D|M)V -> V$1 ("XDV", "VD"), ("XMV", "VM"), // X(D|M)IX -> V$1IV ("XDIX", "VDIV"), ("XMIX", "VMIV"), ]; // Level 4 extra rules let lvl4_rules: &[(&str, &str)] = &[ // V(D|M)IV -> I$1 ("VDIV", "ID"), ("VMIV", "IM"), ]; // Helper to apply a batch of (from -> to) globally, in order. fn apply_rules(mut t: String, rules: &[(&str, &str)]) -> String { for (from, to) in rules { if t.contains(from) { t = t.replace(from, to); } } t } s = apply_rules(s, base_rules); if form >= 2 { s = apply_rules(s, lvl2_rules); } if form >= 3 { s = apply_rules(s, lvl3_rules); } if form >= 4 { s = apply_rules(s, lvl4_rules); } Ok(s) }