mod cache;
mod config_lint;
#[cfg(all(layout_detection, not(target_arch = "wasm32")))]
mod layout;
#[cfg(not(all(layout_detection, not(target_arch = "wasm32"))))]
mod layout_unavailable;
mod ocr;
pub use cache::{CleanOutcome, clean_obsolete};
use crate::core::config::ExtractionConfig;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProbeStatus {
Pass,
Warn,
Fail,
Skip,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoctorCheck {
pub name: String,
pub status: ProbeStatus,
pub message: String,
}
impl DoctorCheck {
pub fn pass(name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
name: name.into(),
status: ProbeStatus::Pass,
message: message.into(),
}
}
pub fn warn(name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
name: name.into(),
status: ProbeStatus::Warn,
message: message.into(),
}
}
pub fn fail(name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
name: name.into(),
status: ProbeStatus::Fail,
message: message.into(),
}
}
pub fn skip(name: impl Into<String>, message: impl Into<String>) -> Self {
Self {
name: name.into(),
status: ProbeStatus::Skip,
message: message.into(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DoctorReport {
pub checks: Vec<DoctorCheck>,
}
impl DoctorReport {
pub fn is_ok(&self) -> bool {
self.checks.iter().all(|c| c.status != ProbeStatus::Fail)
}
}
pub fn doctor(config: &ExtractionConfig) -> DoctorReport {
let mut checks = Vec::new();
checks.extend(config_lint::lint_config(config));
checks.extend(ocr::probe_ocr(config));
#[cfg(all(layout_detection, not(target_arch = "wasm32")))]
checks.extend(layout::probe_layout(config));
#[cfg(not(all(layout_detection, not(target_arch = "wasm32"))))]
checks.extend(layout_unavailable::probe_layout(config));
checks.extend(cache::check_cache(config));
DoctorReport { checks }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_reports_layout_as_unconfigured() {
let report = doctor(&ExtractionConfig::default());
assert!(
report
.checks
.iter()
.any(|check| check.name.starts_with("layout") && check.status == ProbeStatus::Skip),
"default doctor report must distinguish unconfigured layout from a successful probe"
);
}
#[cfg(feature = "ocr")]
#[test]
fn default_config_probes_compiled_native_ocr_backend() {
let report = doctor(&ExtractionConfig::default());
let check = report
.checks
.iter()
.find(|check| check.name == "ocr.tesseract")
.unwrap_or_else(|| {
panic!(
"default doctor report must probe the compiled OCR backend: {:?}",
report.checks
)
});
assert!(matches!(check.status, ProbeStatus::Pass | ProbeStatus::Skip));
assert!(
check.message.starts_with("tesseract ") || check.message.starts_with("tessdata "),
"compiled backend probe must report its runtime or tessdata result: {}",
check.message
);
}
#[cfg(all(not(feature = "ocr"), feature = "ocr-wasm"))]
#[test]
fn default_config_probes_compiled_wasm_ocr_backend() {
let report = doctor(&ExtractionConfig::default());
let check = report
.checks
.iter()
.find(|check| check.name == "ocr.tesseract")
.unwrap_or_else(|| {
panic!(
"default doctor report must probe the compiled OCR backend: {:?}",
report.checks
)
});
assert_eq!(check.status, ProbeStatus::Skip);
assert_eq!(check.message, "no probe implemented for this backend");
}
#[cfg(not(any(feature = "ocr", feature = "ocr-wasm")))]
#[test]
fn default_config_skips_when_default_ocr_backend_is_not_compiled() {
let report = doctor(&ExtractionConfig::default());
let check = report
.checks
.iter()
.find(|check| check.name == "ocr.tesseract")
.expect("default doctor report must describe the unavailable default OCR backend");
assert_eq!(check.status, ProbeStatus::Skip);
assert_eq!(
check.message,
"default OCR backend is not compiled in (enable `ocr` or `ocr-wasm`)"
);
assert!(report.is_ok(), "an unavailable inferred default must not fail doctor");
}
#[cfg(not(any(feature = "ocr", feature = "ocr-wasm")))]
#[test]
fn explicitly_configured_unavailable_ocr_backend_fails() {
let config = ExtractionConfig {
ocr: Some(crate::core::config::OcrConfig::default()),
..Default::default()
};
let report = doctor(&config);
let check = report
.checks
.iter()
.find(|check| check.name == "ocr.tesseract")
.expect("doctor must report the explicitly configured OCR backend");
assert_eq!(check.status, ProbeStatus::Fail);
assert!(!report.is_ok(), "an unavailable configured backend must fail doctor");
}
#[cfg(not(any(feature = "ocr", feature = "ocr-wasm")))]
#[test]
fn forced_ocr_without_backend_support_fails() {
let config = ExtractionConfig {
force_ocr: true,
..Default::default()
};
let report = doctor(&config);
let check = report
.checks
.iter()
.find(|check| check.name == "ocr.tesseract")
.expect("doctor must report the backend required by forced OCR");
assert_eq!(check.status, ProbeStatus::Fail);
assert!(!report.is_ok(), "forced OCR without backend support must fail doctor");
}
#[test]
fn effective_disabled_ocr_is_reported_as_skipped() {
let shorthand_disabled = ExtractionConfig {
ocr: Some(crate::core::config::OcrConfig {
enabled: false,
..Default::default()
}),
..Default::default()
};
let top_level_disabled = ExtractionConfig {
disable_ocr: true,
..Default::default()
};
for config in [shorthand_disabled, top_level_disabled] {
let report = doctor(&config);
let check = report
.checks
.iter()
.find(|check| check.name == "ocr.tesseract")
.expect("disabled OCR must remain visible in the report");
assert_eq!(check.status, ProbeStatus::Skip);
assert_eq!(check.message, "OCR is disabled by configuration");
}
}
#[test]
fn report_ok_only_without_failures() {
let mut report = DoctorReport::default();
assert!(report.is_ok());
report.checks.push(DoctorCheck::pass("a", "fine"));
report.checks.push(DoctorCheck::skip("b", "not cached"));
report.checks.push(DoctorCheck::warn("c", "stray files"));
assert!(report.is_ok());
report.checks.push(DoctorCheck::fail("d", "broken"));
assert!(!report.is_ok());
}
#[test]
fn probe_status_serializes_lowercase() {
let check = DoctorCheck::fail("ocr.vlm", "no key");
let json = serde_json::to_value(&check).unwrap();
assert_eq!(json["status"], "fail");
let roundtrip: DoctorCheck = serde_json::from_value(json).unwrap();
assert_eq!(roundtrip.status, ProbeStatus::Fail);
assert_eq!(roundtrip.message, "no key");
let warn = serde_json::to_value(DoctorCheck::warn("cache.xberg", "stray")).unwrap();
assert_eq!(warn["status"], "warn");
}
}