Skip to content

Roman Numeral Converter — Rust source

Convert integers up to 3,999,999 to Roman numerals and back. Vinculum overline above 3,999, canonical-form validation, a step-by-step greedy breakdown, and 14 language sources. Runs entirely in your browser.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

// roman-numeral-converter - Roman <-> Arabic (vinculum, 1..3,999,999).
//
// Language: Rust (2021 edition, standard library only)
// Source:   CosmoDev polyglot showcase port of the Roman Numeral Converter tool,
//           ported from src/lib/roman-numeral.ts (the canonical TypeScript
//           implementation); kept in lock-step with the Go twin at
//           cli/roman-numeral-converter/roman-numeral-converter.go.
// License:  display source - part of CosmoDev's polyglot tool pages.
//
// Design goals:
//   - Pure + deterministic; never panics on bad input (returns None / "").
//   - Functionally equivalent to the TS/Go reference: same inputs -> same outputs.
//   - Self-contained: std only (no crates).
//
// Algorithm: one ordered (value, symbol) table for 1..3,999 drives both
// directions. to_roman greedily subtracts the largest fitting symbol; above
// 3,999 the thousands part is rendered with the same table and each glyph
// gains a combining overline (U+0305) meaning x 1,000. from_roman scans
// left-to-right where a smaller letter before a larger one subtracts
// (IV = 4, CM = 900), then RE-RENDERS the parsed total and rejects anything
// that doesn't round-trip - that one check enforces canonical form
// (rejecting "IIII", "VV", "IC", plain "MMMM" for 4,000).

/// Combining overline, U+0305 (vinculum: value x 1,000), as UTF-8 bytes.
const OVERLINE: &str = "\u{305}";
/// Combining macron, U+0304 - visually near-identical; accepted on input.
const MACRON: &str = "\u{304}";

pub const MAX_ROMAN: i64 = 3_999_999;

/// Ordered (value, symbol) pairs for 1..3,999, largest first - including the
/// subtractive pairs (CM, CD, XC, XL, IX, IV) so greedy yields canonical form.
const BASE: [(i64, &str); 13] = [
    (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"),
];

/// Each letter's face value - the whole numeral system in seven entries.
fn letter_value(c: char) -> i64 {
    match c {
        'I' => 1,
        'V' => 5,
        'X' => 10,
        'L' => 50,
        'C' => 100,
        'D' => 500,
        'M' => 1000,
        _ => 0,
    }
}

/// Attach the combining mark to every character (value x 1,000).
fn overline(s: &str) -> String {
    let mut out = String::with_capacity(s.len() * 3);
    for c in s.chars() {
        out.push(c);
        out.push_str(OVERLINE);
    }
    out
}

/// Greedy render of 1..3,999.
fn to_roman_base(v: i64) -> String {
    let mut out = String::new();
    let mut v = v;
    for (val, sym) in BASE {
        while v >= val {
            out.push_str(sym);
            v -= val;
        }
    }
    out
}

/// Converts 1..3,999,999 ("" when out of range). Above 3,999 the thousands
/// part carries a combining overline per glyph.
pub fn to_roman(n: i64) -> String {
    if !(1..=MAX_ROMAN).contains(&n) {
        return String::new();
    }
    if n <= 3999 {
        return to_roman_base(n);
    }
    let mut out = overline(&to_roman_base(n / 1000));
    let rest = n % 1000;
    if rest > 0 {
        out.push_str(&to_roman_base(rest));
    }
    out
}

/// One left-to-right pass where a smaller letter before a larger one
/// subtracts. Returns junk for non-canonical strings - the round-trip in
/// from_roman is the canonicality gate.
fn scan_value(s: &str) -> i64 {
    let mut total = 0i64;
    let mut chars = s.chars().peekable();
    while let Some(c) = chars.next() {
        let v = letter_value(c);
        let next = chars.peek().map_or(0, |n| letter_value(*n));
        total += if next > v { -v } else { v };
    }
    total
}

/// Parses a canonical numeral (plain or vinculum), or None. Trimmed and
/// uppercased first; a pasted macron counts as the overline mark.
pub fn from_roman(s: &str) -> Option<i64> {
    let input = s.trim().to_uppercase().replace(MACRON, OVERLINE);

    // Split into the overlined glyphs and the plain glyphs.
    let mut over = String::new();
    let mut plain = String::new();
    let mut chars = input.chars().peekable();
    while let Some(c) = chars.next() {
        if letter_value(c) == 0 {
            return None;
        }
        if chars.peek() == Some(&'\u{305}') {
            chars.next(); // consume the mark
            over.push(c);
        } else {
            plain.push(c);
        }
    }

    let mut total = 0i64;
    if !over.is_empty() {
        total += scan_value(&over) * 1000;
    }
    if !plain.is_empty() {
        total += scan_value(&plain);
    }
    if !(1..=MAX_ROMAN).contains(&total) {
        return None;
    }
    (to_roman(total) == input).then_some(total)
}

// ---------- showcase (run: rustc rust.rs && ./rust) ----------
fn main() {
    // to_roman - known values, both scales
    assert_eq!(to_roman(1), "I");
    assert_eq!(to_roman(1994), "MCMXCIV");
    assert_eq!(to_roman(3999), "MMMCMXCIX");
    assert_eq!(to_roman(4000), "I\u{305}V\u{305}");
    assert_eq!(to_roman(4001), "I\u{305}V\u{305}I");
    assert_eq!(to_roman(3_999_999), "M\u{305}M\u{305}M\u{305}C\u{305}M\u{305}X\u{305}C\u{305}I\u{305}X\u{305}CMXCIX");
    // to_roman - out of range
    assert_eq!(to_roman(0), "");
    assert_eq!(to_roman(4_000_000), "");
    // from_roman - canonical, with case/whitespace/macron tolerance
    assert_eq!(from_roman("MCMXCIV"), Some(1994));
    assert_eq!(from_roman("  mcmxciv  "), Some(1994));
    assert_eq!(from_roman("I\u{305}V\u{305}"), Some(4000));
    assert_eq!(from_roman("I\u{304}V\u{304}"), Some(4000)); // macron
    // from_roman - non-canonical / invalid
    assert_eq!(from_roman("IIII"), None);
    assert_eq!(from_roman("VV"), None);
    assert_eq!(from_roman("IC"), None);
    assert_eq!(from_roman("MMMM"), None); // 4,000 must be vinculum
    assert_eq!(from_roman("ABC"), None);
    assert_eq!(from_roman(""), None);
    println!("all showcase assertions passed");
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →