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
28pub mod scan_undeclared;
29pub use scan_undeclared::{scan_undeclared, ScanError, UndeclaredRepo};
30
31const GITIGNORE_EXT_KEY: &str = "x-gitignore";
32
33#[non_exhaustive]
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41pub enum CheckKind {
42 ManifestSchema,
44 GitignoreSync,
46 OnDiskDrift,
49 ConfigLint,
51 SyntheticPack,
56 QuarantineGc,
61 QuarantineRestore,
67 ParentGitTracksPackContent,
77}
78
79impl CheckKind {
80 pub fn label(self) -> &'static str {
82 match self {
83 CheckKind::ManifestSchema => "manifest-schema",
84 CheckKind::GitignoreSync => "gitignore-sync",
85 CheckKind::OnDiskDrift => "on-disk-drift",
86 CheckKind::ConfigLint => "config-lint",
87 CheckKind::SyntheticPack => "synthetic-pack",
88 CheckKind::QuarantineGc => "quarantine-gc",
89 CheckKind::QuarantineRestore => "quarantine-restore",
90 CheckKind::ParentGitTracksPackContent => "parent-git-tracks-pack-content",
91 }
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
98pub enum Severity {
99 Ok,
101 Warning,
103 Error,
105}
106
107#[non_exhaustive]
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct Finding {
117 pub check: CheckKind,
119 pub severity: Severity,
121 pub pack: Option<String>,
123 pub detail: String,
125 pub auto_fixable: bool,
129 pub synthetic: bool,
135}
136
137impl Finding {
138 pub fn ok(check: CheckKind) -> Self {
140 Self {
141 check,
142 severity: Severity::Ok,
143 pack: None,
144 detail: String::new(),
145 auto_fixable: false,
146 synthetic: false,
147 }
148 }
149}
150
151#[derive(Debug, Clone, Default)]
155pub struct CheckResult {
156 pub findings: Vec<Finding>,
158}
159
160impl CheckResult {
161 pub fn single(finding: Finding) -> Self {
163 Self { findings: vec![finding] }
164 }
165
166 pub fn worst(&self) -> Severity {
168 self.findings.iter().map(|f| f.severity).max().unwrap_or(Severity::Ok)
169 }
170}
171
172#[derive(Debug, Clone, Default)]
174pub struct DoctorReport {
175 pub findings: Vec<Finding>,
177}
178
179impl DoctorReport {
180 pub fn worst(&self) -> Severity {
182 self.findings.iter().map(|f| f.severity).max().unwrap_or(Severity::Ok)
183 }
184
185 pub fn exit_code(&self) -> i32 {
191 match self.worst() {
192 Severity::Ok => 0,
193 Severity::Warning => 1,
194 Severity::Error => 2,
195 }
196 }
197}
198
199#[non_exhaustive]
210#[derive(Debug, Clone, Default)]
211pub struct DoctorOpts {
212 pub fix: bool,
215 pub lint_config: bool,
218 pub shallow: Option<usize>,
229 pub prune_quarantine: Option<u32>,
236 pub restore_quarantine: Option<(String, Option<String>)>,
244 pub force: bool,
249}
250
251#[derive(Debug, thiserror::Error)]
255pub enum DoctorError {
256 #[error("manifest read failure: {0}")]
258 ManifestIo(#[source] ManifestError),
259 #[error("gitignore fix failure: {0}")]
261 GitignoreFix(#[source] GitignoreError),
262}
263
264#[allow(clippy::too_many_lines)] pub fn run_doctor(workspace: &Path, opts: &DoctorOpts) -> Result<DoctorReport, DoctorError> {
276 manifest::ensure_event_log_migrated(workspace).map_err(DoctorError::ManifestIo)?;
283
284 let mut report = DoctorReport::default();
285 walk_meta(workspace, opts, 0, &mut report);
286
287 if opts.lint_config {
288 let cfg_result = check_config_lint(workspace);
289 report.findings.extend(cfg_result.findings);
290 }
291
292 if let Some(retain_days) = opts.prune_quarantine {
298 let qc = check_quarantine_gc(workspace, retain_days, true);
299 report.findings.extend(qc.findings);
300 }
301
302 if let Some((ts, basename)) = &opts.restore_quarantine {
309 use crate::tree::quarantine::restore_quarantine;
310 let audit_log = crate::manifest::event_log_path(workspace);
311 let finding = if ts.contains(':') || basename.as_deref().is_some_and(|b| b.contains(':')) {
321 Finding {
322 check: CheckKind::QuarantineRestore,
323 severity: Severity::Error,
324 pack: None,
325 detail: format!(
326 "restore failed: malformed `TS[:BASENAME]` argument (ts={ts:?}, basename={basename:?}) — the syntax splits on the FIRST colon, so an ISO-8601 timestamp like `2026-05-02T10:30:00Z` is ambiguous. Use the trash slot directory name verbatim (e.g. `2026-05-02T10-30-00Z`) followed by at most one colon + basename."
327 ),
328 auto_fixable: false,
329 synthetic: false,
330 }
331 } else {
332 let res = restore_quarantine(
333 workspace,
334 ts,
335 basename.as_deref(),
336 opts.force,
337 Some(&audit_log),
338 );
339 match res {
340 Ok(report_inner) => Finding {
341 check: CheckKind::QuarantineRestore,
342 severity: Severity::Ok,
343 pack: None,
344 detail: format!("restored snapshot to {}", report_inner.dest.display()),
345 auto_fixable: false,
346 synthetic: false,
347 },
348 Err(e) => Finding {
349 check: CheckKind::QuarantineRestore,
350 severity: Severity::Error,
351 pack: None,
352 detail: format!("restore failed: {e}"),
353 auto_fixable: false,
354 synthetic: false,
355 },
356 }
357 };
358 report.findings.push(finding);
359 }
360
361 if opts.fix {
362 let manifest_path = workspace.join(".grex").join("events.jsonl");
366 let packs = match manifest::read_all(&manifest_path) {
367 Ok(evs) => Some(manifest::fold(evs)),
368 Err(_) => None,
369 };
370 apply_fixes(workspace, packs.as_ref(), &mut report)?;
371 }
372
373 Ok(report)
374}
375
376fn walk_meta(meta_dir: &Path, opts: &DoctorOpts, depth: usize, report: &mut DoctorReport) {
386 run_meta_checks(meta_dir, report);
387
388 if let Some(cap) = opts.shallow {
389 if depth >= cap {
390 return;
391 }
392 }
393
394 let manifest_path = meta_dir.join(".grex").join("pack.yaml");
397 let raw = match std::fs::read_to_string(&manifest_path) {
398 Ok(s) => s,
399 Err(_) => return,
400 };
401 let manifest = match crate::pack::parse(&raw) {
402 Ok(m) => m,
403 Err(_) => return,
404 };
405 for child in &manifest.children {
406 let segment = child.path.clone().unwrap_or_else(|| child.effective_path());
407 let child_meta = meta_dir.join(&segment);
408 if child_meta.join(".grex").join("pack.yaml").is_file() {
409 walk_meta(&child_meta, opts, depth + 1, report);
410 }
411 }
412}
413
414fn run_meta_checks(meta_dir: &Path, report: &mut DoctorReport) {
418 let manifest_path = meta_dir.join(".grex").join("events.jsonl");
419 let (schema_result, events_opt) = check_manifest_schema(&manifest_path);
420 report.findings.extend(schema_result.findings.clone());
421
422 let packs = events_opt.map(manifest::fold);
426
427 let (lock, lock_finding) = read_synthetic_lock(meta_dir);
432 if let Some(f) = lock_finding {
433 report.findings.push(f);
434 }
435
436 let gi_result = match &packs {
437 Some(p) => check_gitignore_sync(meta_dir, p),
438 None => CheckResult::single(Finding {
439 check: CheckKind::GitignoreSync,
440 severity: Severity::Warning,
441 pack: None,
442 detail: "skipped: manifest unreadable".to_string(),
443 auto_fixable: false,
444 synthetic: false,
445 }),
446 };
447 report.findings.extend(gi_result.findings);
448
449 let drift_result = match &packs {
450 Some(p) => check_on_disk_drift(meta_dir, p, &lock),
451 None => CheckResult::single(Finding {
452 check: CheckKind::OnDiskDrift,
453 severity: Severity::Warning,
454 pack: None,
455 detail: "skipped: manifest unreadable".to_string(),
456 auto_fixable: false,
457 synthetic: false,
458 }),
459 };
460 report.findings.extend(drift_result.findings);
461
462 let synth = check_synthetic_packs(&lock);
463 report.findings.extend(synth.findings);
464
465 let parent_findings = check_parent_git_tracks_pack_content(meta_dir, packs.as_ref());
472 report.findings.extend(parent_findings.findings);
473}
474
475#[allow(clippy::too_many_lines)]
484pub fn check_quarantine_gc(meta_dir: &Path, retain_days: u32, prune: bool) -> CheckResult {
485 use crate::tree::quarantine::{parse_iso8601_quarantine, prune_quarantine, RetentionConfig};
486 use std::time::{Duration, SystemTime};
487
488 let trash_root = meta_dir.join(".grex").join("trash");
489 if !trash_root.is_dir() {
490 return CheckResult::single(Finding::ok(CheckKind::QuarantineGc));
491 }
492 let cutoff = SystemTime::now()
493 .checked_sub(Duration::from_secs(u64::from(retain_days) * 86_400))
494 .unwrap_or(SystemTime::UNIX_EPOCH);
495
496 if prune {
497 let retention = RetentionConfig { retain_days };
498 let audit_log = crate::manifest::event_log_path(meta_dir);
499 let report = match prune_quarantine(meta_dir, retention, Some(&audit_log)) {
500 Ok(r) => r,
501 Err(e) => {
502 return CheckResult::single(Finding {
503 check: CheckKind::QuarantineGc,
504 severity: Severity::Warning,
505 pack: None,
506 detail: format!("GC sweep failed: {e}"),
507 auto_fixable: false,
508 synthetic: false,
509 });
510 }
511 };
512 if report.pruned.is_empty() && report.failed.is_empty() {
513 return CheckResult::single(Finding::ok(CheckKind::QuarantineGc));
514 }
515 let mut detail =
516 format!("pruned {} entr{}", report.pruned.len(), pluralize(report.pruned.len()));
517 if !report.failed.is_empty() {
518 detail.push_str(&format!("; {} failed", report.failed.len()));
519 }
520 return CheckResult::single(Finding {
521 check: CheckKind::QuarantineGc,
522 severity: if report.failed.is_empty() { Severity::Warning } else { Severity::Error },
523 pack: None,
524 detail,
525 auto_fixable: false,
526 synthetic: false,
527 });
528 }
529
530 let entries = match std::fs::read_dir(&trash_root) {
532 Ok(e) => e,
533 Err(e) => {
534 return CheckResult::single(Finding {
535 check: CheckKind::QuarantineGc,
536 severity: Severity::Warning,
537 pack: None,
538 detail: format!("cannot read trash bucket: {e}"),
539 auto_fixable: false,
540 synthetic: false,
541 });
542 }
543 };
544 let mut stale = 0usize;
545 for ent in entries.flatten() {
546 let name = ent.file_name();
547 let Some(name_str) = name.to_str() else { continue };
548 let Some(ts) = parse_iso8601_quarantine(name_str) else { continue };
549 if ts < cutoff {
550 stale += 1;
551 }
552 }
553 if stale == 0 {
554 CheckResult::single(Finding::ok(CheckKind::QuarantineGc))
555 } else {
556 CheckResult::single(Finding {
557 check: CheckKind::QuarantineGc,
558 severity: Severity::Warning,
559 pack: None,
560 detail: format!(
561 "{stale} stale entr{} older than {retain_days}d (run `grex doctor --prune-quarantine --retain-days {retain_days}` to sweep)",
562 pluralize(stale),
563 ),
564 auto_fixable: false,
565 synthetic: false,
566 })
567 }
568}
569
570fn pluralize(n: usize) -> &'static str {
571 if n == 1 {
572 "y"
573 } else {
574 "ies"
575 }
576}
577
578fn apply_fixes(
583 workspace: &Path,
584 packs: Option<&std::collections::HashMap<String, PackState>>,
585 report: &mut DoctorReport,
586) -> Result<(), DoctorError> {
587 let to_fix: Vec<(String, String)> = report
589 .findings
590 .iter()
591 .filter(|f| f.check == CheckKind::GitignoreSync && f.auto_fixable)
592 .filter_map(|f| f.pack.clone().map(|p| (p, f.detail.clone())))
593 .collect();
594
595 let Some(packs) = packs else {
596 return Ok(());
597 };
598
599 for (pack_id, _detail) in to_fix {
600 let Some(state) = packs.get(&pack_id) else { continue };
601 let gi_path = workspace.join(".gitignore");
602 let expected = expected_patterns_for_pack(workspace, state);
603 let patterns_ref: Vec<&str> = expected.iter().map(String::as_str).collect();
604 upsert_managed_block(&gi_path, &state.id, &patterns_ref)
605 .map_err(DoctorError::GitignoreFix)?;
606 }
607
608 let refreshed = check_gitignore_sync(workspace, packs);
610 report.findings.retain(|f| f.check != CheckKind::GitignoreSync);
611 report.findings.extend(refreshed.findings);
612 Ok(())
613}
614
615pub fn check_manifest_schema(manifest_path: &Path) -> (CheckResult, Option<Vec<Event>>) {
618 if !manifest_path.exists() {
619 return (CheckResult::single(Finding::ok(CheckKind::ManifestSchema)), Some(Vec::new()));
621 }
622 match manifest::read_all(manifest_path) {
623 Ok(evs) => (CheckResult::single(Finding::ok(CheckKind::ManifestSchema)), Some(evs)),
624 Err(ManifestError::Corruption { line, source }) => {
625 let detail = format!("corruption at line {line}: {source}");
626 (
627 CheckResult::single(Finding {
628 check: CheckKind::ManifestSchema,
629 severity: Severity::Error,
630 pack: None,
631 detail,
632 auto_fixable: false,
633 synthetic: false,
634 }),
635 None,
636 )
637 }
638 Err(e) => {
639 let detail = format!("io error: {e}");
640 (
641 CheckResult::single(Finding {
642 check: CheckKind::ManifestSchema,
643 severity: Severity::Error,
644 pack: None,
645 detail,
646 auto_fixable: false,
647 synthetic: false,
648 }),
649 None,
650 )
651 }
652 }
653}
654
655fn expected_patterns_for_pack(workspace: &Path, state: &PackState) -> Vec<String> {
663 if !is_builtin_pack_type(&state.pack_type) {
664 return Vec::new();
665 }
666
667 let mut expected: Vec<String> =
668 default_managed_gitignore_patterns().iter().map(|p| (*p).to_string()).collect();
669
670 for pattern in authored_gitignore_patterns(workspace, state) {
671 if !expected.iter().any(|p| p == &pattern) {
672 expected.push(pattern);
673 }
674 }
675
676 expected
677}
678
679fn is_builtin_pack_type(pack_type: &str) -> bool {
680 matches!(pack_type, "meta" | "declarative" | "scripted")
681}
682
683fn authored_gitignore_patterns(workspace: &Path, state: &PackState) -> Vec<String> {
684 let pack_yaml = workspace.join(&state.path).join(".grex").join("pack.yaml");
685 let Ok(contents) = std::fs::read_to_string(pack_yaml) else {
686 return Vec::new();
687 };
688 let Ok(pack) = crate::pack::parse(&contents) else {
689 return Vec::new();
690 };
691 let Some(raw) = pack.extensions.get(GITIGNORE_EXT_KEY) else {
692 return Vec::new();
693 };
694 let Some(seq) = raw.as_sequence() else {
695 return Vec::new();
696 };
697 seq.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()
698}
699
700pub fn check_gitignore_sync(
704 workspace: &Path,
705 packs: &std::collections::HashMap<String, PackState>,
706) -> CheckResult {
707 let mut findings = Vec::new();
708 let ordered: BTreeMap<_, _> = packs.iter().collect();
710 let gi_path = workspace.join(".gitignore");
711 for (id, state) in ordered {
712 match read_managed_block(&gi_path, id) {
713 Ok(Some(actual)) => {
714 let expected = expected_patterns_for_pack(workspace, state);
715 if actual != expected {
716 findings.push(Finding {
717 check: CheckKind::GitignoreSync,
718 severity: Severity::Warning,
719 pack: Some(id.clone()),
720 detail: format!(
721 "managed block drift: expected {} line(s), got {}",
722 expected.len(),
723 actual.len()
724 ),
725 auto_fixable: true,
726 synthetic: false,
727 });
728 }
729 }
730 Ok(None) => {
731 }
733 Err(e) => {
734 findings.push(Finding {
735 check: CheckKind::GitignoreSync,
736 severity: Severity::Warning,
737 pack: Some(id.clone()),
738 detail: format!("cannot read managed block: {e}"),
739 auto_fixable: matches!(e, GitignoreError::UnclosedBlock { .. }),
740 synthetic: false,
741 });
742 }
743 }
744 }
745 if findings.is_empty() {
746 findings.push(Finding::ok(CheckKind::GitignoreSync));
747 }
748 CheckResult { findings }
749}
750
751pub fn check_on_disk_drift(
762 workspace: &Path,
763 packs: &std::collections::HashMap<String, PackState>,
764 lock: &HashMap<String, LockEntry>,
765) -> CheckResult {
766 let mut findings = Vec::new();
767 let registered_paths: BTreeSet<PathBuf> =
768 packs.values().map(|p| PathBuf::from(&p.path)).collect();
769 collect_manifest_to_disk_findings(workspace, packs, &mut findings);
770 collect_disk_to_manifest_findings(workspace, ®istered_paths, lock, &mut findings);
771 if findings.is_empty() {
772 findings.push(Finding::ok(CheckKind::OnDiskDrift));
773 }
774 CheckResult { findings }
775}
776
777fn collect_manifest_to_disk_findings(
780 workspace: &Path,
781 packs: &std::collections::HashMap<String, PackState>,
782 findings: &mut Vec<Finding>,
783) {
784 let ordered: BTreeMap<_, _> = packs.iter().collect();
785 for (id, state) in ordered {
786 let full = workspace.join(&state.path);
787 if !full.exists() {
788 findings.push(drift_error(id, format!("registered pack dir missing: {}", state.path)));
789 continue;
790 }
791 match std::fs::symlink_metadata(&full) {
792 Ok(md) if !md.is_dir() => findings.push(drift_error(
793 id,
794 format!("registered pack path is not a directory: {}", state.path),
795 )),
796 Ok(_) => {}
797 Err(e) => findings.push(drift_error(id, format!("stat failed: {e}"))),
798 }
799 }
800}
801
802fn collect_disk_to_manifest_findings(
810 workspace: &Path,
811 registered_paths: &BTreeSet<PathBuf>,
812 lock: &HashMap<String, LockEntry>,
813 findings: &mut Vec<Finding>,
814) {
815 let Ok(entries) = std::fs::read_dir(workspace) else { return };
816 for ent in entries.flatten() {
817 let Ok(ft) = ent.file_type() else { continue };
818 if !ft.is_dir() {
819 continue;
820 }
821 let name = ent.file_name();
822 let Some(name_str) = name.to_str() else { continue };
823 if name_str.starts_with('.') || is_housekeeping_dir(name_str) {
824 continue;
825 }
826 if registered_paths.contains(&PathBuf::from(name_str)) {
827 continue;
828 }
829 if lock.get(name_str).is_some_and(|e| e.synthetic) {
830 continue;
831 }
832 findings.push(Finding {
833 check: CheckKind::OnDiskDrift,
834 severity: Severity::Warning,
835 pack: None,
836 detail: format!("unregistered directory on disk: {name_str}"),
837 auto_fixable: false,
838 synthetic: false,
839 });
840 }
841}
842
843fn drift_error(id: &str, detail: String) -> Finding {
845 Finding {
846 check: CheckKind::OnDiskDrift,
847 severity: Severity::Error,
848 pack: Some(id.to_string()),
849 detail,
850 auto_fixable: false,
851 synthetic: false,
852 }
853}
854
855fn is_housekeeping_dir(name: &str) -> bool {
857 matches!(name, "target" | "node_modules" | "crates" | "openspec" | "dist")
858}
859
860pub fn check_config_lint(workspace: &Path) -> CheckResult {
866 let mut findings = Vec::new();
867 check_openspec_config_yaml(workspace, &mut findings);
868 check_omne_cfg_markdown(workspace, &mut findings);
869 if findings.is_empty() {
870 findings.push(Finding::ok(CheckKind::ConfigLint));
871 }
872 CheckResult { findings }
873}
874
875fn check_openspec_config_yaml(workspace: &Path, findings: &mut Vec<Finding>) {
878 let cfg_yaml = workspace.join("openspec").join("config.yaml");
879 if !cfg_yaml.exists() {
880 return;
881 }
882 match std::fs::read_to_string(&cfg_yaml) {
883 Ok(s) => {
884 if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&s) {
885 findings
886 .push(config_lint_warning(format!("openspec/config.yaml parse error: {e}")));
887 }
888 }
889 Err(e) => {
890 findings.push(config_lint_warning(format!("openspec/config.yaml unreadable: {e}")))
891 }
892 }
893}
894
895fn check_omne_cfg_markdown(workspace: &Path, findings: &mut Vec<Finding>) {
898 let cfg_dir = workspace.join(".omne").join("cfg");
899 if !cfg_dir.is_dir() {
900 return;
901 }
902 let Ok(entries) = std::fs::read_dir(&cfg_dir) else { return };
903 for ent in entries.flatten() {
904 let path = ent.path();
905 if path.extension().and_then(|s| s.to_str()) != Some("md") {
906 continue;
907 }
908 if let Err(e) = std::fs::read_to_string(&path) {
909 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string();
910 findings.push(config_lint_warning(format!(".omne/cfg/{name} unreadable: {e}")));
911 }
912 }
913}
914
915fn read_synthetic_lock(workspace: &Path) -> (HashMap<String, LockEntry>, Option<Finding>) {
930 let lock_path = workspace.join(".grex").join("grex.lock.jsonl");
931 match read_lockfile(&lock_path) {
932 Ok(map) => (map, None),
933 Err(err @ LockfileError::Corruption { .. }) | Err(err @ LockfileError::Io(_)) => {
934 let finding = Finding {
935 check: CheckKind::ManifestSchema,
936 severity: Severity::Warning,
937 pack: None,
938 detail: format!("lockfile corruption: {err}"),
939 auto_fixable: false,
940 synthetic: false,
941 };
942 (HashMap::new(), Some(finding))
943 }
944 Err(err) => {
949 let finding = Finding {
950 check: CheckKind::ManifestSchema,
951 severity: Severity::Warning,
952 pack: None,
953 detail: format!("lockfile corruption: {err}"),
954 auto_fixable: false,
955 synthetic: false,
956 };
957 (HashMap::new(), Some(finding))
958 }
959 }
960}
961
962pub fn check_synthetic_packs(lock: &HashMap<String, LockEntry>) -> CheckResult {
973 let mut findings = Vec::new();
974 let ordered: BTreeMap<_, _> = lock.iter().collect();
975 for (id, entry) in ordered {
976 if !entry.synthetic {
977 continue;
978 }
979 findings.push(Finding {
980 check: CheckKind::SyntheticPack,
981 severity: Severity::Ok,
982 pack: Some(id.clone()),
983 detail: "OK (synthetic)".to_string(),
984 auto_fixable: false,
985 synthetic: true,
986 });
987 }
988 CheckResult { findings }
989}
990
991pub fn check_parent_git_tracks_pack_content(
1010 meta_dir: &Path,
1011 packs: Option<&HashMap<String, PackState>>,
1012) -> CheckResult {
1013 let Some(packs) = packs else {
1014 return CheckResult::default();
1015 };
1016 let Some(parent_repo) = find_parent_git_repo(meta_dir) else {
1017 return CheckResult::default();
1018 };
1019 let mut findings = Vec::new();
1020 let ordered: BTreeMap<_, _> = packs.iter().collect();
1021 for (id, state) in ordered {
1022 let pack_abs = meta_dir.join(&state.path);
1024 let Ok(pack_rel) = pack_abs.strip_prefix(&parent_repo) else {
1025 continue;
1026 };
1027 let rel_str = pack_rel.to_string_lossy();
1028 if rel_str.is_empty() {
1029 continue;
1030 }
1031 if parent_git_path_tracked(&parent_repo, rel_str.as_ref()) {
1032 findings.push(Finding {
1033 check: CheckKind::ParentGitTracksPackContent,
1034 severity: Severity::Ok,
1035 pack: Some(id.clone()),
1036 detail: format!(
1037 "advisory: pack `{id}` at `{rel_str}` is tracked by the parent meta-repo's git index. Add it to the meta-repo's `.gitignore` (and `git rm --cached` once) to clear this finding. grex never writes to the parent `.gitignore` automatically."
1038 ),
1039 auto_fixable: false,
1040 synthetic: false,
1041 });
1042 }
1043 }
1044 CheckResult { findings }
1045}
1046
1047fn find_parent_git_repo(start: &Path) -> Option<PathBuf> {
1058 let mut cur = start.parent()?;
1059 loop {
1060 if cur.join(".git").exists() {
1061 return Some(cur.to_path_buf());
1062 }
1063 cur = cur.parent()?;
1064 }
1065}
1066
1067fn parent_git_path_tracked(repo: &Path, rel_path: &str) -> bool {
1072 use std::process::{Command, Stdio};
1073 let normalised = rel_path.replace('\\', "/");
1074 let status = Command::new("git")
1075 .arg("-C")
1076 .arg(repo)
1077 .args(["ls-files", "--error-unmatch", "--"])
1078 .arg(&normalised)
1079 .stdout(Stdio::null())
1080 .stderr(Stdio::null())
1081 .status();
1082 matches!(status, Ok(s) if s.success())
1083}
1084
1085fn config_lint_warning(detail: String) -> Finding {
1087 Finding {
1088 check: CheckKind::ConfigLint,
1089 severity: Severity::Warning,
1090 pack: None,
1091 detail,
1092 auto_fixable: false,
1093 synthetic: false,
1094 }
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099 use super::*;
1100 use crate::manifest::{append_event, Event, SCHEMA_VERSION};
1101 use chrono::{TimeZone, Utc};
1102 use std::fs;
1103 use tempfile::tempdir;
1104
1105 fn ts() -> chrono::DateTime<Utc> {
1106 Utc.with_ymd_and_hms(2026, 4, 22, 10, 0, 0).unwrap()
1107 }
1108
1109 fn fs_snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
1118 fn walk(dir: &Path, root: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
1119 let entries = match fs::read_dir(dir) {
1120 Ok(e) => e,
1121 Err(_) => return,
1122 };
1123 for entry in entries.flatten() {
1124 let path = entry.path();
1125 let name = entry.file_name();
1126 if name == ".git" || name == "target" {
1127 continue;
1128 }
1129 let ft = match entry.file_type() {
1130 Ok(t) => t,
1131 Err(_) => continue,
1132 };
1133 if ft.is_dir() {
1134 walk(&path, root, out);
1135 } else if ft.is_file() {
1136 let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
1137 let bytes = fs::read(&path).unwrap_or_default();
1138 out.insert(rel, bytes);
1139 }
1140 }
1141 }
1142 let mut out = BTreeMap::new();
1143 walk(root, root, &mut out);
1144 out
1145 }
1146
1147 fn seed_pack(workspace: &Path, id: &str) {
1148 seed_pack_with_type(workspace, id, "declarative");
1149 }
1150
1151 fn seed_pack_with_type(workspace: &Path, id: &str, pack_type: &str) {
1152 let m = workspace.join(".grex/events.jsonl");
1153 append_event(
1154 &m,
1155 &Event::Add {
1156 ts: ts(),
1157 id: id.into(),
1158 url: format!("https://example/{id}"),
1159 path: id.into(),
1160 pack_type: pack_type.into(),
1161 schema_version: SCHEMA_VERSION.into(),
1162 },
1163 )
1164 .unwrap();
1165 fs::create_dir_all(workspace.join(id)).unwrap();
1166 }
1167
1168 fn write_pack_yaml(workspace: &Path, id: &str, yaml: &str) {
1169 let dir = workspace.join(id).join(".grex");
1170 fs::create_dir_all(&dir).unwrap();
1171 fs::write(dir.join("pack.yaml"), yaml).unwrap();
1172 }
1173
1174 #[test]
1177 fn schema_clean_is_ok() {
1178 let d = tempdir().unwrap();
1179 seed_pack(d.path(), "a");
1180 let (r, evs) = check_manifest_schema(&d.path().join(".grex/events.jsonl"));
1181 assert_eq!(r.worst(), Severity::Ok);
1182 assert_eq!(evs.unwrap().len(), 1);
1183 }
1184
1185 #[test]
1186 fn schema_corruption_is_error() {
1187 let d = tempdir().unwrap();
1188 let m = d.path().join(".grex/events.jsonl");
1191 fs::create_dir_all(m.parent().unwrap()).unwrap();
1192 fs::write(&m, b"not-json\n").unwrap();
1193 append_event(
1194 &m,
1195 &Event::Add {
1196 ts: ts(),
1197 id: "x".into(),
1198 url: "u".into(),
1199 path: "x".into(),
1200 pack_type: "declarative".into(),
1201 schema_version: SCHEMA_VERSION.into(),
1202 },
1203 )
1204 .unwrap();
1205
1206 let (r, evs) = check_manifest_schema(&m);
1207 assert_eq!(r.worst(), Severity::Error);
1208 assert!(evs.is_none(), "corruption must disable downstream checks");
1209 }
1210
1211 #[test]
1212 fn schema_missing_manifest_is_ok() {
1213 let d = tempdir().unwrap();
1214 let (r, evs) = check_manifest_schema(&d.path().join(".grex/events.jsonl"));
1215 assert_eq!(r.worst(), Severity::Ok);
1216 assert!(evs.unwrap().is_empty());
1217 }
1218
1219 #[test]
1222 fn expected_patterns_for_pack_populates_builtin_defaults() {
1223 for pack_type in ["meta", "declarative", "scripted"] {
1224 let d = tempdir().unwrap();
1225 seed_pack_with_type(d.path(), pack_type, pack_type);
1226 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1227 let packs = manifest::fold(events);
1228 let state = packs.get(pack_type).unwrap();
1229 assert_eq!(
1230 expected_patterns_for_pack(d.path(), state),
1231 vec![".grex-lock".to_string()],
1232 "pack type: {pack_type}"
1233 );
1234 }
1235 }
1236
1237 #[test]
1238 fn expected_patterns_for_pack_merges_authored_extensions_for_builtins() {
1239 for pack_type in ["meta", "declarative", "scripted"] {
1240 let d = tempdir().unwrap();
1241 let id = format!("{pack_type}-pack");
1242 let authored = format!("{pack_type}-cache/");
1243 seed_pack_with_type(d.path(), &id, pack_type);
1244 write_pack_yaml(
1245 d.path(),
1246 &id,
1247 &format!(
1248 "schema_version: \"1\"\nname: {id}\ntype: {pack_type}\nx-gitignore:\n - \".grex-lock\"\n - {authored}\n",
1249 ),
1250 );
1251 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1252 let packs = manifest::fold(events);
1253 let state = packs.get(&id).unwrap();
1254 assert_eq!(
1255 expected_patterns_for_pack(d.path(), state),
1256 vec![".grex-lock".to_string(), authored],
1257 "pack type: {pack_type}"
1258 );
1259 }
1260 }
1261
1262 #[test]
1263 fn gitignore_clean_block_is_ok() {
1264 let d = tempdir().unwrap();
1265 seed_pack(d.path(), "a");
1266 upsert_managed_block(
1268 &d.path().join(".gitignore"),
1269 "a",
1270 default_managed_gitignore_patterns(),
1271 )
1272 .unwrap();
1273 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1274 let packs = manifest::fold(events);
1275 let r = check_gitignore_sync(d.path(), &packs);
1276 assert_eq!(r.worst(), Severity::Ok);
1277 }
1278
1279 #[test]
1280 fn gitignore_drift_is_warning_and_autofixable() {
1281 let d = tempdir().unwrap();
1282 seed_pack(d.path(), "a");
1283 upsert_managed_block(&d.path().join(".gitignore"), "a", &["unexpected-line"]).unwrap();
1285 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1286 let packs = manifest::fold(events);
1287 let r = check_gitignore_sync(d.path(), &packs);
1288 assert_eq!(r.worst(), Severity::Warning);
1289 assert!(r.findings.iter().any(|f| f.auto_fixable));
1290 }
1291
1292 #[test]
1293 fn gitignore_authored_patterns_are_not_reported_as_drift() {
1294 let d = tempdir().unwrap();
1295 seed_pack(d.path(), "a");
1296 write_pack_yaml(
1297 d.path(),
1298 "a",
1299 "schema_version: \"1\"\nname: a\ntype: declarative\nx-gitignore:\n - target/\n - \"*.log\"\n",
1300 );
1301 upsert_managed_block(
1302 &d.path().join(".gitignore"),
1303 "a",
1304 &[".grex-lock", "target/", "*.log"],
1305 )
1306 .unwrap();
1307 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1308 let packs = manifest::fold(events);
1309 let r = check_gitignore_sync(d.path(), &packs);
1310 assert_eq!(r.worst(), Severity::Ok);
1311 }
1312
1313 #[test]
1316 fn on_disk_missing_pack_is_error() {
1317 let d = tempdir().unwrap();
1318 seed_pack(d.path(), "a");
1319 fs::remove_dir_all(d.path().join("a")).unwrap();
1321 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1322 let packs = manifest::fold(events);
1323 let r = check_on_disk_drift(d.path(), &packs, &HashMap::new());
1324 assert_eq!(r.worst(), Severity::Error);
1325 }
1326
1327 #[test]
1328 fn on_disk_unregistered_dir_is_warning() {
1329 let d = tempdir().unwrap();
1330 seed_pack(d.path(), "a");
1331 fs::create_dir_all(d.path().join("stranger")).unwrap();
1332 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1333 let packs = manifest::fold(events);
1334 let r = check_on_disk_drift(d.path(), &packs, &HashMap::new());
1335 assert_eq!(r.worst(), Severity::Warning);
1336 }
1337
1338 #[test]
1339 fn on_disk_clean_workspace_is_ok() {
1340 let d = tempdir().unwrap();
1341 seed_pack(d.path(), "a");
1342 let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1343 let packs = manifest::fold(events);
1344 let r = check_on_disk_drift(d.path(), &packs, &HashMap::new());
1345 assert_eq!(r.worst(), Severity::Ok);
1346 }
1347
1348 #[test]
1351 fn config_lint_absent_dir_is_ok() {
1352 let d = tempdir().unwrap();
1353 let r = check_config_lint(d.path());
1354 assert_eq!(r.worst(), Severity::Ok);
1355 }
1356
1357 #[test]
1358 fn config_lint_bad_yaml_is_warning() {
1359 let d = tempdir().unwrap();
1360 fs::create_dir_all(d.path().join("openspec")).unwrap();
1361 fs::write(d.path().join("openspec").join("config.yaml"), "::: bad: : yaml : [").unwrap();
1362 let r = check_config_lint(d.path());
1363 assert_eq!(r.worst(), Severity::Warning);
1364 }
1365
1366 #[test]
1369 fn exit_code_roll_up_ok_is_zero() {
1370 let mut r = DoctorReport::default();
1371 r.findings.push(Finding::ok(CheckKind::ManifestSchema));
1372 assert_eq!(r.exit_code(), 0);
1373 }
1374
1375 #[test]
1376 fn exit_code_roll_up_warning_is_one() {
1377 let mut r = DoctorReport::default();
1378 r.findings.push(Finding::ok(CheckKind::ManifestSchema));
1379 r.findings.push(Finding {
1380 check: CheckKind::GitignoreSync,
1381 severity: Severity::Warning,
1382 pack: None,
1383 detail: String::new(),
1384 auto_fixable: true,
1385 synthetic: false,
1386 });
1387 assert_eq!(r.exit_code(), 1);
1388 }
1389
1390 #[test]
1391 fn exit_code_roll_up_error_is_two() {
1392 let mut r = DoctorReport::default();
1393 r.findings.push(Finding {
1394 check: CheckKind::OnDiskDrift,
1395 severity: Severity::Error,
1396 pack: None,
1397 detail: String::new(),
1398 auto_fixable: false,
1399 synthetic: false,
1400 });
1401 assert_eq!(r.exit_code(), 2);
1402 }
1403
1404 #[test]
1405 fn exit_code_roll_up_warn_and_error_is_two() {
1406 let mut r = DoctorReport::default();
1407 r.findings.push(Finding {
1408 check: CheckKind::GitignoreSync,
1409 severity: Severity::Warning,
1410 pack: None,
1411 detail: String::new(),
1412 auto_fixable: true,
1413 synthetic: false,
1414 });
1415 r.findings.push(Finding {
1416 check: CheckKind::OnDiskDrift,
1417 severity: Severity::Error,
1418 pack: None,
1419 detail: String::new(),
1420 auto_fixable: false,
1421 synthetic: false,
1422 });
1423 assert_eq!(r.exit_code(), 2);
1424 }
1425
1426 #[test]
1429 fn run_doctor_clean_workspace_exits_zero() {
1430 let d = tempdir().unwrap();
1431 seed_pack(d.path(), "a");
1432 upsert_managed_block(
1433 &d.path().join(".gitignore"),
1434 "a",
1435 default_managed_gitignore_patterns(),
1436 )
1437 .unwrap();
1438 let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1439 assert_eq!(report.exit_code(), 0);
1440 }
1441
1442 #[test]
1443 fn run_doctor_gitignore_drift_exits_one() {
1444 let d = tempdir().unwrap();
1445 seed_pack(d.path(), "a");
1446 upsert_managed_block(&d.path().join(".gitignore"), "a", &["drift"]).unwrap();
1447 let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1448 assert_eq!(report.exit_code(), 1);
1449 }
1450
1451 #[test]
1452 fn run_doctor_fix_heals_gitignore_drift() {
1453 let d = tempdir().unwrap();
1454 seed_pack(d.path(), "a");
1455 upsert_managed_block(&d.path().join(".gitignore"), "a", &["drift"]).unwrap();
1456 let opts = DoctorOpts { fix: true, lint_config: false, ..DoctorOpts::default() };
1457 let report = run_doctor(d.path(), &opts).unwrap();
1458 assert_eq!(report.exit_code(), 0, "fix must zero out exit code");
1459 let again = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1461 assert_eq!(again.exit_code(), 0);
1462 }
1463
1464 #[test]
1465 fn run_doctor_fix_does_not_touch_manifest_on_schema_error() {
1466 let d = tempdir().unwrap();
1467 let m = d.path().join(".grex/events.jsonl");
1469 fs::create_dir_all(m.parent().unwrap()).unwrap();
1470 fs::write(&m, b"garbage-line\n").unwrap();
1471 append_event(
1472 &m,
1473 &Event::Add {
1474 ts: ts(),
1475 id: "x".into(),
1476 url: "u".into(),
1477 path: "x".into(),
1478 pack_type: "declarative".into(),
1479 schema_version: SCHEMA_VERSION.into(),
1480 },
1481 )
1482 .unwrap();
1483 let before_bytes = fs::read(&m).unwrap();
1484 let before = fs_snapshot(d.path());
1485
1486 let opts = DoctorOpts { fix: true, lint_config: false, ..DoctorOpts::default() };
1487 let report = run_doctor(d.path(), &opts).unwrap();
1488 assert_eq!(report.exit_code(), 2, "schema error → exit 2");
1489
1490 let after_bytes = fs::read(&m).unwrap();
1494 assert_eq!(before_bytes, after_bytes, "manifest bytes must be unchanged");
1495 let after = fs_snapshot(d.path());
1496 assert_eq!(before, after, "--fix must not write anywhere on schema error");
1497 }
1498
1499 #[test]
1500 fn run_doctor_fix_does_not_touch_disk_on_drift_error() {
1501 let d = tempdir().unwrap();
1502 seed_pack(d.path(), "a");
1503 fs::remove_dir_all(d.path().join("a")).unwrap();
1505
1506 let before = fs_snapshot(d.path());
1512
1513 let opts = DoctorOpts { fix: true, lint_config: false, ..DoctorOpts::default() };
1514 let report = run_doctor(d.path(), &opts).unwrap();
1515 assert_eq!(report.exit_code(), 2);
1516
1517 let after = fs_snapshot(d.path());
1518 assert_eq!(before, after, "--fix must not write anywhere on drift error");
1519 assert!(!d.path().join("a").exists(), "missing pack dir must stay missing");
1520 }
1521
1522 #[test]
1523 fn run_doctor_config_lint_skipped_by_default() {
1524 let d = tempdir().unwrap();
1525 seed_pack(d.path(), "a");
1526 upsert_managed_block(
1527 &d.path().join(".gitignore"),
1528 "a",
1529 default_managed_gitignore_patterns(),
1530 )
1531 .unwrap();
1532 fs::create_dir_all(d.path().join("openspec")).unwrap();
1534 fs::write(d.path().join("openspec").join("config.yaml"), ": : : [bad").unwrap();
1535 let before = fs_snapshot(d.path());
1536 let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1537 assert_eq!(report.exit_code(), 0, "config-lint must be skipped by default");
1538 assert!(
1539 !report.findings.iter().any(|f| f.check == CheckKind::ConfigLint),
1540 "no ConfigLint finding when --lint-config absent"
1541 );
1542 let after = fs_snapshot(d.path());
1544 assert_eq!(before, after, "default doctor run must be read-only");
1545 }
1546
1547 #[test]
1548 fn run_doctor_lint_config_flag_reports_config() {
1549 let d = tempdir().unwrap();
1550 seed_pack(d.path(), "a");
1551 upsert_managed_block(
1552 &d.path().join(".gitignore"),
1553 "a",
1554 default_managed_gitignore_patterns(),
1555 )
1556 .unwrap();
1557 fs::create_dir_all(d.path().join("openspec")).unwrap();
1558 fs::write(d.path().join("openspec").join("config.yaml"), ": : : [bad").unwrap();
1559 let opts = DoctorOpts { fix: false, lint_config: true, ..DoctorOpts::default() };
1560 let report = run_doctor(d.path(), &opts).unwrap();
1561 assert_eq!(report.exit_code(), 1);
1562 assert!(report.findings.iter().any(|f| f.check == CheckKind::ConfigLint));
1563 }
1564
1565 #[test]
1572 fn run_doctor_synthetic_pack_reports_ok_synthetic_and_exits_zero() {
1573 use crate::lockfile::{write_lockfile, LockEntry};
1574 use std::collections::HashMap;
1575
1576 let d = tempdir().unwrap();
1577 seed_pack(d.path(), "a");
1578 upsert_managed_block(
1579 &d.path().join(".gitignore"),
1580 "a",
1581 default_managed_gitignore_patterns(),
1582 )
1583 .unwrap();
1584
1585 let lock_dir = d.path().join(".grex");
1587 fs::create_dir_all(&lock_dir).unwrap();
1588 let lock_path = lock_dir.join("grex.lock.jsonl");
1589 let mut lock = HashMap::new();
1590 lock.insert(
1591 "a".to_string(),
1592 LockEntry {
1593 id: "a".into(),
1594 path: "a".into(),
1595 sha: "deadbeef".into(),
1596 branch: "main".into(),
1597 installed_at: ts(),
1598 actions_hash: String::new(),
1599 schema_version: "1".into(),
1600 synthetic: true,
1601 },
1602 );
1603 write_lockfile(&lock_path, &lock).unwrap();
1604
1605 let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1608 assert_eq!(report.exit_code(), 0, "synthetic-only workspace must exit 0");
1609 let synth: Vec<_> =
1610 report.findings.iter().filter(|f| f.check == CheckKind::SyntheticPack).collect();
1611 assert_eq!(synth.len(), 1, "exactly one synthetic-pack finding");
1612 assert_eq!(synth[0].pack.as_deref(), Some("a"));
1613 assert_eq!(synth[0].detail, "OK (synthetic)");
1614 assert!(synth[0].synthetic, "Finding.synthetic must be true");
1615 assert_eq!(synth[0].severity, Severity::Ok);
1616
1617 for f in &report.findings {
1620 assert!(f.severity != Severity::Error, "no error-severity finding allowed; got: {f:?}",);
1621 }
1622 }
1623
1624 #[test]
1631 fn run_doctor_corrupt_lockfile_emits_warning_finding() {
1632 let d = tempdir().unwrap();
1633 seed_pack(d.path(), "a");
1636 upsert_managed_block(
1637 &d.path().join(".gitignore"),
1638 "a",
1639 default_managed_gitignore_patterns(),
1640 )
1641 .unwrap();
1642
1643 let lock_dir = d.path().join(".grex");
1645 fs::create_dir_all(&lock_dir).unwrap();
1646 fs::write(lock_dir.join("grex.lock.jsonl"), b"not-json-at-all\n").unwrap();
1647
1648 let report = run_doctor(d.path(), &DoctorOpts::default())
1649 .expect("doctor must complete despite lockfile corruption");
1650
1651 let lock_warns: Vec<_> = report
1654 .findings
1655 .iter()
1656 .filter(|f| {
1657 f.check == CheckKind::ManifestSchema
1658 && f.severity == Severity::Warning
1659 && f.detail.contains("lockfile corruption")
1660 })
1661 .collect();
1662 assert_eq!(
1663 lock_warns.len(),
1664 1,
1665 "exactly one lockfile-corruption warning expected; got: {:?}",
1666 report.findings,
1667 );
1668
1669 assert!(
1672 report.findings.iter().any(|f| f.check == CheckKind::GitignoreSync),
1673 "gitignore-sync check must still run",
1674 );
1675 assert!(
1676 report.findings.iter().any(|f| f.check == CheckKind::OnDiskDrift),
1677 "on-disk-drift check must still run",
1678 );
1679 }
1680
1681 #[test]
1691 fn run_doctor_restore_quarantine_rejects_colon_in_timestamp() {
1692 let d = tempdir().unwrap();
1693 seed_pack(d.path(), "a");
1694 upsert_managed_block(
1695 &d.path().join(".gitignore"),
1696 "a",
1697 default_managed_gitignore_patterns(),
1698 )
1699 .unwrap();
1700
1701 let opts = DoctorOpts {
1702 restore_quarantine: Some(("2026-05-02T10:30:00Z".into(), Some("pack-a".into()))),
1703 ..DoctorOpts::default()
1704 };
1705 let report = run_doctor(d.path(), &opts).unwrap();
1706
1707 let restore_findings: Vec<_> =
1708 report.findings.iter().filter(|f| f.check == CheckKind::QuarantineRestore).collect();
1709 assert_eq!(restore_findings.len(), 1, "exactly one restore finding expected");
1710 let f = restore_findings[0];
1711 assert_eq!(f.severity, Severity::Error);
1712 assert!(
1713 f.detail.contains("malformed") && f.detail.contains("FIRST colon"),
1714 "detail must explain the colon foot-gun: {}",
1715 f.detail,
1716 );
1717 assert_eq!(report.exit_code(), 2, "Error severity rolls up to exit 2");
1718 }
1719
1720 fn write_meta_manifest(meta_dir: &Path, name: &str, children: &[(&str, &str)]) {
1726 let grex_dir = meta_dir.join(".grex");
1727 fs::create_dir_all(&grex_dir).unwrap();
1728 let mut yaml = format!("schema_version: \"1\"\nname: {name}\ntype: meta\n");
1729 if !children.is_empty() {
1730 yaml.push_str("children:\n");
1731 for (segment, url) in children {
1732 yaml.push_str(&format!(" - url: {url}\n path: {segment}\n"));
1733 }
1734 }
1735 fs::write(grex_dir.join("pack.yaml"), yaml).unwrap();
1736 }
1737
1738 fn seed_meta_with_pack(meta_dir: &Path, meta_name: &str, pack_id: &str) {
1742 write_meta_manifest(meta_dir, meta_name, &[]);
1743 let m = meta_dir.join(".grex").join("events.jsonl");
1744 append_event(
1745 &m,
1746 &Event::Add {
1747 ts: ts(),
1748 id: pack_id.into(),
1749 url: format!("https://example/{pack_id}"),
1750 path: pack_id.into(),
1751 pack_type: "declarative".into(),
1752 schema_version: SCHEMA_VERSION.into(),
1753 },
1754 )
1755 .unwrap();
1756 fs::create_dir_all(meta_dir.join(pack_id)).unwrap();
1757 }
1758
1759 #[test]
1763 fn test_doctor_recurses_default() {
1764 let d = tempdir().unwrap();
1765 let root = d.path();
1766
1767 write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1769 let m = root.join(".grex").join("events.jsonl");
1771 append_event(
1772 &m,
1773 &Event::Add {
1774 ts: ts(),
1775 id: "alpha".into(),
1776 url: "https://example.invalid/alpha.git".into(),
1777 path: "alpha".into(),
1778 pack_type: "meta".into(),
1779 schema_version: SCHEMA_VERSION.into(),
1780 },
1781 )
1782 .unwrap();
1783
1784 let alpha = root.join("alpha");
1786 write_meta_manifest(&alpha, "alpha", &[("gamma", "https://example.invalid/gamma.git")]);
1787 let am = alpha.join(".grex").join("events.jsonl");
1788 append_event(
1789 &am,
1790 &Event::Add {
1791 ts: ts(),
1792 id: "gamma".into(),
1793 url: "https://example.invalid/gamma.git".into(),
1794 path: "gamma".into(),
1795 pack_type: "declarative".into(),
1796 schema_version: SCHEMA_VERSION.into(),
1797 },
1798 )
1799 .unwrap();
1800 fs::create_dir_all(alpha.join("gamma")).unwrap();
1801
1802 let gamma = alpha.join("gamma");
1804 seed_meta_with_pack(&gamma, "gamma", "delta");
1805
1806 let report = run_doctor(root, &DoctorOpts::default()).unwrap();
1807
1808 let schema_oks: Vec<_> = report
1809 .findings
1810 .iter()
1811 .filter(|f| f.check == CheckKind::ManifestSchema && f.severity == Severity::Ok)
1812 .collect();
1813 assert_eq!(
1814 schema_oks.len(),
1815 3,
1816 "three metas visited (root + alpha + gamma); got: {:?}",
1817 report.findings,
1818 );
1819 }
1820
1821 #[test]
1824 fn test_doctor_shallow_zero_root_only() {
1825 let d = tempdir().unwrap();
1826 let root = d.path();
1827
1828 write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1829 let m = root.join(".grex").join("events.jsonl");
1830 append_event(
1831 &m,
1832 &Event::Add {
1833 ts: ts(),
1834 id: "alpha".into(),
1835 url: "https://example.invalid/alpha.git".into(),
1836 path: "alpha".into(),
1837 pack_type: "meta".into(),
1838 schema_version: SCHEMA_VERSION.into(),
1839 },
1840 )
1841 .unwrap();
1842 let alpha = root.join("alpha");
1843 seed_meta_with_pack(&alpha, "alpha", "leaf");
1844
1845 let opts = DoctorOpts { shallow: Some(0), ..DoctorOpts::default() };
1846 let report = run_doctor(root, &opts).unwrap();
1847 let schema_oks: Vec<_> = report
1848 .findings
1849 .iter()
1850 .filter(|f| f.check == CheckKind::ManifestSchema && f.severity == Severity::Ok)
1851 .collect();
1852 assert_eq!(schema_oks.len(), 1, "shallow=0 must halt at root; got: {:?}", report.findings,);
1853 }
1854
1855 #[test]
1859 fn test_doctor_shallow_n_stops_at_n() {
1860 let d = tempdir().unwrap();
1861 let root = d.path();
1862
1863 write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1864 let m = root.join(".grex").join("events.jsonl");
1865 append_event(
1866 &m,
1867 &Event::Add {
1868 ts: ts(),
1869 id: "alpha".into(),
1870 url: "https://example.invalid/alpha.git".into(),
1871 path: "alpha".into(),
1872 pack_type: "meta".into(),
1873 schema_version: SCHEMA_VERSION.into(),
1874 },
1875 )
1876 .unwrap();
1877
1878 let alpha = root.join("alpha");
1879 write_meta_manifest(&alpha, "alpha", &[("gamma", "https://example.invalid/gamma.git")]);
1880 let am = alpha.join(".grex").join("events.jsonl");
1881 append_event(
1882 &am,
1883 &Event::Add {
1884 ts: ts(),
1885 id: "gamma".into(),
1886 url: "https://example.invalid/gamma.git".into(),
1887 path: "gamma".into(),
1888 pack_type: "meta".into(),
1889 schema_version: SCHEMA_VERSION.into(),
1890 },
1891 )
1892 .unwrap();
1893 fs::create_dir_all(alpha.join("gamma")).unwrap();
1894
1895 let gamma = alpha.join("gamma");
1896 seed_meta_with_pack(&gamma, "gamma", "delta");
1897
1898 let opts = DoctorOpts { shallow: Some(1), ..DoctorOpts::default() };
1899 let report = run_doctor(root, &opts).unwrap();
1900 let schema_oks: Vec<_> = report
1901 .findings
1902 .iter()
1903 .filter(|f| f.check == CheckKind::ManifestSchema && f.severity == Severity::Ok)
1904 .collect();
1905 assert_eq!(
1906 schema_oks.len(),
1907 2,
1908 "shallow=1 must visit root + depth-1; got: {:?}",
1909 report.findings,
1910 );
1911 }
1912
1913 #[test]
1918 fn test_doctor_no_fs_mutations() {
1919 let d = tempdir().unwrap();
1920 let root = d.path();
1921
1922 write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1924 let m = root.join(".grex").join("events.jsonl");
1925 append_event(
1926 &m,
1927 &Event::Add {
1928 ts: ts(),
1929 id: "alpha".into(),
1930 url: "https://example.invalid/alpha.git".into(),
1931 path: "alpha".into(),
1932 pack_type: "meta".into(),
1933 schema_version: SCHEMA_VERSION.into(),
1934 },
1935 )
1936 .unwrap();
1937
1938 let alpha = root.join("alpha");
1942 seed_meta_with_pack(&alpha, "alpha", "leaf");
1943 upsert_managed_block(&alpha.join(".gitignore"), "leaf", &["drifted-pattern"]).unwrap();
1944
1945 let before = fs_snapshot(root);
1946 let report = run_doctor(root, &DoctorOpts::default()).unwrap();
1947 let after = fs_snapshot(root);
1948
1949 assert_eq!(before, after, "recursive doctor walk must perform zero writes");
1950 assert!(
1954 report
1955 .findings
1956 .iter()
1957 .any(|f| f.check == CheckKind::GitignoreSync && f.severity == Severity::Warning),
1958 "expected sub-meta gitignore-drift warning; got: {:?}",
1959 report.findings,
1960 );
1961 }
1962
1963 proptest::proptest! {
1966 #![proptest_config(proptest::prelude::ProptestConfig { cases: 128, ..Default::default() })]
1967
1968 #[test]
1969 fn prop_exit_code_matches_worst_severity(
1970 severities in proptest::collection::vec(0u8..3, 0..20)
1971 ) {
1972 let mut r = DoctorReport::default();
1973 for s in &severities {
1974 let sev = match s {
1975 0 => Severity::Ok,
1976 1 => Severity::Warning,
1977 _ => Severity::Error,
1978 };
1979 r.findings.push(Finding {
1980 check: CheckKind::ManifestSchema,
1981 severity: sev,
1982 pack: None,
1983 detail: String::new(),
1984 auto_fixable: false,
1985 synthetic: false,
1986 });
1987 }
1988 let worst = severities.iter().max().copied().unwrap_or(0);
1989 let expected = match worst { 0 => 0, 1 => 1, _ => 2 };
1990 proptest::prop_assert_eq!(r.exit_code(), expected);
1991 }
1992 }
1993}