pdftract/crates/pdftract-cli/src/doctor/mod.rs
jedarden e6bf3dd290 feat(pdftract-3s2i): implement Phase 5.5.2 validation filter
Implement per-word validation filter for assisted-OCR BrokenVector path.

Changes:
- Add SpanSource::OcrAssisted variant to hybrid.rs
- Add Span::ocr_assisted() helper method
- Implement validate_ocr_with_position_hints() in ocr.rs
  - 5pt distance threshold for position validation
  - 0.4 confidence cap for rejected words
  - Linear scan for nearest-neighbor lookup
- Add unit tests for validation filter

Closes: pdftract-3s2i

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 04:57:17 -04:00

203 lines
5.8 KiB
Rust

//! Doctor subcommand - environment health checks
use anyhow::Result;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::PathBuf;
// Private checks module
mod checks;
mod output;
pub use checks::registry::all_checks;
/// Result of a single doctor check
#[derive(Debug, Clone)]
pub struct CheckResult {
/// Human-readable check name
pub name: &'static str,
/// Check status
pub status: CheckStatus,
/// Human-readable detail message
pub detail: String,
}
/// Status of a doctor check
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckStatus {
/// Check passed
Ok,
/// Check passed with warnings
Warn,
/// Check failed
Fail,
/// Check not applicable (feature not compiled)
NotApplicable,
}
/// Context passed to each check
#[derive(Debug, Clone)]
pub struct DoctorCtx {
/// Requested OCR languages (from --lang flag)
pub requested_langs: Vec<String>,
/// Cache directory path (from --cache-dir flag)
pub cache_dir: Option<PathBuf>,
/// Profile search path (from --profile-dir flag)
pub profile_dir: Option<PathBuf>,
/// Feature flags compiled in
pub features: DoctorFeatures,
}
/// Feature flags compiled into the binary
#[derive(Debug, Clone, Default)]
pub struct DoctorFeatures {
pub ocr: bool,
pub full_render: bool,
pub remote: bool,
pub profiles: bool,
pub serve: bool,
pub mcp: bool,
pub inspect: bool,
pub grep: bool,
pub cache: bool,
pub receipts: bool,
pub markdown: bool,
}
impl DoctorFeatures {
/// Detect compiled features from build-time environment variables
pub fn from_build() -> Self {
let compiled_features = env!("COMPILED_FEATURES");
Self {
ocr: compiled_features.contains("OCR"),
full_render: compiled_features.contains("FULL_RENDER"),
remote: compiled_features.contains("REMOTE"),
profiles: compiled_features.contains("PROFILES"),
serve: compiled_features.contains("SERVE"),
mcp: compiled_features.contains("MCP"),
inspect: compiled_features.contains("INSPECT"),
grep: compiled_features.contains("GREP"),
cache: compiled_features.contains("CACHE"),
receipts: compiled_features.contains("RECEIPTS"),
markdown: compiled_features.contains("MARKDOWN"),
}
}
}
/// Trait for environment checks
pub trait Check: Send + Sync {
/// Human-readable check name
fn name(&self) -> &'static str;
/// Run the check, returning a result
fn run(&self, ctx: &DoctorCtx) -> CheckResult;
}
/// Wrapper that catches panics in Check::run
pub fn run_check_safe<C: Check + ?Sized>(check: &C, ctx: &DoctorCtx) -> CheckResult {
let name = check.name();
match catch_unwind(AssertUnwindSafe(|| check.run(ctx))) {
Ok(result) => result,
Err(panic) => {
let panic_msg = if let Some(s) = panic.downcast_ref::<String>() {
s.clone()
} else if let Some(s) = panic.downcast_ref::<&str>() {
s.to_string()
} else {
"unknown panic".to_string()
};
CheckResult {
name,
status: CheckStatus::Fail,
detail: format!("Panic during check: {}", panic_msg),
}
}
}
}
/// Get version information for the binary
pub fn version_info() -> String {
format!(
"{} (git: {})\nFeatures: {}",
env!("CARGO_PKG_VERSION"),
env!("GIT_SHA"),
env!("COMPILED_FEATURES")
)
}
/// Options for the doctor subcommand
pub struct DoctorOptions {
/// Print compiled features and exit
pub features: bool,
/// Output results as JSON
pub json: bool,
/// Disable colored output
pub no_color: bool,
/// Exit with code 1 if any check reports FAIL
pub exit_on_fail: bool,
/// Verify the profile search path includes DIR
pub profile_dir: Option<PathBuf>,
/// Verify DIR is writable and has sufficient space
pub cache_dir: Option<PathBuf>,
/// Requested OCR languages (default: eng)
pub lang: Vec<String>,
}
/// Run the doctor subcommand
pub fn run(opts: DoctorOptions) -> Result<()> {
// If --features is set, print features and exit
if opts.features {
let features = DoctorFeatures::from_build();
output::output_features(&features);
return Ok(());
}
// Build the doctor context
let ctx = DoctorCtx {
requested_langs: if opts.lang.is_empty() {
vec!["eng".to_string()]
} else {
opts.lang
},
cache_dir: opts.cache_dir,
profile_dir: opts.profile_dir,
features: DoctorFeatures::from_build(),
};
// Run all checks
let checks = all_checks();
let mut results: Vec<CheckResult> = Vec::new();
for check in &checks {
let result = run_check_safe(&**check, &ctx);
results.push(result);
}
// Output results
if opts.json {
output::output_json(&results);
} else {
output::output_text(
&results,
&output::TextOptions {
no_color: opts.no_color,
},
)?;
}
// Determine exit code per plan section 6.10 line 2520-2521:
// - Exit 0: all checks OK or WARN (no FAIL)
// - Exit 1: at least one check is FAIL
// - Exit 2: CLI parse error (clap default, unreachable here)
// Note: opts.exit_on_fail is unused in the computation because the default policy
// already exits 1 on any FAIL. The flag is retained for CI script readability and
// forward-compatibility (future --warn-as-fail or --no-exit-on-fall would invert).
let has_fail = results.iter().any(|r| r.status == CheckStatus::Fail);
if has_fail {
std::process::exit(1);
}
Ok(())
}