1use 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 #[serde(default)]
18 pub roots: Vec<PathBuf>,
19 #[serde(default)]
21 pub aliases: BTreeMap<String, String>,
22 #[serde(default = "default_llm_command")]
24 pub llm_command: String,
25 #[serde(default = "default_sessions_dir")]
27 pub sessions_dir: PathBuf,
28 #[serde(default = "default_max_sessions")]
30 pub max_sessions: usize,
31 #[serde(default = "default_max_session_kb")]
33 pub max_session_kb: u64,
34 #[serde(default = "default_scan_depth")]
36 pub scan_depth: usize,
37 #[serde(default)]
40 pub inventory_ttl_secs: u64,
41 #[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 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 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 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
166fn 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 if path_prefix_match(root, prefix) {
176 return true;
177 }
178 if label.eq_ignore_ascii_case(filter) {
180 return true;
181 }
182 let path_needle = needle
184 .strip_prefix("~/")
185 .or_else(|| needle.strip_prefix('~'))
186 .unwrap_or(needle);
187 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
200fn 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
217fn 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
230pub 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 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 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 assert_eq!(
525 expand_tilde("/abs/path"),
526 std::path::PathBuf::from("/abs/path")
527 );
528 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}