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,
Doctor,
Batch {
files: Vec<String>,
#[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)
}
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
}
}
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;
}
};
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(),
},
);
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"
);
}
}
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),
);
}
},
}
}
}
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)
}
}
}
}
}