1use anyhow::Context;
2use anyhow::Result;
3use anyhow::anyhow;
4use anyhow::bail;
5use atomicwrites::AtomicFile;
6use atomicwrites::OverwriteBehavior;
7use colored::Colorize;
8use serde_json::Value;
9use serde_json::json;
10use std::collections::HashSet;
11use std::fs;
12use std::fs::OpenOptions;
13use std::io::Write;
14use std::path::Path;
15use std::path::PathBuf;
16use std::time::SystemTime;
17use std::time::UNIX_EPOCH;
18
19#[derive(Debug, Clone)]
20pub struct InjectionSummary {
21 pub settings_path: PathBuf,
22 pub added_additional_dirs: Vec<PathBuf>,
23 pub added_allow_rules: Vec<String>,
24 pub already_present_additional_dirs: Vec<PathBuf>,
25 pub already_present_allow_rules: Vec<String>,
26 pub warn_conflicting_denies: Vec<String>,
27}
28
29pub fn inject_additional_directories(repo_root: &Path) -> Result<InjectionSummary> {
35 let settings_path = get_local_settings_path(repo_root);
36 ensure_parent_dir(&settings_path)?;
37
38 let td = repo_root.join(".thoughts-data");
40 let canonical_thoughts_data = match fs::canonicalize(&td) {
41 Ok(p) => p,
42 Err(e) => {
43 eprintln!(
44 "{}: Failed to canonicalize {} ({}). Falling back to non-canonical path.",
45 "Warning".yellow(),
46 td.display(),
47 e
48 );
49 td
50 }
51 };
52
53 let ReadOutcome {
54 mut value,
55 had_valid_json,
56 } = read_or_init_settings(&settings_path)?;
57
58 ensure_permissions_scaffold(&mut value);
60
61 let mut added_additional_dirs = Vec::new();
63 let mut already_present_additional_dirs = Vec::new();
64 let mut added_allow_rules = Vec::new();
65 let mut already_present_allow_rules = Vec::new();
66
67 {
69 let permissions = value
70 .get_mut("permissions")
71 .ok_or_else(|| anyhow!("permissions key missing after scaffold — this is a bug"))?;
72
73 ensure_array_field(permissions, "additionalDirectories", &settings_path)?;
74
75 let add_dirs = permissions["additionalDirectories"]
76 .as_array_mut()
77 .ok_or_else(|| {
78 anyhow!("additionalDirectories is not an array after scaffold — this is a bug")
79 })?;
80
81 let mut existing_add_dirs: HashSet<String> = add_dirs
83 .iter()
84 .filter_map(|v| v.as_str().map(std::string::ToString::to_string))
85 .collect();
86
87 let dir_str = canonical_thoughts_data.to_string_lossy().to_string();
89 if existing_add_dirs.contains(&dir_str) {
90 already_present_additional_dirs.push(canonical_thoughts_data);
91 } else {
92 add_dirs.push(Value::String(dir_str.clone()));
93 existing_add_dirs.insert(dir_str);
94 added_additional_dirs.push(canonical_thoughts_data);
95 }
96 }
97
98 let warn_conflicting_denies = {
100 let permissions = value
101 .get_mut("permissions")
102 .ok_or_else(|| anyhow!("permissions key missing after scaffold — this is a bug"))?;
103
104 ensure_array_field(permissions, "allow", &settings_path)?;
105
106 let allow = permissions["allow"]
107 .as_array_mut()
108 .ok_or_else(|| anyhow!("allow is not an array after scaffold — this is a bug"))?;
109
110 let mut existing_allow: HashSet<String> = allow
112 .iter()
113 .filter_map(|v| v.as_str().map(std::string::ToString::to_string))
114 .collect();
115
116 let required_rules = vec![
118 "Read(thoughts/**)".to_string(),
119 "Read(context/**)".to_string(),
120 "Read(references/**)".to_string(),
121 ];
122
123 for r in required_rules {
124 if existing_allow.contains(&r) {
125 already_present_allow_rules.push(r);
126 } else {
127 allow.push(Value::String(r.clone()));
128 existing_allow.insert(r.clone());
129 added_allow_rules.push(r);
130 }
131 }
132
133 collect_conflicting_denies(permissions, &existing_allow)
135 };
136
137 if !added_additional_dirs.is_empty() || !added_allow_rules.is_empty() {
139 if had_valid_json && settings_path.exists() {
140 backup_valid_to_bak(&settings_path).with_context(|| {
141 format!("Failed to create backup for {}", settings_path.display())
142 })?;
143 }
144 let serialized = serde_json::to_string_pretty(&value)
145 .context("Failed to serialize Claude settings JSON")?;
146
147 AtomicFile::new(&settings_path, OverwriteBehavior::AllowOverwrite)
148 .write(|f| f.write_all(serialized.as_bytes()))
149 .with_context(|| format!("Failed to write {}", settings_path.display()))?;
150 }
151
152 if let Err(e) = prune_malformed_backups(&settings_path, 3) {
154 eprintln!(
155 "{}: Failed to prune malformed Claude backups: {}",
156 "Warning".yellow(),
157 e
158 );
159 }
160 Ok(InjectionSummary {
161 settings_path,
162 added_additional_dirs,
163 added_allow_rules,
164 already_present_additional_dirs,
165 already_present_allow_rules,
166 warn_conflicting_denies,
167 })
168}
169
170fn get_local_settings_path(repo_root: &Path) -> PathBuf {
171 repo_root.join(".claude").join("settings.local.json")
172}
173
174fn ensure_parent_dir(settings_path: &Path) -> Result<()> {
175 if let Some(parent) = settings_path.parent() {
176 fs::create_dir_all(parent)
177 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
178 }
179 Ok(())
180}
181
182struct ReadOutcome {
183 value: Value,
184 had_valid_json: bool,
185}
186
187fn read_or_init_settings(settings_path: &Path) -> Result<ReadOutcome> {
188 if !settings_path.exists() {
189 return Ok(ReadOutcome {
190 value: json!({}),
191 had_valid_json: false,
192 });
193 }
194
195 let raw = fs::read_to_string(settings_path)
196 .with_context(|| format!("Failed to read {}", settings_path.display()))?;
197
198 if let Ok(value) = serde_json::from_str::<Value>(&raw) {
199 Ok(ReadOutcome {
200 value,
201 had_valid_json: true,
202 })
203 } else {
204 let malformed =
206 quarantine_malformed_settings(settings_path, &raw, current_malformed_backup_suffix())?;
207 eprintln!(
208 "{}: Existing Claude settings were malformed. Quarantined to {}",
209 "Warning".yellow(),
210 malformed.display()
211 );
212 if let Err(e) = prune_malformed_backups(settings_path, 3) {
214 eprintln!(
215 "{}: Failed to prune malformed Claude backups: {}",
216 "Warning".yellow(),
217 e
218 );
219 }
220 Ok(ReadOutcome {
221 value: json!({}),
222 had_valid_json: false,
223 })
224 }
225}
226
227fn current_malformed_backup_suffix() -> u64 {
228 SystemTime::now()
229 .duration_since(UNIX_EPOCH)
230 .unwrap_or_default()
231 .as_nanos()
232 .try_into()
233 .unwrap_or(u64::MAX)
234}
235
236fn quarantine_malformed_settings(
237 settings_path: &Path,
238 raw: &str,
239 initial_suffix: u64,
240) -> Result<PathBuf> {
241 let mut suffix = initial_suffix;
242 loop {
243 let malformed = settings_path.with_extension(format!("json.malformed.{suffix}.bak"));
244 match OpenOptions::new()
245 .write(true)
246 .create_new(true)
247 .open(&malformed)
248 {
249 Ok(mut file) => {
250 file.write_all(raw.as_bytes()).with_context(|| {
251 format!(
252 "Failed to write quarantined malformed Claude settings {}",
253 malformed.display()
254 )
255 })?;
256 drop(file);
257 fs::remove_file(settings_path).with_context(|| {
258 format!(
259 "Failed to remove malformed Claude settings {} after quarantine",
260 settings_path.display()
261 )
262 })?;
263 return Ok(malformed);
264 }
265 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
266 suffix = suffix.checked_add(1).ok_or_else(|| {
267 anyhow!(
268 "Exhausted malformed backup suffixes for {}",
269 settings_path.display()
270 )
271 })?;
272 }
273 Err(e) => {
274 return Err(e).with_context(|| {
275 format!(
276 "Failed to reserve malformed Claude settings quarantine path {}",
277 malformed.display()
278 )
279 });
280 }
281 }
282 }
283}
284
285fn ensure_permissions_scaffold(root: &mut Value) {
287 if !root.is_object() {
288 *root = json!({});
289 }
290 if !root
291 .get("permissions")
292 .is_some_and(serde_json::Value::is_object)
293 {
294 root["permissions"] = json!({});
295 }
296 if root["permissions"].get("deny").is_none() {
297 root["permissions"]["deny"] = json!([]);
298 }
299 if root["permissions"].get("ask").is_none() {
300 root["permissions"]["ask"] = json!([]);
301 }
302}
303
304fn ensure_array_field(permissions: &mut Value, key: &str, settings_path: &Path) -> Result<()> {
305 match permissions.get(key) {
306 None => {
307 permissions[key] = json!([]);
308 Ok(())
309 }
310 Some(value) if value.is_array() => Ok(()),
311 Some(_) => bail!(
312 "permissions.{key} must be an array in {}",
313 settings_path.display()
314 ),
315 }
316}
317
318fn backup_valid_to_bak(settings_path: &Path) -> Result<()> {
319 let bak = settings_path.with_extension("json.bak");
320 fs::copy(settings_path, &bak).with_context(|| {
321 format!(
322 "Failed to copy {} -> {}",
323 settings_path.display(),
324 bak.display()
325 )
326 })?;
327 Ok(())
328}
329
330fn collect_conflicting_denies(permissions: &Value, allow_set: &HashSet<String>) -> Vec<String> {
331 let mut conflicts = Vec::new();
332 if let Some(deny) = permissions.get("deny").and_then(|d| d.as_array()) {
333 for d in deny {
334 if let Some(ds) = d.as_str()
335 && allow_set.contains(ds)
336 {
337 conflicts.push(ds.to_string());
338 }
339 }
340 }
341 conflicts
342}
343
344fn prune_malformed_backups(settings_path: &Path, keep: usize) -> Result<usize> {
345 let dir = settings_path
346 .parent()
347 .ok_or_else(|| anyhow!("Missing parent dir for settings"))?;
348 let prefix = "settings.local.json.malformed.";
349 let suffix = ".bak";
350 let mut entries: Vec<(u64, PathBuf)> = Vec::new();
351 for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? {
352 let p = entry?.path();
353 let Some(name_os) = p.file_name() else {
354 continue;
355 };
356 let name = name_os.to_string_lossy();
357 if !name.starts_with(prefix) || !name.ends_with(suffix) {
358 continue;
359 }
360 let ts_str = &name[prefix.len()..name.len() - suffix.len()];
361 if let Ok(ts) = ts_str.parse::<u64>() {
362 entries.push((ts, p));
363 }
364 }
365 entries.sort_by_key(|(ts, _)| *ts);
367 entries.reverse();
368 let mut deleted = 0usize;
369 for (_, p) in entries.into_iter().skip(keep) {
370 match fs::remove_file(&p) {
371 Ok(()) => deleted += 1,
372 Err(e) => eprintln!(
373 "{}: Failed to remove old malformed backup {}: {}",
374 "Warning".yellow(),
375 p.display(),
376 e
377 ),
378 }
379 }
380 Ok(deleted)
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use tempfile::TempDir;
387
388 #[test]
389 fn creates_file_and_adds_additional_dir_and_rules() {
390 let td = TempDir::new().unwrap();
391 let repo = td.path();
392
393 let td_path = repo.join(".thoughts-data");
395 fs::create_dir_all(&td_path).unwrap();
396
397 let summary = inject_additional_directories(repo).unwrap();
398
399 assert!(
401 summary
402 .settings_path
403 .ends_with(".claude/settings.local.json")
404 );
405
406 assert_eq!(summary.added_additional_dirs.len(), 1);
408 assert_eq!(summary.added_allow_rules.len(), 3);
409
410 let content = fs::read_to_string(&summary.settings_path).unwrap();
411 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
412 let add_dirs = json["permissions"]["additionalDirectories"]
413 .as_array()
414 .unwrap();
415 let allow = json["permissions"]["allow"].as_array().unwrap();
416
417 let add_dirs_strs: Vec<&str> = add_dirs.iter().filter_map(|v| v.as_str()).collect();
419 assert_eq!(add_dirs_strs.len(), 1);
420 assert!(add_dirs_strs[0].ends_with("/.thoughts-data"));
421
422 let allow_strs: Vec<&str> = allow.iter().filter_map(|v| v.as_str()).collect();
423 assert!(allow_strs.contains(&"Read(thoughts/**)"));
424 assert!(allow_strs.contains(&"Read(context/**)"));
425 assert!(allow_strs.contains(&"Read(references/**)"));
426 }
427
428 #[test]
429 fn idempotent_no_duplicates() {
430 let td = TempDir::new().unwrap();
431 let repo = td.path();
432 fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
433
434 let _ = inject_additional_directories(repo).unwrap();
435 let again = inject_additional_directories(repo).unwrap();
436
437 assert!(again.added_additional_dirs.is_empty());
438 assert!(again.added_allow_rules.is_empty());
439
440 let content = fs::read_to_string(&again.settings_path).unwrap();
441 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
442 let allow = json["permissions"]["allow"].as_array().unwrap();
443
444 let mut seen = std::collections::HashSet::new();
445 for item in allow {
446 if let Some(s) = item.as_str() {
447 assert!(seen.insert(s.to_string()), "Duplicate found: {s}");
448 }
449 }
450 }
451
452 #[test]
453 fn malformed_settings_is_quarantined() {
454 let td = TempDir::new().unwrap();
455 let repo = td.path();
456 fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
457
458 let settings = repo.join(".claude").join("settings.local.json");
459 fs::create_dir_all(settings.parent().unwrap()).unwrap();
460 fs::write(&settings, "not-json").unwrap();
461
462 let summary = inject_additional_directories(repo).unwrap();
463 assert!(summary.settings_path.exists());
464
465 let dir = settings.parent().unwrap();
467 let entries = fs::read_dir(dir).unwrap();
468 let mut found_malformed = false;
469 for e in entries {
470 let p = e.unwrap().path();
471 let name = p.file_name().unwrap().to_string_lossy();
472 if name.contains("settings.local.json.malformed.") {
473 found_malformed = true;
474 break;
475 }
476 }
477 assert!(found_malformed);
478 }
479
480 #[test]
481 fn quarantine_uses_next_numeric_suffix_when_backup_exists() {
482 let td = TempDir::new().unwrap();
483 let settings = td.path().join(".claude").join("settings.local.json");
484 fs::create_dir_all(settings.parent().unwrap()).unwrap();
485 fs::write(&settings, "second").unwrap();
486
487 let existing = settings.with_extension("json.malformed.42.bak");
488 fs::write(&existing, "first").unwrap();
489
490 let malformed = super::quarantine_malformed_settings(&settings, "second", 42).unwrap();
491
492 assert_eq!(malformed, settings.with_extension("json.malformed.43.bak"));
493 assert_eq!(fs::read_to_string(&existing).unwrap(), "first");
494 assert_eq!(fs::read_to_string(&malformed).unwrap(), "second");
495 assert!(!settings.exists());
496 }
497
498 #[test]
499 fn backup_valid_before_write() {
500 let td = TempDir::new().unwrap();
501 let repo = td.path();
502 fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
503
504 let settings = repo.join(".claude").join("settings.local.json");
505 fs::create_dir_all(settings.parent().unwrap()).unwrap();
506 fs::write(
507 &settings,
508 r#"{"permissions":{"allow":[],"deny":[],"ask":[]}}"#,
509 )
510 .unwrap();
511
512 let _ = inject_additional_directories(repo).unwrap();
513 let bak = settings.with_extension("json.bak");
514 assert!(bak.exists());
515 }
516
517 #[test]
518 fn rejects_non_array_additional_directories() {
519 let td = TempDir::new().unwrap();
520 let repo = td.path();
521 fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
522
523 let settings = repo.join(".claude").join("settings.local.json");
524 fs::create_dir_all(settings.parent().unwrap()).unwrap();
525 fs::write(
526 &settings,
527 r#"{"permissions":{"additionalDirectories":"bad","allow":[],"deny":[],"ask":[]}}"#,
528 )
529 .unwrap();
530
531 let err = inject_additional_directories(repo).unwrap_err();
532
533 assert!(
534 err.to_string()
535 .contains("permissions.additionalDirectories must be an array")
536 );
537 }
538
539 #[test]
540 fn rejects_non_array_allow() {
541 let td = TempDir::new().unwrap();
542 let repo = td.path();
543 fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
544
545 let settings = repo.join(".claude").join("settings.local.json");
546 fs::create_dir_all(settings.parent().unwrap()).unwrap();
547 fs::write(
548 &settings,
549 r#"{"permissions":{"additionalDirectories":[],"allow":"bad","deny":[],"ask":[]}}"#,
550 )
551 .unwrap();
552
553 let err = inject_additional_directories(repo).unwrap_err();
554
555 assert!(
556 err.to_string()
557 .contains("permissions.allow must be an array")
558 );
559 }
560
561 #[test]
562 fn fallback_to_non_canonical_on_missing_path() {
563 let td = TempDir::new().unwrap();
564 let repo = td.path();
565 let summary = inject_additional_directories(repo).unwrap();
567
568 let content = fs::read_to_string(&summary.settings_path).unwrap();
569 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
570 let add_dirs = json["permissions"]["additionalDirectories"]
571 .as_array()
572 .unwrap();
573 let add_dirs_strs: Vec<&str> = add_dirs.iter().filter_map(|v| v.as_str()).collect();
574 assert_eq!(add_dirs_strs.len(), 1);
575 assert!(add_dirs_strs[0].ends_with("/.thoughts-data"));
576 }
577
578 #[test]
579 fn prunes_to_last_three_malformed_backups() {
580 let td = TempDir::new().unwrap();
581 let repo = td.path();
582 let settings = repo.join(".claude").join("settings.local.json");
583 fs::create_dir_all(settings.parent().unwrap()).unwrap();
584
585 for ts in [100, 200, 300, 400, 500] {
587 let p = settings.with_extension(format!("json.malformed.{ts}.bak"));
588 fs::write(&p, b"{}").unwrap();
589 }
590
591 let deleted = super::prune_malformed_backups(&settings, 3).unwrap();
592 assert_eq!(deleted, 2);
593
594 let kept: Vec<u64> = fs::read_dir(settings.parent().unwrap())
595 .unwrap()
596 .filter_map(|e| {
597 let name = e.unwrap().file_name().to_string_lossy().into_owned();
598 if let Some(s) = name
599 .strip_prefix("settings.local.json.malformed.")
600 .and_then(|s| s.strip_suffix(".bak"))
601 {
602 s.parse::<u64>().ok()
603 } else {
604 None
605 }
606 })
607 .collect();
608
609 assert_eq!(kept.len(), 3);
610 assert!(kept.contains(&300) && kept.contains(&400) && kept.contains(&500));
611 }
612
613 #[test]
614 fn ignores_non_numeric_malformed_backups() {
615 let td = TempDir::new().unwrap();
616 let repo = td.path();
617 let settings = repo.join(".claude").join("settings.local.json");
618 fs::create_dir_all(settings.parent().unwrap()).unwrap();
619
620 let bad = settings.with_extension("json.malformed.bad.bak");
622 fs::write(&bad, b"{}").unwrap();
623
624 let deleted = super::prune_malformed_backups(&settings, 3).unwrap();
625 assert_eq!(deleted, 0);
626 assert!(bad.exists());
627 }
628
629 #[test]
630 fn quarantine_then_prune_leaves_three() {
631 let td = TempDir::new().unwrap();
632 let repo = td.path();
633 fs::create_dir_all(repo.join(".thoughts-data")).unwrap();
634
635 for _ in 0..5 {
637 let settings = repo.join(".claude").join("settings.local.json");
638 fs::create_dir_all(settings.parent().unwrap()).unwrap();
639 fs::write(&settings, "not-json").unwrap();
640 let _ = inject_additional_directories(repo).unwrap();
641 }
642
643 let dir = repo.join(".claude");
645 let count = fs::read_dir(&dir)
646 .unwrap()
647 .filter(|e| {
648 e.as_ref()
649 .ok()
650 .and_then(|x| {
651 x.file_name()
652 .to_str()
653 .map(|s| s.contains("settings.local.json.malformed."))
654 })
655 .unwrap_or(false)
656 })
657 .count();
658 assert!(count <= 3);
659 }
660}