use anyhow::{anyhow, Context, Result};
use clap::Parser;
use furigana::tts::TtsOptions;
use furigana::Furigana;
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
#[derive(Parser, Debug)]
#[command(
name = "furigana-corpus-check",
about = "Run corpora and report expected-match rate (single Furigana instance)"
)]
struct Args {
#[arg(required = true)]
corpus: Vec<PathBuf>,
#[arg(long)]
rules_dir: Option<PathBuf>,
#[arg(long)]
core_dict_dir: Vec<PathBuf>,
#[arg(short, long)]
verbose: bool,
}
#[derive(Debug, Deserialize)]
struct CorpusFile {
#[serde(default, rename = "case")]
cases: Vec<Case>,
}
#[derive(Debug, Deserialize)]
struct Case {
input: String,
#[serde(default = "default_mode")]
mode: String,
#[serde(default)]
expected: Option<String>,
#[serde(default)]
#[allow(dead_code)]
note: Option<String>,
}
fn default_mode() -> String {
"ruby".into()
}
fn run_case(f: &Furigana, input: &str, mode: &str) -> Result<String> {
Ok(match mode {
"hiragana" => f.to_hiragana(input),
"ruby" => f.to_ruby(input),
"tts" => f.to_tts(input, &TtsOptions::default()),
"romaji" => f.to_romaji(input, furigana::romaji::RomajiStyle::Hepburn),
"kanji" => input.to_string(),
other => return Err(anyhow!("unsupported mode: {:?}", other)),
})
}
fn build_furigana(args: &Args) -> Result<Furigana> {
let mut b = Furigana::builder();
if let Some(rules) = &args.rules_dir {
b = b.rules_dir(rules);
}
for d in &args.core_dict_dir {
b = b.core_dict_dir(d);
}
b.build().context("build Furigana")
}
fn collect_corpus_files(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
for p in paths {
if p.is_dir() {
collect_toml_in_dir(p, &mut out).with_context(|| format!("walk corpus dir: {p:?}"))?;
} else {
out.push(p.clone());
}
}
out.sort();
out.dedup();
Ok(out)
}
fn collect_toml_in_dir(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_toml_in_dir(&path, out)?;
} else if path.extension().is_some_and(|e| e == "toml") {
out.push(path);
}
}
Ok(())
}
#[derive(Default)]
struct Stats {
cases: usize,
expected_total: usize,
correct: usize,
errors: usize,
}
fn run_file(f: &Furigana, path: &Path, verbose: bool) -> Result<Stats> {
let content = fs::read_to_string(path).with_context(|| format!("read corpus: {path:?}"))?;
let corpus: CorpusFile =
toml::from_str(&content).with_context(|| format!("parse corpus TOML: {path:?}"))?;
let mut st = Stats {
cases: corpus.cases.len(),
..Stats::default()
};
let name = path.file_name().map_or_else(
|| path.display().to_string(),
|n| n.to_string_lossy().into_owned(),
);
for (i, case) in corpus.cases.iter().enumerate() {
let actual = match run_case(f, &case.input, &case.mode) {
Ok(s) => s,
Err(e) => {
eprintln!("error {name}#{i}: {e}");
st.errors += 1;
continue;
}
};
if let Some(expected) = &case.expected {
st.expected_total += 1;
let pass = actual == *expected;
if pass {
st.correct += 1;
if verbose {
println!("OK {name}#{i}: {:?} -> {actual}", case.input);
}
} else {
println!(
"FAIL {name}#{i}: input={:?} mode={:?}",
case.input, case.mode
);
println!(" expected: {expected:?}");
println!(" actual: {actual}");
}
}
}
Ok(st)
}
fn run() -> Result<ExitCode> {
let args = Args::parse();
let files = collect_corpus_files(&args.corpus)?;
if files.is_empty() {
eprintln!("warning: no corpus TOML found");
return Ok(ExitCode::SUCCESS);
}
let f = build_furigana(&args)?;
let morph_dict = if cfg!(feature = "dict-unidic") {
"unidic"
} else {
"ipadic"
};
let mut grand = Stats::default();
let mut per_file: Vec<(String, Stats)> = Vec::new();
for path in &files {
let st = run_file(&f, path, args.verbose)?;
grand.cases += st.cases;
grand.expected_total += st.expected_total;
grand.correct += st.correct;
grand.errors += st.errors;
per_file.push((path.display().to_string(), st));
}
println!();
println!("=== Summary (morph dict: {morph_dict}) ===");
for (name, st) in &per_file {
if st.expected_total > 0 {
let pct = (st.correct * 100) as f64 / st.expected_total as f64;
println!("{name}: {}/{} ({pct:.1}%)", st.correct, st.expected_total);
} else {
println!("{name}: {} cases (expected なし)", st.cases);
}
}
println!("Total cases: {}", grand.cases);
if grand.expected_total > 0 {
let pct = (grand.correct * 100) as f64 / grand.expected_total as f64;
println!(
"Expected match: {}/{} ({pct:.1}%)",
grand.correct, grand.expected_total
);
}
if grand.errors > 0 {
println!("Errors: {}", grand.errors);
}
if grand.correct < grand.expected_total || grand.errors > 0 {
Ok(ExitCode::FAILURE)
} else {
Ok(ExitCode::SUCCESS)
}
}
fn main() -> ExitCode {
match run() {
Ok(code) => code,
Err(e) => {
eprintln!("error: {e:?}");
ExitCode::FAILURE
}
}
}