1use anyhow::{bail, Context, Result};
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct Config {
20 pub watchers: Vec<String>,
22
23 pub auto_link: bool,
25
26 pub auto_link_threshold: f64,
28
29 pub commit_footer: bool,
31}
32
33impl Default for Config {
34 fn default() -> Self {
35 Self {
36 watchers: vec!["claude-code".to_string()],
37 auto_link: false,
38 auto_link_threshold: 0.7,
39 commit_footer: false,
40 }
41 }
42}
43
44impl Config {
45 pub fn load() -> Result<Self> {
49 let path = Self::config_path()?;
50 Self::load_from_path(&path)
51 }
52
53 #[allow(dead_code)]
57 pub fn save(&self) -> Result<()> {
58 let path = Self::config_path()?;
59 self.save_to_path(&path)
60 }
61
62 pub fn load_from_path(path: &Path) -> Result<Self> {
66 if !path.exists() {
67 return Ok(Self::default());
68 }
69
70 let content = fs::read_to_string(path)
71 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
72
73 if content.trim().is_empty() {
74 return Ok(Self::default());
75 }
76
77 let config: Config = serde_yaml::from_str(&content)
78 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
79
80 Ok(config)
81 }
82
83 pub fn save_to_path(&self, path: &Path) -> Result<()> {
87 if let Some(parent) = path.parent() {
88 fs::create_dir_all(parent).with_context(|| {
89 format!("Failed to create config directory: {}", parent.display())
90 })?;
91 }
92
93 let content = serde_yaml::to_string(self).context("Failed to serialize config")?;
94
95 fs::write(path, content)
96 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
97
98 Ok(())
99 }
100
101 pub fn get(&self, key: &str) -> Option<String> {
111 match key {
112 "watchers" => Some(self.watchers.join(",")),
113 "auto_link" => Some(self.auto_link.to_string()),
114 "auto_link_threshold" => Some(self.auto_link_threshold.to_string()),
115 "commit_footer" => Some(self.commit_footer.to_string()),
116 _ => None,
117 }
118 }
119
120 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
130 match key {
131 "watchers" => {
132 self.watchers = value
133 .split(',')
134 .map(|s| s.trim().to_string())
135 .filter(|s| !s.is_empty())
136 .collect();
137 }
138 "auto_link" => {
139 self.auto_link = parse_bool(value)
140 .with_context(|| format!("Invalid value for auto_link: '{value}'"))?;
141 }
142 "auto_link_threshold" => {
143 let threshold: f64 = value
144 .parse()
145 .with_context(|| format!("Invalid value for auto_link_threshold: '{value}'"))?;
146 if !(0.0..=1.0).contains(&threshold) {
147 bail!("auto_link_threshold must be between 0.0 and 1.0, got {threshold}");
148 }
149 self.auto_link_threshold = threshold;
150 }
151 "commit_footer" => {
152 self.commit_footer = parse_bool(value)
153 .with_context(|| format!("Invalid value for commit_footer: '{value}'"))?;
154 }
155 _ => {
156 bail!("Unknown configuration key: '{key}'");
157 }
158 }
159 Ok(())
160 }
161
162 pub fn config_path() -> Result<PathBuf> {
166 let config_dir = dirs::home_dir()
167 .ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?
168 .join(".lore");
169
170 Ok(config_dir.join("config.yaml"))
171 }
172
173 pub fn valid_keys() -> &'static [&'static str] {
175 &[
176 "watchers",
177 "auto_link",
178 "auto_link_threshold",
179 "commit_footer",
180 ]
181 }
182}
183
184fn parse_bool(value: &str) -> Result<bool> {
188 match value.to_lowercase().as_str() {
189 "true" | "1" | "yes" => Ok(true),
190 "false" | "0" | "no" => Ok(false),
191 _ => bail!("Expected 'true' or 'false', got '{value}'"),
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use tempfile::TempDir;
199
200 #[test]
201 fn test_default_config() {
202 let config = Config::default();
203 assert_eq!(config.watchers, vec!["claude-code".to_string()]);
204 assert!(!config.auto_link);
205 assert!((config.auto_link_threshold - 0.7).abs() < f64::EPSILON);
206 assert!(!config.commit_footer);
207 }
208
209 #[test]
210 fn test_load_nonexistent_returns_default() {
211 let temp_dir = TempDir::new().unwrap();
212 let path = temp_dir.path().join("nonexistent.yaml");
213
214 let config = Config::load_from_path(&path).unwrap();
215 assert_eq!(config, Config::default());
216 }
217
218 #[test]
219 fn test_save_and_load() {
220 let temp_dir = TempDir::new().unwrap();
221 let path = temp_dir.path().join("config.yaml");
222
223 let config = Config {
224 auto_link: true,
225 auto_link_threshold: 0.8,
226 watchers: vec!["claude-code".to_string(), "cursor".to_string()],
227 ..Default::default()
228 };
229
230 config.save_to_path(&path).unwrap();
231
232 let loaded = Config::load_from_path(&path).unwrap();
233 assert_eq!(loaded, config);
234 }
235
236 #[test]
237 fn test_save_creates_parent_directories() {
238 let temp_dir = TempDir::new().unwrap();
239 let path = temp_dir
240 .path()
241 .join("nested")
242 .join("dir")
243 .join("config.yaml");
244
245 let config = Config::default();
246 config.save_to_path(&path).unwrap();
247
248 assert!(path.exists());
249 }
250
251 #[test]
252 fn test_get_watchers() {
253 let config = Config {
254 watchers: vec!["claude-code".to_string(), "cursor".to_string()],
255 ..Default::default()
256 };
257
258 assert_eq!(
259 config.get("watchers"),
260 Some("claude-code,cursor".to_string())
261 );
262 }
263
264 #[test]
265 fn test_get_auto_link() {
266 let config = Config {
267 auto_link: true,
268 ..Default::default()
269 };
270
271 assert_eq!(config.get("auto_link"), Some("true".to_string()));
272 }
273
274 #[test]
275 fn test_get_auto_link_threshold() {
276 let config = Config::default();
277 assert_eq!(config.get("auto_link_threshold"), Some("0.7".to_string()));
278 }
279
280 #[test]
281 fn test_get_commit_footer() {
282 let config = Config::default();
283 assert_eq!(config.get("commit_footer"), Some("false".to_string()));
284 }
285
286 #[test]
287 fn test_get_unknown_key() {
288 let config = Config::default();
289 assert_eq!(config.get("unknown_key"), None);
290 }
291
292 #[test]
293 fn test_set_watchers() {
294 let mut config = Config::default();
295 config
296 .set("watchers", "claude-code, cursor, copilot")
297 .unwrap();
298
299 assert_eq!(
300 config.watchers,
301 vec![
302 "claude-code".to_string(),
303 "cursor".to_string(),
304 "copilot".to_string()
305 ]
306 );
307 }
308
309 #[test]
310 fn test_set_auto_link() {
311 let mut config = Config::default();
312
313 config.set("auto_link", "true").unwrap();
314 assert!(config.auto_link);
315
316 config.set("auto_link", "false").unwrap();
317 assert!(!config.auto_link);
318
319 config.set("auto_link", "yes").unwrap();
320 assert!(config.auto_link);
321
322 config.set("auto_link", "no").unwrap();
323 assert!(!config.auto_link);
324 }
325
326 #[test]
327 fn test_set_auto_link_threshold() {
328 let mut config = Config::default();
329
330 config.set("auto_link_threshold", "0.5").unwrap();
331 assert!((config.auto_link_threshold - 0.5).abs() < f64::EPSILON);
332
333 config.set("auto_link_threshold", "0.0").unwrap();
334 assert!((config.auto_link_threshold - 0.0).abs() < f64::EPSILON);
335
336 config.set("auto_link_threshold", "1.0").unwrap();
337 assert!((config.auto_link_threshold - 1.0).abs() < f64::EPSILON);
338 }
339
340 #[test]
341 fn test_set_auto_link_threshold_invalid_range() {
342 let mut config = Config::default();
343
344 assert!(config.set("auto_link_threshold", "-0.1").is_err());
345 assert!(config.set("auto_link_threshold", "1.1").is_err());
346 assert!(config.set("auto_link_threshold", "2.0").is_err());
347 }
348
349 #[test]
350 fn test_set_auto_link_threshold_invalid_format() {
351 let mut config = Config::default();
352
353 assert!(config.set("auto_link_threshold", "not_a_number").is_err());
354 }
355
356 #[test]
357 fn test_set_commit_footer() {
358 let mut config = Config::default();
359
360 config.set("commit_footer", "true").unwrap();
361 assert!(config.commit_footer);
362
363 config.set("commit_footer", "false").unwrap();
364 assert!(!config.commit_footer);
365 }
366
367 #[test]
368 fn test_set_unknown_key() {
369 let mut config = Config::default();
370
371 assert!(config.set("unknown_key", "value").is_err());
372 }
373
374 #[test]
375 fn test_set_invalid_bool() {
376 let mut config = Config::default();
377
378 assert!(config.set("auto_link", "maybe").is_err());
379 }
380
381 #[test]
382 fn test_load_empty_file_returns_default() {
383 let temp_dir = TempDir::new().unwrap();
384 let path = temp_dir.path().join("config.yaml");
385
386 fs::write(&path, "").unwrap();
387
388 let config = Config::load_from_path(&path).unwrap();
389 assert_eq!(config, Config::default());
390 }
391
392 #[test]
393 fn test_valid_keys() {
394 let keys = Config::valid_keys();
395 assert!(keys.contains(&"watchers"));
396 assert!(keys.contains(&"auto_link"));
397 assert!(keys.contains(&"auto_link_threshold"));
398 assert!(keys.contains(&"commit_footer"));
399 }
400
401 #[test]
402 fn test_parse_bool() {
403 assert!(parse_bool("true").unwrap());
404 assert!(parse_bool("TRUE").unwrap());
405 assert!(parse_bool("True").unwrap());
406 assert!(parse_bool("1").unwrap());
407 assert!(parse_bool("yes").unwrap());
408 assert!(parse_bool("YES").unwrap());
409
410 assert!(!parse_bool("false").unwrap());
411 assert!(!parse_bool("FALSE").unwrap());
412 assert!(!parse_bool("False").unwrap());
413 assert!(!parse_bool("0").unwrap());
414 assert!(!parse_bool("no").unwrap());
415 assert!(!parse_bool("NO").unwrap());
416
417 assert!(parse_bool("invalid").is_err());
418 }
419}