Skip to main content

the_code_graph_cli/commands/
setup_helpers.rs

1use domain::error::{CodeGraphError, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5/// Resolve the path to the settings JSON file.
6///
7/// - `global == true`  → `$HOME/.claude/settings.json`
8/// - `global == false` → `<project_root>/.claude/settings.json`
9///
10/// Returns an error when `global` is false and `project_root` is `None`.
11pub(super) fn resolve_settings_path(project_root: Option<&Path>, global: bool) -> Result<PathBuf> {
12    if global {
13        let home =
14            std::env::var("HOME").map_err(|_| CodeGraphError::Other("$HOME is not set".into()))?;
15        let home = std::fs::canonicalize(&home).map_err(|e| {
16            CodeGraphError::Other(format!("failed to canonicalize $HOME '{home}': {e}"))
17        })?;
18        Ok(home.join(".claude").join("settings.json"))
19    } else {
20        match project_root {
21            Some(root) => Ok(root.join(".claude").join("settings.json")),
22            None => Err(CodeGraphError::Other(
23                "project root is required for non-global settings".into(),
24            )),
25        }
26    }
27}
28
29/// Ensure `.code-graph/` is listed in `<project_root>/.gitignore`.
30///
31/// Returns `true` if the entry was added, `false` if it was already present.
32pub(super) fn ensure_gitignore_entry(project_root: &Path) -> Result<bool> {
33    let gitignore = project_root.join(".gitignore");
34
35    let content = if gitignore.exists() {
36        fs::read_to_string(&gitignore).map_err(|e| CodeGraphError::FileSystem {
37            path: gitignore.clone(),
38            source: e,
39        })?
40    } else {
41        String::new()
42    };
43
44    // Check for an exact line match
45    let already_present = content.lines().any(|line| line.trim() == ".code-graph/");
46
47    if already_present {
48        return Ok(false);
49    }
50
51    // Ensure there is a trailing newline before appending
52    let mut new_content = content.clone();
53    if !new_content.is_empty() && !new_content.ends_with('\n') {
54        new_content.push('\n');
55    }
56    new_content.push_str("# Code Graph data\n.code-graph/\n");
57
58    fs::write(&gitignore, new_content).map_err(|e| CodeGraphError::FileSystem {
59        path: gitignore,
60        source: e,
61    })?;
62
63    Ok(true)
64}
65
66/// Remove `.code-graph/` (and the `# Code Graph data` comment) from
67/// `<project_root>/.gitignore`.
68///
69/// Returns `true` if any lines were removed, `false` if no changes were made
70/// or the file does not exist.
71pub(super) fn remove_gitignore_entry(project_root: &Path) -> Result<bool> {
72    let gitignore = project_root.join(".gitignore");
73
74    if !gitignore.exists() {
75        return Ok(false);
76    }
77
78    let content = fs::read_to_string(&gitignore).map_err(|e| CodeGraphError::FileSystem {
79        path: gitignore.clone(),
80        source: e,
81    })?;
82
83    let original_line_count = content.lines().count();
84
85    let filtered: Vec<&str> = content
86        .lines()
87        .filter(|line| {
88            let trimmed = line.trim();
89            trimmed != ".code-graph/" && trimmed != "# Code Graph data"
90        })
91        .collect();
92
93    let removed = filtered.len() < original_line_count;
94
95    if removed {
96        let mut new_content = filtered.join("\n");
97        // Preserve a trailing newline if the original had one
98        if content.ends_with('\n') {
99            new_content.push('\n');
100        }
101        fs::write(&gitignore, new_content).map_err(|e| CodeGraphError::FileSystem {
102            path: gitignore,
103            source: e,
104        })?;
105    }
106
107    Ok(removed)
108}
109
110/// Return the full path to `binary` if it can be found on `$PATH`.
111pub(super) fn find_on_path(binary: &str) -> Option<PathBuf> {
112    which::which(binary).ok()
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use std::fs;
119    use tempfile::tempdir;
120
121    // ── resolve_settings_path ────────────────────────────────────────────────
122
123    #[test]
124    fn resolve_settings_path_local() {
125        let dir = tempdir().unwrap();
126        let root = dir.path();
127        let path = resolve_settings_path(Some(root), false).unwrap();
128        assert_eq!(path, root.join(".claude").join("settings.json"));
129    }
130
131    #[test]
132    fn resolve_settings_path_global() {
133        let path = resolve_settings_path(None, true).unwrap();
134        let home = std::env::var("HOME").unwrap();
135        assert_eq!(
136            path,
137            PathBuf::from(home).join(".claude").join("settings.json")
138        );
139    }
140
141    #[test]
142    fn resolve_settings_path_local_without_root_errors() {
143        let result = resolve_settings_path(None, false);
144        assert!(result.is_err(), "expected error when project_root is None");
145    }
146
147    // ── ensure_gitignore_entry ───────────────────────────────────────────────
148
149    #[test]
150    fn ensure_gitignore_creates_file() {
151        let dir = tempdir().unwrap();
152        let root = dir.path();
153        let result = ensure_gitignore_entry(root).unwrap();
154        assert!(result, "expected true when entry was added");
155        let content = fs::read_to_string(root.join(".gitignore")).unwrap();
156        assert!(content.contains(".code-graph/"));
157    }
158
159    #[test]
160    fn ensure_gitignore_appends() {
161        let dir = tempdir().unwrap();
162        let root = dir.path();
163        let gitignore = root.join(".gitignore");
164        fs::write(&gitignore, "target/\n").unwrap();
165
166        let result = ensure_gitignore_entry(root).unwrap();
167        assert!(result, "expected true when entry was appended");
168        let content = fs::read_to_string(&gitignore).unwrap();
169        assert!(content.contains("target/"));
170        assert!(content.contains(".code-graph/"));
171    }
172
173    #[test]
174    fn ensure_gitignore_idempotent() {
175        let dir = tempdir().unwrap();
176        let root = dir.path();
177        let gitignore = root.join(".gitignore");
178        fs::write(&gitignore, "# Code Graph data\n.code-graph/\n").unwrap();
179
180        let result = ensure_gitignore_entry(root).unwrap();
181        assert!(!result, "expected false when entry already present");
182
183        let content = fs::read_to_string(&gitignore).unwrap();
184        // Verify no duplicate was added
185        assert_eq!(content.matches(".code-graph/").count(), 1);
186    }
187
188    #[test]
189    fn ensure_gitignore_handles_no_trailing_newline() {
190        let dir = tempdir().unwrap();
191        let root = dir.path();
192        let gitignore = root.join(".gitignore");
193        // No trailing newline
194        fs::write(&gitignore, "target/").unwrap();
195
196        let result = ensure_gitignore_entry(root).unwrap();
197        assert!(result, "expected true when entry was appended");
198        let content = fs::read_to_string(&gitignore).unwrap();
199        // Verify the original line and the new entry are on separate lines
200        assert!(content.contains("target/\n"));
201        assert!(content.contains(".code-graph/"));
202    }
203
204    // ── remove_gitignore_entry ───────────────────────────────────────────────
205
206    #[test]
207    fn remove_gitignore_entry_removes_line_and_comment() {
208        let dir = tempdir().unwrap();
209        let root = dir.path();
210        let gitignore = root.join(".gitignore");
211        fs::write(&gitignore, "target/\n# Code Graph data\n.code-graph/\n").unwrap();
212
213        let result = remove_gitignore_entry(root).unwrap();
214        assert!(result, "expected true when lines were removed");
215
216        let content = fs::read_to_string(&gitignore).unwrap();
217        assert!(!content.contains(".code-graph/"));
218        assert!(!content.contains("# Code Graph data"));
219        assert!(content.contains("target/"));
220    }
221
222    #[test]
223    fn remove_gitignore_entry_noop_when_absent() {
224        let dir = tempdir().unwrap();
225        let root = dir.path();
226        let gitignore = root.join(".gitignore");
227        fs::write(&gitignore, "target/\n").unwrap();
228
229        let result = remove_gitignore_entry(root).unwrap();
230        assert!(!result, "expected false when no entry found");
231
232        let content = fs::read_to_string(&gitignore).unwrap();
233        assert_eq!(content, "target/\n");
234    }
235
236    #[test]
237    fn remove_gitignore_noop_no_file() {
238        let dir = tempdir().unwrap();
239        let root = dir.path();
240        // No .gitignore at all
241        let result = remove_gitignore_entry(root).unwrap();
242        assert!(!result, "expected false when .gitignore does not exist");
243    }
244
245    // ── find_on_path ─────────────────────────────────────────────────────────
246
247    #[test]
248    fn find_on_path_returns_some_for_existing_binary() {
249        let result = find_on_path("ls");
250        assert!(result.is_some(), "expected Some for 'ls'");
251    }
252
253    #[test]
254    fn find_on_path_returns_none_for_nonexistent() {
255        let result = find_on_path("nonexistent_binary_xyz_123");
256        assert!(result.is_none(), "expected None for nonexistent binary");
257    }
258}