bookforge-cli 1.1.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("level")
                    .and_then(Value::as_str)
                    .map(|level| level == "error")
                    .unwrap_or(false)
            })
        })
        .unwrap_or(false);
    if has_error {
        anyhow::bail!("report contains QA warning(s) with severity 'error'");
    }

    Ok(())
}