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