1use anyhow::{bail, Context, Result};
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::{Path, PathBuf};
13use uuid::Uuid;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct Config {
21 pub watchers: Vec<String>,
23
24 pub auto_link: bool,
26
27 pub auto_link_threshold: f64,
29
30 pub commit_footer: bool,
32
33 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub machine_id: Option<String>,
38
39 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub machine_name: Option<String>,
44}
45
46impl Default for Config {
47 fn default() -> Self {
48 Self {
49 watchers: vec!["claude-code".to_string()],
50 auto_link: false,
51 auto_link_threshold: 0.7,
52 commit_footer: false,
53 machine_id: None,
54 machine_name: None,
55 }
56 }
57}
58
59impl Config {
60 pub fn load() -> Result<Self> {
64 let path = Self::config_path()?;
65 Self::load_from_path(&path)
66 }
67
68 pub fn save(&self) -> Result<()> {
72 let path = Self::config_path()?;
73 self.save_to_path(&path)
74 }
75
76 pub fn load_from_path(path: &Path) -> Result<Self> {
80 if !path.exists() {
81 return Ok(Self::default());
82 }
83
84 let content = fs::read_to_string(path)
85 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
86
87 if content.trim().is_empty() {
88 return Ok(Self::default());
89 }
90
91 let config: Config = serde_yaml::from_str(&content)
92 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
93
94 Ok(config)
95 }
96
97 pub fn save_to_path(&self, path: &Path) -> Result<()> {
101 if let Some(parent) = path.parent() {
102 fs::create_dir_all(parent).with_context(|| {
103 format!("Failed to create config directory: {}", parent.display())
104 })?;
105 }
106
107 let content = serde_yaml::to_string(self).context("Failed to serialize config")?;
108
109 fs::write(path, content)
110 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
111
112 Ok(())
113 }
114
115 pub fn get_or_create_machine_id(&mut self) -> Result<String> {
121 if let Some(ref id) = self.machine_id {
122 return Ok(id.clone());
123 }
124
125 let id = Uuid::new_v4().to_string();
126 self.machine_id = Some(id.clone());
127 self.save()?;
128 Ok(id)
129 }
130
131 pub fn get_machine_name(&self) -> String {
136 if let Some(ref name) = self.machine_name {
137 return name.clone();
138 }
139
140 hostname::get()
141 .ok()
142 .and_then(|h| h.into_string().ok())
143 .unwrap_or_else(|| "unknown".to_string())
144 }
145
146 pub fn set_machine_name(&mut self, name: &str) -> Result<()> {
152 self.machine_name = Some(name.to_string());
153 self.save()
154 }
155
156 pub fn get(&self, key: &str) -> Option<String> {
168 match key {
169 "watchers" => Some(self.watchers.join(",")),
170 "auto_link" => Some(self.auto_link.to_string()),
171 "auto_link_threshold" => Some(self.auto_link_threshold.to_string()),
172 "commit_footer" => Some(self.commit_footer.to_string()),
173 "machine_id" => self.machine_id.clone(),
174 "machine_name" => Some(self.get_machine_name()),
175 _ => None,
176 }
177 }
178
179 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
192 match key {
193 "watchers" => {
194 self.watchers = value
195 .split(',')
196 .map(|s| s.trim().to_string())
197 .filter(|s| !s.is_empty())
198 .collect();
199 }
200 "auto_link" => {
201 self.auto_link = parse_bool(value)
202 .with_context(|| format!("Invalid value for auto_link: '{value}'"))?;
203 }
204 "auto_link_threshold" => {
205 let threshold: f64 = value
206 .parse()
207 .with_context(|| format!("Invalid value for auto_link_threshold: '{value}'"))?;
208 if !(0.0..=1.0).contains(&threshold) {
209 bail!("auto_link_threshold must be between 0.0 and 1.0, got {threshold}");
210 }
211 self.auto_link_threshold = threshold;
212 }
213 "commit_footer" => {
214 self.commit_footer = parse_bool(value)
215 .with_context(|| format!("Invalid value for commit_footer: '{value}'"))?;
216 }
217 "machine_name" => {
218 self.machine_name = Some(value.to_string());
219 }
220 "machine_id" => {
221 bail!("machine_id cannot be set manually; it is auto-generated");
222 }
223 _ => {
224 bail!("Unknown configuration key: '{key}'");
225 }
226 }
227 Ok(())
228 }
229
230 pub fn config_path() -> Result<PathBuf> {
234 let config_dir = dirs::home_dir()
235 .ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?
236 .join(".lore");
237
238 Ok(config_dir.join("config.yaml"))
239 }
240
241 pub fn valid_keys() -> &'static [&'static str] {
243 &[
244 "watchers",
245 "auto_link",
246 "auto_link_threshold",
247 "commit_footer",
248 "machine_id",
249 "machine_name",
250 ]
251 }
252}
253
254fn parse_bool(value: &str) -> Result<bool> {
258 match value.to_lowercase().as_str() {
259 "true" | "1" | "yes" => Ok(true),
260 "false" | "0" | "no" => Ok(false),
261 _ => bail!("Expected 'true' or 'false', got '{value}'"),
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use tempfile::TempDir;
269
270 #[test]
271 fn test_default_config() {
272 let config = Config::default();
273 assert_eq!(config.watchers, vec!["claude-code".to_string()]);
274 assert!(!config.auto_link);
275 assert!((config.auto_link_threshold - 0.7).abs() < f64::EPSILON);
276 assert!(!config.commit_footer);
277 assert!(config.machine_id.is_none());
278 assert!(config.machine_name.is_none());
279 }
280
281 #[test]
282 fn test_load_nonexistent_returns_default() {
283 let temp_dir = TempDir::new().unwrap();
284 let path = temp_dir.path().join("nonexistent.yaml");
285
286 let config = Config::load_from_path(&path).unwrap();
287 assert_eq!(config, Config::default());
288 }
289
290 #[test]
291 fn test_save_and_load() {
292 let temp_dir = TempDir::new().unwrap();
293 let path = temp_dir.path().join("config.yaml");
294
295 let config = Config {
296 auto_link: true,
297 auto_link_threshold: 0.8,
298 watchers: vec!["claude-code".to_string(), "cursor".to_string()],
299 ..Default::default()
300 };
301
302 config.save_to_path(&path).unwrap();
303
304 let loaded = Config::load_from_path(&path).unwrap();
305 assert_eq!(loaded, config);
306 }
307
308 #[test]
309 fn test_save_creates_parent_directories() {
310 let temp_dir = TempDir::new().unwrap();
311 let path = temp_dir
312 .path()
313 .join("nested")
314 .join("dir")
315 .join("config.yaml");
316
317 let config = Config::default();
318 config.save_to_path(&path).unwrap();
319
320 assert!(path.exists());
321 }
322
323 #[test]
324 fn test_get_watchers() {
325 let config = Config {
326 watchers: vec!["claude-code".to_string(), "cursor".to_string()],
327 ..Default::default()
328 };
329
330 assert_eq!(
331 config.get("watchers"),
332 Some("claude-code,cursor".to_string())
333 );
334 }
335
336 #[test]
337 fn test_get_auto_link() {
338 let config = Config {
339 auto_link: true,
340 ..Default::default()
341 };
342
343 assert_eq!(config.get("auto_link"), Some("true".to_string()));
344 }
345
346 #[test]
347 fn test_get_auto_link_threshold() {
348 let config = Config::default();
349 assert_eq!(config.get("auto_link_threshold"), Some("0.7".to_string()));
350 }
351
352 #[test]
353 fn test_get_commit_footer() {
354 let config = Config::default();
355 assert_eq!(config.get("commit_footer"), Some("false".to_string()));
356 }
357
358 #[test]
359 fn test_get_unknown_key() {
360 let config = Config::default();
361 assert_eq!(config.get("unknown_key"), None);
362 }
363
364 #[test]
365 fn test_set_watchers() {
366 let mut config = Config::default();
367 config
368 .set("watchers", "claude-code, cursor, copilot")
369 .unwrap();
370
371 assert_eq!(
372 config.watchers,
373 vec![
374 "claude-code".to_string(),
375 "cursor".to_string(),
376 "copilot".to_string()
377 ]
378 );
379 }
380
381 #[test]
382 fn test_set_auto_link() {
383 let mut config = Config::default();
384
385 config.set("auto_link", "true").unwrap();
386 assert!(config.auto_link);
387
388 config.set("auto_link", "false").unwrap();
389 assert!(!config.auto_link);
390
391 config.set("auto_link", "yes").unwrap();
392 assert!(config.auto_link);
393
394 config.set("auto_link", "no").unwrap();
395 assert!(!config.auto_link);
396 }
397
398 #[test]
399 fn test_set_auto_link_threshold() {
400 let mut config = Config::default();
401
402 config.set("auto_link_threshold", "0.5").unwrap();
403 assert!((config.auto_link_threshold - 0.5).abs() < f64::EPSILON);
404
405 config.set("auto_link_threshold", "0.0").unwrap();
406 assert!((config.auto_link_threshold - 0.0).abs() < f64::EPSILON);
407
408 config.set("auto_link_threshold", "1.0").unwrap();
409 assert!((config.auto_link_threshold - 1.0).abs() < f64::EPSILON);
410 }
411
412 #[test]
413 fn test_set_auto_link_threshold_invalid_range() {
414 let mut config = Config::default();
415
416 assert!(config.set("auto_link_threshold", "-0.1").is_err());
417 assert!(config.set("auto_link_threshold", "1.1").is_err());
418 assert!(config.set("auto_link_threshold", "2.0").is_err());
419 }
420
421 #[test]
422 fn test_set_auto_link_threshold_invalid_format() {
423 let mut config = Config::default();
424
425 assert!(config.set("auto_link_threshold", "not_a_number").is_err());
426 }
427
428 #[test]
429 fn test_set_commit_footer() {
430 let mut config = Config::default();
431
432 config.set("commit_footer", "true").unwrap();
433 assert!(config.commit_footer);
434
435 config.set("commit_footer", "false").unwrap();
436 assert!(!config.commit_footer);
437 }
438
439 #[test]
440 fn test_set_unknown_key() {
441 let mut config = Config::default();
442
443 assert!(config.set("unknown_key", "value").is_err());
444 }
445
446 #[test]
447 fn test_set_invalid_bool() {
448 let mut config = Config::default();
449
450 assert!(config.set("auto_link", "maybe").is_err());
451 }
452
453 #[test]
454 fn test_load_empty_file_returns_default() {
455 let temp_dir = TempDir::new().unwrap();
456 let path = temp_dir.path().join("config.yaml");
457
458 fs::write(&path, "").unwrap();
459
460 let config = Config::load_from_path(&path).unwrap();
461 assert_eq!(config, Config::default());
462 }
463
464 #[test]
465 fn test_valid_keys() {
466 let keys = Config::valid_keys();
467 assert!(keys.contains(&"watchers"));
468 assert!(keys.contains(&"auto_link"));
469 assert!(keys.contains(&"auto_link_threshold"));
470 assert!(keys.contains(&"commit_footer"));
471 assert!(keys.contains(&"machine_id"));
472 assert!(keys.contains(&"machine_name"));
473 }
474
475 #[test]
476 fn test_parse_bool() {
477 assert!(parse_bool("true").unwrap());
478 assert!(parse_bool("TRUE").unwrap());
479 assert!(parse_bool("True").unwrap());
480 assert!(parse_bool("1").unwrap());
481 assert!(parse_bool("yes").unwrap());
482 assert!(parse_bool("YES").unwrap());
483
484 assert!(!parse_bool("false").unwrap());
485 assert!(!parse_bool("FALSE").unwrap());
486 assert!(!parse_bool("False").unwrap());
487 assert!(!parse_bool("0").unwrap());
488 assert!(!parse_bool("no").unwrap());
489 assert!(!parse_bool("NO").unwrap());
490
491 assert!(parse_bool("invalid").is_err());
492 }
493
494 #[test]
495 fn test_get_machine_name_returns_custom_name() {
496 let config = Config {
497 machine_name: Some("my-laptop".to_string()),
498 ..Default::default()
499 };
500 assert_eq!(config.get_machine_name(), "my-laptop");
501 }
502
503 #[test]
504 fn test_get_machine_name_returns_hostname_when_not_set() {
505 let config = Config::default();
506 let name = config.get_machine_name();
507 assert!(!name.is_empty());
509 }
510
511 #[test]
512 fn test_set_machine_name_via_set_method() {
513 let mut config = Config::default();
514 config.set("machine_name", "dev-workstation").unwrap();
515 assert_eq!(config.machine_name, Some("dev-workstation".to_string()));
516 }
517
518 #[test]
519 fn test_get_machine_name_via_get_method() {
520 let config = Config {
521 machine_name: Some("test-machine".to_string()),
522 ..Default::default()
523 };
524 assert_eq!(config.get("machine_name"), Some("test-machine".to_string()));
525 }
526
527 #[test]
528 fn test_get_machine_id_returns_none_when_not_set() {
529 let config = Config::default();
530 assert_eq!(config.get("machine_id"), None);
531 }
532
533 #[test]
534 fn test_get_machine_id_returns_value_when_set() {
535 let config = Config {
536 machine_id: Some("test-uuid-1234".to_string()),
537 ..Default::default()
538 };
539 assert_eq!(config.get("machine_id"), Some("test-uuid-1234".to_string()));
540 }
541
542 #[test]
543 fn test_set_machine_id_fails() {
544 let mut config = Config::default();
545 let result = config.set("machine_id", "some-uuid");
546 assert!(result.is_err());
547 assert!(result
548 .unwrap_err()
549 .to_string()
550 .contains("cannot be set manually"));
551 }
552
553 #[test]
554 fn test_machine_id_and_name_omitted_from_yaml_when_none() {
555 let config = Config::default();
556 let yaml = serde_yaml::to_string(&config).unwrap();
557 assert!(!yaml.contains("machine_id"));
558 assert!(!yaml.contains("machine_name"));
559 }
560
561 #[test]
562 fn test_machine_id_and_name_included_in_yaml_when_set() {
563 let config = Config {
564 machine_id: Some("uuid-1234".to_string()),
565 machine_name: Some("my-machine".to_string()),
566 ..Default::default()
567 };
568 let yaml = serde_yaml::to_string(&config).unwrap();
569 assert!(yaml.contains("machine_id"));
570 assert!(yaml.contains("machine_name"));
571 }
572
573 #[test]
574 fn test_save_and_load_with_machine_identity() {
575 let temp_dir = TempDir::new().unwrap();
576 let path = temp_dir.path().join("config.yaml");
577
578 let config = Config {
579 machine_id: Some("test-uuid".to_string()),
580 machine_name: Some("test-name".to_string()),
581 ..Default::default()
582 };
583
584 config.save_to_path(&path).unwrap();
585 let loaded = Config::load_from_path(&path).unwrap();
586
587 assert_eq!(loaded.machine_id, Some("test-uuid".to_string()));
588 assert_eq!(loaded.machine_name, Some("test-name".to_string()));
589 }
590}