bookforge-cli 1.5.0

CLI-first EPUB translation engine with deterministic structure rebuild and review loop.
use anyhow::Result;
use bookforge_core::config::SegmentationConfig;
use bookforge_core::segment::build_segments;
use bookforge_epub::{inspect_epub, read_epub};
use clap::Args;
use serde_json::Value;
use std::{fs, path::PathBuf};

#[derive(Debug, Args)]
pub struct ValidateArgs {
    pub input: PathBuf,

    #[arg(long)]
    pub report: Option<PathBuf>,
}

pub async fn run(args: ValidateArgs) -> Result<()> {
    println!("Input: {}", args.input.display());

    let inspection = inspect_epub(&args.input)?;
    let book = read_epub(&args.input)?;
    let segments = build_segments(&book, &SegmentationConfig::default())?;
    let token_estimate = segments
        .iter()
        .map(|segment| segment.source.token_estimate)
        .sum::<usize>();

    println!("Package: {}", inspection.package_path);
    println!("Spine count: {}", inspection.spine_count);
    println!("XHTML spine count: {}", inspection.xhtml_spine_count);
    println!("Section count: {}", book.sections.len());
    println!("Block count: {}", book.blocks.len());
    println!("Segment count: {}", segments.len());
    println!("Estimated token count: {token_estimate}");

    if let Some(report_path) = args.report.or_else(|| default_report_path(&args.input)) {
        validate_report(&report_path)?;
    }

    println!("Validation: ok");
    Ok(())
}

fn default_report_path(input: &std::path::Path) -> Option<PathBuf> {
    let stem = input.file_stem()?.to_str()?;
    Some(input.with_file_name(format!("{stem}.report.json")))
}

fn validate_report(path: &PathBuf) -> Result<()> {
    if !path.exists() {
        return Ok(());
    }

    let parsed = serde_json::from_str::<Value>(&fs::read_to_string(path)?)?;
    let warnings = parsed
        .get("qa_warnings")
        .and_then(Value::as_array)
        .map(Vec::len)
        .unwrap_or(0);
    let failed = parsed
        .get("failed_segments")
        .and_then(Value::as_u64)
        .unwrap_or(0);
    let needs_review = parsed
        .get("needs_review_segments")
        .and_then(Value::as_u64)
        .unwrap_or(0);
    let retry_pending = parsed
        .get("retry_pending_segments")
        .and_then(Value::as_u64)
        .unwrap_or(0);

    println!("Report: {}", path.display());
    println!("QA warnings: {warnings}");
    println!("Failed segments: {failed}");
    println!("Needs review segments: {needs_review}");
    println!("Retry pending segments: {retry_pending}");

    if failed > 0 {
        anyhow::bail!("report contains failed segments");
    }
    if needs_review > 0 {
        anyhow::bail!("report contains segments that need review");
    }
    if retry_pending > 0 {
        anyhow::bail!("report contains retry-pending segments");
    }

    let has_error = parsed
        .get("qa_warnings")
        .and_then(Value::as_array)
        .map(|warnings| {
            warnings.iter().any(|w| {
                w.get("severity")
                    .and_then(Value::as_str)
                    .map(|severity| severity == "error")
                    .unwrap_or(false)
            })
        })
        .unwrap_or(false);
    if has_error {
        anyhow::bail!("report contains QA warning(s) with severity 'error'");
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validate_report_rejects_error_severity_warning() {
        let temp = tempfile::tempdir().expect("temp dir should be created");
        let report = temp.path().join("report.json");
        fs::write(
            &report,
            serde_json::json!({
                "qa_warnings": [{
                    "severity": "error",
                    "kind": "qa_review",
                    "segment_id": "seg_a",
                    "message": "bad translation"
                }],
                "failed_segments": 0,
                "needs_review_segments": 0,
                "retry_pending_segments": 0
            })
            .to_string(),
        )
        .expect("report should write");

        let err = validate_report(&report).expect_err("error severity should fail");

        assert!(
            err.to_string()
                .contains("report contains QA warning(s) with severity 'error'")
        );
    }

    #[test]
    fn validate_report_allows_non_error_warnings() {
        let temp = tempfile::tempdir().expect("temp dir should be created");
        let report = temp.path().join("report.json");
        fs::write(
            &report,
            serde_json::json!({
                "qa_warnings": [{
                    "severity": "warning",
                    "kind": "untranslated",
                    "segment_id": "seg_a",
                    "message": "same title"
                }],
                "failed_segments": 0,
                "needs_review_segments": 0,
                "retry_pending_segments": 0
            })
            .to_string(),
        )
        .expect("report should write");

        validate_report(&report).expect("warning-only report should pass");
    }
}