thoughts_tool/utils/
claude_settings.rs

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
21/// Inject Claude Code permissions using the additionalDirectories mechanism and
22/// narrow relative allow patterns. Works for both worktrees and regular repos.
23/// - Adds canonical(repo_root/.thoughts-data) to permissions.additionalDirectories
24/// - Adds three allow rules: Read(thoughts/**), Read(context/**), Read(references/**)
25/// - Atomic, idempotent, and never panics on malformed JSON (quarantines instead)
26pub 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    // Resolve .thoughts-data canonical path; fallback to non-canonical on error
31    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 (including additionalDirectories and allow arrays)
51    ensure_permissions_scaffold(&mut value);
52
53    // Prepare to track changes
54    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    // Work with additionalDirectories and allow in a nested scope to avoid borrow conflicts
60    {
61        let permissions = value.get_mut("permissions").unwrap();
62
63        // Ensure additionalDirectories array exists
64        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        // Build existing set for deduplication
75        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        // 1) Insert canonical .thoughts-data path into additionalDirectories
81        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    // Now work with allow rules in a separate scope
92    let warn_conflicting_denies = {
93        let permissions = value.get_mut("permissions").unwrap();
94
95        // Ensure allow array exists
96        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        // Build existing set for deduplication
107        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        // 2) Insert narrow relative allow rules
113        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        // Best-effort conflict detection (exact string matches)
130        collect_conflicting_denies(permissions, &existing_allow)
131    };
132
133    // Only write if something changed
134    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    // Best-effort prune at end of operation to keep directory tidy
148    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            // Malformed JSON: quarantine and start fresh
200            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            // Best-effort prune after quarantine
212            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
227/// Ensure permissions, allow, deny, ask scaffolding exists without overwriting unrelated keys.
228fn 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    // Sort newest first
290    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        // Create .thoughts-data to allow canonicalization
318        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        // Path correctness
324        assert!(
325            summary
326                .settings_path
327                .ends_with(".claude/settings.local.json")
328        );
329
330        // Should have added both: at least one additional dir and all rules
331        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        // Verify contents
342        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        // Look for quarantine
390        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        // Intentionally do NOT create .thoughts-data so canonicalize fails
428        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        // Create 5 malformed backups with increasing timestamps
448        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        // Badly named file should be ignored by prune
483        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        // Corrupt settings multiple times to force quarantine
498        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        // Count malformed backups (should be <= 3)
506        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}