Skip to main content

thoughts_tool/utils/
claude_settings.rs

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
27/// Inject Claude Code permissions using the additionalDirectories mechanism and
28/// narrow relative allow patterns. Works for both worktrees and regular repos.
29/// - Adds canonical(repo_root/.thoughts-data) to permissions.additionalDirectories
30/// - Adds three allow rules: Read(thoughts/**), Read(context/**), Read(references/**)
31/// - Atomic, idempotent, and never panics on malformed JSON (quarantines instead)
32pub 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    // Resolve .thoughts-data canonical path; fallback to non-canonical on error
37    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 (including additionalDirectories and allow arrays)
57    ensure_permissions_scaffold(&mut value);
58
59    // Prepare to track changes
60    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    // Work with additionalDirectories and allow in a nested scope to avoid borrow conflicts
66    {
67        let permissions = value.get_mut("permissions").unwrap();
68
69        // Ensure additionalDirectories array exists
70        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        // Build existing set for deduplication
81        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        // 1) Insert canonical .thoughts-data path into additionalDirectories
87        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    // Now work with allow rules in a separate scope
98    let warn_conflicting_denies = {
99        let permissions = value.get_mut("permissions").unwrap();
100
101        // Ensure allow array exists
102        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        // Build existing set for deduplication
113        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        // 2) Insert narrow relative allow rules
119        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        // Best-effort conflict detection (exact string matches)
136        collect_conflicting_denies(permissions, &existing_allow)
137    };
138
139    // Only write if something changed
140    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    // Best-effort prune at end of operation to keep directory tidy
154    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            // Malformed JSON: quarantine and start fresh
206            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            // Best-effort prune after quarantine
218            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
233/// Ensure permissions, allow, deny, ask scaffolding exists without overwriting unrelated keys.
234fn 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    // Sort newest first
296    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        // Create .thoughts-data to allow canonicalization
324        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        // Path correctness
330        assert!(
331            summary
332                .settings_path
333                .ends_with(".claude/settings.local.json")
334        );
335
336        // Should have added both: at least one additional dir and all rules
337        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        // Verify contents
348        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        // Look for quarantine
396        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        // Intentionally do NOT create .thoughts-data so canonicalize fails
434        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        // Create 5 malformed backups with increasing timestamps
454        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        // Badly named file should be ignored by prune
489        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        // Corrupt settings multiple times to force quarantine
504        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        // Count malformed backups (should be <= 3)
512        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}