1use 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 #[serde(default)]
13 pub roots: Vec<PathBuf>,
14 #[serde(default)]
16 pub aliases: BTreeMap<String, String>,
17 #[serde(default = "default_llm_command")]
19 pub llm_command: String,
20 #[serde(default = "default_sessions_dir")]
22 pub sessions_dir: PathBuf,
23 #[serde(default = "default_max_sessions")]
25 pub max_sessions: usize,
26 #[serde(default = "default_max_session_kb")]
28 pub max_session_kb: u64,
29 #[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 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 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
123fn 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 if path_prefix_match(root, prefix) {
133 return true;
134 }
135 if label.eq_ignore_ascii_case(filter) {
137 return true;
138 }
139 let path_needle = needle
141 .strip_prefix("~/")
142 .or_else(|| needle.strip_prefix('~'))
143 .unwrap_or(needle);
144 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
157fn 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
174fn 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
187pub 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 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 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 assert_eq!(
415 expand_tilde("/abs/path"),
416 std::path::PathBuf::from("/abs/path")
417 );
418 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}