1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::Result;
6use crate::git::GitOps;
7
8#[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#[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#[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
41pub 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
67fn 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
77fn 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
98fn 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
130fn check_credentials() -> DoctorCheck {
132 use crate::config::user_config::{ProviderType, UserConfig};
133
134 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 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
186fn check_global_setup() -> Vec<DoctorCheck> {
188 use crate::config::user_config::UserConfig;
189
190 let mut checks = Vec::new();
191
192 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 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
253fn 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}