Skip to main content

chronicle/
doctor.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::Result;
6use crate::git::GitOps;
7
8/// Status of a single doctor check.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum DoctorStatus {
12    Pass,
13    Warn,
14    Fail,
15}
16
17/// Result of a single doctor check.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct DoctorCheck {
20    pub name: String,
21    pub status: DoctorStatus,
22    pub message: String,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub fix_hint: Option<String>,
25}
26
27/// Full doctor report.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct DoctorReport {
30    pub version: String,
31    pub checks: Vec<DoctorCheck>,
32    pub overall: DoctorStatus,
33}
34
35impl DoctorReport {
36    pub fn has_failures(&self) -> bool {
37        self.overall == DoctorStatus::Fail
38    }
39}
40
41/// Run all doctor checks and produce a report.
42pub fn run_doctor(git_ops: &dyn GitOps, git_dir: &Path) -> Result<DoctorReport> {
43    let mut checks = vec![
44        check_version(),
45        check_notes_ref(git_ops),
46        check_hooks(git_dir),
47        check_config(git_ops),
48    ];
49    checks.extend(check_global_setup());
50
51    let overall = if checks.iter().any(|c| c.status == DoctorStatus::Fail) {
52        DoctorStatus::Fail
53    } else if checks.iter().any(|c| c.status == DoctorStatus::Warn) {
54        DoctorStatus::Warn
55    } else {
56        DoctorStatus::Pass
57    };
58
59    Ok(DoctorReport {
60        version: env!("CARGO_PKG_VERSION").to_string(),
61        checks,
62        overall,
63    })
64}
65
66/// Check: report binary version (always passes).
67fn check_version() -> DoctorCheck {
68    DoctorCheck {
69        name: "version".to_string(),
70        status: DoctorStatus::Pass,
71        message: format!("chronicle {}", env!("CARGO_PKG_VERSION")),
72        fix_hint: None,
73    }
74}
75
76/// Check: notes ref exists.
77fn check_notes_ref(git_ops: &dyn GitOps) -> DoctorCheck {
78    match git_ops.resolve_ref("refs/notes/chronicle") {
79        Ok(_) => DoctorCheck {
80            name: "notes_ref".to_string(),
81            status: DoctorStatus::Pass,
82            message: "refs/notes/chronicle exists".to_string(),
83            fix_hint: None,
84        },
85        Err(_) => DoctorCheck {
86            name: "notes_ref".to_string(),
87            status: DoctorStatus::Warn,
88            message: "refs/notes/chronicle not found (no annotations yet)".to_string(),
89            fix_hint: Some(
90                "Run `git chronicle annotate --commit HEAD` to create the first annotation."
91                    .to_string(),
92            ),
93        },
94    }
95}
96
97/// Check: hooks installed.
98fn check_hooks(git_dir: &Path) -> DoctorCheck {
99    let hooks_dir = git_dir.join("hooks");
100    let post_commit = hooks_dir.join("post-commit");
101
102    if post_commit.exists() {
103        let content = std::fs::read_to_string(&post_commit).unwrap_or_default();
104        if content.contains("chronicle") {
105            DoctorCheck {
106                name: "hooks".to_string(),
107                status: DoctorStatus::Pass,
108                message: "post-commit hook installed".to_string(),
109                fix_hint: None,
110            }
111        } else {
112            DoctorCheck {
113                name: "hooks".to_string(),
114                status: DoctorStatus::Warn,
115                message: "post-commit hook exists but does not reference chronicle".to_string(),
116                fix_hint: Some("Run `git chronicle init` to reinstall hooks.".to_string()),
117            }
118        }
119    } else {
120        DoctorCheck {
121            name: "hooks".to_string(),
122            status: DoctorStatus::Fail,
123            message: "post-commit hook not installed".to_string(),
124            fix_hint: Some("Run `git chronicle init` to install hooks.".to_string()),
125        }
126    }
127}
128
129/// Check: global setup (skills, hooks).
130fn check_global_setup() -> Vec<DoctorCheck> {
131    let mut checks = Vec::new();
132
133    // Check skills directory
134    if let Ok(home) = std::env::var("HOME") {
135        let skills_dir = PathBuf::from(&home)
136            .join(".claude")
137            .join("skills")
138            .join("chronicle");
139        if skills_dir.exists() {
140            checks.push(DoctorCheck {
141                name: "global_skills".to_string(),
142                status: DoctorStatus::Pass,
143                message: "Claude Code skills installed".to_string(),
144                fix_hint: None,
145            });
146        } else {
147            checks.push(DoctorCheck {
148                name: "global_skills".to_string(),
149                status: DoctorStatus::Warn,
150                message: "Claude Code skills not installed".to_string(),
151                fix_hint: Some("Run `git chronicle setup` to install skills.".to_string()),
152            });
153        }
154    }
155
156    checks
157}
158
159/// Check: chronicle config is valid.
160fn check_config(git_ops: &dyn GitOps) -> DoctorCheck {
161    match git_ops.config_get("chronicle.enabled") {
162        Ok(Some(val)) if val == "true" || val == "1" => DoctorCheck {
163            name: "config".to_string(),
164            status: DoctorStatus::Pass,
165            message: "chronicle is enabled".to_string(),
166            fix_hint: None,
167        },
168        Ok(_) => DoctorCheck {
169            name: "config".to_string(),
170            status: DoctorStatus::Fail,
171            message: "chronicle is not enabled in git config".to_string(),
172            fix_hint: Some("Run `git chronicle init` to initialize.".to_string()),
173        },
174        Err(_) => DoctorCheck {
175            name: "config".to_string(),
176            status: DoctorStatus::Fail,
177            message: "could not read git config".to_string(),
178            fix_hint: Some("Run `git chronicle init` to initialize.".to_string()),
179        },
180    }
181}