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_credentials(),
48        check_config(git_ops),
49    ];
50    checks.extend(check_global_setup());
51
52    let overall = if checks.iter().any(|c| c.status == DoctorStatus::Fail) {
53        DoctorStatus::Fail
54    } else if checks.iter().any(|c| c.status == DoctorStatus::Warn) {
55        DoctorStatus::Warn
56    } else {
57        DoctorStatus::Pass
58    };
59
60    Ok(DoctorReport {
61        version: env!("CARGO_PKG_VERSION").to_string(),
62        checks,
63        overall,
64    })
65}
66
67/// Check: report binary version (always passes).
68fn check_version() -> DoctorCheck {
69    DoctorCheck {
70        name: "version".to_string(),
71        status: DoctorStatus::Pass,
72        message: format!("chronicle {}", env!("CARGO_PKG_VERSION")),
73        fix_hint: None,
74    }
75}
76
77/// Check: notes ref exists.
78fn check_notes_ref(git_ops: &dyn GitOps) -> DoctorCheck {
79    match git_ops.resolve_ref("refs/notes/chronicle") {
80        Ok(_) => DoctorCheck {
81            name: "notes_ref".to_string(),
82            status: DoctorStatus::Pass,
83            message: "refs/notes/chronicle exists".to_string(),
84            fix_hint: None,
85        },
86        Err(_) => DoctorCheck {
87            name: "notes_ref".to_string(),
88            status: DoctorStatus::Warn,
89            message: "refs/notes/chronicle not found (no annotations yet)".to_string(),
90            fix_hint: Some(
91                "Run `git chronicle annotate --commit HEAD` to create the first annotation."
92                    .to_string(),
93            ),
94        },
95    }
96}
97
98/// Check: hooks installed.
99fn check_hooks(git_dir: &Path) -> DoctorCheck {
100    let hooks_dir = git_dir.join("hooks");
101    let post_commit = hooks_dir.join("post-commit");
102
103    if post_commit.exists() {
104        let content = std::fs::read_to_string(&post_commit).unwrap_or_default();
105        if content.contains("chronicle") {
106            DoctorCheck {
107                name: "hooks".to_string(),
108                status: DoctorStatus::Pass,
109                message: "post-commit hook installed".to_string(),
110                fix_hint: None,
111            }
112        } else {
113            DoctorCheck {
114                name: "hooks".to_string(),
115                status: DoctorStatus::Warn,
116                message: "post-commit hook exists but does not reference chronicle".to_string(),
117                fix_hint: Some("Run `git chronicle init` to reinstall hooks.".to_string()),
118            }
119        }
120    } else {
121        DoctorCheck {
122            name: "hooks".to_string(),
123            status: DoctorStatus::Fail,
124            message: "post-commit hook not installed".to_string(),
125            fix_hint: Some("Run `git chronicle init` to install hooks.".to_string()),
126        }
127    }
128}
129
130/// Check: API credentials available.
131fn check_credentials() -> DoctorCheck {
132    use crate::config::user_config::{ProviderType, UserConfig};
133
134    // Check user config first
135    if let Ok(Some(config)) = UserConfig::load() {
136        match config.provider.provider_type {
137            ProviderType::ClaudeCode => {
138                return DoctorCheck {
139                    name: "credentials".to_string(),
140                    status: DoctorStatus::Pass,
141                    message: "provider: claude-code (uses Claude Code auth)".to_string(),
142                    fix_hint: None,
143                };
144            }
145            ProviderType::Anthropic => {
146                if std::env::var("ANTHROPIC_API_KEY").is_ok() {
147                    return DoctorCheck {
148                        name: "credentials".to_string(),
149                        status: DoctorStatus::Pass,
150                        message: "provider: anthropic, ANTHROPIC_API_KEY found".to_string(),
151                        fix_hint: None,
152                    };
153                } else {
154                    return DoctorCheck {
155                        name: "credentials".to_string(),
156                        status: DoctorStatus::Fail,
157                        message: "provider: anthropic, but ANTHROPIC_API_KEY not set".to_string(),
158                        fix_hint: Some(
159                            "Set the ANTHROPIC_API_KEY environment variable.".to_string(),
160                        ),
161                    };
162                }
163            }
164            ProviderType::None => {}
165        }
166    }
167
168    // Fall back to env var check
169    if std::env::var("ANTHROPIC_API_KEY").is_ok() {
170        DoctorCheck {
171            name: "credentials".to_string(),
172            status: DoctorStatus::Pass,
173            message: "ANTHROPIC_API_KEY found".to_string(),
174            fix_hint: None,
175        }
176    } else {
177        DoctorCheck {
178            name: "credentials".to_string(),
179            status: DoctorStatus::Fail,
180            message: "no LLM provider configured".to_string(),
181            fix_hint: Some("Run `git chronicle setup` to configure a provider.".to_string()),
182        }
183    }
184}
185
186/// Check: global setup (user config, skills, hooks).
187fn check_global_setup() -> Vec<DoctorCheck> {
188    use crate::config::user_config::UserConfig;
189
190    let mut checks = Vec::new();
191
192    // Check user config exists
193    match UserConfig::load() {
194        Ok(Some(config)) => {
195            checks.push(DoctorCheck {
196                name: "global_config".to_string(),
197                status: DoctorStatus::Pass,
198                message: format!(
199                    "~/.git-chronicle.toml exists (provider: {})",
200                    config.provider.provider_type
201                ),
202                fix_hint: None,
203            });
204        }
205        Ok(None) => {
206            checks.push(DoctorCheck {
207                name: "global_config".to_string(),
208                status: DoctorStatus::Warn,
209                message: "~/.git-chronicle.toml not found".to_string(),
210                fix_hint: Some(
211                    "Run `git chronicle setup` to configure Chronicle globally.".to_string(),
212                ),
213            });
214        }
215        Err(e) => {
216            checks.push(DoctorCheck {
217                name: "global_config".to_string(),
218                status: DoctorStatus::Fail,
219                message: format!("~/.git-chronicle.toml parse error: {e}"),
220                fix_hint: Some(
221                    "Run `git chronicle setup --force` to recreate the config file.".to_string(),
222                ),
223            });
224        }
225    }
226
227    // Check skills directory
228    if let Ok(home) = std::env::var("HOME") {
229        let skills_dir = PathBuf::from(&home)
230            .join(".claude")
231            .join("skills")
232            .join("chronicle");
233        if skills_dir.exists() {
234            checks.push(DoctorCheck {
235                name: "global_skills".to_string(),
236                status: DoctorStatus::Pass,
237                message: "Claude Code skills installed".to_string(),
238                fix_hint: None,
239            });
240        } else {
241            checks.push(DoctorCheck {
242                name: "global_skills".to_string(),
243                status: DoctorStatus::Warn,
244                message: "Claude Code skills not installed".to_string(),
245                fix_hint: Some("Run `git chronicle setup` to install skills.".to_string()),
246            });
247        }
248    }
249
250    checks
251}
252
253/// Check: chronicle config is valid.
254fn check_config(git_ops: &dyn GitOps) -> DoctorCheck {
255    match git_ops.config_get("chronicle.enabled") {
256        Ok(Some(val)) if val == "true" || val == "1" => DoctorCheck {
257            name: "config".to_string(),
258            status: DoctorStatus::Pass,
259            message: "chronicle is enabled".to_string(),
260            fix_hint: None,
261        },
262        Ok(_) => DoctorCheck {
263            name: "config".to_string(),
264            status: DoctorStatus::Fail,
265            message: "chronicle is not enabled in git config".to_string(),
266            fix_hint: Some("Run `git chronicle init` to initialize.".to_string()),
267        },
268        Err(_) => DoctorCheck {
269            name: "config".to_string(),
270            status: DoctorStatus::Fail,
271            message: "could not read git config".to_string(),
272            fix_hint: Some("Run `git chronicle init` to initialize.".to_string()),
273        },
274    }
275}