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_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
66fn 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
76fn 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
97fn 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
129fn check_global_setup() -> Vec<DoctorCheck> {
131 let mut checks = Vec::new();
132
133 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
159fn 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}