Skip to main content

chronicle/setup/
mod.rs

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/// Options for the setup command.
15#[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/// Report of what setup did.
25#[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
32/// Run the full setup process.
33pub fn run_setup(options: &SetupOptions) -> Result<SetupReport, SetupError> {
34    let home = home_dir()?;
35
36    // 1. Verify binary on PATH
37    verify_binary_on_path()?;
38
39    // 2. Install skills
40    let mut skills_installed = Vec::new();
41    if !options.skip_skills {
42        skills_installed = install_skills(&home, options)?;
43    }
44
45    // 3. Install hooks
46    let mut hooks_installed = Vec::new();
47    if !options.skip_hooks {
48        hooks_installed = install_hooks(&home, options)?;
49    }
50
51    // 4. Update CLAUDE.md
52    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
73/// Verify that git-chronicle is accessible on PATH.
74fn 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
84/// Install skill files to ~/.claude/skills/chronicle/.
85fn 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
114/// Install hook files to ~/.claude/hooks/.
115fn 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
159/// Update ~/.claude/CLAUDE.md with marker-delimited Chronicle section.
160fn 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
202/// Apply marker-delimited content to a string.
203/// - If markers exist, replace content between them.
204/// - If no markers, append the content.
205/// - If the string is empty, just use the content.
206pub fn apply_marker_content(existing: &str, snippet: &str) -> String {
207    if existing.contains(CLAUDE_MD_BEGIN) && existing.contains(CLAUDE_MD_END) {
208        // Replace content between markers (inclusive)
209        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}