bookforge-cli 1.1.0

CLI-first EPUB translation engine with deterministic structure rebuild and review loop.
use clap::Args;

use std::path::PathBuf;

use bookforge_core::RunConfigSnapshot;
use bookforge_store::{JobRecord, JobStore, JobSummary};

use crate::{
    performance::{RunPerformanceSummary, performance_summary_from_events},
    report::report_paths,
};

#[derive(Debug, Args)]
pub struct StatusArgs {
    pub job_id: String,
}

pub async fn run(args: StatusArgs) -> anyhow::Result<()> {
    let store = JobStore::open_default()?;
    let Some(job) = store.get_job(&args.job_id)? else {
        anyhow::bail!("job '{}' was not found", args.job_id);
    };

    let Some(summary) = store.summary(&args.job_id)? else {
        anyhow::bail!("job '{}' summary unavailable", args.job_id);
    };
    let snapshot = store.load_job_config_snapshot(&args.job_id)?;

    println!("Job: {}", summary.id);
    println!("Status: {}", summary.status);
    println!();
    println!("Input: {}", job.input_path.display());
    println!("Output: {}", job.output_path.display());
    println!("Source: {}", job.source_lang.as_deref().unwrap_or("auto"));
    println!("Target: {}", job.target_lang);
    println!();
    println!("Provider: {}", job.provider);
    println!("Model: {}", job.model);
    if let Some(ref base_url) = job.base_url {
        println!("Base URL: {base_url}");
    }
    if let Some(ref api_key_env) = job.api_key_env {
        println!("API key env: {api_key_env}");
    }
    println!();
    println!("Segments:");
    println!("  total:       {}", summary.total_segments);
    println!("  succeeded:   {}", summary.succeeded);
    println!("  cached:      {}", summary.cached);
    println!("  needs review: {}", summary.needs_review);
    println!("  failed:      {}", summary.failed);
    println!("  retry pending: {}", summary.retry_pending);
    println!();
    println!("Tokens:");
    println!("  input:  {}", summary.input_tokens);
    println!("  output: {}", summary.output_tokens);
    println!();
    println!("Retried segments: {}", summary.retried);

    let event_log_path = event_log_path_for_status(&job, snapshot.as_ref(), &args.job_id);
    if event_log_path.exists() {
        println!("Event log: {}", event_log_path.display());
    }
    let report_path = report_path_for_status(&job, snapshot.as_ref());
    if report_path.exists() {
        println!("Report: {}", report_path.display());
    }

    println!();
    println!("Performance:");
    match performance_summary_from_events(&event_log_path)? {
        Some(perf) => print_performance(&perf),
        None => println!("  unavailable: no event log found"),
    }

    match guidance_for_summary(&summary) {
        Some(StatusGuidance::Resume) => println!("Resume: bookforge resume {}", args.job_id),
        Some(StatusGuidance::Review) => {
            println!(
                "Review: segments need manual review; default resume skips needs-review segments."
            );
        }
        None => {}
    }

    Ok(())
}

fn event_log_path_for_status(
    job: &JobRecord,
    snapshot: Option<&RunConfigSnapshot>,
    job_id: &str,
) -> PathBuf {
    job.events_path
        .clone()
        .or_else(|| snapshot.and_then(|snapshot| snapshot.events_path.clone()))
        .unwrap_or_else(|| PathBuf::from(format!(".bookforge/runs/{job_id}/events.jsonl")))
}

