Skip to main content

open_loops/
config.rs

1//! Config persisted at <base>/config.toml.
2//! The base path comes from outside (main resolves OPEN_LOOPS_HOME or ~/.open-loops)
3//! so tests can inject a tempdir — nothing here reads environment variables.
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct Config {
11    /// Directories where git repositories are searched.
12    #[serde(default)]
13    pub roots: Vec<PathBuf>,
14    /// Optional per-root label override, keyed by the canonical root path.
15    #[serde(default)]
16    pub aliases: BTreeMap<String, String>,
17    /// Command that receives the prompt on stdin and returns the answer on stdout.
18    #[serde(default = "default_llm_command")]
19    pub llm_command: String,
20    /// Claude Code sessions directory.
21    #[serde(default = "default_sessions_dir")]
22    pub sessions_dir: PathBuf,
23    /// Maximum number of sessions used in distillation.
24    #[serde(default = "default_max_sessions")]
25    pub max_sessions: usize,
26    /// KB read from the tail of each session.
27    #[serde(default = "default_max_session_kb")]
28    pub max_session_kb: u64,
29    /// Maximum directory depth (from each root) to search for git repositories.
30    #[serde(default = "default_scan_depth")]
31    pub scan_depth: usize,
32}
33
34fn default_llm_command() -> String {
35    "claude -p".into()
36}
37
38fn default_sessions_dir() -> PathBuf {
39    dirs::home_dir()
40        .unwrap_or_default()
41        .join(".claude/projects")
42}
43
44fn default_max_sessions() -> usize {
45    3
46}
47
48fn default_max_session_kb() -> u64 {
49    50
50}
51
52fn default_scan_depth() -> usize {
53    4
54}
55
56impl Default for Config {
57    fn default() -> Self {
58        Self {
59            roots: vec![],
60            aliases: BTreeMap::new(),
61            llm_command: default_llm_command(),
62            sessions_dir: default_sessions_dir(),
63            max_sessions: default_max_sessions(),
64            max_session_kb: default_max_session_kb(),
65            scan_depth: default_scan_depth(),
66        }
67    }
68}
69
70impl Config {
71    /// Resolves a stable label per root (alias, else basename). Errors when two
72    /// roots resolve to the same label and no alias disambiguates them.
73    pub fn resolve_labels(&self) -> Result<Vec<(std::path::PathBuf, String)>> {
74        let mut out: Vec<(std::path::PathBuf, String)> = Vec::new();
75        for root in &self.roots {
76            let label = self
77                .aliases
78                .get(&root.to_string_lossy().into_owned())
79                .cloned()
80                .unwrap_or_else(|| {
81                    root.file_name()
82                        .map(|n| n.to_string_lossy().into_owned())
83                        .unwrap_or_else(|| root.to_string_lossy().into_owned())
84                });
85            if let Some((other, _)) = out.iter().find(|(_, l)| *l == label) {
86                anyhow::bail!(
87                    "roots {} and {} share label '{label}'; set an alias in config.toml",
88                    other.display(),
89                    root.display()
90                );
91            }
92            out.push((root.clone(), label));
93        }
94        Ok(out)
95    }
96
97    /// Subset of configured roots matching `plan.root_filter`. Path values are
98    /// tilde-expanded and canonicalized, then matched as a prefix against roots
99    /// (ADR 0003). Label/path substring match is a fallback for short aliases.
100    pub fn resolve_scan_roots(
101        &self,
102        plan: &crate::query::ScanPlan,
103    ) -> Result<Vec<std::path::PathBuf>> {
104        let labels = self.resolve_labels()?;
105        let Some(filter) = &plan.root_filter else {
106            return Ok(self.roots.clone());
107        };
108        let mut prefix = expand_tilde(filter);
109        if prefix.exists() {
110            if let Ok(canon) = std::fs::canonicalize(&prefix) {
111                prefix = canon;
112            }
113        }
114        let needle = filter.to_lowercase();
115        Ok(labels
116            .into_iter()
117            .filter(|(root, label)| root_matches_filter(root, label, filter, &needle, &prefix))
118            .map(|(root, _)| root)
119            .collect())
120    }
121}
122
123/// True when `root`/`label` match a `root:` filter (ADR 0003).
124fn root_matches_filter(
125    root: &std::path::Path,
126    label: &str,
127    filter: &str,
128    needle: &str,
129    prefix: &std::path::Path,
130) -> bool {
131    // Canonical path prefix after tilde-expand (e.g. root:~/work).
132    if path_prefix_match(root, prefix) {
133        return true;
134    }
135    // Alias shortcut (e.g. root:w) — exact label, not substring (avoids "w" ⊂ "personal").
136    if label.eq_ignore_ascii_case(filter) {
137        return true;
138    }
139    // Path tail after optional ~/ (e.g. root:~/work or root:personal).
140    let path_needle = needle
141        .strip_prefix("~/")
142        .or_else(|| needle.strip_prefix('~'))
143        .unwrap_or(needle);
144    // Path component — basename only, not the full temp path.
145    if root
146        .file_name()
147        .is_some_and(|n| n.to_string_lossy().eq_ignore_ascii_case(path_needle))
148    {
149        return true;
150    }
151    let root_str = root.to_string_lossy().to_lowercase();
152    root_str.ends_with(path_needle)
153        || root_str.contains(&format!("/{path_needle}"))
154        || root_str.contains(&format!("\\{path_needle}"))
155}
156
157/// Prefix/equality match tolerant of canonical vs non-canonical paths (Windows `\\?\`).
158fn path_prefix_match(root: &Path, prefix: &Path) -> bool {
159    if root == prefix || root.starts_with(prefix) || prefix.starts_with(root) {
160        return true;
161    }
162    let root_ok = root.exists();
163    let prefix_ok = prefix.exists();
164    if root_ok && prefix_ok {
165        if let (Ok(r), Ok(p)) = (std::fs::canonicalize(root), std::fs::canonicalize(prefix)) {
166            if r == p || r.starts_with(&p) || p.starts_with(&r) {
167                return true;
168            }
169        }
170    }
171    false
172}
173
174/// Expands a leading `~` to the home directory (ADR `root:` filter).
175fn expand_tilde(path: &str) -> std::path::PathBuf {
176    if let Some(rest) = path.strip_prefix("~/") {
177        dirs::home_dir()
178            .map(|h| h.join(rest))
179            .unwrap_or_else(|| std::path::PathBuf::from(path))
180    } else if path == "~" {
181        dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(path))
182    } else {
183        std::path::PathBuf::from(path)
184    }
185}
186
187/// Label of the configured root that owns `repo` (longest path prefix wins).
188pub fn label_for_repo(labels: &[(std::path::PathBuf, String)], repo: &std::path::Path) -> String {
189    labels
190        .iter()
191        .filter(|(root, _)| repo.starts_with(root))
192        .max_by_key(|(root, _)| root.as_os_str().len())
193        .map(|(_, label)| label.clone())
194        .unwrap_or_else(|| {
195            repo.parent()
196                .and_then(|p| p.file_name())
197                .map(|n| n.to_string_lossy().into_owned())
198                .unwrap_or_default()
199        })
200}
201
202pub struct Store {
203    base: PathBuf,
204}
205
206impl Store {
207    pub fn new(base: PathBuf) -> Self {
208        Self { base }
209    }
210
211    pub fn config_path(&self) -> PathBuf {
212        self.base.join("config.toml")
213    }
214
215    pub fn load(&self) -> Result<Config> {
216        let path = self.config_path();
217        if !path.exists() {
218            return Ok(Config::default());
219        }
220        let raw = std::fs::read_to_string(&path)
221            .with_context(|| format!("reading {}", path.display()))?;
222        toml::from_str(&raw).with_context(|| format!("invalid config.toml at {}", path.display()))
223    }
224
225    pub fn save(&self, config: &Config) -> Result<()> {
226        std::fs::create_dir_all(&self.base)
227            .with_context(|| format!("creating {}", self.base.display()))?;
228        std::fs::write(self.config_path(), toml::to_string_pretty(config)?)?;
229        Ok(())
230    }
231
232    pub fn add_roots(&self, paths: &[PathBuf]) -> Result<Config> {
233        let mut config = self.load()?;
234        for p in paths {
235            let abs = std::fs::canonicalize(p)
236                .with_context(|| format!("nonexistent root: {}", p.display()))?;
237            if !config.roots.contains(&abs) {
238                config.roots.push(abs);
239            }
240        }
241        self.save(&config)?;
242        Ok(config)
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn load_without_file_returns_default() {
252        let tmp = tempfile::tempdir().unwrap();
253        let store = Store::new(tmp.path().to_path_buf());
254        let cfg = store.load().unwrap();
255        assert!(cfg.roots.is_empty());
256        assert_eq!(cfg.llm_command, "claude -p");
257        assert_eq!(cfg.max_sessions, 3);
258        assert_eq!(cfg.max_session_kb, 50);
259    }
260
261    #[test]
262    fn save_and_load_roundtrip() {
263        let tmp = tempfile::tempdir().unwrap();
264        let store = Store::new(tmp.path().join("state"));
265        let cfg = Config {
266            llm_command: "cat".into(),
267            ..Config::default()
268        };
269        store.save(&cfg).unwrap();
270        assert_eq!(store.load().unwrap().llm_command, "cat");
271    }
272
273    #[test]
274    fn add_roots_canonicalizes_and_deduplicates() {
275        let tmp = tempfile::tempdir().unwrap();
276        let store = Store::new(tmp.path().join("state"));
277        let root = tmp.path().join("projects");
278        std::fs::create_dir_all(&root).unwrap();
279        store.add_roots(std::slice::from_ref(&root)).unwrap();
280        let cfg = store.add_roots(std::slice::from_ref(&root)).unwrap();
281        assert_eq!(cfg.roots.len(), 1);
282        assert!(cfg.roots[0].is_absolute());
283    }
284
285    #[test]
286    fn add_roots_fails_for_nonexistent_dir() {
287        let tmp = tempfile::tempdir().unwrap();
288        let store = Store::new(tmp.path().join("state"));
289        let err = store
290            .add_roots(&[tmp.path().join("does-not-exist")])
291            .unwrap_err();
292        assert!(err.to_string().contains("nonexistent root"));
293    }
294
295    #[test]
296    fn resolve_labels_uses_basename_then_alias() {
297        let tmp = tempfile::tempdir().unwrap();
298        let store = Store::new(tmp.path().join("state"));
299        let work = tmp.path().join("work");
300        let personal = tmp.path().join("personal");
301        std::fs::create_dir_all(&work).unwrap();
302        std::fs::create_dir_all(&personal).unwrap();
303        let mut cfg = Config {
304            roots: vec![work.clone(), personal.clone()],
305            ..Config::default()
306        };
307        let labels = cfg.resolve_labels().unwrap();
308        assert!(labels.contains(&(work.clone(), "work".to_string())));
309        // alias overrides basename
310        cfg.aliases
311            .insert(personal.to_string_lossy().into_owned(), "p".into());
312        let labels = cfg.resolve_labels().unwrap();
313        assert!(labels.contains(&(personal.clone(), "p".to_string())));
314        let _ = store;
315    }
316
317    #[test]
318    fn config_scan_depth_defaults_to_four() {
319        let cfg = Config::default();
320        assert_eq!(cfg.scan_depth, 4);
321    }
322
323    #[test]
324    fn config_scan_depth_roundtrips_from_toml() {
325        let tmp = tempfile::tempdir().unwrap();
326        let store = Store::new(tmp.path().join("state"));
327        let cfg = Config {
328            scan_depth: 6,
329            ..Config::default()
330        };
331        store.save(&cfg).unwrap();
332        assert_eq!(store.load().unwrap().scan_depth, 6);
333    }
334
335    #[test]
336    fn resolve_scan_roots_filters_by_label_and_path() {
337        let tmp = tempfile::tempdir().unwrap();
338        let work = tmp.path().join("work");
339        let personal = tmp.path().join("personal");
340        std::fs::create_dir_all(&work).unwrap();
341        std::fs::create_dir_all(&personal).unwrap();
342        let mut cfg = Config {
343            roots: vec![work.clone(), personal.clone()],
344            ..Config::default()
345        };
346        cfg.aliases
347            .insert(work.to_string_lossy().into_owned(), "w".into());
348
349        let all = cfg
350            .resolve_scan_roots(&crate::query::ScanPlan::default())
351            .unwrap();
352        assert_eq!(all.len(), 2);
353
354        let by_label = cfg
355            .resolve_scan_roots(&crate::query::parse("root:w").unwrap())
356            .unwrap();
357        assert_eq!(by_label, vec![work.clone()]);
358
359        let by_path = cfg
360            .resolve_scan_roots(&crate::query::parse("root:personal").unwrap())
361            .unwrap();
362        assert_eq!(by_path, vec![personal]);
363    }
364
365    #[test]
366    fn resolve_scan_roots_short_alias_does_not_match_unrelated_path_noise() {
367        // Temp dirs like `.tmp02Wc68` contain the letter 'w'; a loose full-path
368        // substring match must not pull in every root when filtering root:w.
369        let tmp = tempfile::tempdir().unwrap();
370        let work = tmp.path().join("work");
371        let personal = tmp.path().join("personal");
372        std::fs::create_dir_all(&work).unwrap();
373        std::fs::create_dir_all(&personal).unwrap();
374        let mut cfg = Config {
375            roots: vec![work.clone(), personal.clone()],
376            ..Config::default()
377        };
378        cfg.aliases
379            .insert(work.to_string_lossy().into_owned(), "w".into());
380
381        let matched = cfg
382            .resolve_scan_roots(&crate::query::parse("root:w").unwrap())
383            .unwrap();
384        assert_eq!(matched, vec![work]);
385    }
386
387    #[test]
388    fn resolve_scan_roots_tilde_expands_to_prefix_match() {
389        let home = dirs::home_dir().expect("home dir");
390        let tmp = tempfile::tempdir().unwrap();
391        let work = home.join(format!(
392            ".loops-test-{}",
393            tmp.path().file_name().unwrap().to_string_lossy()
394        ));
395        std::fs::create_dir_all(&work).unwrap();
396        let cfg = Config {
397            roots: vec![work.clone()],
398            ..Config::default()
399        };
400        let filter = format!("~/{}", work.file_name().unwrap().to_string_lossy());
401        let matched = cfg
402            .resolve_scan_roots(&crate::query::parse(&format!("root:{filter}")).unwrap())
403            .unwrap();
404        assert_eq!(matched, vec![work.clone()]);
405        let _ = std::fs::remove_dir_all(&work);
406    }
407
408    #[test]
409    fn expand_tilde_handles_prefix_bare_and_literal() {
410        let home = dirs::home_dir().expect("home dir");
411        assert_eq!(expand_tilde("~/work"), home.join("work"));
412        assert_eq!(expand_tilde("~"), home);
413        // no leading tilde → returned verbatim, never touches $HOME
414        assert_eq!(
415            expand_tilde("/abs/path"),
416            std::path::PathBuf::from("/abs/path")
417        );
418        // a tilde mid-string is NOT a home marker
419        assert_eq!(expand_tilde("a~b"), std::path::PathBuf::from("a~b"));
420    }
421
422    #[test]
423    fn resolve_labels_errors_on_collision_without_alias() {
424        let tmp = tempfile::tempdir().unwrap();
425        let a = tmp.path().join("a/repos");
426        let b = tmp.path().join("b/repos");
427        std::fs::create_dir_all(&a).unwrap();
428        std::fs::create_dir_all(&b).unwrap();
429        let cfg = Config {
430            roots: vec![a, b],
431            ..Config::default()
432        };
433        let err = cfg.resolve_labels().unwrap_err().to_string();
434        assert!(err.contains("share label"), "got: {err}");
435        assert!(err.contains("alias"), "got: {err}");
436    }
437}