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, HashSet};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct ContextDef {
11    pub filter: String,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct Config {
16    /// Directories where git repositories are searched.
17    #[serde(default)]
18    pub roots: Vec<PathBuf>,
19    /// Optional per-root label override, keyed by the canonical root path.
20    #[serde(default)]
21    pub aliases: BTreeMap<String, String>,
22    /// Command that receives the prompt on stdin and returns the answer on stdout.
23    #[serde(default = "default_llm_command")]
24    pub llm_command: String,
25    /// Claude Code sessions directory.
26    #[serde(default = "default_sessions_dir")]
27    pub sessions_dir: PathBuf,
28    /// Maximum number of sessions used in distillation.
29    #[serde(default = "default_max_sessions")]
30    pub max_sessions: usize,
31    /// KB read from the tail of each session.
32    #[serde(default = "default_max_session_kb")]
33    pub max_session_kb: u64,
34    /// Maximum directory depth (from each root) to search for git repositories.
35    #[serde(default = "default_scan_depth")]
36    pub scan_depth: usize,
37    /// Seconds before an inventory entry is considered expired regardless of SHA
38    /// match. 0 (default) means SHA-only validation with no time-based expiry.
39    #[serde(default)]
40    pub inventory_ttl_secs: u64,
41    /// Named query scopes (`@name` in queries) mapped to filter strings.
42    #[serde(default)]
43    pub contexts: BTreeMap<String, ContextDef>,
44}
45
46fn default_llm_command() -> String {
47    "claude -p".into()
48}
49
50fn default_sessions_dir() -> PathBuf {
51    dirs::home_dir()
52        .unwrap_or_default()
53        .join(".claude/projects")
54}
55
56fn default_max_sessions() -> usize {
57    3
58}
59
60fn default_max_session_kb() -> u64 {
61    50
62}
63
64fn default_scan_depth() -> usize {
65    4
66}
67
68impl Default for Config {
69    fn default() -> Self {
70        Self {
71            roots: vec![],
72            aliases: BTreeMap::new(),
73            llm_command: default_llm_command(),
74            sessions_dir: default_sessions_dir(),
75            max_sessions: default_max_sessions(),
76            max_session_kb: default_max_session_kb(),
77            scan_depth: default_scan_depth(),
78            inventory_ttl_secs: 0,
79            contexts: BTreeMap::new(),
80        }
81    }
82}
83
84impl Config {
85    /// Returns the filter string for a named context.
86    pub fn context_filter(&self, name: &str) -> Result<&str> {
87        self.contexts
88            .get(name)
89            .map(|c| c.filter.as_str())
90            .ok_or_else(|| {
91                anyhow::anyhow!(
92                    "unknown context '@{name}'; define [contexts.{name}] in config.toml"
93                )
94            })
95    }
96
97    /// Resolves a stable label per root (alias, else basename). Errors when two
98    /// roots resolve to the same label and no alias disambiguates them.
99    pub fn resolve_labels(&self) -> Result<Vec<(std::path::PathBuf, String)>> {
100        let mut out: Vec<(std::path::PathBuf, String)> = Vec::new();
101        for root in &self.roots {
102            let label = self
103                .aliases
104                .get(&root.to_string_lossy().into_owned())
105                .cloned()
106                .unwrap_or_else(|| {
107                    root.file_name()
108                        .map(|n| n.to_string_lossy().into_owned())
109                        .unwrap_or_else(|| root.to_string_lossy().into_owned())
110                });
111            if let Some((other, _)) = out.iter().find(|(_, l)| *l == label) {
112                anyhow::bail!(
113                    "roots {} and {} share label '{label}'; set an alias in config.toml",
114                    other.display(),
115                    root.display()
116                );
117            }
118            out.push((root.clone(), label));
119        }
120        Ok(out)
121    }
122
123    /// Subset of configured roots matching `plan.root_filters`. Path values are
124    /// tilde-expanded and canonicalized, then matched as a prefix against roots
125    /// (ADR 0003). Label/path substring match is a fallback for short aliases.
126    /// Multiple filters are ANDed (intersection).
127    pub fn resolve_scan_roots(
128        &self,
129        plan: &crate::query::ScanPlan,
130    ) -> Result<Vec<std::path::PathBuf>> {
131        if plan.root_filters.is_empty() {
132            return Ok(self.roots.clone());
133        }
134        let labels = self.resolve_labels()?;
135        let mut acc: Option<HashSet<PathBuf>> = None;
136        for filter in &plan.root_filters {
137            let subset = self.roots_matching_filter(filter, &labels)?;
138            acc = Some(match acc {
139                None => subset,
140                Some(prev) => prev.intersection(&subset).cloned().collect(),
141            });
142        }
143        Ok(acc.unwrap().into_iter().collect())
144    }
145
146    fn roots_matching_filter(
147        &self,
148        filter: &str,
149        labels: &[(PathBuf, String)],
150    ) -> Result<HashSet<PathBuf>> {
151        let mut prefix = expand_tilde(filter);
152        if prefix.exists() {
153            if let Ok(canon) = std::fs::canonicalize(&prefix) {
154                prefix = canon;
155            }
156        }
157        let needle = filter.to_lowercase();
158        Ok(labels
159            .iter()
160            .filter(|(root, label)| root_matches_filter(root, label, filter, &needle, &prefix))
161            .map(|(root, _)| root.clone())
162            .collect())
163    }
164}
165
166/// True when `root`/`label` match a `root:` filter (ADR 0003).
167fn root_matches_filter(
168    root: &std::path::Path,
169    label: &str,
170    filter: &str,
171    needle: &str,
172    prefix: &std::path::Path,
173) -> bool {
174    // Canonical path prefix after tilde-expand (e.g. root:~/work).
175    if path_prefix_match(root, prefix) {
176        return true;
177    }
178    // Alias shortcut (e.g. root:w) — exact label, not substring (avoids "w" ⊂ "personal").
179    if label.eq_ignore_ascii_case(filter) {
180        return true;
181    }
182    // Path tail after optional ~/ (e.g. root:~/work or root:personal).
183    let path_needle = needle
184        .strip_prefix("~/")
185        .or_else(|| needle.strip_prefix('~'))
186        .unwrap_or(needle);
187    // Path component — basename only, not the full temp path.
188    if root
189        .file_name()
190        .is_some_and(|n| n.to_string_lossy().eq_ignore_ascii_case(path_needle))
191    {
192        return true;
193    }
194    let root_str = root.to_string_lossy().to_lowercase();
195    root_str.ends_with(path_needle)
196        || root_str.contains(&format!("/{path_needle}"))
197        || root_str.contains(&format!("\\{path_needle}"))
198}
199
200/// Prefix/equality match tolerant of canonical vs non-canonical paths (Windows `\\?\`).
201fn path_prefix_match(root: &Path, prefix: &Path) -> bool {
202    if root == prefix || root.starts_with(prefix) || prefix.starts_with(root) {
203        return true;
204    }
205    let root_ok = root.exists();
206    let prefix_ok = prefix.exists();
207    if root_ok && prefix_ok {
208        if let (Ok(r), Ok(p)) = (std::fs::canonicalize(root), std::fs::canonicalize(prefix)) {
209            if r == p || r.starts_with(&p) || p.starts_with(&r) {
210                return true;
211            }
212        }
213    }
214    false
215}
216
217/// Expands a leading `~` to the home directory (ADR `root:` filter).
218fn expand_tilde(path: &str) -> std::path::PathBuf {
219    if let Some(rest) = path.strip_prefix("~/") {
220        dirs::home_dir()
221            .map(|h| h.join(rest))
222            .unwrap_or_else(|| std::path::PathBuf::from(path))
223    } else if path == "~" {
224        dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(path))
225    } else {
226        std::path::PathBuf::from(path)
227    }
228}
229
230/// Label of the configured root that owns `repo` (longest path prefix wins).
231pub fn label_for_repo(labels: &[(std::path::PathBuf, String)], repo: &std::path::Path) -> String {
232    labels
233        .iter()
234        .filter(|(root, _)| repo.starts_with(root))
235        .max_by_key(|(root, _)| root.as_os_str().len())
236        .map(|(_, label)| label.clone())
237        .unwrap_or_else(|| {
238            repo.parent()
239                .and_then(|p| p.file_name())
240                .map(|n| n.to_string_lossy().into_owned())
241                .unwrap_or_default()
242        })
243}
244
245pub struct Store {
246    base: PathBuf,
247}
248
249impl Store {
250    pub fn new(base: PathBuf) -> Self {
251        Self { base }
252    }
253
254    pub fn config_path(&self) -> PathBuf {
255        self.base.join("config.toml")
256    }
257
258    pub fn load(&self) -> Result<Config> {
259        let path = self.config_path();
260        if !path.exists() {
261            return Ok(Config::default());
262        }
263        let raw = std::fs::read_to_string(&path)
264            .with_context(|| format!("reading {}", path.display()))?;
265        toml::from_str(&raw).with_context(|| format!("invalid config.toml at {}", path.display()))
266    }
267
268    pub fn save(&self, config: &Config) -> Result<()> {
269        std::fs::create_dir_all(&self.base)
270            .with_context(|| format!("creating {}", self.base.display()))?;
271        std::fs::write(self.config_path(), toml::to_string_pretty(config)?)?;
272        Ok(())
273    }
274
275    pub fn add_roots(&self, paths: &[PathBuf]) -> Result<Config> {
276        let mut config = self.load()?;
277        for p in paths {
278            let abs = std::fs::canonicalize(p)
279                .with_context(|| format!("nonexistent root: {}", p.display()))?;
280            if !config.roots.contains(&abs) {
281                config.roots.push(abs);
282            }
283        }
284        self.save(&config)?;
285        Ok(config)
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn load_without_file_returns_default() {
295        let tmp = tempfile::tempdir().unwrap();
296        let store = Store::new(tmp.path().to_path_buf());
297        let cfg = store.load().unwrap();
298        assert!(cfg.roots.is_empty());
299        assert_eq!(cfg.llm_command, "claude -p");
300        assert_eq!(cfg.max_sessions, 3);
301        assert_eq!(cfg.max_session_kb, 50);
302    }
303
304    #[test]
305    fn save_and_load_roundtrip() {
306        let tmp = tempfile::tempdir().unwrap();
307        let store = Store::new(tmp.path().join("state"));
308        let cfg = Config {
309            llm_command: "cat".into(),
310            ..Config::default()
311        };
312        store.save(&cfg).unwrap();
313        assert_eq!(store.load().unwrap().llm_command, "cat");
314    }
315
316    #[test]
317    fn add_roots_canonicalizes_and_deduplicates() {
318        let tmp = tempfile::tempdir().unwrap();
319        let store = Store::new(tmp.path().join("state"));
320        let root = tmp.path().join("projects");
321        std::fs::create_dir_all(&root).unwrap();
322        store.add_roots(std::slice::from_ref(&root)).unwrap();
323        let cfg = store.add_roots(std::slice::from_ref(&root)).unwrap();
324        assert_eq!(cfg.roots.len(), 1);
325        assert!(cfg.roots[0].is_absolute());
326    }
327
328    #[test]
329    fn add_roots_fails_for_nonexistent_dir() {
330        let tmp = tempfile::tempdir().unwrap();
331        let store = Store::new(tmp.path().join("state"));
332        let err = store
333            .add_roots(&[tmp.path().join("does-not-exist")])
334            .unwrap_err();
335        assert!(err.to_string().contains("nonexistent root"));
336    }
337
338    #[test]
339    fn resolve_labels_uses_basename_then_alias() {
340        let tmp = tempfile::tempdir().unwrap();
341        let store = Store::new(tmp.path().join("state"));
342        let work = tmp.path().join("work");
343        let personal = tmp.path().join("personal");
344        std::fs::create_dir_all(&work).unwrap();
345        std::fs::create_dir_all(&personal).unwrap();
346        let mut cfg = Config {
347            roots: vec![work.clone(), personal.clone()],
348            ..Config::default()
349        };
350        let labels = cfg.resolve_labels().unwrap();
351        assert!(labels.contains(&(work.clone(), "work".to_string())));
352        // alias overrides basename
353        cfg.aliases
354            .insert(personal.to_string_lossy().into_owned(), "p".into());
355        let labels = cfg.resolve_labels().unwrap();
356        assert!(labels.contains(&(personal.clone(), "p".to_string())));
357        let _ = store;
358    }
359
360    #[test]
361    fn config_scan_depth_defaults_to_four() {
362        let cfg = Config::default();
363        assert_eq!(cfg.scan_depth, 4);
364    }
365
366    #[test]
367    fn config_scan_depth_roundtrips_from_toml() {
368        let tmp = tempfile::tempdir().unwrap();
369        let store = Store::new(tmp.path().join("state"));
370        let cfg = Config {
371            scan_depth: 6,
372            ..Config::default()
373        };
374        store.save(&cfg).unwrap();
375        assert_eq!(store.load().unwrap().scan_depth, 6);
376    }
377
378    #[test]
379    fn config_inventory_ttl_secs_defaults_to_zero() {
380        let cfg = Config::default();
381        assert_eq!(cfg.inventory_ttl_secs, 0);
382    }
383
384    #[test]
385    fn config_inventory_ttl_secs_roundtrips_from_toml() {
386        let tmp = tempfile::tempdir().unwrap();
387        let store = Store::new(tmp.path().join("state"));
388        let cfg = Config {
389            inventory_ttl_secs: 3600,
390            ..Config::default()
391        };
392        store.save(&cfg).unwrap();
393        assert_eq!(store.load().unwrap().inventory_ttl_secs, 3600);
394    }
395
396    #[test]
397    fn resolve_scan_roots_filters_by_label_and_path() {
398        let tmp = tempfile::tempdir().unwrap();
399        let work = tmp.path().join("work");
400        let personal = tmp.path().join("personal");
401        std::fs::create_dir_all(&work).unwrap();
402        std::fs::create_dir_all(&personal).unwrap();
403        let mut cfg = Config {
404            roots: vec![work.clone(), personal.clone()],
405            ..Config::default()
406        };
407        cfg.aliases
408            .insert(work.to_string_lossy().into_owned(), "w".into());
409
410        let all = cfg
411            .resolve_scan_roots(&crate::query::ScanPlan::default())
412            .unwrap();
413        assert_eq!(all.len(), 2);
414
415        let by_label = cfg
416            .resolve_scan_roots(&crate::query::parse("root:w").unwrap())
417            .unwrap();
418        assert_eq!(by_label, vec![work.clone()]);
419
420        let by_path = cfg
421            .resolve_scan_roots(&crate::query::parse("root:personal").unwrap())
422            .unwrap();
423        assert_eq!(by_path, vec![personal]);
424    }
425
426    #[test]
427    fn resolve_scan_roots_short_alias_does_not_match_unrelated_path_noise() {
428        // Temp dirs like `.tmp02Wc68` contain the letter 'w'; a loose full-path
429        // substring match must not pull in every root when filtering root:w.
430        let tmp = tempfile::tempdir().unwrap();
431        let work = tmp.path().join("work");
432        let personal = tmp.path().join("personal");
433        std::fs::create_dir_all(&work).unwrap();
434        std::fs::create_dir_all(&personal).unwrap();
435        let mut cfg = Config {
436            roots: vec![work.clone(), personal.clone()],
437            ..Config::default()
438        };
439        cfg.aliases
440            .insert(work.to_string_lossy().into_owned(), "w".into());
441
442        let matched = cfg
443            .resolve_scan_roots(&crate::query::parse("root:w").unwrap())
444            .unwrap();
445        assert_eq!(matched, vec![work]);
446    }
447
448    #[test]
449    fn resolve_scan_roots_intersection_empty_when_filters_disjoint() {
450        let tmp = tempfile::tempdir().unwrap();
451        let work = tmp.path().join("work");
452        let personal = tmp.path().join("personal");
453        std::fs::create_dir_all(&work).unwrap();
454        std::fs::create_dir_all(&personal).unwrap();
455        let mut cfg = Config {
456            roots: vec![work.clone(), personal.clone()],
457            ..Config::default()
458        };
459        cfg.aliases
460            .insert(work.to_string_lossy().into_owned(), "w".into());
461
462        let plan = crate::query::ScanPlan {
463            root_filters: vec!["w".into(), "personal".into()],
464            ..Default::default()
465        };
466        let matched = cfg.resolve_scan_roots(&plan).unwrap();
467        assert!(matched.is_empty());
468    }
469
470    #[test]
471    fn resolve_scan_roots_single_filter_matches_same_as_one_root_token() {
472        let tmp = tempfile::tempdir().unwrap();
473        let work = tmp.path().join("work");
474        let personal = tmp.path().join("personal");
475        std::fs::create_dir_all(&work).unwrap();
476        std::fs::create_dir_all(&personal).unwrap();
477        let mut cfg = Config {
478            roots: vec![work.clone(), personal.clone()],
479            ..Config::default()
480        };
481        cfg.aliases
482            .insert(work.to_string_lossy().into_owned(), "w".into());
483
484        let via_parse = cfg
485            .resolve_scan_roots(&crate::query::parse("root:w").unwrap())
486            .unwrap();
487        let via_vec = cfg
488            .resolve_scan_roots(&crate::query::ScanPlan {
489                root_filters: vec!["w".into()],
490                ..Default::default()
491            })
492            .unwrap();
493        assert_eq!(via_vec, via_parse);
494        assert_eq!(via_vec, vec![work]);
495    }
496
497    #[test]
498    fn resolve_scan_roots_tilde_expands_to_prefix_match() {
499        let home = dirs::home_dir().expect("home dir");
500        let tmp = tempfile::tempdir().unwrap();
501        let work = home.join(format!(
502            ".loops-test-{}",
503            tmp.path().file_name().unwrap().to_string_lossy()
504        ));
505        std::fs::create_dir_all(&work).unwrap();
506        let cfg = Config {
507            roots: vec![work.clone()],
508            ..Config::default()
509        };
510        let filter = format!("~/{}", work.file_name().unwrap().to_string_lossy());
511        let matched = cfg
512            .resolve_scan_roots(&crate::query::parse(&format!("root:{filter}")).unwrap())
513            .unwrap();
514        assert_eq!(matched, vec![work.clone()]);
515        let _ = std::fs::remove_dir_all(&work);
516    }
517
518    #[test]
519    fn expand_tilde_handles_prefix_bare_and_literal() {
520        let home = dirs::home_dir().expect("home dir");
521        assert_eq!(expand_tilde("~/work"), home.join("work"));
522        assert_eq!(expand_tilde("~"), home);
523        // no leading tilde → returned verbatim, never touches $HOME
524        assert_eq!(
525            expand_tilde("/abs/path"),
526            std::path::PathBuf::from("/abs/path")
527        );
528        // a tilde mid-string is NOT a home marker
529        assert_eq!(expand_tilde("a~b"), std::path::PathBuf::from("a~b"));
530    }
531
532    #[test]
533    fn resolve_labels_errors_on_collision_without_alias() {
534        let tmp = tempfile::tempdir().unwrap();
535        let a = tmp.path().join("a/repos");
536        let b = tmp.path().join("b/repos");
537        std::fs::create_dir_all(&a).unwrap();
538        std::fs::create_dir_all(&b).unwrap();
539        let cfg = Config {
540            roots: vec![a, b],
541            ..Config::default()
542        };
543        let err = cfg.resolve_labels().unwrap_err().to_string();
544        assert!(err.contains("share label"), "got: {err}");
545        assert!(err.contains("alias"), "got: {err}");
546    }
547
548    #[test]
549    fn config_contexts_default_empty() {
550        let cfg = Config::default();
551        assert!(cfg.contexts.is_empty());
552    }
553
554    #[test]
555    fn config_contexts_roundtrip_from_toml() {
556        let tmp = tempfile::tempdir().unwrap();
557        let store = Store::new(tmp.path().join("state"));
558        let cfg = Config {
559            contexts: BTreeMap::from([(
560                "work".into(),
561                ContextDef {
562                    filter: "root:work".into(),
563                },
564            )]),
565            ..Config::default()
566        };
567        store.save(&cfg).unwrap();
568        let loaded = store.load().unwrap();
569        assert_eq!(
570            loaded.contexts.get("work"),
571            Some(&ContextDef {
572                filter: "root:work".into(),
573            })
574        );
575    }
576
577    #[test]
578    fn context_filter_returns_filter_for_known_context() {
579        let cfg = Config {
580            contexts: BTreeMap::from([(
581                "work".into(),
582                ContextDef {
583                    filter: "root:work".into(),
584                },
585            )]),
586            ..Config::default()
587        };
588        assert_eq!(cfg.context_filter("work").unwrap(), "root:work");
589    }
590
591    #[test]
592    fn context_filter_errors_for_unknown_context() {
593        let cfg = Config::default();
594        let err = cfg.context_filter("missing").unwrap_err().to_string();
595        assert!(err.contains("unknown context '@missing'"), "got: {err}");
596        assert!(err.contains("[contexts.missing]"), "got: {err}");
597    }
598}