1use std::path::Path;
12
13use serde::Serialize;
14
15use crate::config::Config;
16use crate::context::resolve_agent_docs;
17use crate::error::RepographError;
18use crate::git::validate_git_repo;
19use crate::search::IndexStatus;
20
21pub const DOCTOR_SCHEMA_VERSION: u32 = 1;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[serde(rename_all = "lowercase")]
29pub enum Severity {
30 Ok,
32 Warn,
34 Error,
36}
37
38impl Severity {
39 const fn rank(self) -> u8 {
40 match self {
41 Self::Error => 2,
42 Self::Warn => 1,
43 Self::Ok => 0,
44 }
45 }
46}
47
48impl PartialOrd for Severity {
49 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
50 Some(self.cmp(other))
51 }
52}
53
54impl Ord for Severity {
55 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
56 self.rank().cmp(&other.rank())
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
63pub enum Check {
64 ConfigPresent,
66 ConfigParse,
68 AgentsConfigured,
70 ProjectsRootExists,
72 RepoPathExists,
74 RepoIsGitRepo,
77 WorkspaceMembersResolve,
79 AgentDocPresent,
83 SkillArtifactFresh,
88 SearchIndex,
92}
93
94#[derive(Debug, Clone, Serialize)]
101pub struct Finding {
102 pub check: Check,
103 pub severity: Severity,
104 pub target: String,
105 pub message: String,
106}
107
108#[derive(Debug, Clone, Copy, Default, Serialize)]
110pub struct Summary {
111 pub ok: u32,
112 pub warn: u32,
113 pub error: u32,
114 pub total: u32,
115}
116
117#[derive(Debug, Clone, Serialize)]
120pub struct DoctorReport {
121 pub schema_version: u32,
122 pub generated_at: String,
123 pub checks: Vec<Finding>,
124 pub summary: Summary,
125}
126
127impl DoctorReport {
128 #[must_use]
143 pub fn run(
144 config_load: Result<&Config, &RepographError>,
145 config_path: &Path,
146 generated_at: String,
147 ) -> Self {
148 let mut findings: Vec<Finding> = Vec::new();
149 let file_exists = config_path.is_file();
150 findings.push(config_present_finding(config_path, file_exists));
151
152 let config = match config_load {
153 Ok(c) => {
154 if file_exists {
155 findings.push(Finding {
156 check: Check::ConfigParse,
157 severity: Severity::Ok,
158 target: config_path.display().to_string(),
159 message: "config file is valid TOML".to_string(),
160 });
161 }
162 c
163 }
164 Err(err) => {
165 findings.push(Finding {
166 check: Check::ConfigParse,
167 severity: Severity::Error,
168 target: config_path.display().to_string(),
169 message: format!("config could not be loaded: {err}"),
170 });
171 return assemble(findings, generated_at);
172 }
173 };
174
175 let agents_configured = config.agents().is_some();
176 findings.push(agents_configured_finding(config_path, agents_configured));
177 if let Some(f) = projects_root_finding(config) {
178 findings.push(f);
179 }
180
181 for (name, repo) in config.repos() {
182 findings.extend(check_repo(name, &repo.path));
183 }
184 findings.extend(check_workspaces(config));
185
186 if agents_configured {
187 findings.extend(check_agent_docs(config));
188 }
189
190 assemble(findings, generated_at)
191 }
192
193 #[must_use]
200 pub fn with_index_check(mut self, status: &IndexStatus) -> Self {
201 self.checks.push(index_finding(status));
202 sort_findings(&mut self.checks);
203 self.summary = tally(&self.checks);
204 self
205 }
206
207 #[must_use]
218 pub fn with_skill_artifact_check(
219 mut self,
220 selected: &[crate::agents::AgentId],
221 home: &Path,
222 cwd: &Path,
223 ) -> Self {
224 self.checks
225 .extend(skill_artifact_findings(selected, home, cwd));
226 sort_findings(&mut self.checks);
227 self.summary = tally(&self.checks);
228 self
229 }
230}
231
232fn skill_artifact_findings(
234 selected: &[crate::agents::AgentId],
235 home: &Path,
236 cwd: &Path,
237) -> Vec<Finding> {
238 use crate::agent_artifact::{
239 ARTIFACT_BODY_VERSION, Scope, capabilities_for, has_artifact_writer, installed_version,
240 resolve_path,
241 };
242
243 let mut findings = Vec::new();
244 for &agent in selected {
245 if !has_artifact_writer(agent) {
246 continue;
247 }
248 for &capability in capabilities_for(agent) {
249 let target = format!("{} / {}", agent.as_str(), capability.skill_name());
250 let user_path = resolve_path(agent, capability, Scope::User, home, cwd);
252 let project_path = resolve_path(agent, capability, Scope::Project, home, cwd);
253 let found = [user_path, project_path]
254 .into_iter()
255 .find_map(|p| fs_err::read_to_string(&p).ok());
256
257 let finding = match found {
258 None => Finding {
259 check: Check::SkillArtifactFresh,
260 severity: Severity::Warn,
261 target,
262 message: "skill artifact missing — run `repograph init`".to_string(),
263 },
264 Some(contents) => match installed_version(&contents) {
265 Some(v) if v >= ARTIFACT_BODY_VERSION => Finding {
266 check: Check::SkillArtifactFresh,
267 severity: Severity::Ok,
268 target,
269 message: format!("skill artifact current (v{v})"),
270 },
271 Some(v) => Finding {
272 check: Check::SkillArtifactFresh,
273 severity: Severity::Warn,
274 target,
275 message: format!(
276 "skill artifact is stale (installed v{v} < current v{ARTIFACT_BODY_VERSION}) — run `repograph init`"
277 ),
278 },
279 None => Finding {
280 check: Check::SkillArtifactFresh,
281 severity: Severity::Warn,
282 target,
283 message: "skill artifact has no version stamp — run `repograph init`"
284 .to_string(),
285 },
286 },
287 };
288 findings.push(finding);
289 }
290 }
291 findings
292}
293
294fn index_finding(status: &IndexStatus) -> Finding {
295 const TARGET: &str = "search index";
296 if !status.present {
297 Finding {
298 check: Check::SearchIndex,
299 severity: Severity::Warn,
300 target: TARGET.to_string(),
301 message: "no search index built yet — run `repograph index`".to_string(),
302 }
303 } else if !status.readable {
304 Finding {
305 check: Check::SearchIndex,
306 severity: Severity::Warn,
307 target: TARGET.to_string(),
308 message: "search index is unreadable or corrupt — run `repograph index` to rebuild"
309 .to_string(),
310 }
311 } else if status.stale.is_empty() {
312 Finding {
313 check: Check::SearchIndex,
314 severity: Severity::Ok,
315 target: TARGET.to_string(),
316 message: "search index present and current".to_string(),
317 }
318 } else {
319 Finding {
320 check: Check::SearchIndex,
321 severity: Severity::Warn,
322 target: status.stale.join(", "),
323 message: format!(
324 "search index is stale or missing for: {} — run `repograph index`",
325 status.stale.join(", ")
326 ),
327 }
328 }
329}
330
331fn config_present_finding(config_path: &Path, file_exists: bool) -> Finding {
332 if file_exists {
333 Finding {
334 check: Check::ConfigPresent,
335 severity: Severity::Ok,
336 target: config_path.display().to_string(),
337 message: "config file is present".to_string(),
338 }
339 } else {
340 Finding {
341 check: Check::ConfigPresent,
342 severity: Severity::Error,
343 target: config_path.display().to_string(),
344 message: "config file does not exist".to_string(),
345 }
346 }
347}
348
349fn agents_configured_finding(config_path: &Path, agents_configured: bool) -> Finding {
350 if agents_configured {
351 Finding {
352 check: Check::AgentsConfigured,
353 severity: Severity::Ok,
354 target: config_path.display().to_string(),
355 message: "[agents] section is present".to_string(),
356 }
357 } else {
358 Finding {
359 check: Check::AgentsConfigured,
360 severity: Severity::Warn,
361 target: config_path.display().to_string(),
362 message: "[agents] section missing — run `repograph init`".to_string(),
363 }
364 }
365}
366
367fn projects_root_finding(config: &Config) -> Option<Finding> {
368 let root = config.settings()?.projects_root.as_deref()?;
369 if root.is_dir() {
370 Some(Finding {
371 check: Check::ProjectsRootExists,
372 severity: Severity::Ok,
373 target: root.display().to_string(),
374 message: "[settings].projects_root exists".to_string(),
375 })
376 } else {
377 Some(Finding {
378 check: Check::ProjectsRootExists,
379 severity: Severity::Warn,
380 target: root.display().to_string(),
381 message: format!(
382 "[settings].projects_root does not exist: {}",
383 root.display()
384 ),
385 })
386 }
387}
388
389fn check_workspaces(config: &Config) -> Vec<Finding> {
390 let mut out = Vec::new();
391 for (ws_name, workspace) in config.workspaces() {
392 for member in &workspace.members {
393 if config.repos().contains_key(member) {
394 out.push(Finding {
395 check: Check::WorkspaceMembersResolve,
396 severity: Severity::Ok,
397 target: format!("{ws_name} / {member}"),
398 message: "member resolves to a registered repo".to_string(),
399 });
400 } else {
401 out.push(Finding {
402 check: Check::WorkspaceMembersResolve,
403 severity: Severity::Warn,
404 target: ws_name.clone(),
405 message: format!(
406 "workspace member '{member}' is not a registered repo (dangling)"
407 ),
408 });
409 }
410 }
411 }
412 out
413}
414
415fn check_agent_docs(config: &Config) -> Vec<Finding> {
416 let mut out = Vec::new();
417 let selected: &[crate::agents::AgentId] =
418 config.agents().map_or(&[], |a| a.selected.as_slice());
419 if selected.is_empty() {
420 return out;
421 }
422 for (name, repo) in config.repos() {
423 if !repo.path.is_dir() {
424 continue;
425 }
426 for agent in selected {
427 let (docs, _) = resolve_agent_docs(&repo.path, std::slice::from_ref(agent));
428 let has_file = docs.iter().any(|d| !d.files.is_empty());
429 let target = format!("{name} / {}", agent.as_str());
430 if has_file {
431 out.push(Finding {
432 check: Check::AgentDocPresent,
433 severity: Severity::Ok,
434 target,
435 message: "at least one matching agent doc found".to_string(),
436 });
437 } else {
438 out.push(Finding {
439 check: Check::AgentDocPresent,
440 severity: Severity::Warn,
441 target,
442 message: format!(
443 "no files matched {} patterns ({})",
444 agent.as_str(),
445 agent.file_patterns().join(", ")
446 ),
447 });
448 }
449 }
450 }
451 out
452}
453
454fn check_repo(name: &str, repo_path: &Path) -> Vec<Finding> {
455 let mut out = Vec::with_capacity(2);
456 if repo_path.exists() {
457 out.push(Finding {
458 check: Check::RepoPathExists,
459 severity: Severity::Ok,
460 target: name.to_string(),
461 message: format!("path exists: {}", repo_path.display()),
462 });
463 match validate_git_repo(repo_path) {
464 Ok(_) => out.push(Finding {
465 check: Check::RepoIsGitRepo,
466 severity: Severity::Ok,
467 target: name.to_string(),
468 message: "path is a git repository".to_string(),
469 }),
470 Err(e) => out.push(Finding {
471 check: Check::RepoIsGitRepo,
472 severity: Severity::Error,
473 target: name.to_string(),
474 message: format!("path is not a git repository: {e}"),
475 }),
476 }
477 } else {
478 out.push(Finding {
479 check: Check::RepoPathExists,
480 severity: Severity::Error,
481 target: name.to_string(),
482 message: format!("path does not exist: {}", repo_path.display()),
483 });
484 }
485 out
486}
487
488fn assemble(mut findings: Vec<Finding>, generated_at: String) -> DoctorReport {
489 sort_findings(&mut findings);
490 let summary = tally(&findings);
491 DoctorReport {
492 schema_version: DOCTOR_SCHEMA_VERSION,
493 generated_at,
494 checks: findings,
495 summary,
496 }
497}
498
499fn sort_findings(findings: &mut [Finding]) {
500 findings.sort_by(|a, b| {
501 b.severity
502 .cmp(&a.severity)
503 .then_with(|| a.check.cmp(&b.check))
504 .then_with(|| a.target.cmp(&b.target))
505 });
506}
507
508fn tally(findings: &[Finding]) -> Summary {
509 findings.iter().fold(Summary::default(), |mut acc, f| {
510 match f.severity {
511 Severity::Ok => acc.ok += 1,
512 Severity::Warn => acc.warn += 1,
513 Severity::Error => acc.error += 1,
514 }
515 acc.total += 1;
516 acc
517 })
518}
519
520#[cfg(test)]
521mod tests {
522 #![allow(clippy::unwrap_used, clippy::expect_used)]
523 use super::*;
524 use crate::agents::AgentId;
525 use crate::config::{Agents, CONFIG_FILE_NAME, Repo, Settings};
526 use std::path::PathBuf;
527 use tempfile::TempDir;
528
529 fn ts() -> String {
530 "2026-05-24T00:00:00Z".to_string()
531 }
532
533 fn init_git_repo(parent: &Path, name: &str) -> PathBuf {
534 let path = parent.join(name);
535 std::fs::create_dir_all(&path).unwrap();
536 let repo = git2::Repository::init(&path).unwrap();
537 let sig = git2::Signature::now("T", "t@e").unwrap();
538 let tree_id = {
539 let mut index = repo.index().unwrap();
540 index.write_tree().unwrap()
541 };
542 let tree = repo.find_tree(tree_id).unwrap();
543 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
544 .unwrap();
545 crate::path::canonicalize(&path).unwrap()
546 }
547
548 fn write_config(dir: &Path, body: &str) {
549 std::fs::create_dir_all(dir).unwrap();
550 std::fs::write(dir.join(CONFIG_FILE_NAME), body).unwrap();
551 }
552
553 fn count(report: &DoctorReport, check: Check, severity: Severity) -> usize {
554 report
555 .checks
556 .iter()
557 .filter(|f| f.check == check && f.severity == severity)
558 .count()
559 }
560
561 #[test]
562 fn missing_config_file_emits_config_present_error() {
563 let tmp = TempDir::new().unwrap();
564 let path = tmp.path().join(CONFIG_FILE_NAME);
565 let cfg = Config::default();
566 let report = DoctorReport::run(Ok(&cfg), &path, ts());
567 assert_eq!(count(&report, Check::ConfigPresent, Severity::Error), 1);
568 assert!(report.summary.error >= 1);
569 }
570
571 #[test]
572 fn config_load_error_short_circuits_after_parse() {
573 let tmp = TempDir::new().unwrap();
574 let path = tmp.path().join(CONFIG_FILE_NAME);
575 write_config(tmp.path(), "[unterminated");
577 let err = Config::load(tmp.path()).unwrap_err();
578 let report = DoctorReport::run(Err(&err), &path, ts());
579 assert_eq!(count(&report, Check::ConfigParse, Severity::Error), 1);
580 assert!(
582 report
583 .checks
584 .iter()
585 .all(|f| matches!(f.check, Check::ConfigPresent | Check::ConfigParse))
586 );
587 }
588
589 #[test]
590 fn agents_missing_emits_warn_and_skips_agent_doc_present() {
591 let tmp = TempDir::new().unwrap();
592 let repo = init_git_repo(tmp.path(), "api");
593 let mut cfg = Config::default();
594 cfg.add_repo(
595 "api".into(),
596 Repo {
597 path: repo,
598 description: None,
599 stack: vec![],
600 },
601 )
602 .unwrap();
603 cfg.save(tmp.path()).unwrap();
604 let path = tmp.path().join(CONFIG_FILE_NAME);
605 let report = DoctorReport::run(Ok(&cfg), &path, ts());
606 assert_eq!(count(&report, Check::AgentsConfigured, Severity::Warn), 1);
607 assert_eq!(count(&report, Check::AgentDocPresent, Severity::Ok), 0);
608 assert_eq!(count(&report, Check::AgentDocPresent, Severity::Warn), 0);
609 }
610
611 #[test]
612 fn projects_root_missing_emits_warn() {
613 let tmp = TempDir::new().unwrap();
614 let mut cfg = Config::default();
615 cfg.set_settings(Some(Settings {
616 projects_root: Some(tmp.path().join("does-not-exist")),
617 }));
618 cfg.save(tmp.path()).unwrap();
619 let path = tmp.path().join(CONFIG_FILE_NAME);
620 let report = DoctorReport::run(Ok(&cfg), &path, ts());
621 assert_eq!(count(&report, Check::ProjectsRootExists, Severity::Warn), 1);
622 }
623
624 #[test]
625 fn projects_root_existing_emits_ok() {
626 let tmp = TempDir::new().unwrap();
627 let mut cfg = Config::default();
628 cfg.set_settings(Some(Settings {
629 projects_root: Some(tmp.path().to_path_buf()),
630 }));
631 cfg.save(tmp.path()).unwrap();
632 let path = tmp.path().join(CONFIG_FILE_NAME);
633 let report = DoctorReport::run(Ok(&cfg), &path, ts());
634 assert_eq!(count(&report, Check::ProjectsRootExists, Severity::Ok), 1);
635 }
636
637 #[test]
638 fn missing_repo_path_emits_error_and_skips_git_check() {
639 let tmp = TempDir::new().unwrap();
640 let mut cfg = Config::default();
641 cfg.add_repo(
642 "ghost".into(),
643 Repo {
644 path: tmp.path().join("does-not-exist"),
645 description: None,
646 stack: vec![],
647 },
648 )
649 .unwrap();
650 cfg.save(tmp.path()).unwrap();
651 let path = tmp.path().join(CONFIG_FILE_NAME);
652 let report = DoctorReport::run(Ok(&cfg), &path, ts());
653 assert_eq!(count(&report, Check::RepoPathExists, Severity::Error), 1);
654 assert_eq!(count(&report, Check::RepoIsGitRepo, Severity::Error), 0);
655 assert_eq!(count(&report, Check::RepoIsGitRepo, Severity::Ok), 0);
656 assert!(report.summary.error >= 1);
657 }
658
659 #[test]
660 fn non_git_path_emits_repo_path_ok_and_git_error() {
661 let tmp = TempDir::new().unwrap();
662 let plain_dir = tmp.path().join("notes");
663 std::fs::create_dir_all(&plain_dir).unwrap();
664 let mut cfg = Config::default();
665 cfg.add_repo(
666 "notes".into(),
667 Repo {
668 path: plain_dir,
669 description: None,
670 stack: vec![],
671 },
672 )
673 .unwrap();
674 cfg.save(tmp.path()).unwrap();
675 let path = tmp.path().join(CONFIG_FILE_NAME);
676 let report = DoctorReport::run(Ok(&cfg), &path, ts());
677 assert_eq!(count(&report, Check::RepoPathExists, Severity::Ok), 1);
678 assert_eq!(count(&report, Check::RepoIsGitRepo, Severity::Error), 1);
679 }
680
681 #[test]
682 fn healthy_git_repo_emits_both_ok() {
683 let tmp = TempDir::new().unwrap();
684 let repo = init_git_repo(tmp.path(), "api");
685 let mut cfg = Config::default();
686 cfg.add_repo(
687 "api".into(),
688 Repo {
689 path: repo,
690 description: None,
691 stack: vec![],
692 },
693 )
694 .unwrap();
695 cfg.save(tmp.path()).unwrap();
696 let path = tmp.path().join(CONFIG_FILE_NAME);
697 let report = DoctorReport::run(Ok(&cfg), &path, ts());
698 assert_eq!(count(&report, Check::RepoPathExists, Severity::Ok), 1);
699 assert_eq!(count(&report, Check::RepoIsGitRepo, Severity::Ok), 1);
700 }
701
702 #[test]
703 fn dangling_workspace_member_emits_warn() {
704 let tmp = TempDir::new().unwrap();
705 let repo = init_git_repo(tmp.path(), "api");
706 let mut cfg = Config::default();
707 cfg.add_repo(
708 "api".into(),
709 Repo {
710 path: repo,
711 description: None,
712 stack: vec![],
713 },
714 )
715 .unwrap();
716 cfg.create_workspace("acme".into(), None).unwrap();
717 cfg.add_members("acme", &["api".into()]).unwrap();
718 cfg.remove_repo("api").unwrap();
721 cfg.save(tmp.path()).unwrap();
722 let path = tmp.path().join(CONFIG_FILE_NAME);
723 let report = DoctorReport::run(Ok(&cfg), &path, ts());
724 let dangling = report
725 .checks
726 .iter()
727 .filter(|f| {
728 f.check == Check::WorkspaceMembersResolve
729 && f.severity == Severity::Warn
730 && f.message.contains("api")
731 })
732 .count();
733 assert_eq!(dangling, 1);
734 assert_eq!(report.summary.error, 0);
735 }
736
737 #[test]
738 fn agent_doc_missing_emits_warn() {
739 let tmp = TempDir::new().unwrap();
740 let repo = init_git_repo(tmp.path(), "api");
741 let mut cfg = Config::default();
743 cfg.add_repo(
744 "api".into(),
745 Repo {
746 path: repo,
747 description: None,
748 stack: vec![],
749 },
750 )
751 .unwrap();
752 cfg.set_agents(Some(Agents {
753 selected: vec![AgentId::ClaudeCode],
754 }));
755 cfg.save(tmp.path()).unwrap();
756 let path = tmp.path().join(CONFIG_FILE_NAME);
757 let report = DoctorReport::run(Ok(&cfg), &path, ts());
758 assert_eq!(count(&report, Check::AgentDocPresent, Severity::Warn), 1);
759 assert_eq!(report.summary.error, 0);
760 }
761
762 #[test]
763 fn agent_doc_present_emits_ok() {
764 let tmp = TempDir::new().unwrap();
765 let repo = init_git_repo(tmp.path(), "api");
766 std::fs::write(repo.join("CLAUDE.md"), "context\n").unwrap();
767 let mut cfg = Config::default();
768 cfg.add_repo(
769 "api".into(),
770 Repo {
771 path: repo,
772 description: None,
773 stack: vec![],
774 },
775 )
776 .unwrap();
777 cfg.set_agents(Some(Agents {
778 selected: vec![AgentId::ClaudeCode],
779 }));
780 cfg.save(tmp.path()).unwrap();
781 let path = tmp.path().join(CONFIG_FILE_NAME);
782 let report = DoctorReport::run(Ok(&cfg), &path, ts());
783 assert_eq!(count(&report, Check::AgentDocPresent, Severity::Ok), 1);
784 assert_eq!(report.summary.error, 0);
785 assert_eq!(report.summary.warn, 0);
786 }
787
788 #[test]
789 fn summary_totals_match_findings() {
790 let tmp = TempDir::new().unwrap();
791 let mut cfg = Config::default();
792 cfg.set_agents(Some(Agents { selected: vec![] }));
793 cfg.save(tmp.path()).unwrap();
794 let path = tmp.path().join(CONFIG_FILE_NAME);
795 let report = DoctorReport::run(Ok(&cfg), &path, ts());
796 assert_eq!(
797 report.summary.total,
798 report.summary.ok + report.summary.warn + report.summary.error
799 );
800 assert_eq!(report.summary.total as usize, report.checks.len());
801 }
802
803 #[test]
804 fn findings_sorted_severity_desc_then_check_asc_then_target_asc() {
805 let findings = vec![
807 Finding {
808 check: Check::AgentDocPresent,
809 severity: Severity::Ok,
810 target: "z".into(),
811 message: String::new(),
812 },
813 Finding {
814 check: Check::RepoPathExists,
815 severity: Severity::Error,
816 target: "a".into(),
817 message: String::new(),
818 },
819 Finding {
820 check: Check::AgentsConfigured,
821 severity: Severity::Warn,
822 target: "b".into(),
823 message: String::new(),
824 },
825 Finding {
826 check: Check::ConfigPresent,
827 severity: Severity::Ok,
828 target: "a".into(),
829 message: String::new(),
830 },
831 ];
832 let report = assemble(findings, ts());
833 let order: Vec<_> = report
834 .checks
835 .iter()
836 .map(|f| (f.severity, f.check, f.target.clone()))
837 .collect();
838 assert_eq!(order[0].0, Severity::Error);
839 assert_eq!(order[1].0, Severity::Warn);
840 assert_eq!(order[2].0, Severity::Ok);
841 assert_eq!(order[3].0, Severity::Ok);
842 assert!(matches!(order[2].1, Check::ConfigPresent));
847 assert!(matches!(order[3].1, Check::AgentDocPresent));
848 }
849
850 #[test]
851 fn severity_ordering_error_is_max() {
852 assert!(Severity::Error > Severity::Warn);
853 assert!(Severity::Warn > Severity::Ok);
854 assert!(Severity::Error > Severity::Ok);
855 }
856
857 #[test]
858 fn json_envelope_has_documented_top_level_keys() {
859 let tmp = TempDir::new().unwrap();
860 let path = tmp.path().join(CONFIG_FILE_NAME);
861 let cfg = Config::default();
862 let report = DoctorReport::run(Ok(&cfg), &path, ts());
863 let v = serde_json::to_value(&report).unwrap();
864 assert_eq!(v["schema_version"], 1);
865 assert!(v["generated_at"].is_string());
866 assert!(v["checks"].is_array());
867 assert!(v["summary"].is_object());
868 assert!(v["summary"]["total"].is_number());
869 }
870
871 fn index_check(report: &DoctorReport) -> &Finding {
872 report
873 .checks
874 .iter()
875 .find(|f| f.check == Check::SearchIndex)
876 .expect("index check present")
877 }
878
879 #[test]
880 fn with_index_check_missing_is_warn() {
881 let tmp = TempDir::new().unwrap();
882 let path = tmp.path().join(CONFIG_FILE_NAME);
883 let report = DoctorReport::run(Ok(&Config::default()), &path, ts())
884 .with_index_check(&IndexStatus::default());
885 let f = index_check(&report);
886 assert_eq!(f.severity, Severity::Warn);
887 assert!(f.message.contains("repograph index"));
888 }
889
890 #[test]
891 fn with_index_check_present_current_is_ok() {
892 let tmp = TempDir::new().unwrap();
893 let path = tmp.path().join(CONFIG_FILE_NAME);
894 let status = IndexStatus {
895 present: true,
896 readable: true,
897 stale: vec![],
898 };
899 let report =
900 DoctorReport::run(Ok(&Config::default()), &path, ts()).with_index_check(&status);
901 assert_eq!(index_check(&report).severity, Severity::Ok);
902 }
903
904 #[test]
905 fn with_index_check_stale_names_repo_and_warns() {
906 let tmp = TempDir::new().unwrap();
907 let path = tmp.path().join(CONFIG_FILE_NAME);
908 let status = IndexStatus {
909 present: true,
910 readable: true,
911 stale: vec!["api".to_string()],
912 };
913 let report =
914 DoctorReport::run(Ok(&Config::default()), &path, ts()).with_index_check(&status);
915 let f = index_check(&report);
916 assert_eq!(f.severity, Severity::Warn);
917 assert!(f.message.contains("api"));
918 }
919
920 #[test]
921 fn with_index_check_recomputes_summary_total() {
922 let tmp = TempDir::new().unwrap();
923 let path = tmp.path().join(CONFIG_FILE_NAME);
924 let before = DoctorReport::run(Ok(&Config::default()), &path, ts());
925 let before_total = before.summary.total;
926 let after = before.with_index_check(&IndexStatus::default());
927 assert_eq!(after.summary.total, before_total + 1);
928 assert_eq!(after.summary.total as usize, after.checks.len());
929 }
930
931 #[test]
932 fn check_serializes_as_pascal_case_variant_name() {
933 let f = Finding {
934 check: Check::RepoIsGitRepo,
935 severity: Severity::Ok,
936 target: "x".into(),
937 message: "y".into(),
938 };
939 let v = serde_json::to_value(&f).unwrap();
940 assert_eq!(v["check"], "RepoIsGitRepo");
941 assert_eq!(v["severity"], "ok");
942 }
943
944 fn install_current(home: &Path, agent: AgentId, capability: crate::agent_artifact::Capability) {
949 use crate::agent_artifact::{Scope, render_artifact, resolve_path};
950 let path = resolve_path(
951 agent,
952 capability,
953 Scope::User,
954 home,
955 Path::new("/unused-cwd"),
956 );
957 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
958 std::fs::write(&path, render_artifact(agent, capability)).unwrap();
959 }
960
961 #[test]
962 fn skill_artifact_missing_is_warn_with_init_hint() {
963 let tmp = TempDir::new().unwrap();
964 let path = tmp.path().join(CONFIG_FILE_NAME);
965 let home = tmp.path().join("home");
966 let report = DoctorReport::run(Ok(&Config::default()), &path, ts())
967 .with_skill_artifact_check(&[AgentId::ClaudeCode], &home, Path::new("/cwd"));
968 assert_eq!(count(&report, Check::SkillArtifactFresh, Severity::Warn), 2);
970 let f = report
971 .checks
972 .iter()
973 .find(|f| f.check == Check::SkillArtifactFresh)
974 .unwrap();
975 assert!(f.message.contains("repograph init"), "missing init hint");
976 }
977
978 #[test]
979 fn skill_artifact_current_is_ok() {
980 use crate::agent_artifact::Capability;
981 let tmp = TempDir::new().unwrap();
982 let path = tmp.path().join(CONFIG_FILE_NAME);
983 let home = tmp.path().join("home");
984 install_current(&home, AgentId::ClaudeCode, Capability::Consumer);
985 install_current(&home, AgentId::ClaudeCode, Capability::Setup);
986 let report = DoctorReport::run(Ok(&Config::default()), &path, ts())
987 .with_skill_artifact_check(&[AgentId::ClaudeCode], &home, Path::new("/cwd"));
988 assert_eq!(count(&report, Check::SkillArtifactFresh, Severity::Ok), 2);
989 assert_eq!(count(&report, Check::SkillArtifactFresh, Severity::Warn), 0);
990 }
991
992 #[test]
993 fn skill_artifact_stale_version_is_warn() {
994 use crate::agent_artifact::{Capability, Scope, resolve_path};
995 let tmp = TempDir::new().unwrap();
996 let path = tmp.path().join(CONFIG_FILE_NAME);
997 let home = tmp.path().join("home");
998 let p = resolve_path(
1000 AgentId::ClaudeCode,
1001 Capability::Consumer,
1002 Scope::User,
1003 &home,
1004 Path::new("/cwd"),
1005 );
1006 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1007 std::fs::write(
1008 &p,
1009 "---\nname: repograph\n---\n\n<!-- repograph:begin v0 -->\nOLD\n<!-- repograph:end -->\n",
1010 )
1011 .unwrap();
1012 install_current(&home, AgentId::ClaudeCode, Capability::Setup);
1013 let report = DoctorReport::run(Ok(&Config::default()), &path, ts())
1014 .with_skill_artifact_check(&[AgentId::ClaudeCode], &home, Path::new("/cwd"));
1015 let stale = report
1016 .checks
1017 .iter()
1018 .find(|f| f.check == Check::SkillArtifactFresh && f.severity == Severity::Warn)
1019 .expect("a stale warn finding");
1020 assert!(stale.message.contains("stale"), "names staleness");
1021 assert!(stale.message.contains("repograph init"));
1022 }
1023
1024 #[test]
1025 fn empty_agent_selection_produces_no_skill_findings() {
1026 let tmp = TempDir::new().unwrap();
1027 let path = tmp.path().join(CONFIG_FILE_NAME);
1028 let report = DoctorReport::run(Ok(&Config::default()), &path, ts())
1029 .with_skill_artifact_check(&[], &tmp.path().join("home"), Path::new("/cwd"));
1030 assert_eq!(count(&report, Check::SkillArtifactFresh, Severity::Ok), 0);
1031 assert_eq!(count(&report, Check::SkillArtifactFresh, Severity::Warn), 0);
1032 }
1033
1034 #[test]
1035 fn skill_check_does_not_mutate_artifacts() {
1036 use crate::agent_artifact::Capability;
1037 let tmp = TempDir::new().unwrap();
1038 let path = tmp.path().join(CONFIG_FILE_NAME);
1039 let home = tmp.path().join("home");
1040 install_current(&home, AgentId::ClaudeCode, Capability::Consumer);
1041 install_current(&home, AgentId::ClaudeCode, Capability::Setup);
1042 let consumer = home.join(".claude/skills/repograph/SKILL.md");
1043 let before = std::fs::metadata(&consumer).unwrap().modified().unwrap();
1044 let _ = DoctorReport::run(Ok(&Config::default()), &path, ts()).with_skill_artifact_check(
1045 &[AgentId::ClaudeCode],
1046 &home,
1047 Path::new("/cwd"),
1048 );
1049 let after = std::fs::metadata(&consumer).unwrap().modified().unwrap();
1050 assert_eq!(before, after, "doctor must not rewrite the artifact");
1051 }
1052}