the_code_graph_cli/commands/
setup_helpers.rs1use domain::error::{CodeGraphError, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5pub(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
29pub(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 let already_present = content.lines().any(|line| line.trim() == ".code-graph/");
46
47 if already_present {
48 return Ok(false);
49 }
50
51 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
66pub(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 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
110pub(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 #[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 #[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 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 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 assert!(content.contains("target/\n"));
201 assert!(content.contains(".code-graph/"));
202 }
203
204 #[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 let result = remove_gitignore_entry(root).unwrap();
242 assert!(!result, "expected false when .gitignore does not exist");
243 }
244
245 #[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}