use std::path::PathBuf;
use std::process::ExitCode;
use anyhow::Result;
use clap::{Parser, Subcommand};
use chk_defaced::finding::{Report, Severity};
use chk_defaced::registry::FontRegistry;
use chk_defaced::scan;
#[derive(Parser)]
#[command(name = "chk_defaced", version, about = "Detect 'defaced' documents and index the system fonts' cmaps")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
BuildRegistry {
#[arg(long)]
fonts_dir: Option<PathBuf>,
#[arg(short, long, default_value = "fonts-index.json")]
out: PathBuf,
#[arg(long)]
slim: bool,
#[arg(long)]
pretty: bool,
},
Scan {
paths: Vec<PathBuf>,
#[arg(long)]
registry: Option<PathBuf>,
#[arg(long, default_value = "human")]
format_out: String,
#[arg(long)]
ocr: bool,
#[arg(long, default_value = "ita+eng")]
ocr_lang: String,
},
}
fn default_fonts_dir() -> PathBuf {
if let Ok(windir) = std::env::var("WINDIR") {
return PathBuf::from(windir).join("Fonts");
}
for p in [r"C:\Windows\Fonts", "/usr/share/fonts", "/System/Library/Fonts"] {
let pb = PathBuf::from(p);
if pb.is_dir() {
return pb;
}
}
PathBuf::from(r"C:\Windows\Fonts")
}
fn run() -> Result<ExitCode> {
let cli = Cli::parse();
match cli.cmd {
Cmd::BuildRegistry { fonts_dir, out, slim, pretty } => {
let dir = fonts_dir.unwrap_or_else(default_fonts_dir);
eprintln!("Indexing fonts in {} …", dir.display());
let reg = FontRegistry::build_from_dir(&dir, !slim)?;
reg.save_json(&out, pretty)?;
eprintln!(
"Indexed {} font faces ({} unparsable files skipped) → {}",
reg.count,
reg.skipped.len(),
out.display()
);
Ok(ExitCode::SUCCESS)
}
Cmd::Scan { paths, registry, format_out, ocr, ocr_lang } => {
if paths.is_empty() {
anyhow::bail!("no files to analyze");
}
let reg = match registry {
Some(p) => Some(FontRegistry::load_json(&p)?),
None => None,
};
#[cfg(feature = "ocr-specimen")]
let ocr_engine = if ocr {
use chk_defaced::ocr::{OcrHint, TesseractOcr};
match TesseractOcr::new(None, &ocr_lang, OcrHint::SingleLine) {
Ok(e) => Some(e),
Err(e) => {
eprintln!("[warn] --ocr disabled: could not initialize Tesseract ({e:#})");
None
}
}
} else {
None
};
#[cfg(not(feature = "ocr-specimen"))]
if ocr {
eprintln!("[warn] --ocr ignored: rebuild with `--features ocr-specimen`");
}
let _ = &ocr_lang;
let scan_one = |path: &std::path::Path| -> Result<Report> {
#[cfg(feature = "ocr-specimen")]
if let Some(engine) = ocr_engine.as_ref() {
return scan::scan_path_with_ocr(path, reg.as_ref(), engine);
}
scan::scan_path(path, reg.as_ref())
};
let mut reports = Vec::new();
for path in &paths {
match scan_one(path) {
Ok(r) => reports.push(r),
Err(e) => eprintln!("[error] {}: {e:#}", path.display()),
}
}
if format_out == "json" {
println!("{}", serde_json::to_string_pretty(&reports)?);
} else {
for r in &reports {
print_human(r);
}
}
let worst = reports.iter().filter_map(|r| r.max_severity()).max();
Ok(if matches!(worst, Some(s) if s >= Severity::High) {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
})
}
}
}
fn print_human(r: &Report) {
println!("\n=== {} [{}] — {} font(s) examined ===", r.file, r.format, r.fonts_examined);
if r.findings.is_empty() {
println!(" no anomalies detected ✓");
return;
}
let mut findings: Vec<_> = r.findings.iter().collect();
findings.sort_by(|a, b| b.severity.cmp(&a.severity));
for f in findings {
println!(
" [{:?}] {} — {} ({}) conf={:.2}",
f.severity, f.rule, f.message, f.location, f.confidence
);
}
}
fn main() -> ExitCode {
match run() {
Ok(code) => code,
Err(e) => {
eprintln!("error: {e:#}");
ExitCode::from(2)
}
}
}