Skip to main content

repograph_core/
doctor.rs

1//! Read-only health checks against the on-disk config.
2//!
3//! `DoctorReport::run` walks a closed catalog of [`Check`]s against a loaded
4//! [`Config`] and emits one [`Finding`] per check per target.
5//!
6//! Every check is read-only; no config writes, no network operations, no
7//! `git fetch`. The report is the contract: a stable, versioned envelope
8//! downstream consumers (CI health gates, the future MCP server) can parse
9//! without ambiguity.
10
11use 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
21/// Current schema version of the [`DoctorReport`] JSON envelope. Additive-only
22/// at `1`; any breaking change bumps this.
23pub const DOCTOR_SCHEMA_VERSION: u32 = 1;
24
25/// Severity of a single [`Finding`]. Ordering is `Error > Warn > Ok` so the
26/// sort in `DoctorReport::run` puts the most pressing findings first.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[serde(rename_all = "lowercase")]
29pub enum Severity {
30    /// The check passed for this target.
31    Ok,
32    /// The check surfaced a sub-optimal-but-non-broken state.
33    Warn,
34    /// The check surfaced a broken state that gates exit code `1`.
35    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/// Closed catalog of the v1 health checks. Adding a variant is a deliberate
61/// schema extension — downstream consumers branch on this enum's string value.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
63pub enum Check {
64    /// Config file exists at the resolved config dir.
65    ConfigPresent,
66    /// Config file parses as TOML. Only run when `ConfigPresent` passed.
67    ConfigParse,
68    /// `[agents]` section is present in the config.
69    AgentsConfigured,
70    /// `[settings].projects_root`, if set, points at an existing directory.
71    ProjectsRootExists,
72    /// Per repo: the registered path exists on disk.
73    RepoPathExists,
74    /// Per repo: the path opens as a `git2::Repository`. Only run when
75    /// `RepoPathExists` passed for the same repo.
76    RepoIsGitRepo,
77    /// Per workspace member: the member name resolves to a registered repo.
78    WorkspaceMembersResolve,
79    /// Per repo × per selected agent: at least one file matches the agent's
80    /// pattern set. Only run when `AgentsConfigured` passed and the
81    /// selection is non-empty.
82    AgentDocPresent,
83    /// Per selected agent × capability: the installed skill artifact exists and
84    /// its version stamp matches the running binary. Read-only — reports drift,
85    /// never repairs it. Appended by the binary via
86    /// [`DoctorReport::with_skill_artifact_check`].
87    SkillArtifactFresh,
88    /// The cross-repo search index exists and is current relative to every
89    /// registered repo's HEAD. Appended by the binary via
90    /// [`DoctorReport::with_index_check`].
91    SearchIndex,
92}
93
94/// One row in the report.
95///
96/// `target` is opaque to the renderer — it's the string the human / agent
97/// reads to know which repo / workspace / config path the finding is about.
98/// By convention: a bare name (`"api"`, `"acme"`), a `"<repo> / <agent>"`
99/// pair, or a config-file path.
100#[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/// Tally of findings by severity. `total == ok + warn + error == checks.len()`.
109#[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/// Top-level payload emitted by `repograph doctor`. `schema_version` is the
118/// contract — additive-only at `1`; breaking changes bump it.
119#[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    /// Run every check applicable to the given config-load outcome and return
129    /// a sorted [`DoctorReport`].
130    ///
131    /// - `config_load` is `Ok(&config)` when the config loaded (including the
132    ///   "missing file → empty config" case), or `Err(&err)` when the load
133    ///   itself surfaced an error (malformed TOML, I/O error other than
134    ///   `NotFound`). The binary maps `PermissionDenied` to exit `4` *before*
135    ///   calling this function; what reaches here is `ConfigParse` or other
136    ///   non-permission `Io` failures.
137    /// - `config_path` is the file the `ConfigPresent` check probes and
138    ///   reports in its `target` field.
139    /// - `generated_at` is the RFC 3339 UTC timestamp the binary stamps via
140    ///   the `time` crate (core stays free of time deps, same pattern as
141    ///   `context-command`).
142    #[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    /// Append the search-index health finding, then re-sort and re-tally.
194    ///
195    /// The binary owns data-dir resolution and the [`IndexStatus`] probe, so it
196    /// computes the status and folds it into the report here. The finding is
197    /// `ok` when the index is present and current, `warn` when it is missing,
198    /// unreadable, or stale relative to one or more repos' HEAD.
199    #[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    /// Fold in a read-only freshness check for the installed skill artifacts.
208    ///
209    /// For each selected agent (with a writer) and each of its capabilities,
210    /// resolves the expected install path under both user and project scope,
211    /// reads whichever exists, and compares its version stamp to the running
212    /// binary's [`crate::agent_artifact::ARTIFACT_BODY_VERSION`]. Reports `ok`
213    /// when current, `warn` when missing or stale — and never writes, creates,
214    /// or repairs the artifact. When `selected` is empty (no `[agents]`), no
215    /// findings are produced. The binary owns `home`/`cwd` resolution and folds
216    /// the result in here, mirroring [`Self::with_index_check`].
217    #[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
232/// Build the per-(agent, capability) freshness findings. Pure and read-only.
233fn 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            // The artifact may live at user or project scope; accept either.
251            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        // Synthesize a parse failure using a real RepographError::ConfigParse.
576        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        // Catalog short-circuits: no per-repo checks even if config might have them.
581        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        // Forcibly tombstone: remove `api` from registry so the workspace
719        // member becomes dangling.
720        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        // No CLAUDE.md written.
742        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        // Synthesize a report with mixed severities and verify the sort order.
806        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        // Ok ties: check name ascending — ConfigPresent < AgentDocPresent in enum order
843        // (variants declared in spec order, not alphabetical — adjust the test if needed).
844        // Re-check sort: derive `Ord` on Check sorts by variant declaration order.
845        // ConfigPresent is declared first, so it comes before AgentDocPresent.
846        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    // ---- skill-artifact freshness check ----
945
946    /// Write the current-version artifact for `(agent, capability)` at its
947    /// user-scope path under `home`.
948    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        // claude-code yields two capabilities, both missing.
969        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        // Install a deliberately old-version block for the consumer skill.
999        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}