docparser-cli 0.1.0

docparser 命令行:PDF / DOCX / XLSX / PPTX / TXT / MD / CSV / HTML → text / markdown / json
//! `docparser-cli`:docparser-core 的 Rust CLI(clap)。
//!
//! 与 Python 参考 CLI 共享 IR 语义;当前内置纯文本插件(txt/md/csv/html),
//! PDF/OOXML 后端按发布形态里程碑逐步接入。
//!
//! 用法:
//!   docparser-cli parse note.md --format json
//!   docparser-cli backends

use std::process::ExitCode;

use clap::{Parser, Subcommand};

use docparser_core::engine::DocumentEngine;
use docparser_core::registry::Registry;
use docparser_core::{emit, plugins};

#[derive(Parser)]
#[command(
    name = "docparser-cli",
    version,
    about = "独立文档解析 CLI(Rust 核心)"
)]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// 解析单个文件
    Parse {
        /// 文件路径
        path: String,
        /// 输出格式
        #[arg(short, long, default_value = "json", value_parser = ["json", "text", "markdown"])]
        format: String,
    },
    /// 列出已注册后端
    Backends,
    /// 离线自检(PDFium / OCR 引擎 / 模型 manifest / 后端注册)
    Doctor,
    /// 并发批处理:多个文件并行解析(每 worker 子进程独立 Pdfium/OCR 引擎)
    Batch {
        /// 待解析文件(可多个)
        files: Vec<String>,
        /// 并发 worker 数(= 子进程数)
        #[arg(short, long, default_value_t = 4)]
        jobs: usize,
    },
}

fn build_engine() -> DocumentEngine {
    let mut reg = Registry::new();
    plugins::text::register_defaults(&mut reg);
    docparser_epub::register_defaults(&mut reg);
    docparser_pdf::register_defaults(&mut reg);
    docparser_office::register_defaults(&mut reg);
    DocumentEngine::new(reg)
}

/// 并发批处理:`jobs` 个子进程(各自独立 Pdfium/OCR 引擎)并行解析文件,
/// stdout 逐文件输出一个 JSON 文档(jsonl);任一个失败退出码 1。
fn batch_run(files: Vec<String>, jobs: usize) -> ExitCode {
    use std::sync::{Arc, Mutex};

    let exe = match std::env::current_exe() {
        Ok(e) => e,
        Err(_) => {
            eprintln!("batch: 无法定位自身可执行文件");
            return ExitCode::from(2);
        }
    };
    if files.is_empty() {
        eprintln!("batch: 至少需要一个文件");
        return ExitCode::from(2);
    }
    let jobs = jobs.max(1).min(files.len());
    let out: Arc<Mutex<Vec<(usize, String, String)>>> = Arc::new(Mutex::new(Vec::new()));
    let mut handles = Vec::new();
    for w in 0..jobs {
        let slice: Vec<(usize, String)> = files
            .iter()
            .enumerate()
            .filter(|(i, _)| i % jobs == w)
            .map(|(i, f)| (i, f.clone()))
            .collect();
        let exe = exe.clone();
        let out = out.clone();
        handles.push(std::thread::spawn(move || {
            for (i, f) in slice {
                let r = std::process::Command::new(&exe)
                    .args(["parse", &f, "--format", "json"])
                    .output();
                let mut rows = out.lock().unwrap();
                match r {
                    Ok(o) if o.status.success() => {
                        let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
                        rows.push((i, text, String::new()));
                    }
                    Ok(o) => {
                        rows.push((
                            i,
                            String::new(),
                            String::from_utf8_lossy(&o.stderr).to_string(),
                        ));
                    }
                    Err(e) => rows.push((i, String::new(), format!("{e}"))),
                }
            }
        }));
    }
    for h in handles {
        let _ = h.join();
    }
    let mut rows = out.lock().unwrap().clone();
    rows.sort_by_key(|r| r.0);
    let mut fails = 0usize;
    for (_, line, err) in rows {
        if err.is_empty() {
            println!("{line}");
        } else {
            fails += 1;
            eprintln!("{err}");
        }
    }
    if fails > 0 {
        ExitCode::from(1)
    } else {
        ExitCode::SUCCESS
    }
}

