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;
19
20pub const DOCTOR_SCHEMA_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "lowercase")]
28pub enum Severity {
29 Ok,
31 Warn,
33 Error,
35}
36
37impl Severity {
38 const fn rank(self) -> u8 {
39 match self {
40 Self::Error => 2,
41 Self::Warn => 1,
42 Self::Ok => 0,
43 }
44 }
45}
46
47impl PartialOrd for Severity {
48 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
49 Some(self.cmp(other))
50 }
51}
52
53impl Ord for Severity {
54 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
55 self.rank().cmp(&other.rank())
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
62pub enum Check {
63 ConfigPresent,
65 ConfigParse,
67 AgentsConfigured,
69 ProjectsRootExists,
71 RepoPathExists,
73 RepoIsGitRepo,
76 WorkspaceMembersResolve,
78 AgentDocPresent,
82}
83
84#[derive(Debug, Clone, Serialize)]
91pub struct Finding {
92 pub check: Check,
93 pub severity: Severity,
94 pub target: String,
95 pub message: String,
96}
97
98#[derive(Debug, Clone, Copy, Default, Serialize)]
100pub struct Summary {
101 pub ok: u32,
102 pub warn: u32,
103 pub error: u32,
104 pub total: u32,
105}
106
107#[derive(Debug, Clone, Serialize)]
110pub struct DoctorReport {
111 pub schema_version: u32,
112 pub generated_at: String,
113 pub checks: Vec<Finding>,
114 pub summary: Summary,
115}
116
117impl DoctorReport {
118 #[must_use]
133 pub fn run(
134 config_load: Result<&Config, &RepographError>,
135 config_path: &Path,
136 generated_at: String,
137 ) -> Self {
138 let mut findings: Vec<Finding> = Vec::new();
139 let file_exists = config_path.is_file();
140 findings.push(config_present_finding(config_path, file_exists));
141
142 let config = match config_load {
143 Ok(c) => {
144 if file_exists {
145 findings.push(Finding {
146 check: Check::ConfigParse,
147 severity: Severity::Ok,
148 target: config_path.display().to_string(),
149 message: "config file is valid TOML".to_string(),
150 });
151 }
152 c
153 }
154 Err(err) => {
155 findings.push(Finding {
156 check: Check::ConfigParse,
157 severity: Severity::Error,
158 target: config_path.display().to_string(),
159 message: format!("config could not be loaded: {err}"),
160 });
161 return assemble(findings, generated_at);
162 }
163 };
164
165 let agents_configured = config.agents().is_some();
166 findings.push(agents_configured_finding(config_path, agents_configured));
167 if let Some(f) = projects_root_finding(config) {
168 findings.push(f);
169 }
170
171 for (name, repo) in config.repos() {
172 findings.extend(check_repo(name, &repo.path));
173 }
174 findings.extend(check_workspaces(config));
175
176 if agents_configured {
177 findings.extend(check_agent_docs(config));
178 }
179
180 assemble(findings, generated_at)
181 }
182}
183
184fn config_present_finding(config_path: &Path, file_exists: bool) -> Finding {
185 if file_exists {
186 Finding {
187 check: Check::ConfigPresent,
188 severity: Severity::Ok,
189 target: config_path.display().to_string(),
190 message: "config file is present".to_string(),
191 }
192 } else {
193 Finding {
194 check: Check::ConfigPresent,
195 severity: Severity::Error,
196 target: config_path.display().to_string(),
197 message: "config file does not exist".to_string(),
198 }
199 }
200}
201
202fn agents_configured_finding(config_path: &Path, agents_configured: bool) -> Finding {
203 if agents_configured {
204 Finding {
205 check: Check::AgentsConfigured,
206 severity: Severity::Ok,
207 target: config_path.display().to_string(),
208 message: "[agents] section is present".to_string(),
209 }
210 } else {
211 Finding {
212 check: Check::AgentsConfigured,
213 severity: Severity::Warn,
214 target: config_path.display().to_string(),
215 message: "[agents] section missing — run `repograph init`".to_string(),
216 }
217 }
218}
219
220fn projects_root_finding(config: &Config) -> Option<Finding> {
221 let root = config.settings()?.projects_root.as_deref()?;
222 if root.is_dir() {
223 Some(Finding {
224 check: Check::ProjectsRootExists,
225 severity: Severity::Ok,
226 target: root.display().to_string(),
227 message: "[settings].projects_root exists".to_string(),
228 })
229 } else {
230 Some(Finding {
231 check: Check::ProjectsRootExists,
232 severity: Severity::Warn,
233 target: root.display().to_string(),
234 message: format!(
235 "[settings].projects_root does not exist: {}",
236 root.display()
237 ),
238 })
239 }
240}
241
242fn check_workspaces(config: &Config) -> Vec<Finding> {
243 let mut out = Vec::new();
244 for (ws_name, workspace) in config.workspaces() {
245 for member in &workspace.members {
246 if config.repos().contains_key(member) {
247 out.push(Finding {
248 check: Check::WorkspaceMembersResolve,
249 severity: Severity::Ok,
250 target: format!("{ws_name} / {member}"),
251 message: "member resolves to a registered repo".to_string(),
252 });
253 } else {
254 out.push(Finding {
255 check: Check::WorkspaceMembersResolve,
256 severity: Severity::Warn,
257 target: ws_name.clone(),
258 message: format!(
259 "workspace member '{member}' is not a registered repo (dangling)"
260 ),
261 });
262 }
263 }
264 }
265 out
266}
267
268fn check_agent_docs(config: &Config) -> Vec<Finding> {
269 let mut out = Vec::new();
270 let selected: &[crate::agents::AgentId] =
271 config.agents().map_or(&[], |a| a.selected.as_slice());
272 if selected.is_empty() {
273 return out;
274 }
275 for (name, repo) in config.repos() {
276 if !repo.path.is_dir() {
277 continue;
278 }
279 for agent in selected {
280 let (docs, _) = resolve_agent_docs(&repo.path, std::slice::from_ref(agent));
281 let has_file = docs.iter().any(|d| !d.files.is_empty());
282 let target = format!("{name} / {}", agent.as_str());
283 if has_file {
284 out.push(Finding {
285 check: Check::AgentDocPresent,
286 severity: Severity::Ok,
287 target,
288 message: "at least one matching agent doc found".to_string(),
289 });
290 } else {
291 out.push(Finding {
292 check: Check::AgentDocPresent,
293 severity: Severity::Warn,
294 target,
295 message: format!(
296 "no files matched {} patterns ({})",
297 agent.as_str(),
298 agent.file_patterns().join(", ")
299 ),
300 });
301 }
302 }
303 }
304 out
305}
306
307fn check_repo(name: &str, repo_path: &Path) -> Vec<Finding> {
308 let mut out = Vec::with_capacity(2);
309 if repo_path.exists() {
310 out.push(Finding {
311 check: Check::RepoPathExists,
312 severity: Severity::Ok,
313 target: name.to_string(),
314 message: format!("path exists: {}", repo_path.display()),
315 });
316 match validate_git_repo(repo_path) {
317 Ok(_) => out.push(Finding {
318 check: Check::RepoIsGitRepo,
319 severity: Severity::Ok,
320 target: name.to_string(),
321 message: "path is a git repository".to_string(),
322 }),
323 Err(e) => out.push(Finding {
324 check: Check::RepoIsGitRepo,
325 severity: Severity::Error,
326 target: name.to_string(),
327 message: format!("path is not a git repository: {e}"),
328 }),
329 }
330 } else {
331 out.push(Finding {
332 check: Check::RepoPathExists,
333 severity: Severity::Error,
334 target: name.to_string(),
335 message: format!("path does not exist: {}", repo_path.display()),
336 });
337 }
338 out
339}
340
341fn assemble(mut findings: Vec<Finding>, generated_at: String) -> DoctorReport {
342 findings.sort_by(|a, b| {
343 b.severity
344 .cmp(&a.severity)
345 .then_with(|| a.check.cmp(&b.check))
346 .then_with(|| a.target.cmp(&b.target))
347 });
348 let summary = findings.iter().fold(Summary::default(), |mut acc, f| {
349 match f.severity {
350 Severity::Ok => acc.ok += 1,
351 Severity::Warn => acc.warn += 1,
352 Severity::Error => acc.error += 1,
353 }
354 acc.total += 1;
355 acc
356 });
357 DoctorReport {
358 schema_version: DOCTOR_SCHEMA_VERSION,
359 generated_at,
360 checks: findings,
361 summary,
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 #![allow(clippy::unwrap_used, clippy::expect_used)]
368 use super::*;
369 use crate::agents::AgentId;
370 use crate::config::{Agents, CONFIG_FILE_NAME, Repo, Settings};
371 use std::path::PathBuf;
372 use tempfile::TempDir;
373
374 fn ts() -> String {
375 "2026-05-24T00:00:00Z".to_string()
376 }
377
378 fn init_git_repo(parent: &Path, name: &str) -> PathBuf {
379 let path = parent.join(name);
380 std::fs::create_dir_all(&path).unwrap();
381 let repo = git2::Repository::init(&path).unwrap();
382 let sig = git2::Signature::now("T", "t@e").unwrap();
383 let tree_id = {
384 let mut index = repo.index().unwrap();
385 index.write_tree().unwrap()
386 };
387 let tree = repo.find_tree(tree_id).unwrap();
388 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
389 .unwrap();
390 crate::path::canonicalize(&path).unwrap()
391 }
392
393 fn write_config(dir: &Path, body: &str) {
394 std::fs::create_dir_all(dir).unwrap();
395 std::fs::write(dir.join(CONFIG_FILE_NAME), body).unwrap();
396 }
397
398 fn count(report: &DoctorReport, check: Check, severity: Severity) -> usize {
399 report
400 .checks
401 .iter()
402 .filter(|f| f.check == check && f.severity == severity)
403 .count()
404 }
405
406 #[test]
407 fn missing_config_file_emits_config_present_error() {
408 let tmp = TempDir::new().unwrap();
409 let path = tmp.path().join(CONFIG_FILE_NAME);
410 let cfg = Config::default();
411 let report = DoctorReport::run(Ok(&cfg), &path, ts());
412 assert_eq!(count(&report, Check::ConfigPresent, Severity::Error), 1);
413 assert!(report.summary.error >= 1);
414 }
415
416 #[test]
417 fn config_load_error_short_circuits_after_parse() {
418 let tmp = TempDir::new().unwrap();
419 let path = tmp.path().join(CONFIG_FILE_NAME);
420 write_config(tmp.path(), "[unterminated");
422 let err = Config::load(tmp.path()).unwrap_err();
423 let report = DoctorReport::run(Err(&err), &path, ts());
424 assert_eq!(count(&report, Check::ConfigParse, Severity::Error), 1);
425 assert!(
427 report
428 .checks
429 .iter()
430 .all(|f| matches!(f.check, Check::ConfigPresent | Check::ConfigParse))
431 );
432 }
433
434 #[test]
435 fn agents_missing_emits_warn_and_skips_agent_doc_present() {
436 let tmp = TempDir::new().unwrap();
437 let repo = init_git_repo(tmp.path(), "api");
438 let mut cfg = Config::default();
439 cfg.add_repo(
440 "api".into(),
441 Repo {
442 path: repo,
443 description: None,
444 stack: vec![],
445 },
446 )
447 .unwrap();
448 cfg.save(tmp.path()).unwrap();
449 let path = tmp.path().join(CONFIG_FILE_NAME);
450 let report = DoctorReport::run(Ok(&cfg), &path, ts());
451 assert_eq!(count(&report, Check::AgentsConfigured, Severity::Warn), 1);
452 assert_eq!(count(&report, Check::AgentDocPresent, Severity::Ok), 0);
453 assert_eq!(count(&report, Check::AgentDocPresent, Severity::Warn), 0);
454 }
455
456 #[test]
457 fn projects_root_missing_emits_warn() {
458 let tmp = TempDir::new().unwrap();
459 let mut cfg = Config::default();
460 cfg.set_settings(Some(Settings {
461 projects_root: Some(tmp.path().join("does-not-exist")),
462 }));
463 cfg.save(tmp.path()).unwrap();
464 let path = tmp.path().join(CONFIG_FILE_NAME);
465 let report = DoctorReport::run(Ok(&cfg), &path, ts());
466 assert_eq!(count(&report, Check::ProjectsRootExists, Severity::Warn), 1);
467 }
468
469 #[test]
470 fn projects_root_existing_emits_ok() {
471 let tmp = TempDir::new().unwrap();
472 let mut cfg = Config::default();
473 cfg.set_settings(Some(Settings {
474 projects_root: Some(tmp.path().to_path_buf()),
475 }));
476 cfg.save(tmp.path()).unwrap();
477 let path = tmp.path().join(CONFIG_FILE_NAME);
478 let report = DoctorReport::run(Ok(&cfg), &path, ts());
479 assert_eq!(count(&report, Check::ProjectsRootExists, Severity::Ok), 1);
480 }
481
482 #[test]
483 fn missing_repo_path_emits_error_and_skips_git_check() {
484 let tmp = TempDir::new().unwrap();
485 let mut cfg = Config::default();
486 cfg.add_repo(
487 "ghost".into(),
488 Repo {
489 path: tmp.path().join("does-not-exist"),
490 description: None,
491 stack: vec![],
492 },
493 )
494 .unwrap();
495 cfg.save(tmp.path()).unwrap();
496 let path = tmp.path().join(CONFIG_FILE_NAME);
497 let report = DoctorReport::run(Ok(&cfg), &path, ts());
498 assert_eq!(count(&report, Check::RepoPathExists, Severity::Error), 1);
499 assert_eq!(count(&report, Check::RepoIsGitRepo, Severity::Error), 0);
500 assert_eq!(count(&report, Check::RepoIsGitRepo, Severity::Ok), 0);
501 assert!(report.summary.error >= 1);
502 }
503
504 #[test]
505 fn non_git_path_emits_repo_path_ok_and_git_error() {
506 let tmp = TempDir::new().unwrap();
507 let plain_dir = tmp.path().join("notes");
508 std::fs::create_dir_all(&plain_dir).unwrap();
509 let mut cfg = Config::default();
510 cfg.add_repo(
511 "notes".into(),
512 Repo {
513 path: plain_dir,
514 description: None,
515 stack: vec![],
516 },
517 )
518 .unwrap();
519 cfg.save(tmp.path()).unwrap();
520 let path = tmp.path().join(CONFIG_FILE_NAME);
521 let report = DoctorReport::run(Ok(&cfg), &path, ts());
522 assert_eq!(count(&report, Check::RepoPathExists, Severity::Ok), 1);
523 assert_eq!(count(&report, Check::RepoIsGitRepo, Severity::Error), 1);
524 }
525
526 #[test]
527 fn healthy_git_repo_emits_both_ok() {
528 let tmp = TempDir::new().unwrap();
529 let repo = init_git_repo(tmp.path(), "api");
530 let mut cfg = Config::default();
531 cfg.add_repo(
532 "api".into(),
533 Repo {
534 path: repo,
535 description: None,
536 stack: vec![],
537 },
538 )
539 .unwrap();
540 cfg.save(tmp.path()).unwrap();
541 let path = tmp.path().join(CONFIG_FILE_NAME);
542 let report = DoctorReport::run(Ok(&cfg), &path, ts());
543 assert_eq!(count(&report, Check::RepoPathExists, Severity::Ok), 1);
544 assert_eq!(count(&report, Check::RepoIsGitRepo, Severity::Ok), 1);
545 }
546
547 #[test]
548 fn dangling_workspace_member_emits_warn() {
549 let tmp = TempDir::new().unwrap();
550 let repo = init_git_repo(tmp.path(), "api");
551 let mut cfg = Config::default();
552 cfg.add_repo(
553 "api".into(),
554 Repo {
555 path: repo,
556 description: None,
557 stack: vec![],
558 },
559 )
560 .unwrap();
561 cfg.create_workspace("acme".into(), None).unwrap();
562 cfg.add_members("acme", &["api".into()]).unwrap();
563 cfg.remove_repo("api").unwrap();
566 cfg.save(tmp.path()).unwrap();
567 let path = tmp.path().join(CONFIG_FILE_NAME);
568 let report = DoctorReport::run(Ok(&cfg), &path, ts());
569 let dangling = report
570 .checks
571 .iter()
572 .filter(|f| {
573 f.check == Check::WorkspaceMembersResolve
574 && f.severity == Severity::Warn
575 && f.message.contains("api")
576 })
577 .count();
578 assert_eq!(dangling, 1);
579 assert_eq!(report.summary.error, 0);
580 }
581
582 #[test]
583 fn agent_doc_missing_emits_warn() {
584 let tmp = TempDir::new().unwrap();
585 let repo = init_git_repo(tmp.path(), "api");
586 let mut cfg = Config::default();
588 cfg.add_repo(
589 "api".into(),
590 Repo {
591 path: repo,
592 description: None,
593 stack: vec![],
594 },
595 )
596 .unwrap();
597 cfg.set_agents(Some(Agents {
598 selected: vec![AgentId::ClaudeCode],
599 }));
600 cfg.save(tmp.path()).unwrap();
601 let path = tmp.path().join(CONFIG_FILE_NAME);
602 let report = DoctorReport::run(Ok(&cfg), &path, ts());
603 assert_eq!(count(&report, Check::AgentDocPresent, Severity::Warn), 1);
604 assert_eq!(report.summary.error, 0);
605 }
606
607 #[test]
608 fn agent_doc_present_emits_ok() {
609 let tmp = TempDir::new().unwrap();
610 let repo = init_git_repo(tmp.path(), "api");
611 std::fs::write(repo.join("CLAUDE.md"), "context\n").unwrap();
612 let mut cfg = Config::default();
613 cfg.add_repo(
614 "api".into(),
615 Repo {
616 path: repo,
617 description: None,
618 stack: vec![],
619 },
620 )
621 .unwrap();
622 cfg.set_agents(Some(Agents {
623 selected: vec![AgentId::ClaudeCode],
624 }));
625 cfg.save(tmp.path()).unwrap();
626 let path = tmp.path().join(CONFIG_FILE_NAME);
627 let report = DoctorReport::run(Ok(&cfg), &path, ts());
628 assert_eq!(count(&report, Check::AgentDocPresent, Severity::Ok), 1);
629 assert_eq!(report.summary.error, 0);
630 assert_eq!(report.summary.warn, 0);
631 }
632
633 #[test]
634 fn summary_totals_match_findings() {
635 let tmp = TempDir::new().unwrap();
636 let mut cfg = Config::default();
637 cfg.set_agents(Some(Agents { selected: vec![] }));
638 cfg.save(tmp.path()).unwrap();
639 let path = tmp.path().join(CONFIG_FILE_NAME);
640 let report = DoctorReport::run(Ok(&cfg), &path, ts());
641 assert_eq!(
642 report.summary.total,
643 report.summary.ok + report.summary.warn + report.summary.error
644 );
645 assert_eq!(report.summary.total as usize, report.checks.len());
646 }
647
648 #[test]
649 fn findings_sorted_severity_desc_then_check_asc_then_target_asc() {
650 let findings = vec![
652 Finding {
653 check: Check::AgentDocPresent,
654 severity: Severity::Ok,
655 target: "z".into(),
656 message: String::new(),
657 },
658 Finding {
659 check: Check::RepoPathExists,
660 severity: Severity::Error,
661 target: "a".into(),
662 message: String::new(),
663 },
664 Finding {
665 check: Check::AgentsConfigured,
666 severity: Severity::Warn,
667 target: "b".into(),
668 message: String::new(),
669 },
670 Finding {
671 check: Check::ConfigPresent,
672 severity: Severity::Ok,
673 target: "a".into(),
674 message: String::new(),
675 },
676 ];
677 let report = assemble(findings, ts());
678 let order: Vec<_> = report
679 .checks
680 .iter()
681 .map(|f| (f.severity, f.check, f.target.clone()))
682 .collect();
683 assert_eq!(order[0].0, Severity::Error);
684 assert_eq!(order[1].0, Severity::Warn);
685 assert_eq!(order[2].0, Severity::Ok);
686 assert_eq!(order[3].0, Severity::Ok);
687 assert!(matches!(order[2].1, Check::ConfigPresent));
692 assert!(matches!(order[3].1, Check::AgentDocPresent));
693 }
694
695 #[test]
696 fn severity_ordering_error_is_max() {
697 assert!(Severity::Error > Severity::Warn);
698 assert!(Severity::Warn > Severity::Ok);
699 assert!(Severity::Error > Severity::Ok);
700 }
701
702 #[test]
703 fn json_envelope_has_documented_top_level_keys() {
704 let tmp = TempDir::new().unwrap();
705 let path = tmp.path().join(CONFIG_FILE_NAME);
706 let cfg = Config::default();
707 let report = DoctorReport::run(Ok(&cfg), &path, ts());
708 let v = serde_json::to_value(&report).unwrap();
709 assert_eq!(v["schema_version"], 1);
710 assert!(v["generated_at"].is_string());
711 assert!(v["checks"].is_array());
712 assert!(v["summary"].is_object());
713 assert!(v["summary"]["total"].is_number());
714 }
715
716 #[test]
717 fn check_serializes_as_pascal_case_variant_name() {
718 let f = Finding {
719 check: Check::RepoIsGitRepo,
720 severity: Severity::Ok,
721 target: "x".into(),
722 message: "y".into(),
723 };
724 let v = serde_json::to_value(&f).unwrap();
725 assert_eq!(v["check"], "RepoIsGitRepo");
726 assert_eq!(v["severity"], "ok");
727 }
728}