use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::fs::gitignore::{read_managed_block, upsert_managed_block, GitignoreError};
use crate::manifest::{self, Event, ManifestError, PackState};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CheckKind {
ManifestSchema,
GitignoreSync,
OnDiskDrift,
ConfigLint,
}
impl CheckKind {
pub fn label(self) -> &'static str {
match self {
CheckKind::ManifestSchema => "manifest-schema",
CheckKind::GitignoreSync => "gitignore-sync",
CheckKind::OnDiskDrift => "on-disk-drift",
CheckKind::ConfigLint => "config-lint",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Ok,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub check: CheckKind,
pub severity: Severity,
pub pack: Option<String>,
pub detail: String,
pub auto_fixable: bool,
}
impl Finding {
pub fn ok(check: CheckKind) -> Self {
Self {
check,
severity: Severity::Ok,
pack: None,
detail: String::new(),
auto_fixable: false,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CheckResult {
pub findings: Vec<Finding>,
}
impl CheckResult {
pub fn single(finding: Finding) -> Self {
Self { findings: vec![finding] }
}
pub fn worst(&self) -> Severity {
self.findings.iter().map(|f| f.severity).max().unwrap_or(Severity::Ok)
}
}
#[derive(Debug, Clone, Default)]
pub struct DoctorReport {
pub findings: Vec<Finding>,
}
impl DoctorReport {
pub fn worst(&self) -> Severity {
self.findings.iter().map(|f| f.severity).max().unwrap_or(Severity::Ok)
}
pub fn exit_code(&self) -> i32 {
match self.worst() {
Severity::Ok => 0,
Severity::Warning => 1,
Severity::Error => 2,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DoctorOpts {
pub fix: bool,
pub lint_config: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum DoctorError {
#[error("manifest read failure: {0}")]
ManifestIo(#[source] ManifestError),
#[error("gitignore fix failure: {0}")]
GitignoreFix(#[source] GitignoreError),
}
pub fn run_doctor(workspace: &Path, opts: &DoctorOpts) -> Result<DoctorReport, DoctorError> {
let manifest_path = workspace.join("grex.jsonl");
let (schema_result, events_opt) = check_manifest_schema(&manifest_path);
let mut report = DoctorReport::default();
report.findings.extend(schema_result.findings.clone());
let packs = events_opt.map(manifest::fold);
let gi_result = match &packs {
Some(p) => check_gitignore_sync(workspace, p),
None => CheckResult::single(Finding {
check: CheckKind::GitignoreSync,
severity: Severity::Warning,
pack: None,
detail: "skipped: manifest unreadable".to_string(),
auto_fixable: false,
}),
};
report.findings.extend(gi_result.findings.clone());
let drift_result = match &packs {
Some(p) => check_on_disk_drift(workspace, p),
None => CheckResult::single(Finding {
check: CheckKind::OnDiskDrift,
severity: Severity::Warning,
pack: None,
detail: "skipped: manifest unreadable".to_string(),
auto_fixable: false,
}),
};
report.findings.extend(drift_result.findings);
if opts.lint_config {
let cfg_result = check_config_lint(workspace);
report.findings.extend(cfg_result.findings);
}
if opts.fix {
apply_fixes(workspace, packs.as_ref(), &mut report)?;
}
Ok(report)
}
fn apply_fixes(
workspace: &Path,
packs: Option<&std::collections::HashMap<String, PackState>>,
report: &mut DoctorReport,
) -> Result<(), DoctorError> {
let to_fix: Vec<(String, String)> = report
.findings
.iter()
.filter(|f| f.check == CheckKind::GitignoreSync && f.auto_fixable)
.filter_map(|f| f.pack.clone().map(|p| (p, f.detail.clone())))
.collect();
let Some(packs) = packs else {
return Ok(());
};
for (pack_id, _detail) in to_fix {
let Some(state) = packs.get(&pack_id) else { continue };
let gi_path = workspace.join(&state.path).join(".gitignore");
let expected = expected_patterns_for_pack(state);
let patterns_ref: Vec<&str> = expected.iter().map(String::as_str).collect();
upsert_managed_block(&gi_path, &state.id, &patterns_ref)
.map_err(DoctorError::GitignoreFix)?;
}
let refreshed = check_gitignore_sync(workspace, packs);
report.findings.retain(|f| f.check != CheckKind::GitignoreSync);
report.findings.extend(refreshed.findings);
Ok(())
}
pub fn check_manifest_schema(manifest_path: &Path) -> (CheckResult, Option<Vec<Event>>) {
if !manifest_path.exists() {
return (CheckResult::single(Finding::ok(CheckKind::ManifestSchema)), Some(Vec::new()));
}
match manifest::read_all(manifest_path) {
Ok(evs) => (CheckResult::single(Finding::ok(CheckKind::ManifestSchema)), Some(evs)),
Err(ManifestError::Corruption { line, source }) => {
let detail = format!("corruption at line {line}: {source}");
(
CheckResult::single(Finding {
check: CheckKind::ManifestSchema,
severity: Severity::Error,
pack: None,
detail,
auto_fixable: false,
}),
None,
)
}
Err(e) => {
let detail = format!("io error: {e}");
(
CheckResult::single(Finding {
check: CheckKind::ManifestSchema,
severity: Severity::Error,
pack: None,
detail,
auto_fixable: false,
}),
None,
)
}
}
}
fn expected_patterns_for_pack(_state: &PackState) -> Vec<String> {
Vec::new()
}
pub fn check_gitignore_sync(
workspace: &Path,
packs: &std::collections::HashMap<String, PackState>,
) -> CheckResult {
let mut findings = Vec::new();
let ordered: BTreeMap<_, _> = packs.iter().collect();
for (id, state) in ordered {
let gi_path = workspace.join(&state.path).join(".gitignore");
match read_managed_block(&gi_path, id) {
Ok(Some(actual)) => {
let expected = expected_patterns_for_pack(state);
if actual != expected {
findings.push(Finding {
check: CheckKind::GitignoreSync,
severity: Severity::Warning,
pack: Some(id.clone()),
detail: format!(
"managed block drift: expected {} line(s), got {}",
expected.len(),
actual.len()
),
auto_fixable: true,
});
}
}
Ok(None) => {
}
Err(e) => {
findings.push(Finding {
check: CheckKind::GitignoreSync,
severity: Severity::Warning,
pack: Some(id.clone()),
detail: format!("cannot read managed block: {e}"),
auto_fixable: matches!(e, GitignoreError::UnclosedBlock { .. }),
});
}
}
}
if findings.is_empty() {
findings.push(Finding::ok(CheckKind::GitignoreSync));
}
CheckResult { findings }
}
pub fn check_on_disk_drift(
workspace: &Path,
packs: &std::collections::HashMap<String, PackState>,
) -> CheckResult {
let mut findings = Vec::new();
let registered_paths: BTreeSet<PathBuf> =
packs.values().map(|p| PathBuf::from(&p.path)).collect();
collect_manifest_to_disk_findings(workspace, packs, &mut findings);
collect_disk_to_manifest_findings(workspace, ®istered_paths, &mut findings);
if findings.is_empty() {
findings.push(Finding::ok(CheckKind::OnDiskDrift));
}
CheckResult { findings }
}
fn collect_manifest_to_disk_findings(
workspace: &Path,
packs: &std::collections::HashMap<String, PackState>,
findings: &mut Vec<Finding>,
) {
let ordered: BTreeMap<_, _> = packs.iter().collect();
for (id, state) in ordered {
let full = workspace.join(&state.path);
if !full.exists() {
findings.push(drift_error(id, format!("registered pack dir missing: {}", state.path)));
continue;
}
match std::fs::symlink_metadata(&full) {
Ok(md) if !md.is_dir() => findings.push(drift_error(
id,
format!("registered pack path is not a directory: {}", state.path),
)),
Ok(_) => {}
Err(e) => findings.push(drift_error(id, format!("stat failed: {e}"))),
}
}
}
fn collect_disk_to_manifest_findings(
workspace: &Path,
registered_paths: &BTreeSet<PathBuf>,
findings: &mut Vec<Finding>,
) {
let Ok(entries) = std::fs::read_dir(workspace) else { return };
for ent in entries.flatten() {
let Ok(ft) = ent.file_type() else { continue };
if !ft.is_dir() {
continue;
}
let name = ent.file_name();
let Some(name_str) = name.to_str() else { continue };
if name_str.starts_with('.') || is_housekeeping_dir(name_str) {
continue;
}
if !registered_paths.contains(&PathBuf::from(name_str)) {
findings.push(Finding {
check: CheckKind::OnDiskDrift,
severity: Severity::Warning,
pack: None,
detail: format!("unregistered directory on disk: {name_str}"),
auto_fixable: false,
});
}
}
}
fn drift_error(id: &str, detail: String) -> Finding {
Finding {
check: CheckKind::OnDiskDrift,
severity: Severity::Error,
pack: Some(id.to_string()),
detail,
auto_fixable: false,
}
}
fn is_housekeeping_dir(name: &str) -> bool {
matches!(name, "target" | "node_modules" | "crates" | "openspec" | "dist")
}
pub fn check_config_lint(workspace: &Path) -> CheckResult {
let mut findings = Vec::new();
check_openspec_config_yaml(workspace, &mut findings);
check_omne_cfg_markdown(workspace, &mut findings);
if findings.is_empty() {
findings.push(Finding::ok(CheckKind::ConfigLint));
}
CheckResult { findings }
}
fn check_openspec_config_yaml(workspace: &Path, findings: &mut Vec<Finding>) {
let cfg_yaml = workspace.join("openspec").join("config.yaml");
if !cfg_yaml.exists() {
return;
}
match std::fs::read_to_string(&cfg_yaml) {
Ok(s) => {
if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&s) {
findings
.push(config_lint_warning(format!("openspec/config.yaml parse error: {e}")));
}
}
Err(e) => {
findings.push(config_lint_warning(format!("openspec/config.yaml unreadable: {e}")))
}
}
}
fn check_omne_cfg_markdown(workspace: &Path, findings: &mut Vec<Finding>) {
let cfg_dir = workspace.join(".omne").join("cfg");
if !cfg_dir.is_dir() {
return;
}
let Ok(entries) = std::fs::read_dir(&cfg_dir) else { return };
for ent in entries.flatten() {
let path = ent.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
if let Err(e) = std::fs::read_to_string(&path) {
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string();
findings.push(config_lint_warning(format!(".omne/cfg/{name} unreadable: {e}")));
}
}
}
fn config_lint_warning(detail: String) -> Finding {
Finding {
check: CheckKind::ConfigLint,
severity: Severity::Warning,
pack: None,
detail,
auto_fixable: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{append_event, Event, SCHEMA_VERSION};
use chrono::{TimeZone, Utc};
use std::fs;
use tempfile::tempdir;
fn ts() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 4, 22, 10, 0, 0).unwrap()
}
fn fs_snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
fn walk(dir: &Path, root: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
if name == ".git" || name == "target" {
continue;
}
let ft = match entry.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if ft.is_dir() {
walk(&path, root, out);
} else if ft.is_file() {
let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
let bytes = fs::read(&path).unwrap_or_default();
out.insert(rel, bytes);
}
}
}
let mut out = BTreeMap::new();
walk(root, root, &mut out);
out
}
fn seed_pack(workspace: &Path, id: &str) {
let m = workspace.join("grex.jsonl");
append_event(
&m,
&Event::Add {
ts: ts(),
id: id.into(),
url: format!("https://example/{id}"),
path: id.into(),
pack_type: "declarative".into(),
schema_version: SCHEMA_VERSION.into(),
},
)
.unwrap();
fs::create_dir_all(workspace.join(id)).unwrap();
}
#[test]
fn schema_clean_is_ok() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
let (r, evs) = check_manifest_schema(&d.path().join("grex.jsonl"));
assert_eq!(r.worst(), Severity::Ok);
assert_eq!(evs.unwrap().len(), 1);
}
#[test]
fn schema_corruption_is_error() {
let d = tempdir().unwrap();
let m = d.path().join("grex.jsonl");
fs::write(&m, b"not-json\n").unwrap();
append_event(
&m,
&Event::Add {
ts: ts(),
id: "x".into(),
url: "u".into(),
path: "x".into(),
pack_type: "declarative".into(),
schema_version: SCHEMA_VERSION.into(),
},
)
.unwrap();
let (r, evs) = check_manifest_schema(&m);
assert_eq!(r.worst(), Severity::Error);
assert!(evs.is_none(), "corruption must disable downstream checks");
}
#[test]
fn schema_missing_manifest_is_ok() {
let d = tempdir().unwrap();
let (r, evs) = check_manifest_schema(&d.path().join("grex.jsonl"));
assert_eq!(r.worst(), Severity::Ok);
assert!(evs.unwrap().is_empty());
}
#[test]
fn gitignore_clean_block_is_ok() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
upsert_managed_block(&d.path().join("a").join(".gitignore"), "a", &[]).unwrap();
let events = manifest::read_all(&d.path().join("grex.jsonl")).unwrap();
let packs = manifest::fold(events);
let r = check_gitignore_sync(d.path(), &packs);
assert_eq!(r.worst(), Severity::Ok);
}
#[test]
fn gitignore_drift_is_warning_and_autofixable() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
upsert_managed_block(&d.path().join("a").join(".gitignore"), "a", &["unexpected-line"])
.unwrap();
let events = manifest::read_all(&d.path().join("grex.jsonl")).unwrap();
let packs = manifest::fold(events);
let r = check_gitignore_sync(d.path(), &packs);
assert_eq!(r.worst(), Severity::Warning);
assert!(r.findings.iter().any(|f| f.auto_fixable));
}
#[test]
fn on_disk_missing_pack_is_error() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
fs::remove_dir_all(d.path().join("a")).unwrap();
let events = manifest::read_all(&d.path().join("grex.jsonl")).unwrap();
let packs = manifest::fold(events);
let r = check_on_disk_drift(d.path(), &packs);
assert_eq!(r.worst(), Severity::Error);
}
#[test]
fn on_disk_unregistered_dir_is_warning() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
fs::create_dir_all(d.path().join("stranger")).unwrap();
let events = manifest::read_all(&d.path().join("grex.jsonl")).unwrap();
let packs = manifest::fold(events);
let r = check_on_disk_drift(d.path(), &packs);
assert_eq!(r.worst(), Severity::Warning);
}
#[test]
fn on_disk_clean_workspace_is_ok() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
let events = manifest::read_all(&d.path().join("grex.jsonl")).unwrap();
let packs = manifest::fold(events);
let r = check_on_disk_drift(d.path(), &packs);
assert_eq!(r.worst(), Severity::Ok);
}
#[test]
fn config_lint_absent_dir_is_ok() {
let d = tempdir().unwrap();
let r = check_config_lint(d.path());
assert_eq!(r.worst(), Severity::Ok);
}
#[test]
fn config_lint_bad_yaml_is_warning() {
let d = tempdir().unwrap();
fs::create_dir_all(d.path().join("openspec")).unwrap();
fs::write(d.path().join("openspec").join("config.yaml"), "::: bad: : yaml : [").unwrap();
let r = check_config_lint(d.path());
assert_eq!(r.worst(), Severity::Warning);
}
#[test]
fn exit_code_roll_up_ok_is_zero() {
let mut r = DoctorReport::default();
r.findings.push(Finding::ok(CheckKind::ManifestSchema));
assert_eq!(r.exit_code(), 0);
}
#[test]
fn exit_code_roll_up_warning_is_one() {
let mut r = DoctorReport::default();
r.findings.push(Finding::ok(CheckKind::ManifestSchema));
r.findings.push(Finding {
check: CheckKind::GitignoreSync,
severity: Severity::Warning,
pack: None,
detail: String::new(),
auto_fixable: true,
});
assert_eq!(r.exit_code(), 1);
}
#[test]
fn exit_code_roll_up_error_is_two() {
let mut r = DoctorReport::default();
r.findings.push(Finding {
check: CheckKind::OnDiskDrift,
severity: Severity::Error,
pack: None,
detail: String::new(),
auto_fixable: false,
});
assert_eq!(r.exit_code(), 2);
}
#[test]
fn exit_code_roll_up_warn_and_error_is_two() {
let mut r = DoctorReport::default();
r.findings.push(Finding {
check: CheckKind::GitignoreSync,
severity: Severity::Warning,
pack: None,
detail: String::new(),
auto_fixable: true,
});
r.findings.push(Finding {
check: CheckKind::OnDiskDrift,
severity: Severity::Error,
pack: None,
detail: String::new(),
auto_fixable: false,
});
assert_eq!(r.exit_code(), 2);
}
#[test]
fn run_doctor_clean_workspace_exits_zero() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
upsert_managed_block(&d.path().join("a").join(".gitignore"), "a", &[]).unwrap();
let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
assert_eq!(report.exit_code(), 0);
}
#[test]
fn run_doctor_gitignore_drift_exits_one() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
upsert_managed_block(&d.path().join("a").join(".gitignore"), "a", &["drift"]).unwrap();
let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
assert_eq!(report.exit_code(), 1);
}
#[test]
fn run_doctor_fix_heals_gitignore_drift() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
upsert_managed_block(&d.path().join("a").join(".gitignore"), "a", &["drift"]).unwrap();
let opts = DoctorOpts { fix: true, lint_config: false };
let report = run_doctor(d.path(), &opts).unwrap();
assert_eq!(report.exit_code(), 0, "fix must zero out exit code");
let again = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
assert_eq!(again.exit_code(), 0);
}
#[test]
fn run_doctor_fix_does_not_touch_manifest_on_schema_error() {
let d = tempdir().unwrap();
let m = d.path().join("grex.jsonl");
fs::write(&m, b"garbage-line\n").unwrap();
append_event(
&m,
&Event::Add {
ts: ts(),
id: "x".into(),
url: "u".into(),
path: "x".into(),
pack_type: "declarative".into(),
schema_version: SCHEMA_VERSION.into(),
},
)
.unwrap();
let before_bytes = fs::read(&m).unwrap();
let before = fs_snapshot(d.path());
let opts = DoctorOpts { fix: true, lint_config: false };
let report = run_doctor(d.path(), &opts).unwrap();
assert_eq!(report.exit_code(), 2, "schema error → exit 2");
let after_bytes = fs::read(&m).unwrap();
assert_eq!(before_bytes, after_bytes, "manifest bytes must be unchanged");
let after = fs_snapshot(d.path());
assert_eq!(before, after, "--fix must not write anywhere on schema error");
}
#[test]
fn run_doctor_fix_does_not_touch_disk_on_drift_error() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
fs::remove_dir_all(d.path().join("a")).unwrap();
let before = fs_snapshot(d.path());
let opts = DoctorOpts { fix: true, lint_config: false };
let report = run_doctor(d.path(), &opts).unwrap();
assert_eq!(report.exit_code(), 2);
let after = fs_snapshot(d.path());
assert_eq!(before, after, "--fix must not write anywhere on drift error");
assert!(!d.path().join("a").exists(), "missing pack dir must stay missing");
}
#[test]
fn run_doctor_config_lint_skipped_by_default() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
upsert_managed_block(&d.path().join("a").join(".gitignore"), "a", &[]).unwrap();
fs::create_dir_all(d.path().join("openspec")).unwrap();
fs::write(d.path().join("openspec").join("config.yaml"), ": : : [bad").unwrap();
let before = fs_snapshot(d.path());
let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
assert_eq!(report.exit_code(), 0, "config-lint must be skipped by default");
assert!(
!report.findings.iter().any(|f| f.check == CheckKind::ConfigLint),
"no ConfigLint finding when --lint-config absent"
);
let after = fs_snapshot(d.path());
assert_eq!(before, after, "default doctor run must be read-only");
}
#[test]
fn run_doctor_lint_config_flag_reports_config() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
upsert_managed_block(&d.path().join("a").join(".gitignore"), "a", &[]).unwrap();
fs::create_dir_all(d.path().join("openspec")).unwrap();
fs::write(d.path().join("openspec").join("config.yaml"), ": : : [bad").unwrap();
let opts = DoctorOpts { fix: false, lint_config: true };
let report = run_doctor(d.path(), &opts).unwrap();
assert_eq!(report.exit_code(), 1);
assert!(report.findings.iter().any(|f| f.check == CheckKind::ConfigLint));
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig { cases: 128, ..Default::default() })]
#[test]
fn prop_exit_code_matches_worst_severity(
severities in proptest::collection::vec(0u8..3, 0..20)
) {
let mut r = DoctorReport::default();
for s in &severities {
let sev = match s {
0 => Severity::Ok,
1 => Severity::Warning,
_ => Severity::Error,
};
r.findings.push(Finding {
check: CheckKind::ManifestSchema,
severity: sev,
pack: None,
detail: String::new(),
auto_fixable: false,
});
}
let worst = severities.iter().max().copied().unwrap_or(0);
let expected = match worst { 0 => 0, 1 => 1, _ => 2 };
proptest::prop_assert_eq!(r.exit_code(), expected);
}
}
}