1use std::collections::{BTreeMap, BTreeSet, HashMap};
21use std::path::{Path, PathBuf};
22
23use crate::fs::gitignore::{read_managed_block, upsert_managed_block, GitignoreError};
24use crate::lockfile::{read_lockfile, LockEntry, LockfileError};
25use crate::manifest::{self, Event, ManifestError, PackState};
26use crate::plugin::pack_type::default_managed_gitignore_patterns;
27
28const GITIGNORE_EXT_KEY: &str = "x-gitignore";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum CheckKind {
33 ManifestSchema,
35 GitignoreSync,
37 OnDiskDrift,
40 ConfigLint,
42 SyntheticPack,
47}
48
49impl CheckKind {
50 pub fn label(self) -> &'static str {
52 match self {
53 CheckKind::ManifestSchema => "manifest-schema",
54 CheckKind::GitignoreSync => "gitignore-sync",
55 CheckKind::OnDiskDrift => "on-disk-drift",
56 CheckKind::ConfigLint => "config-lint",
57 CheckKind::SyntheticPack => "synthetic-pack",
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
65pub enum Severity {
66 Ok,
68 Warning,
70 Error,
72}
73
74#[non_exhaustive]
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Finding {
84 pub check: CheckKind,
86 pub severity: Severity,
88 pub pack: Option<String>,
90 pub detail: String,
92 pub auto_fixable: bool,
96 pub synthetic: bool,
102}
103
104impl Finding {
105 pub fn ok(check: CheckKind) -> Self {
107 Self {
108 check,
109 severity: Severity::Ok,
110 pack: None,
111 detail: String::new(),
112 auto_fixable: false,
113 synthetic: false,
114 }
115 }
116}
117
118#[derive(Debug, Clone, Default)]
122pub struct CheckResult {
123 pub findings: Vec<Finding>,
125}
126
127impl CheckResult {
128 pub fn single(finding: Finding) -> Self {
130 Self { findings: vec![finding] }
131 }
132
133 pub fn worst(&self) -> Severity {
135 self.findings.iter().map(|f| f.severity).max().unwrap_or(Severity::Ok)
136 }
137}
138
139#[derive(Debug, Clone, Default)]
141pub struct DoctorReport {
142 pub findings: Vec<Finding>,
144}
145
146impl DoctorReport {
147 pub fn worst(&self) -> Severity {
149 self.findings.iter().map(|f| f.severity).max().unwrap_or(Severity::Ok)
150 }
151
152 pub fn exit_code(&self) -> i32 {
158 match self.worst() {
159 Severity::Ok => 0,
160 Severity::Warning => 1,
161 Severity::Error => 2,
162 }
163 }
164}
165
166#[derive(Debug, Clone, Default)]
168pub struct DoctorOpts {
169 pub fix: bool,
172 pub lint_config: bool,
175 pub shallow: Option<usize>,
186}
187
188#[derive(Debug, thiserror::Error)]
192pub enum DoctorError {
193 #[error("manifest read failure: {0}")]
195 ManifestIo(#[source] ManifestError),
196 #[error("gitignore fix failure: {0}")]
198 GitignoreFix(#[source] GitignoreError),
199}
200
201pub fn run_doctor(workspace: &Path, opts: &DoctorOpts) -> Result<DoctorReport, DoctorError> {
212 manifest::ensure_event_log_migrated(workspace).map_err(DoctorError::ManifestIo)?;
219
220 let mut report = DoctorReport::default();
221 walk_meta(workspace, opts, 0, &mut report);
222
223 if opts.lint_config {
224 let cfg_result = check_config_lint(workspace);
225 report.findings.extend(cfg_result.findings);
226 }
227
228 if opts.fix {
229 let manifest_path = workspace.join(".grex").join("events.jsonl");
233 let packs = match manifest::read_all(&manifest_path) {
234 Ok(evs) => Some(manifest::fold(evs)),
235 Err(_) => None,
236 };
237 apply_fixes(workspace, packs.as_ref(), &mut report)?;
238 }
239
240 Ok(report)
241}
242
243fn walk_meta(meta_dir: &Path, opts: &DoctorOpts, depth: usize, report: &mut DoctorReport) {
253 run_meta_checks(meta_dir, report);
254
255 if let Some(cap) = opts.shallow {
256 if depth >= cap {
257 return;
258 }
259 }
260
261 let manifest_path = meta_dir.join(".grex").join("pack.yaml");
264 let raw = match std::fs::read_to_string(&manifest_path) {
265 Ok(s) => s,
266 Err(_) => return,
267 };
268 let manifest = match crate::pack::parse(&raw) {
269 Ok(m) => m,
270 Err(_) => return,
271 };
272 for child in &manifest.children {
273 let segment = child.path.clone().unwrap_or_else(|| child.effective_path());
274 let child_meta = meta_dir.join(&segment);
275 if child_meta.join(".grex").join("pack.yaml").is_file() {
276 walk_meta(&child_meta, opts, depth + 1, report);
277 }
278 }
279}
280
281fn run_meta_checks(meta_dir: &Path, report: &mut DoctorReport) {
285 let manifest_path = meta_dir.join(".grex").join("events.jsonl");
286 let (schema_result, events_opt) = check_manifest_schema(&manifest_path);
287 report.findings.extend(schema_result.findings.clone());
288
289 let packs = events_opt.map(manifest::fold);
293
294 let (lock, lock_finding) = read_synthetic_lock(meta_dir);
299 if let Some(f) = lock_finding {
300 report.findings.push(f);
301 }
302
303 let gi_result = match &packs {
304 Some(p) => check_gitignore_sync(meta_dir, p),
305 None => CheckResult::single(Finding {
306 check: CheckKind::GitignoreSync,
307 severity: Severity::Warning,
308 pack: None,
309 detail: "skipped: manifest unreadable".to_string(),
310 auto_fixable: false,
311 synthetic: false,
312 }),
313 };
314 report.findings.extend(gi_result.findings);
315
316 let drift_result = match &packs {
317 Some(p) => check_on_disk_drift(meta_dir, p, &lock),
318 None => CheckResult::single(Finding {
319 check: CheckKind::OnDiskDrift,
320 severity: Severity::Warning,
321 pack: None,
322 detail: "skipped: manifest unreadable".to_string(),
323 auto_fixable: false,
324 synthetic: false,
325 }),
326 };
327 report.findings.extend(drift_result.findings);
328
329 let synth = check_synthetic_packs(&lock);
330 report.findings.extend(synth.findings);
331}
332
333fn apply_fixes(
338 workspace: &Path,
339 packs: Option<&std::collections::HashMap<String, PackState>>,
340 report: &mut DoctorReport,
341) -> Result<(), DoctorError> {
342 let to_fix: Vec<(String, String)> = report
344 .findings
345 .iter()
346 .filter(|f| f.check == CheckKind::GitignoreSync && f.auto_fixable)
347 .filter_map(|f| f.pack.clone().map(|p| (p, f.detail.clone())))
348 .collect();
349
350 let Some(packs) = packs else {
351 return Ok(());
352 };
353
354 for (pack_id, _detail) in to_fix {
355 let Some(state) = packs.get(&pack_id) else { continue };
356 let gi_path = workspace.join(".gitignore");
357 let expected = expected_patterns_for_pack(workspace, state);
358 let patterns_ref: Vec<&str> = expected.iter().map(String::as_str).collect();
359 upsert_managed_block(&gi_path, &state.id, &patterns_ref)
360 .map_err(DoctorError::GitignoreFix)?;
361 }
362
363 let refreshed = check_gitignore_sync(workspace, packs);
365 report.findings.retain(|f| f.check != CheckKind::GitignoreSync);
366 report.findings.extend(refreshed.findings);
367 Ok(())
368}
369
370pub fn check_manifest_schema(manifest_path: &Path) -> (CheckResult, Option<Vec<Event>>) {
373 if !manifest_path.exists() {
374 return (CheckResult::single(Finding::ok(CheckKind::ManifestSchema)), Some(Vec::new()));
376 }
377 match manifest::read_all(manifest_path) {
378 Ok(evs) => (CheckResult::single(Finding::ok(CheckKind::ManifestSchema)), Some(evs)),
379 Err(ManifestError::Corruption { line, source }) => {
380 let detail = format!("corruption at line {line}: {source}");
381 (
382 CheckResult::single(Finding {
383 check: CheckKind::ManifestSchema,
384 severity: Severity::Error,
385 pack: None,
386 detail,
387 auto_fixable: false,
388 synthetic: false,
389 }),
390 None,
391 )
392 }
393 Err(e) => {
394 let detail = format!("io error: {e}");
395 (
396 CheckResult::single(Finding {
397 check: CheckKind::ManifestSchema,
398 severity: Severity::Error,
399 pack: None,
400 detail,
401 auto_fixable: false,
402 synthetic: false,
403 }),
404 None,
405 )
406 }
407 }
408}
409
410fn expected_patterns_for_pack(workspace: &Path, state: &PackState) -> Vec<String> {
418 if !is_builtin_pack_type(&state.pack_type) {
419 return Vec::new();
420 }
421
422 let mut expected: Vec<String> =
423 default_managed_gitignore_patterns().iter().map(|p| (*p).to_string()).collect();
424
425 for pattern in authored_gitignore_patterns(workspace, state) {
426 if !expected.iter().any(|p| p == &pattern) {
427 expected.push(pattern);
428 }
429 }
430
431 expected
432}
433
434fn is_builtin_pack_type(pack_type: &str) -> bool {
435 matches!(pack_type, "meta" | "declarative" | "scripted")
436}
437
438fn authored_gitignore_patterns(workspace: &Path, state: &PackState) -> Vec<String> {
439 let pack_yaml = workspace.join(&state.path).join(".grex").join("pack.yaml");
440 let Ok(contents) = std::fs::read_to_string(pack_yaml) else {
441 return Vec::new();
442 };
443 let Ok(pack) = crate::pack::parse(&contents) else {
444 return Vec::new();
445 };
446 let Some(raw) = pack.extensions.get(GITIGNORE_EXT_KEY) else {
447 return Vec::new();
448 };
449 let Some(seq) = raw.as_sequence() else {
450 return Vec::new();
451 };
452 seq.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()
453}
454
455pub fn check_gitignore_sync(
459 workspace: &Path,
460 packs: &std::collections::HashMap<String, PackState>,
461) -> CheckResult {
462 let mut findings = Vec::new();
463 let ordered: BTreeMap<_, _> = packs.iter().collect();
465 let gi_path = workspace.join(".gitignore");
466 for (id, state) in ordered {
467 match read_managed_block(&gi_path, id) {
468 Ok(Some(actual)) => {
469 let expected = expected_patterns_for_pack(workspace, state);
470 if actual != expected {
471 findings.push(Finding {
472 check: CheckKind::GitignoreSync,
473 severity: Severity::Warning,
474 pack: Some(id.clone()),
475 detail: format!(
476 "managed block drift: expected {} line(s), got {}",
477 expected.len(),
478 actual.len()
479 ),
480 auto_fixable: true,
481 synthetic: false,
482 });
483 }
484 }
485 Ok(None) => {
486 }
488 Err(e) => {
489 findings.push(Finding {
490 check: CheckKind::GitignoreSync,
491 severity: Severity::Warning,
492 pack: Some(id.clone()),
493 detail: format!("cannot read managed block: {e}"),
494 auto_fixable: matches!(e, GitignoreError::UnclosedBlock { .. }),
495 synthetic: false,
496 });
497 }
498 }
499 }
500 if findings.is_empty() {
501 findings.push(Finding::ok(CheckKind::GitignoreSync));
502 }
503 CheckResult { findings }
504}
505
506pub fn check_on_disk_drift(
517 workspace: &Path,
518 packs: &std::collections::HashMap<String, PackState>,
519 lock: &HashMap<String, LockEntry>,
520) -> CheckResult {
521 let mut findings = Vec::new();
522 let registered_paths: BTreeSet<PathBuf> =
523 packs.values().map(|p| PathBuf::from(&p.path)).collect();
524 collect_manifest_to_disk_findings(workspace, packs, &mut findings);
525 collect_disk_to_manifest_findings(workspace, ®istered_paths, lock, &mut findings);
526 if findings.is_empty() {
527 findings.push(Finding::ok(CheckKind::OnDiskDrift));
528 }
529 CheckResult { findings }
530}
531
532fn collect_manifest_to_disk_findings(
535 workspace: &Path,
536 packs: &std::collections::HashMap<String, PackState>,
537 findings: &mut Vec<Finding>,
538) {
539 let ordered: BTreeMap<_, _> = packs.iter().collect();
540 for (id, state) in ordered {
541 let full = workspace.join(&state.path);
542 if !full.exists() {
543 findings.push(drift_error(id, format!("registered pack dir missing: {}", state.path)));
544 continue;
545 }
546 match std::fs::symlink_metadata(&full) {
547 Ok(md) if !md.is_dir() => findings.push(drift_error(
548 id,
549 format!("registered pack path is not a directory: {}", state.path),
550 )),
551 Ok(_) => {}
552 Err(e) => findings.push(drift_error(id, format!("stat failed: {e}"))),
553 }
554 }
555}
556
557fn collect_disk_to_manifest_findings(
565 workspace: &Path,
566 registered_paths: &BTreeSet<PathBuf>,
567 lock: &HashMap<String, LockEntry>,
568 findings: &mut Vec<Finding>,
569) {
570 let Ok(entries) = std::fs::read_dir(workspace) else { return };
571 for ent in entries.flatten() {
572 let Ok(ft) = ent.file_type() else { continue };
573 if !ft.is_dir() {
574 continue;
575 }
576 let name = ent.file_name();
577 let Some(name_str) = name.to_str() else { continue };
578 if name_str.starts_with('.') || is_housekeeping_dir(name_str) {
579 continue;
580 }
581 if registered_paths.contains(&PathBuf::from(name_str)) {
582 continue;
583 }
584 if lock.get(name_str).is_some_and(|e| e.synthetic) {
585 continue;
586 }
587 findings.push(Finding {
588 check: CheckKind::OnDiskDrift,
589 severity: Severity::Warning,
590 pack: None,
591 detail: format!("unregistered directory on disk: {name_str}"),
592 auto_fixable: false,
593 synthetic: false,
594 });
595 }
596}
597
598fn drift_error(id: &str, detail: String) -> Finding {
600 Finding {
601 check: CheckKind::OnDiskDrift,
602 severity: Severity::Error,
603 pack: Some(id.to_string()),
604 detail,
605 auto_fixable: false,
606 synthetic: false,
607 }
608}
609
610fn is_housekeeping_dir(name: &str) -> bool {
612 matches!(name, "target" | "node_modules" | "crates" | "openspec" | "dist")
613}
614
615pub fn check_config_lint(workspace: &Path) -> CheckResult {
621 let mut findings = Vec::new();
622 check_openspec_config_yaml(workspace, &mut findings);
623 check_omne_cfg_markdown(workspace, &mut findings);
624 if findings.is_empty() {
625 findings.push(Finding::ok(CheckKind::ConfigLint));
626 }
627 CheckResult { findings }
628}
629
630fn check_openspec_config_yaml(workspace: &Path, findings: &mut Vec<Finding>) {
633 let cfg_yaml = workspace.join("openspec").join("config.yaml");
634 if !cfg_yaml.exists() {
635 return;
636 }
637 match std::fs::read_to_string(&cfg_yaml) {
638 Ok(s) => {
639 if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&s) {
640 findings
641 .push(config_lint_warning(format!("openspec/config.yaml parse error: {e}")));
642 }
643 }
644 Err(e) => {
645 findings.push(config_lint_warning(format!("openspec/config.yaml unreadable: {e}")))
646 }
647 }
648}
649
650fn check_omne_cfg_markdown(workspace: &Path, findings: &mut Vec<Finding>) {
653 let cfg_dir = workspace.join(".omne").join("cfg");
654 if !cfg_dir.is_dir() {
655 return;
656 }
657 let Ok(entries) = std::fs::read_dir(&cfg_dir) else { return };
658 for ent in entries.flatten() {
659 let path = ent.path();
660 if path.extension().and_then(|s| s.to_str()) != Some("md") {
661 continue;
662 }
663 if let Err(e) = std::fs::read_to_string(&path) {
664 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string();
665 findings.push(config_lint_warning(format!(".omne/cfg/{name} unreadable: {e}")));
666 }
667 }
668}
669
670fn read_synthetic_lock(workspace: &Path) -> (HashMap<String, LockEntry>, Option<Finding>) {
685 let lock_path = workspace.join(".grex").join("grex.lock.jsonl");
686 match read_lockfile(&lock_path) {
687 Ok(map) => (map, None),
688 Err(err @ LockfileError::Corruption { .. }) | Err(err @ LockfileError::Io(_)) => {
689 let finding = Finding {
690 check: CheckKind::ManifestSchema,
691 severity: Severity::Warning,
692 pack: None,
693 detail: format!("lockfile corruption: {err}"),
694 auto_fixable: false,
695 synthetic: false,
696 };
697 (HashMap::new(), Some(finding))
698 }
699 Err(err) => {
704 let finding = Finding {
705 check: CheckKind::ManifestSchema,
706 severity: Severity::Warning,
707 pack: None,
708 detail: format!("lockfile corruption: {err}"),
709 auto_fixable: false,
710 synthetic: false,
711 };
712 (HashMap::new(), Some(finding))
713 }
714 }
715}
716
717pub fn check_synthetic_packs(lock: &HashMap<String, LockEntry>) -> CheckResult {
728 let mut findings = Vec::new();
729 let ordered: BTreeMap<_, _> = lock.iter().collect();
730 for (id, entry) in ordered {
731 if !entry.synthetic {
732 continue;
733 }
734 findings.push(Finding {
735 check: CheckKind::SyntheticPack,
736 severity: Severity::Ok,
737 pack: Some(id.clone()),
738 detail: "OK (synthetic)".to_string(),
739 auto_fixable: false,
740 synthetic: true,
741 });
742 }
743 CheckResult { findings }
744}
745
746fn config_lint_warning(detail: String) -> Finding {
748 Finding {
749 check: CheckKind::ConfigLint,
750 severity: Severity::Warning,
751 pack: None,
752 detail,
753 auto_fixable: false,
754 synthetic: false,
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761 use crate::manifest::{append_event, Event, SCHEMA_VERSION};
762 use chrono::{TimeZone, Utc};
763 use std::fs;
764 use tempfile::tempdir;
765
766 fn ts() -> chrono::DateTime<Utc> {
767 Utc.with_ymd_and_hms(2026, 4, 22, 10, 0, 0).unwrap()
768 }
769
770 fn fs_snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
779 fn walk(dir: &Path, root: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
780 let entries = match fs::read_dir(dir) {
781 Ok(e) => e,
782 Err(_) => return,
783 };
784 for entry in entries.flatten() {
785 let path = entry.path();
786 let name = entry.file_name();
787 if name == ".git" || name == "target" {
788 continue;
789 }
790 let ft = match entry.file_type() {
791 Ok(t) => t,
792 Err(_) => continue,
793 };
794 if ft.is_dir() {
795 walk(&path, root, out);
796 } else if ft.is_file() {
797 let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
798 let bytes = fs::read(&path).unwrap_or_default();
799 out.insert(rel, bytes);
800 }
801 }
802 }
803 let mut out = BTreeMap::new();
804 walk(root, root, &mut out);
805 out
806 }
807
808 fn seed_pack(workspace: &Path, id: &str) {
809 seed_pack_with_type(workspace, id, "declarative");
810 }
811
812 fn seed_pack_with_type(workspace: &Path, id: &str, pack_type: &str) {
813 let m = workspace.join(".grex/events.jsonl");
814 append_event(
815 &m,
816 &Event::Add {
817 ts: ts(),
818 id: id.into(),
819 url: format!("https://example/{id}"),
820 path: id.into(),
821 pack_type: pack_type.into(),
822 schema_version: SCHEMA_VERSION.into(),
823 },
824 )
825 .unwrap();
826 fs::create_dir_all(workspace.join(id)).unwrap();
827 }
828
829 fn write_pack_yaml(workspace: &Path, id: &str, yaml: &str) {
830 let dir = workspace.join(id).join(".grex");
831 fs::create_dir_all(&dir).unwrap();
832 fs::write(dir.join("pack.yaml"), yaml).unwrap();
833 }
834
835 #[test]
838 fn schema_clean_is_ok() {
839 let d = tempdir().unwrap();
840 seed_pack(d.path(), "a");
841 let (r, evs) = check_manifest_schema(&d.path().join(".grex/events.jsonl"));
842 assert_eq!(r.worst(), Severity::Ok);
843 assert_eq!(evs.unwrap().len(), 1);
844 }
845
846 #[test]
847 fn schema_corruption_is_error() {
848 let d = tempdir().unwrap();
849 let m = d.path().join(".grex/events.jsonl");
852 fs::create_dir_all(m.parent().unwrap()).unwrap();
853 fs::write(&m, b"not-json\n").unwrap();
854 append_event(
855 &m,
856 &Event::Add {
857 ts: ts(),
858 id: "x".into(),
859 url: "u".into(),
860 path: "x".into(),
861 pack_type: "declarative".into(),
862 schema_version: SCHEMA_VERSION.into(),
863 },
864 )
865 .unwrap();
866
867 let (r, evs) = check_manifest_schema(&m);
868 assert_eq!(r.worst(), Severity::Error);
869 assert!(evs.is_none(), "corruption must disable downstream checks");
870 }
871
872 #[test]
873 fn schema_missing_manifest_is_ok() {
874 let d = tempdir().unwrap();
875 let (r, evs) = check_manifest_schema(&d.path().join(".grex/events.jsonl"));
876 assert_eq!(r.worst(), Severity::Ok);
877 assert!(evs.unwrap().is_empty());
878 }
879
880 #[test]
883 fn expected_patterns_for_pack_populates_builtin_defaults() {
884 for pack_type in ["meta", "declarative", "scripted"] {
885 let d = tempdir().unwrap();
886 seed_pack_with_type(d.path(), pack_type, pack_type);
887 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
888 let packs = manifest::fold(events);
889 let state = packs.get(pack_type).unwrap();
890 assert_eq!(
891 expected_patterns_for_pack(d.path(), state),
892 vec![".grex-lock".to_string()],
893 "pack type: {pack_type}"
894 );
895 }
896 }
897
898 #[test]
899 fn expected_patterns_for_pack_merges_authored_extensions_for_builtins() {
900 for pack_type in ["meta", "declarative", "scripted"] {
901 let d = tempdir().unwrap();
902 let id = format!("{pack_type}-pack");
903 let authored = format!("{pack_type}-cache/");
904 seed_pack_with_type(d.path(), &id, pack_type);
905 write_pack_yaml(
906 d.path(),
907 &id,
908 &format!(
909 "schema_version: \"1\"\nname: {id}\ntype: {pack_type}\nx-gitignore:\n - \".grex-lock\"\n - {authored}\n",
910 ),
911 );
912 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
913 let packs = manifest::fold(events);
914 let state = packs.get(&id).unwrap();
915 assert_eq!(
916 expected_patterns_for_pack(d.path(), state),
917 vec![".grex-lock".to_string(), authored],
918 "pack type: {pack_type}"
919 );
920 }
921 }
922
923 #[test]
924 fn gitignore_clean_block_is_ok() {
925 let d = tempdir().unwrap();
926 seed_pack(d.path(), "a");
927 upsert_managed_block(
929 &d.path().join(".gitignore"),
930 "a",
931 default_managed_gitignore_patterns(),
932 )
933 .unwrap();
934 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
935 let packs = manifest::fold(events);
936 let r = check_gitignore_sync(d.path(), &packs);
937 assert_eq!(r.worst(), Severity::Ok);
938 }
939
940 #[test]
941 fn gitignore_drift_is_warning_and_autofixable() {
942 let d = tempdir().unwrap();
943 seed_pack(d.path(), "a");
944 upsert_managed_block(&d.path().join(".gitignore"), "a", &["unexpected-line"]).unwrap();
946 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
947 let packs = manifest::fold(events);
948 let r = check_gitignore_sync(d.path(), &packs);
949 assert_eq!(r.worst(), Severity::Warning);
950 assert!(r.findings.iter().any(|f| f.auto_fixable));
951 }
952
953 #[test]
954 fn gitignore_authored_patterns_are_not_reported_as_drift() {
955 let d = tempdir().unwrap();
956 seed_pack(d.path(), "a");
957 write_pack_yaml(
958 d.path(),
959 "a",
960 "schema_version: \"1\"\nname: a\ntype: declarative\nx-gitignore:\n - target/\n - \"*.log\"\n",
961 );
962 upsert_managed_block(
963 &d.path().join(".gitignore"),
964 "a",
965 &[".grex-lock", "target/", "*.log"],
966 )
967 .unwrap();
968 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
969 let packs = manifest::fold(events);
970 let r = check_gitignore_sync(d.path(), &packs);
971 assert_eq!(r.worst(), Severity::Ok);
972 }
973
974 #[test]
977 fn on_disk_missing_pack_is_error() {
978 let d = tempdir().unwrap();
979 seed_pack(d.path(), "a");
980 fs::remove_dir_all(d.path().join("a")).unwrap();
982 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
983 let packs = manifest::fold(events);
984 let r = check_on_disk_drift(d.path(), &packs, &HashMap::new());
985 assert_eq!(r.worst(), Severity::Error);
986 }
987
988 #[test]
989 fn on_disk_unregistered_dir_is_warning() {
990 let d = tempdir().unwrap();
991 seed_pack(d.path(), "a");
992 fs::create_dir_all(d.path().join("stranger")).unwrap();
993 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
994 let packs = manifest::fold(events);
995 let r = check_on_disk_drift(d.path(), &packs, &HashMap::new());
996 assert_eq!(r.worst(), Severity::Warning);
997 }
998
999 #[test]
1000 fn on_disk_clean_workspace_is_ok() {
1001 let d = tempdir().unwrap();
1002 seed_pack(d.path(), "a");
1003 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1004 let packs = manifest::fold(events);
1005 let r = check_on_disk_drift(d.path(), &packs, &HashMap::new());
1006 assert_eq!(r.worst(), Severity::Ok);
1007 }
1008
1009 #[test]
1012 fn config_lint_absent_dir_is_ok() {
1013 let d = tempdir().unwrap();
1014 let r = check_config_lint(d.path());
1015 assert_eq!(r.worst(), Severity::Ok);
1016 }
1017
1018 #[test]
1019 fn config_lint_bad_yaml_is_warning() {
1020 let d = tempdir().unwrap();
1021 fs::create_dir_all(d.path().join("openspec")).unwrap();
1022 fs::write(d.path().join("openspec").join("config.yaml"), "::: bad: : yaml : [").unwrap();
1023 let r = check_config_lint(d.path());
1024 assert_eq!(r.worst(), Severity::Warning);
1025 }
1026
1027 #[test]
1030 fn exit_code_roll_up_ok_is_zero() {
1031 let mut r = DoctorReport::default();
1032 r.findings.push(Finding::ok(CheckKind::ManifestSchema));
1033 assert_eq!(r.exit_code(), 0);
1034 }
1035
1036 #[test]
1037 fn exit_code_roll_up_warning_is_one() {
1038 let mut r = DoctorReport::default();
1039 r.findings.push(Finding::ok(CheckKind::ManifestSchema));
1040 r.findings.push(Finding {
1041 check: CheckKind::GitignoreSync,
1042 severity: Severity::Warning,
1043 pack: None,
1044 detail: String::new(),
1045 auto_fixable: true,
1046 synthetic: false,
1047 });
1048 assert_eq!(r.exit_code(), 1);
1049 }
1050
1051 #[test]
1052 fn exit_code_roll_up_error_is_two() {
1053 let mut r = DoctorReport::default();
1054 r.findings.push(Finding {
1055 check: CheckKind::OnDiskDrift,
1056 severity: Severity::Error,
1057 pack: None,
1058 detail: String::new(),
1059 auto_fixable: false,
1060 synthetic: false,
1061 });
1062 assert_eq!(r.exit_code(), 2);
1063 }
1064
1065 #[test]
1066 fn exit_code_roll_up_warn_and_error_is_two() {
1067 let mut r = DoctorReport::default();
1068 r.findings.push(Finding {
1069 check: CheckKind::GitignoreSync,
1070 severity: Severity::Warning,
1071 pack: None,
1072 detail: String::new(),
1073 auto_fixable: true,
1074 synthetic: false,
1075 });
1076 r.findings.push(Finding {
1077 check: CheckKind::OnDiskDrift,
1078 severity: Severity::Error,
1079 pack: None,
1080 detail: String::new(),
1081 auto_fixable: false,
1082 synthetic: false,
1083 });
1084 assert_eq!(r.exit_code(), 2);
1085 }
1086
1087 #[test]
1090 fn run_doctor_clean_workspace_exits_zero() {
1091 let d = tempdir().unwrap();
1092 seed_pack(d.path(), "a");
1093 upsert_managed_block(
1094 &d.path().join(".gitignore"),
1095 "a",
1096 default_managed_gitignore_patterns(),
1097 )
1098 .unwrap();
1099 let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1100 assert_eq!(report.exit_code(), 0);
1101 }
1102
1103 #[test]
1104 fn run_doctor_gitignore_drift_exits_one() {
1105 let d = tempdir().unwrap();
1106 seed_pack(d.path(), "a");
1107 upsert_managed_block(&d.path().join(".gitignore"), "a", &["drift"]).unwrap();
1108 let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1109 assert_eq!(report.exit_code(), 1);
1110 }
1111
1112 #[test]
1113 fn run_doctor_fix_heals_gitignore_drift() {
1114 let d = tempdir().unwrap();
1115 seed_pack(d.path(), "a");
1116 upsert_managed_block(&d.path().join(".gitignore"), "a", &["drift"]).unwrap();
1117 let opts = DoctorOpts { fix: true, lint_config: false, ..DoctorOpts::default() };
1118 let report = run_doctor(d.path(), &opts).unwrap();
1119 assert_eq!(report.exit_code(), 0, "fix must zero out exit code");
1120 let again = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1122 assert_eq!(again.exit_code(), 0);
1123 }
1124
1125 #[test]
1126 fn run_doctor_fix_does_not_touch_manifest_on_schema_error() {
1127 let d = tempdir().unwrap();
1128 let m = d.path().join(".grex/events.jsonl");
1130 fs::create_dir_all(m.parent().unwrap()).unwrap();
1131 fs::write(&m, b"garbage-line\n").unwrap();
1132 append_event(
1133 &m,
1134 &Event::Add {
1135 ts: ts(),
1136 id: "x".into(),
1137 url: "u".into(),
1138 path: "x".into(),
1139 pack_type: "declarative".into(),
1140 schema_version: SCHEMA_VERSION.into(),
1141 },
1142 )
1143 .unwrap();
1144 let before_bytes = fs::read(&m).unwrap();
1145 let before = fs_snapshot(d.path());
1146
1147 let opts = DoctorOpts { fix: true, lint_config: false, ..DoctorOpts::default() };
1148 let report = run_doctor(d.path(), &opts).unwrap();
1149 assert_eq!(report.exit_code(), 2, "schema error → exit 2");
1150
1151 let after_bytes = fs::read(&m).unwrap();
1155 assert_eq!(before_bytes, after_bytes, "manifest bytes must be unchanged");
1156 let after = fs_snapshot(d.path());
1157 assert_eq!(before, after, "--fix must not write anywhere on schema error");
1158 }
1159
1160 #[test]
1161 fn run_doctor_fix_does_not_touch_disk_on_drift_error() {
1162 let d = tempdir().unwrap();
1163 seed_pack(d.path(), "a");
1164 fs::remove_dir_all(d.path().join("a")).unwrap();
1166
1167 let before = fs_snapshot(d.path());
1173
1174 let opts = DoctorOpts { fix: true, lint_config: false, ..DoctorOpts::default() };
1175 let report = run_doctor(d.path(), &opts).unwrap();
1176 assert_eq!(report.exit_code(), 2);
1177
1178 let after = fs_snapshot(d.path());
1179 assert_eq!(before, after, "--fix must not write anywhere on drift error");
1180 assert!(!d.path().join("a").exists(), "missing pack dir must stay missing");
1181 }
1182
1183 #[test]
1184 fn run_doctor_config_lint_skipped_by_default() {
1185 let d = tempdir().unwrap();
1186 seed_pack(d.path(), "a");
1187 upsert_managed_block(
1188 &d.path().join(".gitignore"),
1189 "a",
1190 default_managed_gitignore_patterns(),
1191 )
1192 .unwrap();
1193 fs::create_dir_all(d.path().join("openspec")).unwrap();
1195 fs::write(d.path().join("openspec").join("config.yaml"), ": : : [bad").unwrap();
1196 let before = fs_snapshot(d.path());
1197 let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1198 assert_eq!(report.exit_code(), 0, "config-lint must be skipped by default");
1199 assert!(
1200 !report.findings.iter().any(|f| f.check == CheckKind::ConfigLint),
1201 "no ConfigLint finding when --lint-config absent"
1202 );
1203 let after = fs_snapshot(d.path());
1205 assert_eq!(before, after, "default doctor run must be read-only");
1206 }
1207
1208 #[test]
1209 fn run_doctor_lint_config_flag_reports_config() {
1210 let d = tempdir().unwrap();
1211 seed_pack(d.path(), "a");
1212 upsert_managed_block(
1213 &d.path().join(".gitignore"),
1214 "a",
1215 default_managed_gitignore_patterns(),
1216 )
1217 .unwrap();
1218 fs::create_dir_all(d.path().join("openspec")).unwrap();
1219 fs::write(d.path().join("openspec").join("config.yaml"), ": : : [bad").unwrap();
1220 let opts = DoctorOpts { fix: false, lint_config: true, ..DoctorOpts::default() };
1221 let report = run_doctor(d.path(), &opts).unwrap();
1222 assert_eq!(report.exit_code(), 1);
1223 assert!(report.findings.iter().any(|f| f.check == CheckKind::ConfigLint));
1224 }
1225
1226 #[test]
1233 fn run_doctor_synthetic_pack_reports_ok_synthetic_and_exits_zero() {
1234 use crate::lockfile::{write_lockfile, LockEntry};
1235 use std::collections::HashMap;
1236
1237 let d = tempdir().unwrap();
1238 seed_pack(d.path(), "a");
1239 upsert_managed_block(
1240 &d.path().join(".gitignore"),
1241 "a",
1242 default_managed_gitignore_patterns(),
1243 )
1244 .unwrap();
1245
1246 let lock_dir = d.path().join(".grex");
1248 fs::create_dir_all(&lock_dir).unwrap();
1249 let lock_path = lock_dir.join("grex.lock.jsonl");
1250 let mut lock = HashMap::new();
1251 lock.insert(
1252 "a".to_string(),
1253 LockEntry {
1254 id: "a".into(),
1255 path: "a".into(),
1256 sha: "deadbeef".into(),
1257 branch: "main".into(),
1258 installed_at: ts(),
1259 actions_hash: String::new(),
1260 schema_version: "1".into(),
1261 synthetic: true,
1262 },
1263 );
1264 write_lockfile(&lock_path, &lock).unwrap();
1265
1266 let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1269 assert_eq!(report.exit_code(), 0, "synthetic-only workspace must exit 0");
1270 let synth: Vec<_> =
1271 report.findings.iter().filter(|f| f.check == CheckKind::SyntheticPack).collect();
1272 assert_eq!(synth.len(), 1, "exactly one synthetic-pack finding");
1273 assert_eq!(synth[0].pack.as_deref(), Some("a"));
1274 assert_eq!(synth[0].detail, "OK (synthetic)");
1275 assert!(synth[0].synthetic, "Finding.synthetic must be true");
1276 assert_eq!(synth[0].severity, Severity::Ok);
1277
1278 for f in &report.findings {
1281 assert!(f.severity != Severity::Error, "no error-severity finding allowed; got: {f:?}",);
1282 }
1283 }
1284
1285 #[test]
1292 fn run_doctor_corrupt_lockfile_emits_warning_finding() {
1293 let d = tempdir().unwrap();
1294 seed_pack(d.path(), "a");
1297 upsert_managed_block(
1298 &d.path().join(".gitignore"),
1299 "a",
1300 default_managed_gitignore_patterns(),
1301 )
1302 .unwrap();
1303
1304 let lock_dir = d.path().join(".grex");
1306 fs::create_dir_all(&lock_dir).unwrap();
1307 fs::write(lock_dir.join("grex.lock.jsonl"), b"not-json-at-all\n").unwrap();
1308
1309 let report = run_doctor(d.path(), &DoctorOpts::default())
1310 .expect("doctor must complete despite lockfile corruption");
1311
1312 let lock_warns: Vec<_> = report
1315 .findings
1316 .iter()
1317 .filter(|f| {
1318 f.check == CheckKind::ManifestSchema
1319 && f.severity == Severity::Warning
1320 && f.detail.contains("lockfile corruption")
1321 })
1322 .collect();
1323 assert_eq!(
1324 lock_warns.len(),
1325 1,
1326 "exactly one lockfile-corruption warning expected; got: {:?}",
1327 report.findings,
1328 );
1329
1330 assert!(
1333 report.findings.iter().any(|f| f.check == CheckKind::GitignoreSync),
1334 "gitignore-sync check must still run",
1335 );
1336 assert!(
1337 report.findings.iter().any(|f| f.check == CheckKind::OnDiskDrift),
1338 "on-disk-drift check must still run",
1339 );
1340 }
1341
1342 fn write_meta_manifest(meta_dir: &Path, name: &str, children: &[(&str, &str)]) {
1348 let grex_dir = meta_dir.join(".grex");
1349 fs::create_dir_all(&grex_dir).unwrap();
1350 let mut yaml = format!("schema_version: \"1\"\nname: {name}\ntype: meta\n");
1351 if !children.is_empty() {
1352 yaml.push_str("children:\n");
1353 for (segment, url) in children {
1354 yaml.push_str(&format!(" - url: {url}\n path: {segment}\n"));
1355 }
1356 }
1357 fs::write(grex_dir.join("pack.yaml"), yaml).unwrap();
1358 }
1359
1360 fn seed_meta_with_pack(meta_dir: &Path, meta_name: &str, pack_id: &str) {
1364 write_meta_manifest(meta_dir, meta_name, &[]);
1365 let m = meta_dir.join(".grex").join("events.jsonl");
1366 append_event(
1367 &m,
1368 &Event::Add {
1369 ts: ts(),
1370 id: pack_id.into(),
1371 url: format!("https://example/{pack_id}"),
1372 path: pack_id.into(),
1373 pack_type: "declarative".into(),
1374 schema_version: SCHEMA_VERSION.into(),
1375 },
1376 )
1377 .unwrap();
1378 fs::create_dir_all(meta_dir.join(pack_id)).unwrap();
1379 }
1380
1381 #[test]
1385 fn test_doctor_recurses_default() {
1386 let d = tempdir().unwrap();
1387 let root = d.path();
1388
1389 write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1391 let m = root.join(".grex").join("events.jsonl");
1393 append_event(
1394 &m,
1395 &Event::Add {
1396 ts: ts(),
1397 id: "alpha".into(),
1398 url: "https://example.invalid/alpha.git".into(),
1399 path: "alpha".into(),
1400 pack_type: "meta".into(),
1401 schema_version: SCHEMA_VERSION.into(),
1402 },
1403 )
1404 .unwrap();
1405
1406 let alpha = root.join("alpha");
1408 write_meta_manifest(&alpha, "alpha", &[("gamma", "https://example.invalid/gamma.git")]);
1409 let am = alpha.join(".grex").join("events.jsonl");
1410 append_event(
1411 &am,
1412 &Event::Add {
1413 ts: ts(),
1414 id: "gamma".into(),
1415 url: "https://example.invalid/gamma.git".into(),
1416 path: "gamma".into(),
1417 pack_type: "declarative".into(),
1418 schema_version: SCHEMA_VERSION.into(),
1419 },
1420 )
1421 .unwrap();
1422 fs::create_dir_all(alpha.join("gamma")).unwrap();
1423
1424 let gamma = alpha.join("gamma");
1426 seed_meta_with_pack(&gamma, "gamma", "delta");
1427
1428 let report = run_doctor(root, &DoctorOpts::default()).unwrap();
1429
1430 let schema_oks: Vec<_> = report
1431 .findings
1432 .iter()
1433 .filter(|f| f.check == CheckKind::ManifestSchema && f.severity == Severity::Ok)
1434 .collect();
1435 assert_eq!(
1436 schema_oks.len(),
1437 3,
1438 "three metas visited (root + alpha + gamma); got: {:?}",
1439 report.findings,
1440 );
1441 }
1442
1443 #[test]
1446 fn test_doctor_shallow_zero_root_only() {
1447 let d = tempdir().unwrap();
1448 let root = d.path();
1449
1450 write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1451 let m = root.join(".grex").join("events.jsonl");
1452 append_event(
1453 &m,
1454 &Event::Add {
1455 ts: ts(),
1456 id: "alpha".into(),
1457 url: "https://example.invalid/alpha.git".into(),
1458 path: "alpha".into(),
1459 pack_type: "meta".into(),
1460 schema_version: SCHEMA_VERSION.into(),
1461 },
1462 )
1463 .unwrap();
1464 let alpha = root.join("alpha");
1465 seed_meta_with_pack(&alpha, "alpha", "leaf");
1466
1467 let opts = DoctorOpts { shallow: Some(0), ..DoctorOpts::default() };
1468 let report = run_doctor(root, &opts).unwrap();
1469 let schema_oks: Vec<_> = report
1470 .findings
1471 .iter()
1472 .filter(|f| f.check == CheckKind::ManifestSchema && f.severity == Severity::Ok)
1473 .collect();
1474 assert_eq!(schema_oks.len(), 1, "shallow=0 must halt at root; got: {:?}", report.findings,);
1475 }
1476
1477 #[test]
1481 fn test_doctor_shallow_n_stops_at_n() {
1482 let d = tempdir().unwrap();
1483 let root = d.path();
1484
1485 write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1486 let m = root.join(".grex").join("events.jsonl");
1487 append_event(
1488 &m,
1489 &Event::Add {
1490 ts: ts(),
1491 id: "alpha".into(),
1492 url: "https://example.invalid/alpha.git".into(),
1493 path: "alpha".into(),
1494 pack_type: "meta".into(),
1495 schema_version: SCHEMA_VERSION.into(),
1496 },
1497 )
1498 .unwrap();
1499
1500 let alpha = root.join("alpha");
1501 write_meta_manifest(&alpha, "alpha", &[("gamma", "https://example.invalid/gamma.git")]);
1502 let am = alpha.join(".grex").join("events.jsonl");
1503 append_event(
1504 &am,
1505 &Event::Add {
1506 ts: ts(),
1507 id: "gamma".into(),
1508 url: "https://example.invalid/gamma.git".into(),
1509 path: "gamma".into(),
1510 pack_type: "meta".into(),
1511 schema_version: SCHEMA_VERSION.into(),
1512 },
1513 )
1514 .unwrap();
1515 fs::create_dir_all(alpha.join("gamma")).unwrap();
1516
1517 let gamma = alpha.join("gamma");
1518 seed_meta_with_pack(&gamma, "gamma", "delta");
1519
1520 let opts = DoctorOpts { shallow: Some(1), ..DoctorOpts::default() };
1521 let report = run_doctor(root, &opts).unwrap();
1522 let schema_oks: Vec<_> = report
1523 .findings
1524 .iter()
1525 .filter(|f| f.check == CheckKind::ManifestSchema && f.severity == Severity::Ok)
1526 .collect();
1527 assert_eq!(
1528 schema_oks.len(),
1529 2,
1530 "shallow=1 must visit root + depth-1; got: {:?}",
1531 report.findings,
1532 );
1533 }
1534
1535 #[test]
1540 fn test_doctor_no_fs_mutations() {
1541 let d = tempdir().unwrap();
1542 let root = d.path();
1543
1544 write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1546 let m = root.join(".grex").join("events.jsonl");
1547 append_event(
1548 &m,
1549 &Event::Add {
1550 ts: ts(),
1551 id: "alpha".into(),
1552 url: "https://example.invalid/alpha.git".into(),
1553 path: "alpha".into(),
1554 pack_type: "meta".into(),
1555 schema_version: SCHEMA_VERSION.into(),
1556 },
1557 )
1558 .unwrap();
1559
1560 let alpha = root.join("alpha");
1564 seed_meta_with_pack(&alpha, "alpha", "leaf");
1565 upsert_managed_block(&alpha.join(".gitignore"), "leaf", &["drifted-pattern"]).unwrap();
1566
1567 let before = fs_snapshot(root);
1568 let report = run_doctor(root, &DoctorOpts::default()).unwrap();
1569 let after = fs_snapshot(root);
1570
1571 assert_eq!(before, after, "recursive doctor walk must perform zero writes");
1572 assert!(
1576 report
1577 .findings
1578 .iter()
1579 .any(|f| f.check == CheckKind::GitignoreSync && f.severity == Severity::Warning),
1580 "expected sub-meta gitignore-drift warning; got: {:?}",
1581 report.findings,
1582 );
1583 }
1584
1585 proptest::proptest! {
1588 #![proptest_config(proptest::prelude::ProptestConfig { cases: 128, ..Default::default() })]
1589
1590 #[test]
1591 fn prop_exit_code_matches_worst_severity(
1592 severities in proptest::collection::vec(0u8..3, 0..20)
1593 ) {
1594 let mut r = DoctorReport::default();
1595 for s in &severities {
1596 let sev = match s {
1597 0 => Severity::Ok,
1598 1 => Severity::Warning,
1599 _ => Severity::Error,
1600 };
1601 r.findings.push(Finding {
1602 check: CheckKind::ManifestSchema,
1603 severity: sev,
1604 pack: None,
1605 detail: String::new(),
1606 auto_fixable: false,
1607 synthetic: false,
1608 });
1609 }
1610 let worst = severities.iter().max().copied().unwrap_or(0);
1611 let expected = match worst { 0 => 0, 1 => 1, _ => 2 };
1612 proptest::prop_assert_eq!(r.exit_code(), expected);
1613 }
1614 }
1615}