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;
19
20/// Current schema version of the [`DoctorReport`] JSON envelope. Additive-only
21/// at `1`; any breaking change bumps this.
22pub const DOCTOR_SCHEMA_VERSION: u32 = 1;
23
24/// Severity of a single [`Finding`]. Ordering is `Error > Warn > Ok` so the
25/// sort in `DoctorReport::run` puts the most pressing findings first.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "lowercase")]
28pub enum Severity {
29    /// The check passed for this target.
30    Ok,
31    /// The check surfaced a sub-optimal-but-non-broken state.
32    Warn,
33    /// The check surfaced a broken state that gates exit code `1`.
34    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/// Closed catalog of the v1 health checks. Adding a variant is a deliberate
60/// schema extension — downstream consumers branch on this enum's string value.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
62pub enum Check {
63    /// Config file exists at the resolved config dir.
64    ConfigPresent,
65    /// Config file parses as TOML. Only run when `ConfigPresent` passed.
66    ConfigParse,
67    /// `[agents]` section is present in the config.
68    AgentsConfigured,
69    /// `[settings].projects_root`, if set, points at an existing directory.
70    ProjectsRootExists,
71    /// Per repo: the registered path exists on disk.
72    RepoPathExists,
73    /// Per repo: the path opens as a `git2::Repository`. Only run when
74    /// `RepoPathExists` passed for the same repo.
75    RepoIsGitRepo,
76    /// Per workspace member: the member name resolves to a registered repo.
77    WorkspaceMembersResolve,
78    /// Per repo × per selected agent: at least one file matches the agent's
79    /// pattern set. Only run when `AgentsConfigured` passed and the
80    /// selection is non-empty.
81    AgentDocPresent,
82}
83
84/// One row in the report.
85///
86/// `target` is opaque to the renderer — it's the string the human / agent
87/// reads to know which repo / workspace / config path the finding is about.
88/// By convention: a bare name (`"api"`, `"acme"`), a `"<repo> / <agent>"`
89/// pair, or a config-file path.
90#[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/// Tally of findings by severity. `total == ok + warn + error == checks.len()`.
99#[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/// Top-level payload emitted by `repograph doctor`. `schema_version` is the
108/// contract — additive-only at `1`; breaking changes bump it.
109#[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    /// Run every check applicable to the given config-load outcome and return
119    /// a sorted [`DoctorReport`].
120    ///
121    /// - `config_load` is `Ok(&config)` when the config loaded (including the
122    ///   "missing file → empty config" case), or `Err(&err)` when the load
123    ///   itself surfaced an error (malformed TOML, I/O error other than
124    ///   `NotFound`). The binary maps `PermissionDenied` to exit `4` *before*
125    ///   calling this function; what reaches here is `ConfigParse` or other
126    ///   non-permission `Io` failures.
127    /// - `config_path` is the file the `ConfigPresent` check probes and
128    ///   reports in its `target` field.
129    /// - `generated_at` is the RFC 3339 UTC timestamp the binary stamps via
130    ///   the `time` crate (core stays free of time deps, same pattern as
131    ///   `context-command`).
132    #[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        // Synthesize a parse failure using a real RepographError::ConfigParse.
421        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        // Catalog short-circuits: no per-repo checks even if config might have them.
426        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        // Forcibly tombstone: remove `api` from registry so the workspace
564        // member becomes dangling.
565        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        // No CLAUDE.md written.
587        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        // Synthesize a report with mixed severities and verify the sort order.
651        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        // Ok ties: check name ascending — ConfigPresent < AgentDocPresent in enum order
688        // (variants declared in spec order, not alphabetical — adjust the test if needed).
689        // Re-check sort: derive `Ord` on Check sorts by variant declaration order.
690        // ConfigPresent is declared first, so it comes before AgentDocPresent.
691        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}