use std::io::Write;
use super::write_json_stdout;
#[derive(Debug, Clone, Copy)]
pub struct ConfusablePair {
pub name: &'static str,
pub ascii: &'static str,
pub confusable: &'static str,
pub codepoints: &'static str,
pub critical: bool,
}
pub const PAIRS: &[ConfusablePair] = &[
ConfusablePair {
name: "latin-i-vs-cyrillic-i",
ascii: "i",
confusable: "\u{0456}", codepoints: "U+0069 vs U+0456",
critical: true,
},
ConfusablePair {
name: "latin-a-vs-cyrillic-a",
ascii: "a",
confusable: "\u{0430}", codepoints: "U+0061 vs U+0430",
critical: true,
},
ConfusablePair {
name: "latin-e-vs-cyrillic-e",
ascii: "e",
confusable: "\u{0435}", codepoints: "U+0065 vs U+0435",
critical: true,
},
ConfusablePair {
name: "latin-o-vs-cyrillic-o",
ascii: "o",
confusable: "\u{043E}", codepoints: "U+006F vs U+043E",
critical: true,
},
ConfusablePair {
name: "latin-p-vs-cyrillic-er",
ascii: "p",
confusable: "\u{0440}", codepoints: "U+0070 vs U+0440",
critical: true,
},
ConfusablePair {
name: "latin-c-vs-cyrillic-es",
ascii: "c",
confusable: "\u{0441}", codepoints: "U+0063 vs U+0441",
critical: true,
},
ConfusablePair {
name: "latin-x-vs-cyrillic-ha",
ascii: "x",
confusable: "\u{0445}", codepoints: "U+0078 vs U+0445",
critical: false,
},
ConfusablePair {
name: "latin-y-vs-cyrillic-u",
ascii: "y",
confusable: "\u{0443}", codepoints: "U+0079 vs U+0443",
critical: false,
},
ConfusablePair {
name: "latin-o-vs-greek-omicron",
ascii: "o",
confusable: "\u{03BF}", codepoints: "U+006F vs U+03BF",
critical: true,
},
ConfusablePair {
name: "latin-v-vs-greek-nu",
ascii: "v",
confusable: "\u{03BD}", codepoints: "U+0076 vs U+03BD",
critical: false,
},
ConfusablePair {
name: "latin-a-vs-greek-alpha",
ascii: "a",
confusable: "\u{03B1}", codepoints: "U+0061 vs U+03B1",
critical: false,
},
ConfusablePair {
name: "latin-cap-b-vs-greek-beta",
ascii: "B",
confusable: "\u{0392}", codepoints: "U+0042 vs U+0392",
critical: false,
},
ConfusablePair {
name: "latin-a-vs-fullwidth-a",
ascii: "a",
confusable: "\u{FF41}", codepoints: "U+0061 vs U+FF41",
critical: true,
},
ConfusablePair {
name: "latin-cap-g-vs-fullwidth-g",
ascii: "G",
confusable: "\u{FF27}", codepoints: "U+0047 vs U+FF27",
critical: false,
},
ConfusablePair {
name: "latin-cap-o-vs-math-script-o",
ascii: "O",
confusable: "\u{1D4AA}", codepoints: "U+004F vs U+1D4AA",
critical: true,
},
ConfusablePair {
name: "latin-cap-a-vs-math-bold-a",
ascii: "A",
confusable: "\u{1D400}", codepoints: "U+0041 vs U+1D400",
critical: false,
},
ConfusablePair {
name: "latin-cap-s-vs-math-fraktur-s",
ascii: "S",
confusable: "\u{1D516}", codepoints: "U+0053 vs U+1D516",
critical: false,
},
ConfusablePair {
name: "latin-a-vs-latin-a-ring",
ascii: "a",
confusable: "\u{00E5}", codepoints: "U+0061 vs U+00E5",
critical: false,
},
ConfusablePair {
name: "zero-width-space-between-letters",
ascii: "ab",
confusable: "a\u{200B}b",
codepoints: "ab vs a<U+200B>b (zero-width space)",
critical: true,
},
ConfusablePair {
name: "bidi-rtl-override-demo",
ascii: "abc.txt",
confusable: "abc\u{202E}txt.exe",
codepoints: "U+202E RIGHT-TO-LEFT OVERRIDE",
critical: true,
},
];
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VisualAuditResult {
pub audited_at: String,
pub terminal: String,
pub pairs_total: usize,
pub distinguishable: usize,
pub indistinguishable: usize,
pub skipped: usize,
pub results: Vec<PairResult>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PairResult {
pub name: String,
pub codepoints: String,
pub verdict: Verdict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Distinguishable,
Indistinguishable,
Skipped,
}
pub fn select_pairs(selector: Option<&str>) -> (Vec<&'static ConfusablePair>, bool) {
match selector
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("all") => (PAIRS.iter().collect(), true),
Some("critical") | None => (PAIRS.iter().filter(|p| p.critical).collect(), true),
Some(_) => (PAIRS.iter().filter(|p| p.critical).collect(), false),
}
}
pub fn run(non_interactive: bool, pairs: Option<String>, json: bool) -> i32 {
let (selected, recognized) = select_pairs(pairs.as_deref());
if !recognized {
eprintln!(
"tirith visual-audit: unknown --pairs value '{}'; using 'critical' (valid: critical, all)",
pairs.as_deref().unwrap_or("")
);
}
let term = std::env::var("TERM").unwrap_or_default();
if non_interactive {
let result = build_skipped_result(&term, &selected);
return finish(result, json, true);
}
if !is_terminal::is_terminal(std::io::stdin()) {
eprintln!(
"tirith visual-audit: stdin is not a TTY — cannot prompt interactively.\n \
Run this in a real terminal, or pass --non-interactive for a headless (all-skipped) run."
);
let result = build_skipped_result(&term, &selected);
return finish(result, json, false);
}
eprintln!(
"tirith visual-audit: rendering {} confusable pair(s).",
selected.len()
);
eprintln!(
" IMPORTANT: this measures YOUR terminal + font only. The result is local\n \
and not portable to another machine, emulator, or font."
);
eprintln!(
" For each pair, answer whether the two glyphs look DIFFERENT: [y]es / [n]o / [s]kip."
);
eprintln!();
let results = collect_verdicts(&selected, |idx, pair| {
eprintln!(
" ({}/{}) {} — {}",
idx + 1,
selected.len(),
pair.name,
pair.codepoints
);
eprintln!(" reference (ASCII): [{}]", pair.ascii);
eprintln!(" candidate: [{}]", pair.confusable);
prompt_verdict(" distinguishable? [y/n/skip]")
});
let result = tally(&term, results);
finish(result, json, true)
}
fn build_skipped_result(term: &str, selected: &[&'static ConfusablePair]) -> VisualAuditResult {
let results = selected
.iter()
.map(|p| PairResult {
name: p.name.to_string(),
codepoints: p.codepoints.to_string(),
verdict: Verdict::Skipped,
})
.collect();
tally(term, results)
}
fn tally(term: &str, results: Vec<PairResult>) -> VisualAuditResult {
let distinguishable = results
.iter()
.filter(|r| r.verdict == Verdict::Distinguishable)
.count();
let indistinguishable = results
.iter()
.filter(|r| r.verdict == Verdict::Indistinguishable)
.count();
let skipped = results
.iter()
.filter(|r| r.verdict == Verdict::Skipped)
.count();
VisualAuditResult {
audited_at: chrono::Utc::now().to_rfc3339(),
terminal: term.to_string(),
pairs_total: results.len(),
distinguishable,
indistinguishable,
skipped,
results,
}
}
fn finish(result: VisualAuditResult, json: bool, persist: bool) -> i32 {
let mut persist_failed = false;
if persist {
match persist_result(&result) {
Ok(path) => eprintln!("tirith visual-audit: recorded result to {}", path.display()),
Err(e) => {
eprintln!("tirith visual-audit: error — could not save result: {e}");
persist_failed = true;
}
}
}
if json {
if !write_json_stdout(&result, "tirith visual-audit: failed to write JSON output") {
return 1;
}
} else {
print_human_summary(&result);
}
if persist_failed {
1
} else {
0
}
}
fn persist_result(result: &VisualAuditResult) -> Result<std::path::PathBuf, String> {
let config = tirith_core::policy::config_dir()
.ok_or_else(|| "could not resolve the tirith config directory".to_string())?;
std::fs::create_dir_all(&config).map_err(|e| format!("create {}: {e}", config.display()))?;
let path = config.join("visual-audit-result.json");
let bytes = serde_json::to_vec_pretty(result).map_err(|e| format!("serialize: {e}"))?;
super::write_file_atomic(&path, &bytes, true)
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
fn print_human_summary(result: &VisualAuditResult) {
eprintln!();
eprintln!("tirith visual-audit summary (this terminal + font only):");
let term = if result.terminal.is_empty() {
"(unset)"
} else {
result.terminal.as_str()
};
eprintln!(" TERM: {term}");
eprintln!(" pairs presented: {}", result.pairs_total);
eprintln!(" distinguishable: {}", result.distinguishable);
eprintln!(" indistinguishable: {}", result.indistinguishable);
eprintln!(" skipped: {}", result.skipped);
if result.indistinguishable > 0 {
eprintln!();
eprintln!(
" NOTE: {} pair(s) were indistinguishable in THIS terminal. That is a local\n \
rendering risk — a homograph attack using those glyphs could look identical\n \
to legitimate text here. tirith's detection still flags them regardless.",
result.indistinguishable
);
}
eprintln!();
eprintln!(" This result describes only this machine and is not portable.");
}
fn collect_verdicts<F>(selected: &[&'static ConfusablePair], mut prompt: F) -> Vec<PairResult>
where
F: FnMut(usize, &ConfusablePair) -> Option<Verdict>,
{
let mut results: Vec<PairResult> = Vec::with_capacity(selected.len());
let mut input_closed = false;
for (idx, pair) in selected.iter().enumerate() {
let verdict = if input_closed {
Verdict::Skipped
} else {
match prompt(idx, pair) {
Some(v) => v,
None => {
input_closed = true;
eprintln!(
"tirith visual-audit: input closed; recording remaining pairs as skipped."
);
Verdict::Skipped
}
}
};
results.push(PairResult {
name: pair.name.to_string(),
codepoints: pair.codepoints.to_string(),
verdict,
});
}
results
}
fn prompt_verdict(label: &str) -> Option<Verdict> {
eprint!("{label}: ");
let _ = std::io::stderr().flush();
let mut line = String::new();
match std::io::stdin().read_line(&mut line) {
Ok(0) | Err(_) => None,
Ok(_) => Some(verdict_from_answer(&line)),
}
}
fn verdict_from_answer(answer: &str) -> Verdict {
match answer.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => Verdict::Distinguishable,
"n" | "no" => Verdict::Indistinguishable,
_ => Verdict::Skipped,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pair_table_has_expected_breadth() {
assert_eq!(
PAIRS.len(),
20,
"the visual-audit pair set is fixed at 20; got {}",
PAIRS.len()
);
let mut names: Vec<&str> = PAIRS.iter().map(|p| p.name).collect();
names.sort_unstable();
let before = names.len();
names.dedup();
assert_eq!(before, names.len(), "pair names must be unique");
}
#[test]
fn pair_table_carries_required_codepoints() {
let by_name = |n: &str| PAIRS.iter().find(|p| p.name == n).expect("pair present");
assert_eq!(by_name("latin-i-vs-cyrillic-i").confusable, "\u{0456}");
assert_eq!(by_name("latin-o-vs-greek-omicron").confusable, "\u{03BF}");
assert_eq!(by_name("latin-a-vs-fullwidth-a").confusable, "\u{FF41}");
assert_eq!(
by_name("latin-cap-o-vs-math-script-o").confusable,
"\u{1D4AA}"
);
let zw = by_name("zero-width-space-between-letters");
assert!(zw.confusable.contains('\u{200B}'), "must embed U+200B");
assert_eq!(zw.confusable.chars().count(), 3, "a + ZWSP + b = 3 chars");
assert!(
by_name("bidi-rtl-override-demo")
.confusable
.contains('\u{202E}'),
"bidi demo must embed U+202E"
);
for n in [
"latin-a-vs-cyrillic-a",
"latin-e-vs-cyrillic-e",
"latin-p-vs-cyrillic-er",
"latin-o-vs-cyrillic-o",
"latin-v-vs-greek-nu",
"latin-a-vs-greek-alpha",
] {
let _ = by_name(n);
}
}
#[test]
fn select_pairs_subset_and_fallback() {
let (all, ok_all) = select_pairs(Some("all"));
assert!(ok_all);
assert_eq!(all.len(), PAIRS.len());
let (crit, ok_crit) = select_pairs(Some("critical"));
assert!(ok_crit);
assert!(!crit.is_empty(), "critical subset must be non-empty");
assert!(crit.len() < all.len(), "critical must be a strict subset");
let (def, ok_def) = select_pairs(None);
assert!(ok_def);
assert_eq!(def.len(), crit.len());
let (fallback, ok_fallback) = select_pairs(Some("bogus"));
assert!(!ok_fallback);
assert_eq!(fallback.len(), crit.len());
}
#[test]
fn non_interactive_records_all_skipped() {
let (selected, _) = select_pairs(Some("critical"));
let result = build_skipped_result("xterm-256color", &selected);
assert_eq!(result.pairs_total, selected.len());
assert_eq!(result.skipped, selected.len());
assert_eq!(result.distinguishable, 0);
assert_eq!(result.indistinguishable, 0);
assert_eq!(result.terminal, "xterm-256color");
assert_eq!(
result.distinguishable + result.indistinguishable + result.skipped,
result.pairs_total
);
}
#[test]
fn collect_verdicts_stops_prompting_after_eof() {
let (selected, _) = select_pairs(Some("all"));
assert!(
selected.len() >= 3,
"need several pairs to exercise the tail"
);
let mut calls = 0usize;
let results = collect_verdicts(&selected, |idx, _pair| {
calls += 1;
match idx {
0 => Some(Verdict::Distinguishable),
1 => Some(Verdict::Indistinguishable),
_ => None,
}
});
assert_eq!(
calls, 3,
"prompt must be called exactly 3 times (2 answers + 1 EOF), not once per remaining pair"
);
assert_eq!(results.len(), selected.len());
assert_eq!(results[0].verdict, Verdict::Distinguishable);
assert_eq!(results[1].verdict, Verdict::Indistinguishable);
for r in &results[2..] {
assert_eq!(
r.verdict,
Verdict::Skipped,
"every pair after EOF must be skipped"
);
}
let tallied = tally("xterm-256color", results);
assert_eq!(tallied.distinguishable, 1);
assert_eq!(tallied.indistinguishable, 1);
assert_eq!(tallied.skipped, selected.len() - 2);
assert_eq!(tallied.pairs_total, selected.len());
}
#[test]
fn collect_verdicts_records_every_answer_when_stdin_stays_open() {
let (selected, _) = select_pairs(Some("critical"));
let mut calls = 0usize;
let results = collect_verdicts(&selected, |_idx, _pair| {
calls += 1;
Some(Verdict::Distinguishable)
});
assert_eq!(calls, selected.len(), "every pair must be prompted");
assert!(results
.iter()
.all(|r| r.verdict == Verdict::Distinguishable));
}
#[test]
fn verdict_from_answer_maps_yes_no_skip() {
assert_eq!(verdict_from_answer("y"), Verdict::Distinguishable);
assert_eq!(verdict_from_answer("YES\n"), Verdict::Distinguishable);
assert_eq!(verdict_from_answer(" n "), Verdict::Indistinguishable);
assert_eq!(verdict_from_answer("No"), Verdict::Indistinguishable);
assert_eq!(verdict_from_answer("s"), Verdict::Skipped);
assert_eq!(verdict_from_answer("skip"), Verdict::Skipped);
assert_eq!(verdict_from_answer(""), Verdict::Skipped);
assert_eq!(verdict_from_answer("garbage"), Verdict::Skipped);
}
#[test]
fn tally_counts_each_verdict() {
let results = vec![
PairResult {
name: "a".into(),
codepoints: "x".into(),
verdict: Verdict::Distinguishable,
},
PairResult {
name: "b".into(),
codepoints: "y".into(),
verdict: Verdict::Indistinguishable,
},
PairResult {
name: "c".into(),
codepoints: "z".into(),
verdict: Verdict::Skipped,
},
PairResult {
name: "d".into(),
codepoints: "w".into(),
verdict: Verdict::Indistinguishable,
},
];
let r = tally("dumb", results);
assert_eq!(r.distinguishable, 1);
assert_eq!(r.indistinguishable, 2);
assert_eq!(r.skipped, 1);
assert_eq!(r.pairs_total, 4);
}
#[test]
fn result_roundtrips_json() {
let (selected, _) = select_pairs(Some("all"));
let r = build_skipped_result("screen", &selected);
let json = serde_json::to_string(&r).unwrap();
let back: VisualAuditResult = serde_json::from_str(&json).unwrap();
assert_eq!(back.pairs_total, r.pairs_total);
assert_eq!(back.skipped, r.skipped);
assert_eq!(back.terminal, "screen");
assert_eq!(back.results.len(), selected.len());
assert!(back.results.iter().all(|p| p.verdict == Verdict::Skipped));
}
#[test]
fn verdict_serializes_to_legacy_tokens() {
assert_eq!(
serde_json::to_string(&Verdict::Distinguishable).unwrap(),
r#""distinguishable""#
);
assert_eq!(
serde_json::to_string(&Verdict::Indistinguishable).unwrap(),
r#""indistinguishable""#
);
assert_eq!(
serde_json::to_string(&Verdict::Skipped).unwrap(),
r#""skipped""#
);
assert_eq!(
serde_json::from_str::<Verdict>(r#""indistinguishable""#).unwrap(),
Verdict::Indistinguishable
);
let legacy = r#"{"name":"latin-a-vs-cyrillic-a","codepoints":"U+0061 vs U+0430","verdict":"indistinguishable"}"#;
let pr: PairResult = serde_json::from_str(legacy).unwrap();
assert_eq!(pr.verdict, Verdict::Indistinguishable);
}
}