/// 离线自检:PDFium 运行库 / OCR 引擎 / 模型 manifest / 后端注册。
/// 退出码:0 = 无 FAIL;1 = 有 FAIL(CI 可直接用)。
fn doctor() -> ExitCode {
    use std::path::Path;

    let mut fail = false;
    let mut warn = 0usize;
    let mut check = |ok: bool, name: &str, detail: String| {
        let tag = if ok { "[OK]" } else { "[FAIL]" };
        println!("{tag:<8} {name:<22} {detail}");
        if !ok {
            fail = true;
        }
    };

    // ① PDFium 运行库(PDF 后端必需)
    let lib = docparser_pdf::find_library();
    check(
        lib.is_some(),
        "pdfium",
        match &lib {
            Some(p) => p.display().to_string(),
            None => "未找到:设 DOCPARSER_PDFIUM_LIB 或使用 release bundle".into(),
        },
    );

    // ② OCR 引擎(可选:不存在时纯文本路径仍可用 → 记 WARN)
    let ocr_bin = docparser_pdf::find_ocr_binary();
    match &ocr_bin {
        Some(p) => println!("[OK]     {:<22} {}", "ocr-engine", p.display()),
        None => {
            warn += 1;
            println!(
                "[WARN]   {:<22} 未找到(OCR 兜底不可用;纯文本不受影响)",
                "ocr-engine"
            );
        }
    }

    // ③ 模型目录 + manifest(存在则校 size;sha256 校验见 Python `docparser doctor`)
    let models = docparser_pdf::find_ocr_models();
    match &models {
        None => {
            warn += 1;
            println!(
                "[WARN]   {:<22} 未找到(设 DOCPARSER_OCR_MODELS)",
                "ocr-models"
            );
        }
        Some(dir) => {
            let mf = dir.join("manifest.json");
            match std::fs::read_to_string(&mf) {
                Err(_) => {
                    warn += 1;
                    println!(
                        "[WARN]   {:<22} {}(缺 manifest.json)",
                        "ocr-models",
                        dir.display()
                    );
                }
                Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
                    Err(_) => {
                        warn += 1;
                        println!("[WARN]   {:<22} manifest.json 解析失败", "ocr-models");
                    }
                    Ok(v) => {
                        let files = v
                            .get("files")
                            .and_then(|f| f.as_array())
                            .cloned()
                            .unwrap_or_default();
                        let mut bad = 0usize;
                        for f in &files {
                            let rel = f.get("path").and_then(|p| p.as_str()).unwrap_or("");
                            let size = f.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
                            let p: std::path::PathBuf = Path::new(dir).join(rel);
                            if !p.exists() || p.metadata().map(|m| m.len()).unwrap_or(0) != size {
                                bad += 1;
                            }
                        }
                        check(
                            bad == 0,
                            "ocr-models",
                            format!("{}({} 文件,{} 不匹配)", dir.display(), files.len(), bad),
                        );
                    }
                },
            }
        }
    }

    // ④ 后端注册:PDF worker 与文本插件
    let reg = build_engine().registry;
    let backends = reg.list_backends();
    let has_pdfium = backends.iter().any(|b| b.contains("pdfium"));
    check(
        has_pdfium,
        "backend:pdfium-worker",
        format!("{} 个后端已注册", backends.len()),
    );

    println!(
        "\n汇总:{}",
        if fail {
            "FAIL(存在必需项缺失)"
        } else {
            "OK(可能含 WARN)"
        }
    );
    if warn > 0 {
        println!("{warn} 项 WARN 不影响纯文本路径解析。");
    }
    if fail {
        ExitCode::from(1)
    } else {
        ExitCode::SUCCESS
    }
}

fn main() -> ExitCode {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Doctor => doctor(),
        Cmd::Batch { files, jobs } => batch_run(files, jobs),
        Cmd::Backends => {
            let reg = build_engine().registry;
            for b in reg.list_backends() {
                println!("{b}");
            }
            ExitCode::SUCCESS
        }
        Cmd::Parse { path, format } => {
            let eng = build_engine();
            match eng.parse(&path) {
                Ok(doc) => {
                    let out = match format.as_str() {
                        "text" => emit::to_text(&doc),
                        "markdown" => emit::to_markdown(&doc),
                        _ => serde_json::to_string_pretty(&doc).unwrap_or_default(),
                    };
                    println!("{out}");
                    ExitCode::SUCCESS
                }
                Err(e) => {
                    let payload = serde_json::json!({"error": {"code": e.code.as_str(),
                                                      "message": e.message}});
                    eprintln!("{payload}");
                    ExitCode::from(2)
                }
            }
        }
    }
}