1use 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 #[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
98pub 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 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}