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::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
98/// Label of the configured root that owns `repo` (longest path prefix wins).
99pub fn label_for_repo(labels: &[(std::path::PathBuf, String)], repo: &std::path::Path) -> String {
100    labels
101        .iter()
102        .filter(|(root, _)| repo.starts_with(root))
103        .max_by_key(|(root, _)| root.as_os_str().len())
104        .map(|(_, label)| label.clone())
105        .unwrap_or_else(|| {
106            repo.parent()
107                .and_then(|p| p.file_name())
108                .map(|n| n.to_string_lossy().into_owned())
109                .unwrap_or_default()
110        })
111}
112
113pub struct Store {
114    base: PathBuf,
115}
116
117impl Store {
118    pub fn new(base: PathBuf) -> Self {
119        Self { base }
120    }
121
122    pub fn config_path(&self) -> PathBuf {
123        self.base.join("config.toml")
124    }
125
126    pub fn load(&self) -> Result<Config> {
127        let path = self.config_path();
128        if !path.exists() {
129            return Ok(Config::default());
130        }
131        let raw = std::fs::read_to_string(&path)
132            .with_context(|| format!("reading {}", path.display()))?;
133        toml::from_str(&raw).with_context(|| format!("invalid config.toml at {}", path.display()))
134    }
135
136    pub fn save(&self, config: &Config) -> Result<()> {
137        std::fs::create_dir_all(&self.base)
138            .with_context(|| format!("creating {}", self.base.display()))?;
139        std::fs::write(self.config_path(), toml::to_string_pretty(config)?)?;
140        Ok(())
141    }
142
143    pub fn add_roots(&self, paths: &[PathBuf]) -> Result<Config> {
144        let mut config = self.load()?;
145        for p in paths {
146            let abs = std::fs::canonicalize(p)
147                .with_context(|| format!("nonexistent root: {}", p.display()))?;
148            if !config.roots.contains(&abs) {
149                config.roots.push(abs);
150            }
151        }
152        self.save(&config)?;
153        Ok(config)
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn load_without_file_returns_default() {
163        let tmp = tempfile::tempdir().unwrap();
164        let store = Store::new(tmp.path().to_path_buf());
165        let cfg = store.load().unwrap();
166        assert!(cfg.roots.is_empty());
167        assert_eq!(cfg.llm_command, "claude -p");
168        assert_eq!(cfg.max_sessions, 3);
169        assert_eq!(cfg.max_session_kb, 50);
170    }
171
172    #[test]
173    fn save_and_load_roundtrip() {
174        let tmp = tempfile::tempdir().unwrap();
175        let store = Store::new(tmp.path().join("state"));
176        let cfg = Config {
177            llm_command: "cat".into(),
178            ..Config::default()
179        };
180        store.save(&cfg).unwrap();
181        assert_eq!(store.load().unwrap().llm_command, "cat");
182    }
183
184    #[test]
185    fn add_roots_canonicalizes_and_deduplicates() {
186        let tmp = tempfile::tempdir().unwrap();
187        let store = Store::new(tmp.path().join("state"));
188        let root = tmp.path().join("projects");
189        std::fs::create_dir_all(&root).unwrap();
190        store.add_roots(std::slice::from_ref(&root)).unwrap();
191        let cfg = store.add_roots(std::slice::from_ref(&root)).unwrap();
192        assert_eq!(cfg.roots.len(), 1);
193        assert!(cfg.roots[0].is_absolute());
194    }
195
196    #[test]
197    fn add_roots_fails_for_nonexistent_dir() {
198        let tmp = tempfile::tempdir().unwrap();
199        let store = Store::new(tmp.path().join("state"));
200        let err = store
201            .add_roots(&[tmp.path().join("does-not-exist")])
202            .unwrap_err();
203        assert!(err.to_string().contains("nonexistent root"));
204    }
205
206    #[test]
207    fn resolve_labels_uses_basename_then_alias() {
208        let tmp = tempfile::tempdir().unwrap();
209        let store = Store::new(tmp.path().join("state"));
210        let work = tmp.path().join("work");
211        let personal = tmp.path().join("personal");
212        std::fs::create_dir_all(&work).unwrap();
213        std::fs::create_dir_all(&personal).unwrap();
214        let mut cfg = Config {
215            roots: vec![work.clone(), personal.clone()],
216            ..Config::default()
217        };
218        let labels = cfg.resolve_labels().unwrap();
219        assert!(labels.contains(&(work.clone(), "work".to_string())));
220        // alias overrides basename
221        cfg.aliases
222            .insert(personal.to_string_lossy().into_owned(), "p".into());
223        let labels = cfg.resolve_labels().unwrap();
224        assert!(labels.contains(&(personal.clone(), "p".to_string())));
225        let _ = store;
226    }
227
228    #[test]
229    fn config_scan_depth_defaults_to_four() {
230        let cfg = Config::default();
231        assert_eq!(cfg.scan_depth, 4);
232    }
233
234    #[test]
235    fn config_scan_depth_roundtrips_from_toml() {
236        let tmp = tempfile::tempdir().unwrap();
237        let store = Store::new(tmp.path().join("state"));
238        let cfg = Config {
239            scan_depth: 6,
240            ..Config::default()
241        };
242        store.save(&cfg).unwrap();
243        assert_eq!(store.load().unwrap().scan_depth, 6);
244    }
245
246    #[test]
247    fn resolve_labels_errors_on_collision_without_alias() {
248        let tmp = tempfile::tempdir().unwrap();
249        let a = tmp.path().join("a/repos");
250        let b = tmp.path().join("b/repos");
251        std::fs::create_dir_all(&a).unwrap();
252        std::fs::create_dir_all(&b).unwrap();
253        let cfg = Config {
254            roots: vec![a, b],
255            ..Config::default()
256        };
257        let err = cfg.resolve_labels().unwrap_err().to_string();
258        assert!(err.contains("share label"), "got: {err}");
259        assert!(err.contains("alias"), "got: {err}");
260    }
261}