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 →