fn report_path_for_status(job: &JobRecord, snapshot: Option<&RunConfigSnapshot>) -> PathBuf {
    let fallback_reports = report_paths(&job.output_path);
    job.report_markdown_path
        .clone()
        .or_else(|| snapshot.and_then(|snapshot| snapshot.report_markdown_path.clone()))
        .unwrap_or(fallback_reports.markdown)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StatusGuidance {
    Resume,
    Review,
}

fn guidance_for_summary(summary: &JobSummary) -> Option<StatusGuidance> {
    if summary.failed > 0 || summary.retry_pending > 0 || summary.status == "interrupted" {
        Some(StatusGuidance::Resume)
    } else if summary.needs_review > 0 {
        Some(StatusGuidance::Review)
    } else {
        None
    }
}

fn print_performance(perf: &RunPerformanceSummary) {
    println!("  requests: {}", perf.request_count);
    println!(
        "  latency p50/p95: {}/{} ms",
        fmt_opt(perf.p50_latency_ms),
        fmt_opt(perf.p95_latency_ms)
    );
    println!("  retries: {}", perf.retries);
    println!(
        "  429/timeouts/server errors: {}/{}/{}",
        perf.rate_limited, perf.timeouts, perf.server_errors
    );
    println!(
        "  invalid/truncated: {}/{}",
        perf.invalid_responses, perf.truncations
    );
    println!("  checkpoint flushes: {}", perf.checkpoint_flushes);
    if let Some(rate) = perf.blocks_per_minute {
        println!("  blocks/min: {rate:.2}");
    } else {
        println!("  blocks/min: n/a");
    }
}

fn fmt_opt(value: Option<u64>) -> String {
    value
        .map(|value| value.to_string())
        .unwrap_or_else(|| "n/a".to_string())
}

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

    fn job() -> JobRecord {
        JobRecord {
            id: "job_test".to_string(),
            input_path: PathBuf::from("input.epub"),
            input_snapshot_path: None,
            input_sha256: None,
            output_path: PathBuf::from("/tmp/bookforge-status/out.epub"),
            input_hash: "hash".to_string(),
            source_lang: Some("English".to_string()),
            target_lang: "Italian".to_string(),
            provider: "mock".to_string(),
            model: "mock-prefix-target".to_string(),
            base_url: None,
            api_key_env: None,
            status: "succeeded".to_string(),
            events_path: None,
            report_json_path: None,
            report_markdown_path: None,
        }
    }

    fn snapshot_with_paths(
        events_path: Option<PathBuf>,
        report_markdown_path: Option<PathBuf>,
    ) -> RunConfigSnapshot {
        let settings = bookforge_core::TranslationProfile::V1Fast.resolve();
        RunConfigSnapshot {
            input_path: PathBuf::from("input.epub"),
            input_snapshot_path: None,
            input_sha256: None,
            output_path: PathBuf::from("/tmp/bookforge-status/out.epub"),
            events_path,
            report_json_path: None,
            report_markdown_path,
            source_language: Some("English".to_string()),
            target_language: "Italian".to_string(),
            provider: "mock".to_string(),
            model: "mock-prefix-target".to_string(),
            base_url: None,
            api_key_env: None,
            profile: settings.profile,
            provider_preset: None,
            prompt_version: "v1".to_string(),
            cache_namespace: "cache".to_string(),
            settings: bookforge_core::ResolvedRunSettingsSnapshot::from_settings(&settings),
        }
    }

    #[test]
    fn status_reports_actual_output_adjacent_report_path() {
        let job = job();

        assert_eq!(
            report_path_for_status(&job, None),
            PathBuf::from("/tmp/bookforge-status/out.report.md")
        );
    }

    #[test]
    fn status_uses_stored_event_path_when_present() {
        let mut job = job();
        job.events_path = Some(PathBuf::from("/tmp/stored-events.jsonl"));
        let snapshot = snapshot_with_paths(Some(PathBuf::from("/tmp/snapshot-events.jsonl")), None);

        assert_eq!(
            event_log_path_for_status(&job, Some(&snapshot), &job.id),
            PathBuf::from("/tmp/stored-events.jsonl")
        );
    }

    #[test]
    fn status_uses_snapshot_event_path_when_job_event_path_missing() {
        let job = job();
        let snapshot = snapshot_with_paths(Some(PathBuf::from("/tmp/snapshot-events.jsonl")), None);

        assert_eq!(
            event_log_path_for_status(&job, Some(&snapshot), &job.id),
            PathBuf::from("/tmp/snapshot-events.jsonl")
        );
    }

    #[test]
    fn status_prints_resume_when_failed_segments_exist_even_if_job_status_needs_review() {
        let summary = JobSummary {
            id: "job_test".to_string(),
            status: "needs_review".to_string(),
            failed: 1,
            ..JobSummary::default()
        };

        assert_eq!(guidance_for_summary(&summary), Some(StatusGuidance::Resume));
    }

    #[test]
    fn status_prints_resume_when_retry_pending_segments_exist() {
        let summary = JobSummary {
            id: "job_test".to_string(),
            status: "needs_review".to_string(),
            retry_pending: 1,
            ..JobSummary::default()
        };

        assert_eq!(guidance_for_summary(&summary), Some(StatusGuidance::Resume));
    }

    #[test]
    fn status_does_not_print_resume_for_only_needs_review_by_default() {
        let summary = JobSummary {
            id: "job_test".to_string(),
            status: "needs_review".to_string(),
            needs_review: 1,
            ..JobSummary::default()
        };

        assert_eq!(guidance_for_summary(&summary), Some(StatusGuidance::Review));
    }
}