1pub mod embedded;
2
3use std::path::{Path, PathBuf};
4
5use crate::error::setup_error::{
6 BinaryNotFoundSnafu, NoHomeDirectorySnafu, ReadFileSnafu, WriteFileSnafu,
7};
8use crate::error::SetupError;
9use snafu::ResultExt;
10
11const CLAUDE_MD_BEGIN: &str = "<!-- chronicle-setup-begin -->";
12const CLAUDE_MD_END: &str = "<!-- chronicle-setup-end -->";
13
14#[derive(Debug)]
16pub struct SetupOptions {
17 pub force: bool,
18 pub dry_run: bool,
19 pub skip_skills: bool,
20 pub skip_hooks: bool,
21 pub skip_claude_md: bool,
22}
23
24#[derive(Debug)]
26pub struct SetupReport {
27 pub skills_installed: Vec<PathBuf>,
28 pub hooks_installed: Vec<PathBuf>,
29 pub claude_md_updated: bool,
30}
31
32pub fn run_setup(options: &SetupOptions) -> Result<SetupReport, SetupError> {
34 let home = home_dir()?;
35
36 verify_binary_on_path()?;
38
39 let mut skills_installed = Vec::new();
41 if !options.skip_skills {
42 skills_installed = install_skills(&home, options)?;
43 }
44
45 let mut hooks_installed = Vec::new();
47 if !options.skip_hooks {
48 hooks_installed = install_hooks(&home, options)?;
49 }
50
51 let claude_md_updated = if !options.skip_claude_md {
53 update_claude_md(&home, options)?
54 } else {
55 false
56 };
57
58 Ok(SetupReport {
59 skills_installed,
60 hooks_installed,
61 claude_md_updated,
62 })
63}
64
65fn home_dir() -> Result<PathBuf, SetupError> {
66 std::env::var("HOME")
67 .ok()
68 .map(PathBuf::from)
69 .filter(|p| p.is_absolute())
70 .ok_or_else(|| NoHomeDirectorySnafu.build())
71}
72
73fn verify_binary_on_path() -> Result<(), SetupError> {
75 match std::process::Command::new("git-chronicle")
76 .arg("--version")
77 .output()
78 {
79 Ok(output) if output.status.success() => Ok(()),
80 _ => BinaryNotFoundSnafu.fail(),
81 }
82}
83
84fn install_skills(home: &Path, options: &SetupOptions) -> Result<Vec<PathBuf>, SetupError> {
86 let skills = [
87 ("context/SKILL.md", embedded::SKILL_CONTEXT),
88 ("annotate/SKILL.md", embedded::SKILL_ANNOTATE),
89 ];
90
91 let base = home.join(".claude").join("skills").join("chronicle");
92 let mut installed = Vec::new();
93
94 for (rel_path, content) in &skills {
95 let full_path = base.join(rel_path);
96 if options.dry_run {
97 eprintln!("[dry-run] Would create {}", full_path.display());
98 } else {
99 if let Some(parent) = full_path.parent() {
100 std::fs::create_dir_all(parent).context(WriteFileSnafu {
101 path: parent.display().to_string(),
102 })?;
103 }
104 std::fs::write(&full_path, content).context(WriteFileSnafu {
105 path: full_path.display().to_string(),
106 })?;
107 }
108 installed.push(full_path);
109 }
110
111 Ok(installed)
112}
113
114fn install_hooks(home: &Path, options: &SetupOptions) -> Result<Vec<PathBuf>, SetupError> {
116 let hooks = [
117 (
118 "post-tool-use/chronicle-annotate-reminder.sh",
119 embedded::HOOK_ANNOTATE_REMINDER,
120 ),
121 (
122 "pre-tool-use/chronicle-read-context-hint.sh",
123 embedded::HOOK_READ_CONTEXT_HINT,
124 ),
125 ];
126
127 let base = home.join(".claude").join("hooks");
128 let mut installed = Vec::new();
129
130 for (rel_path, content) in &hooks {
131 let full_path = base.join(rel_path);
132 if options.dry_run {
133 eprintln!("[dry-run] Would create {}", full_path.display());
134 } else {
135 if let Some(parent) = full_path.parent() {
136 std::fs::create_dir_all(parent).context(WriteFileSnafu {
137 path: parent.display().to_string(),
138 })?;
139 }
140 std::fs::write(&full_path, content).context(WriteFileSnafu {
141 path: full_path.display().to_string(),
142 })?;
143
144 #[cfg(unix)]
145 {
146 use std::os::unix::fs::PermissionsExt;
147 let perms = std::fs::Permissions::from_mode(0o755);
148 std::fs::set_permissions(&full_path, perms).context(WriteFileSnafu {
149 path: full_path.display().to_string(),
150 })?;
151 }
152 }
153 installed.push(full_path);
154 }
155
156 Ok(installed)
157}
158
159fn update_claude_md(home: &Path, options: &SetupOptions) -> Result<bool, SetupError> {
161 let claude_md_path = home.join(".claude").join("CLAUDE.md");
162
163 if options.dry_run {
164 if claude_md_path.exists() {
165 eprintln!(
166 "[dry-run] Would update {} (add/replace Chronicle section)",
167 claude_md_path.display()
168 );
169 } else {
170 eprintln!(
171 "[dry-run] Would create {} with Chronicle section",
172 claude_md_path.display()
173 );
174 }
175 return Ok(true);
176 }
177
178 if let Some(parent) = claude_md_path.parent() {
179 std::fs::create_dir_all(parent).context(WriteFileSnafu {
180 path: parent.display().to_string(),
181 })?;
182 }
183
184 let existing = if claude_md_path.exists() {
185 std::fs::read_to_string(&claude_md_path).context(ReadFileSnafu {
186 path: claude_md_path.display().to_string(),
187 })?
188 } else {
189 String::new()
190 };
191
192 let snippet = embedded::CLAUDE_MD_SNIPPET;
193 let new_content = apply_marker_content(&existing, snippet);
194
195 std::fs::write(&claude_md_path, &new_content).context(WriteFileSnafu {
196 path: claude_md_path.display().to_string(),
197 })?;
198
199 Ok(true)
200}
201
202pub fn apply_marker_content(existing: &str, snippet: &str) -> String {
207 if existing.contains(CLAUDE_MD_BEGIN) && existing.contains(CLAUDE_MD_END) {
208 let mut result = String::new();
210 let mut in_section = false;
211 let mut replaced = false;
212 for line in existing.lines() {
213 if line.contains(CLAUDE_MD_BEGIN) {
214 in_section = true;
215 if !replaced {
216 result.push_str(snippet);
217 result.push('\n');
218 replaced = true;
219 }
220 continue;
221 }
222 if line.contains(CLAUDE_MD_END) {
223 in_section = false;
224 continue;
225 }
226 if !in_section {
227 result.push_str(line);
228 result.push('\n');
229 }
230 }
231 result
232 } else if existing.is_empty() {
233 format!("{snippet}\n")
234 } else {
235 let mut content = existing.to_string();
236 if !content.ends_with('\n') {
237 content.push('\n');
238 }
239 content.push('\n');
240 content.push_str(snippet);
241 content.push('\n');
242 content
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn test_apply_marker_empty_file() {
252 let result = apply_marker_content(
253 "",
254 "<!-- chronicle-setup-begin -->\nHello\n<!-- chronicle-setup-end -->",
255 );
256 assert!(result.contains("<!-- chronicle-setup-begin -->"));
257 assert!(result.contains("Hello"));
258 assert!(result.contains("<!-- chronicle-setup-end -->"));
259 }
260
261 #[test]
262 fn test_apply_marker_no_markers() {
263 let existing = "# My Project\n\nSome content.\n";
264 let snippet =
265 "<!-- chronicle-setup-begin -->\nChronicle section\n<!-- chronicle-setup-end -->";
266 let result = apply_marker_content(existing, snippet);
267 assert!(result.starts_with("# My Project"));
268 assert!(result.contains("Chronicle section"));
269 assert!(result.contains("<!-- chronicle-setup-begin -->"));
270 }
271
272 #[test]
273 fn test_apply_marker_existing_markers() {
274 let existing = "# My Project\n\n<!-- chronicle-setup-begin -->\nOld content\n<!-- chronicle-setup-end -->\n\nOther stuff\n";
275 let snippet = "<!-- chronicle-setup-begin -->\nNew content\n<!-- chronicle-setup-end -->";
276 let result = apply_marker_content(existing, snippet);
277 assert!(result.contains("New content"));
278 assert!(!result.contains("Old content"));
279 assert!(result.contains("Other stuff"));
280 }
281
282 #[test]
283 fn test_apply_marker_idempotent() {
284 let snippet =
285 "<!-- chronicle-setup-begin -->\nChronicle section\n<!-- chronicle-setup-end -->";
286 let first = apply_marker_content("", snippet);
287 let second = apply_marker_content(&first, snippet);
288 assert_eq!(first, second);
289 }
290}