1use anyhow::{Context, Result, anyhow};
2use atomicwrites::{AtomicFile, OverwriteBehavior};
3use colored::Colorize;
4use serde_json::{Value, json};
5use std::collections::HashSet;
6use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11#[derive(Debug, Clone)]
12pub struct InjectionSummary {
13 pub settings_path: PathBuf,
14 pub added_additional_dirs: Vec<PathBuf>,
15 pub added_allow_rules: Vec<String>,
16 pub already_present_additional_dirs: Vec<PathBuf>,
17 pub already_present_allow_rules: Vec<String>,
18 pub warn_conflicting_denies: Vec<String>,
19}
20
21pub fn inject_additional_directories(repo_root: &Path) -> Result<InjectionSummary> {
27 let settings_path = get_local_settings_path(repo_root);
28 ensure_parent_dir(&settings_path)?;
29
30 let td = repo_root.join(".thoughts-data");
32 let canonical_thoughts_data = match fs::canonicalize(&td) {
33 Ok(p) => p,
34 Err(e) => {
35 eprintln!(
36 "{}: Failed to canonicalize {} ({}). Falling back to non-canonical path.",
37 "Warning".yellow(),
38 td.display(),
39 e
40 );
41 td.clone()
42 }
43 };
44
45 let ReadOutcome {
46 mut value,
47 had_valid_json,
48 } = read_or_init_settings(&settings_path)?;
49
50 ensure_permissions_scaffold(&mut value);
52
53 let mut added_additional_dirs = Vec::new();
55 let mut already_present_additional_dirs = Vec::new();
56 let mut added_allow_rules = Vec::new();
57 let mut already_present_allow_rules = Vec::new();
58
59 {
61 let permissions = value.get_mut("permissions").unwrap();
62
63 if !permissions
65 .get("additionalDirectories")
66 .map(|x| x.is_array())
67 .unwrap_or(false)
68 {
69 permissions["additionalDirectories"] = json!([]);
70 }
71
72 let add_dirs = permissions["additionalDirectories"].as_array_mut().unwrap();
73
74 let mut existing_add_dirs: HashSet<String> = add_dirs
76 .iter()
77 .filter_map(|v| v.as_str().map(|s| s.to_string()))
78 .collect();
79
80 let dir_str = canonical_thoughts_data.to_string_lossy().to_string();
82 if existing_add_dirs.contains(&dir_str) {
83 already_present_additional_dirs.push(canonical_thoughts_data.clone());
84 } else {
85 add_dirs.push(Value::String(dir_str.clone()));
86 existing_add_dirs.insert(dir_str);
87 added_additional_dirs.push(canonical_thoughts_data.clone());
88 }
89 }
90
91 let warn_conflicting_denies = {
93 let permissions = value.get_mut("permissions").unwrap();
94
95 if !permissions
97 .get("allow")
98 .map(|x| x.is_array())
99 .unwrap_or(false)
100 {
101 permissions["allow"] = json!([]);
102 }
103
104 let allow = permissions["allow"].as_array_mut().unwrap();
105
106 let mut existing_allow: HashSet<String> = allow
108 .iter()
109 .filter_map(|v| v.as_str().map(|s| s.to_string()))
110 .collect();
111
112 let required_rules = vec![
114 "Read(thoughts/**)".to_string(),
115 "Read(context/**)".to_string(),
116 "Read(references/**)".to_string(),
117 ];
118
119 for r in required_rules {
120 if existing_allow.contains(&r) {
121 already_present_allow_rules.push(r);
122 } else {
123 allow.push(Value::String(r.clone()));
124 existing_allow.insert(r.clone());
125 added_allow_rules.push(r);
126 }
127 }
128
129 collect_conflicting_denies(permissions, &existing_allow)
131 };
132
133 if !added_additional_dirs.is_empty() || !added_allow_rules.is_empty() {
135 if had_valid_json && settings_path.exists() {
136 backup_valid_to_bak(&settings_path)
137 .with_context(|| format!("Failed to create backup for {:?}", settings_path))?;
138 }
139 let serialized = serde_json::to_string_pretty(&value)
140 .context("Failed to serialize Claude settings JSON")?;
141
142 AtomicFile::new(&settings_path, OverwriteBehavior::AllowOverwrite)
143 .write(|f| f.write_all(serialized.as_bytes()))
144 .with_context(|| format!("Failed to write {:?}", settings_path))?;
145 }
146
147 if let Err(e) = prune_malformed_backups(&settings_path, 3) {
149 eprintln!(
150 "{}: Failed to prune malformed Claude backups: {}",
151 "Warning".yellow(),
152 e
153 );
154 }
155 Ok(InjectionSummary {
156 settings_path,
157 added_additional_dirs,
158 added_allow_rules,
159 already_present_additional_dirs,
160 already_present_allow_rules,
161 warn_conflicting_denies,
162 })
163}
164
165fn get_local_settings_path(repo_root: &Path) -> PathBuf {
166 repo_root.join(".claude").join("settings.local.json")
167}
168
169fn ensure_parent_dir(settings_path: &Path) -> Result<()> {
170 if let Some(parent) = settings_path.parent() {
171 fs::create_dir_all(parent)
172 .with_context(|| format!("Failed to create directory {:?}", parent))?;
173 }
174 Ok(())
175}
176
177struct ReadOutcome {
178 value: Value,
179 had_valid_json: bool,
180}
181
182fn read_or_init_settings(settings_path: &Path) -> Result<ReadOutcome> {
183 if !settings_path.exists() {
184 return Ok(ReadOutcome {
185 value: json!({}),
186 had_valid_json: false,
187 });
188 }
189
190 let raw = fs::read_to_string(settings_path)
191 .with_context(|| format!("Failed to read {:?}", settings_path))?;
192
193 match serde_json::from_str::<Value>(&raw) {
194 Ok(value) => Ok(ReadOutcome {
195 value,
196 had_valid_json: true,
197 }),
198 Err(_) => {
199 let ts = SystemTime::now()
201 .duration_since(UNIX_EPOCH)
202 .unwrap()
203 .as_secs();
204 let malformed = settings_path.with_extension(format!("json.malformed.{}.bak", ts));
205 let _ = fs::rename(settings_path, &malformed);
206 eprintln!(
207 "{}: Existing Claude settings were malformed. Quarantined to {}",
208 "Warning".yellow(),
209 malformed.display()
210 );
211 if let Err(e) = prune_malformed_backups(settings_path, 3) {
213 eprintln!(
214 "{}: Failed to prune malformed Claude backups: {}",
215 "Warning".yellow(),
216 e
217 );
218 }
219 Ok(ReadOutcome {
220 value: json!({}),
221 had_valid_json: false,
222 })
223 }
224 }
225}
226
227fn ensure_permissions_scaffold(root: &mut Value) {
229 if !root.is_object() {
230 *root = json!({});
231 }
232 if !root
233 .get("permissions")
234 .map(|x| x.is_object())
235 .unwrap_or(false)
236 {
237 root["permissions"] = json!({});
238 }
239 if root["permissions"].get("deny").is_none() {
240 root["permissions"]["deny"] = json!([]);
241 }
242 if root["permissions"].get("ask").is_none() {
243 root["permissions"]["ask"] = json!([]);
244 }
245}
246
247fn backup_valid_to_bak(settings_path: &Path) -> Result<()> {
248 let bak = settings_path.with_extension("json.bak");
249 fs::copy(settings_path, &bak)
250 .with_context(|| format!("Failed to copy {:?} -> {:?}", settings_path, bak))?;
251 Ok(())
252}
253
254fn collect_conflicting_denies(permissions: &Value, allow_set: &HashSet<String>) -> Vec<String> {
255 let mut conflicts = Vec::new();
256 if let Some(deny) = permissions.get("deny").and_then(|d| d.as_array()) {
257 for d in deny {
258 if let Some(ds) = d.as_str()
259 && allow_set.contains(ds)
260 {
261 conflicts.push(ds.to_string());
262 }
263 }
264 }
265 conflicts
266}
267
268fn prune_malformed_backups(settings_path: &Path, keep: usize) -> Result<usize> {
269 let dir = settings_path
270 .parent()
271 .ok_or_else(|| anyhow!("Missing parent dir for settings"))?;
272 let prefix = "settings.local.json.malformed.";
273 let suffix = ".bak";
274 let mut entries: Vec<(u64, PathBuf)> = Vec::new();
275 for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {:?}", dir))? {
276 let p = entry?.path();
277 let Some(name_os) = p.file_name() else {
278 continue;
279 };
280 let name = name_os.to_string_lossy();
281 if !name.starts_with(prefix) || !name.ends_with(suffix) {
282 continue;
283 }
284 let ts_str = &name[prefix.len()..name.len() - suffix.len()];
285 if let Ok(ts) = ts_str.parse::<u64>() {
286 entries.push((ts, p));
287 }
288 }
289 entries.sort_by_key(|(ts, _)| *ts);
291 entries.reverse();
292 let mut deleted = 0usize;
293 for (_, p) in entries.into_iter().skip(keep) {
294 match fs::remove_file(&p) {
295 Ok(_) => deleted += 1,
296 Err(e) => eprintln!(
297 "{}: Failed to remove old malformed backup {}: {}",
298 "Warning".yellow(),
299 p.display(),
300 e
301 ),
302 }
303 }
304 Ok(deleted)
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use tempfile::TempDir;
311
312 #[test]
313 fn creates_file_and_adds_additional_dir_and_rules() {
314 let td = TempDir::new().unwrap();
315 let repo = td.path();
316
317 let td_path = repo.join(".thoughts-data");
319 fs::create_dir_all(&td_path).unwrap();
320
321 let summary = inject_additional_directories(repo).unwrap();
322
323 assert!(
325 summary
326 .settings_path
327 .ends_with(".claude/settings.local.json")
328 );
329
330 assert_eq!(summary.added_additional_dirs.len(), 1);
332 assert_eq!(summary.added_allow_rules.len(), 3);
333
334 let content = fs::read_to_string(&summary.settings_path).unwrap();
335 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
336 let add_dirs = json["permissions"]["additionalDirectories"]
337 .as_array()
338 .unwrap();
339 let allow = json["permissions"]["allow"].as_array().unwrap();
340
341 let add_dirs_strs: Vec<&str> = add_dirs.iter().filter_map(|v| v.as_str()).collect();
343 assert_eq!(add_dirs_strs.len(), 1);
344 assert!(add_dirs_strs[0].ends_with("/.thoughts-data"));
345
346 let allow_strs: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
347 assert!(allow_strs.contains(&"Read(thoughts/**)"));
348 assert!(allow_strs.contains(&"Read(context/**)"));
349 assert!(allow_strs.contains(&"Read(references/**)"));
350 }
351
352 #[test]
353 fn idempotent_no_duplicates() {
354 let td = TempDir::new().unwrap();
355 let repo = td.path();
356 fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
357
358 let _ = inject_additional_directories(repo).unwrap();
359 let again = inject_additional_directories(repo).unwrap();
360
361 assert!(again.added_additional_dirs.is_empty());
362 assert!(again.added_allow_rules.is_empty());
363
364 let content = fs::read_to_string(&again.settings_path).unwrap();
365 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
366 let allow = json["permissions"]["allow"].as_array().unwrap();
367
368 let mut seen = std::collections::HashSet::new();
369 for item in allow {
370 if let Some(s) = item.as_str() {
371 assert!(seen.insert(s.to_string()), "Duplicate found: {}", s);
372 }
373 }
374 }
375
376 #[test]
377 fn malformed_settings_is_quarantined() {
378 let td = TempDir::new().unwrap();
379 let repo = td.path();
380 fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
381
382 let settings = repo.join(".claude").join("settings.local.json");
383 fs::create_dir_all(settings.parent().unwrap()).unwrap();
384 fs::write(&settings, "not-json").unwrap();
385
386 let summary = inject_additional_directories(repo).unwrap();
387 assert!(summary.settings_path.exists());
388
389 let dir = settings.parent().unwrap();
391 let entries = fs::read_dir(dir).unwrap();
392 let mut found_malformed = false;
393 for e in entries {
394 let p = e.unwrap().path();
395 let name = p.file_name().unwrap().to_string_lossy();
396 if name.contains("settings.local.json.malformed.") {
397 found_malformed = true;
398 break;
399 }
400 }
401 assert!(found_malformed);
402 }
403
404 #[test]
405 fn backup_valid_before_write() {
406 let td = TempDir::new().unwrap();
407 let repo = td.path();
408 fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
409
410 let settings = repo.join(".claude").join("settings.local.json");
411 fs::create_dir_all(settings.parent().unwrap()).unwrap();
412 fs::write(
413 &settings,
414 r#"{"permissions":{"allow":[],"deny":[],"ask":[]}}"#,
415 )
416 .unwrap();
417
418 let _ = inject_additional_directories(repo).unwrap();
419 let bak = settings.with_extension("json.bak");
420 assert!(bak.exists());
421 }
422
423 #[test]
424 fn fallback_to_non_canonical_on_missing_path() {
425 let td = TempDir::new().unwrap();
426 let repo = td.path();
427 let summary = inject_additional_directories(repo).unwrap();
429
430 let content = fs::read_to_string(&summary.settings_path).unwrap();
431 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
432 let add_dirs = json["permissions"]["additionalDirectories"]
433 .as_array()
434 .unwrap();
435 let add_dirs_strs: Vec<&str> = add_dirs.iter().filter_map(|v| v.as_str()).collect();
436 assert_eq!(add_dirs_strs.len(), 1);
437 assert!(add_dirs_strs[0].ends_with("/.thoughts-data"));
438 }
439
440 #[test]
441 fn prunes_to_last_three_malformed_backups() {
442 let td = TempDir::new().unwrap();
443 let repo = td.path();
444 let settings = repo.join(".claude").join("settings.local.json");
445 fs::create_dir_all(settings.parent().unwrap()).unwrap();
446
447 for ts in [100, 200, 300, 400, 500] {
449 let p = settings.with_extension(format!("json.malformed.{}.bak", ts));
450 fs::write(&p, b"{}").unwrap();
451 }
452
453 let deleted = super::prune_malformed_backups(&settings, 3).unwrap();
454 assert_eq!(deleted, 2);
455
456 let kept: Vec<u64> = fs::read_dir(settings.parent().unwrap())
457 .unwrap()
458 .filter_map(|e| {
459 let name = e.unwrap().file_name().to_string_lossy().into_owned();
460 if let Some(s) = name
461 .strip_prefix("settings.local.json.malformed.")
462 .and_then(|s| s.strip_suffix(".bak"))
463 {
464 s.parse::<u64>().ok()
465 } else {
466 None
467 }
468 })
469 .collect();
470
471 assert_eq!(kept.len(), 3);
472 assert!(kept.contains(&300) && kept.contains(&400) && kept.contains(&500));
473 }
474
475 #[test]
476 fn ignores_non_numeric_malformed_backups() {
477 let td = TempDir::new().unwrap();
478 let repo = td.path();
479 let settings = repo.join(".claude").join("settings.local.json");
480 fs::create_dir_all(settings.parent().unwrap()).unwrap();
481
482 let bad = settings.with_extension("json.malformed.bad.bak");
484 fs::write(&bad, b"{}").unwrap();
485
486 let deleted = super::prune_malformed_backups(&settings, 3).unwrap();
487 assert_eq!(deleted, 0);
488 assert!(bad.exists());
489 }
490
491 #[test]
492 fn quarantine_then_prune_leaves_three() {
493 let td = TempDir::new().unwrap();
494 let repo = td.path();
495 fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
496
497 for _ in 0..5 {
499 let settings = repo.join(".claude").join("settings.local.json");
500 fs::create_dir_all(settings.parent().unwrap()).unwrap();
501 fs::write(&settings, "not-json").unwrap();
502 let _ = inject_additional_directories(repo).unwrap();
503 }
504
505 let dir = repo.join(".claude");
507 let count = fs::read_dir(&dir)
508 .unwrap()
509 .filter(|e| {
510 e.as_ref()
511 .ok()
512 .and_then(|x| {
513 x.file_name()
514 .to_str()
515 .map(|s| s.contains("settings.local.json.malformed."))
516 })
517 .unwrap_or(false)
518 })
519 .count();
520 assert!(count <= 3);
521 }
522}