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