use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use crate::fs::gitignore::{read_managed_block, upsert_managed_block, GitignoreError};
use crate::lockfile::{read_lockfile, LockEntry, LockfileError};
use crate::manifest::{self, Event, ManifestError, PackState};
use crate::plugin::pack_type::default_managed_gitignore_patterns;
const GITIGNORE_EXT_KEY: &str = "x-gitignore";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CheckKind {
ManifestSchema,
GitignoreSync,
OnDiskDrift,
ConfigLint,
SyntheticPack,
}
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",
CheckKind::SyntheticPack => "synthetic-pack",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Ok,
Warning,
Error,
}
#[non_exhaustive]
#[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,
pub synthetic: bool,
}
impl Finding {
pub fn ok(check: CheckKind) -> Self {
Self {
check,
severity: Severity::Ok,
pack: None,
detail: String::new(),
auto_fixable: false,
synthetic: 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 (lock, lock_finding) = read_synthetic_lock(workspace);
if let Some(f) = lock_finding {
report.findings.push(f);
}
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,
synthetic: false,
}),
};
report.findings.extend(gi_result.findings.clone());
let drift_result = match &packs {
Some(p) => check_on_disk_drift(workspace, p, &lock),
None => CheckResult::single(Finding {
check: CheckKind::OnDiskDrift,
severity: Severity::Warning,
pack: None,
detail: "skipped: manifest unreadable".to_string(),
auto_fixable: false,
synthetic: false,
}),
};
report.findings.extend(drift_result.findings);
let synth = check_synthetic_packs(&lock);
report.findings.extend(synth.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(".gitignore");
let expected = expected_patterns_for_pack(workspace, 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,
synthetic: 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,
synthetic: false,
}),
None,
)
}
}
}
fn expected_patterns_for_pack(workspace: &Path, state: &PackState) -> Vec<String> {
if !is_builtin_pack_type(&state.pack_type) {
return Vec::new();
}
let mut expected: Vec<String> =
default_managed_gitignore_patterns().iter().map(|p| (*p).to_string()).collect();
for pattern in authored_gitignore_patterns(workspace, state) {
if !expected.iter().any(|p| p == &pattern) {
expected.push(pattern);
}
}
expected
}
fn is_builtin_pack_type(pack_type: &str) -> bool {
matches!(pack_type, "meta" | "declarative" | "scripted")
}
fn authored_gitignore_patterns(workspace: &Path, state: &PackState) -> Vec<String> {
let pack_yaml = workspace.join(&state.path).join(".grex").join("pack.yaml");
let Ok(contents) = std::fs::read_to_string(pack_yaml) else {
return Vec::new();
};
let Ok(pack) = crate::pack::parse(&contents) else {
return Vec::new();
};
let Some(raw) = pack.extensions.get(GITIGNORE_EXT_KEY) else {
return Vec::new();
};
let Some(seq) = raw.as_sequence() else {
return Vec::new();
};
seq.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()
}
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();
let gi_path = workspace.join(".gitignore");
for (id, state) in ordered {
match read_managed_block(&gi_path, id) {
Ok(Some(actual)) => {
let expected = expected_patterns_for_pack(workspace, 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,
synthetic: false,
});
}
}
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 { .. }),
synthetic: false,
});
}
}
}
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>,
lock: &HashMap<String, LockEntry>,
) -> 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, lock, &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>,
lock: &HashMap<String, LockEntry>,
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)) {
continue;
}
if lock.get(name_str).is_some_and(|e| e.synthetic) {
continue;
}
findings.push(Finding {
check: CheckKind::OnDiskDrift,
severity: Severity::Warning,
pack: None,
detail: format!("unregistered directory on disk: {name_str}"),
auto_fixable: false,
synthetic: 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,
synthetic: 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 read_synthetic_lock(workspace: &Path) -> (HashMap<String, LockEntry>, Option<Finding>) {
let lock_path = workspace.join(".grex").join("grex.lock.jsonl");
match read_lockfile(&lock_path) {
Ok(map) => (map, None),
Err(err @ LockfileError::Corruption { .. }) | Err(err @ LockfileError::Io(_)) => {
let finding = Finding {
check: CheckKind::ManifestSchema,
severity: Severity::Warning,
pack: None,
detail: format!("lockfile corruption: {err}"),
auto_fixable: false,
synthetic: false,
};
(HashMap::new(), Some(finding))
}
Err(err) => {
let finding = Finding {
check: CheckKind::ManifestSchema,
severity: Severity::Warning,
pack: None,
detail: format!("lockfile corruption: {err}"),
auto_fixable: false,
synthetic: false,
};
(HashMap::new(), Some(finding))
}
}
}
pub fn check_synthetic_packs(lock: &HashMap<String, LockEntry>) -> CheckResult {
let mut findings = Vec::new();
let ordered: BTreeMap<_, _> = lock.iter().collect();
for (id, entry) in ordered {
if !entry.synthetic {
continue;
}
findings.push(Finding {
check: CheckKind::SyntheticPack,
severity: Severity::Ok,
pack: Some(id.clone()),
detail: "OK (synthetic)".to_string(),
auto_fixable: false,
synthetic: true,
});
}
CheckResult { findings }
}
fn config_lint_warning(detail: String) -> Finding {
Finding {
check: CheckKind::ConfigLint,
severity: Severity::Warning,
pack: None,
detail,
auto_fixable: false,
synthetic: 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) {
seed_pack_with_type(workspace, id, "declarative");
}
fn seed_pack_with_type(workspace: &Path, id: &str, pack_type: &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: pack_type.into(),
schema_version: SCHEMA_VERSION.into(),
},
)
.unwrap();
fs::create_dir_all(workspace.join(id)).unwrap();
}
fn write_pack_yaml(workspace: &Path, id: &str, yaml: &str) {
let dir = workspace.join(id).join(".grex");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("pack.yaml"), yaml).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 expected_patterns_for_pack_populates_builtin_defaults() {
for pack_type in ["meta", "declarative", "scripted"] {
let d = tempdir().unwrap();
seed_pack_with_type(d.path(), pack_type, pack_type);
let events = manifest::read_all(&d.path().join("grex.jsonl")).unwrap();
let packs = manifest::fold(events);
let state = packs.get(pack_type).unwrap();
assert_eq!(
expected_patterns_for_pack(d.path(), state),
vec![".grex-lock".to_string()],
"pack type: {pack_type}"
);
}
}
#[test]
fn expected_patterns_for_pack_merges_authored_extensions_for_builtins() {
for pack_type in ["meta", "declarative", "scripted"] {
let d = tempdir().unwrap();
let id = format!("{pack_type}-pack");
let authored = format!("{pack_type}-cache/");
seed_pack_with_type(d.path(), &id, pack_type);
write_pack_yaml(
d.path(),
&id,
&format!(
"schema_version: \"1\"\nname: {id}\ntype: {pack_type}\nx-gitignore:\n - \".grex-lock\"\n - {authored}\n",
),
);
let events = manifest::read_all(&d.path().join("grex.jsonl")).unwrap();
let packs = manifest::fold(events);
let state = packs.get(&id).unwrap();
assert_eq!(
expected_patterns_for_pack(d.path(), state),
vec![".grex-lock".to_string(), authored],
"pack type: {pack_type}"
);
}
}
#[test]
fn gitignore_clean_block_is_ok() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
upsert_managed_block(
&d.path().join(".gitignore"),
"a",
default_managed_gitignore_patterns(),
)
.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(".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 gitignore_authored_patterns_are_not_reported_as_drift() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
write_pack_yaml(
d.path(),
"a",
"schema_version: \"1\"\nname: a\ntype: declarative\nx-gitignore:\n - target/\n - \"*.log\"\n",
);
upsert_managed_block(
&d.path().join(".gitignore"),
"a",
&[".grex-lock", "target/", "*.log"],
)
.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 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, &HashMap::new());
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, &HashMap::new());
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, &HashMap::new());
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,
synthetic: false,
});
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,
synthetic: 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,
synthetic: false,
});
r.findings.push(Finding {
check: CheckKind::OnDiskDrift,
severity: Severity::Error,
pack: None,
detail: String::new(),
auto_fixable: false,
synthetic: 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(".gitignore"),
"a",
default_managed_gitignore_patterns(),
)
.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(".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(".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(".gitignore"),
"a",
default_managed_gitignore_patterns(),
)
.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(".gitignore"),
"a",
default_managed_gitignore_patterns(),
)
.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));
}
#[test]
fn run_doctor_synthetic_pack_reports_ok_synthetic_and_exits_zero() {
use crate::lockfile::{write_lockfile, LockEntry};
use std::collections::HashMap;
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
upsert_managed_block(
&d.path().join(".gitignore"),
"a",
default_managed_gitignore_patterns(),
)
.unwrap();
let lock_dir = d.path().join(".grex");
fs::create_dir_all(&lock_dir).unwrap();
let lock_path = lock_dir.join("grex.lock.jsonl");
let mut lock = HashMap::new();
lock.insert(
"a".to_string(),
LockEntry {
id: "a".into(),
sha: "deadbeef".into(),
branch: "main".into(),
installed_at: ts(),
actions_hash: String::new(),
schema_version: "1".into(),
synthetic: true,
},
);
write_lockfile(&lock_path, &lock).unwrap();
let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
assert_eq!(report.exit_code(), 0, "synthetic-only workspace must exit 0");
let synth: Vec<_> =
report.findings.iter().filter(|f| f.check == CheckKind::SyntheticPack).collect();
assert_eq!(synth.len(), 1, "exactly one synthetic-pack finding");
assert_eq!(synth[0].pack.as_deref(), Some("a"));
assert_eq!(synth[0].detail, "OK (synthetic)");
assert!(synth[0].synthetic, "Finding.synthetic must be true");
assert_eq!(synth[0].severity, Severity::Ok);
for f in &report.findings {
assert!(f.severity != Severity::Error, "no error-severity finding allowed; got: {f:?}",);
}
}
#[test]
fn run_doctor_corrupt_lockfile_emits_warning_finding() {
let d = tempdir().unwrap();
seed_pack(d.path(), "a");
upsert_managed_block(
&d.path().join(".gitignore"),
"a",
default_managed_gitignore_patterns(),
)
.unwrap();
let lock_dir = d.path().join(".grex");
fs::create_dir_all(&lock_dir).unwrap();
fs::write(lock_dir.join("grex.lock.jsonl"), b"not-json-at-all\n").unwrap();
let report = run_doctor(d.path(), &DoctorOpts::default())
.expect("doctor must complete despite lockfile corruption");
let lock_warns: Vec<_> = report
.findings
.iter()
.filter(|f| {
f.check == CheckKind::ManifestSchema
&& f.severity == Severity::Warning
&& f.detail.contains("lockfile corruption")
})
.collect();
assert_eq!(
lock_warns.len(),
1,
"exactly one lockfile-corruption warning expected; got: {:?}",
report.findings,
);
assert!(
report.findings.iter().any(|f| f.check == CheckKind::GitignoreSync),
"gitignore-sync check must still run",
);
assert!(
report.findings.iter().any(|f| f.check == CheckKind::OnDiskDrift),
"on-disk-drift check must still run",
);
}
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,
synthetic: 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);
}
}
}