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_saphyr::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_saphyr::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_save_and_load_roundtrip() {
283 let temp_dir = TempDir::new().unwrap();
284 let path = temp_dir.path().join("config.yaml");
285
286 let config = Config {
287 auto_link: true,
288 auto_link_threshold: 0.8,
289 watchers: vec!["claude-code".to_string(), "cursor".to_string()],
290 machine_id: Some("test-uuid".to_string()),
291 machine_name: Some("test-name".to_string()),
292 ..Default::default()
293 };
294
295 config.save_to_path(&path).unwrap();
296 let loaded = Config::load_from_path(&path).unwrap();
297 assert_eq!(loaded, config);
298 }
299
300 #[test]
301 fn test_save_creates_parent_directories() {
302 let temp_dir = TempDir::new().unwrap();
303 let path = temp_dir
304 .path()
305 .join("nested")
306 .join("dir")
307 .join("config.yaml");
308
309 let config = Config::default();
310 config.save_to_path(&path).unwrap();
311
312 assert!(path.exists());
313 }
314
315 #[test]
316 fn test_load_returns_default_for_missing_or_empty_file() {
317 let temp_dir = TempDir::new().unwrap();
318
319 let nonexistent = temp_dir.path().join("nonexistent.yaml");
321 let config = Config::load_from_path(&nonexistent).unwrap();
322 assert_eq!(config, Config::default());
323
324 let empty = temp_dir.path().join("empty.yaml");
326 fs::write(&empty, "").unwrap();
327 let config = Config::load_from_path(&empty).unwrap();
328 assert_eq!(config, Config::default());
329 }
330
331 #[test]
332 fn test_get_returns_expected_values() {
333 let config = Config {
334 watchers: vec!["claude-code".to_string(), "cursor".to_string()],
335 auto_link: true,
336 auto_link_threshold: 0.85,
337 commit_footer: true,
338 machine_id: Some("test-uuid".to_string()),
339 machine_name: Some("test-machine".to_string()),
340 };
341
342 assert_eq!(
343 config.get("watchers"),
344 Some("claude-code,cursor".to_string())
345 );
346 assert_eq!(config.get("auto_link"), Some("true".to_string()));
347 assert_eq!(config.get("auto_link_threshold"), Some("0.85".to_string()));
348 assert_eq!(config.get("commit_footer"), Some("true".to_string()));
349 assert_eq!(config.get("machine_id"), Some("test-uuid".to_string()));
350 assert_eq!(config.get("machine_name"), Some("test-machine".to_string()));
351 assert_eq!(config.get("unknown_key"), None);
352 }
353
354 #[test]
355 fn test_set_updates_values() {
356 let mut config = Config::default();
357
358 config
360 .set("watchers", "claude-code, cursor, copilot")
361 .unwrap();
362 assert_eq!(
363 config.watchers,
364 vec![
365 "claude-code".to_string(),
366 "cursor".to_string(),
367 "copilot".to_string()
368 ]
369 );
370
371 config.set("auto_link", "true").unwrap();
373 assert!(config.auto_link);
374 config.set("auto_link", "no").unwrap();
375 assert!(!config.auto_link);
376
377 config.set("commit_footer", "yes").unwrap();
378 assert!(config.commit_footer);
379
380 config.set("auto_link_threshold", "0.5").unwrap();
382 assert!((config.auto_link_threshold - 0.5).abs() < f64::EPSILON);
383
384 config.set("machine_name", "dev-workstation").unwrap();
386 assert_eq!(config.machine_name, Some("dev-workstation".to_string()));
387 }
388
389 #[test]
390 fn test_set_validates_threshold_range() {
391 let mut config = Config::default();
392
393 config.set("auto_link_threshold", "0.0").unwrap();
395 assert!((config.auto_link_threshold - 0.0).abs() < f64::EPSILON);
396 config.set("auto_link_threshold", "1.0").unwrap();
397 assert!((config.auto_link_threshold - 1.0).abs() < f64::EPSILON);
398
399 assert!(config.set("auto_link_threshold", "-0.1").is_err());
401 assert!(config.set("auto_link_threshold", "1.1").is_err());
402 assert!(config.set("auto_link_threshold", "not_a_number").is_err());
403 }
404
405 #[test]
406 fn test_set_rejects_invalid_input() {
407 let mut config = Config::default();
408
409 assert!(config.set("unknown_key", "value").is_err());
411
412 assert!(config.set("auto_link", "maybe").is_err());
414
415 let result = config.set("machine_id", "some-uuid");
417 assert!(result.is_err());
418 assert!(result
419 .unwrap_err()
420 .to_string()
421 .contains("cannot be set manually"));
422 }
423
424 #[test]
425 fn test_parse_bool_accepts_multiple_formats() {
426 assert!(parse_bool("true").unwrap());
428 assert!(parse_bool("TRUE").unwrap());
429 assert!(parse_bool("1").unwrap());
430 assert!(parse_bool("yes").unwrap());
431 assert!(parse_bool("YES").unwrap());
432
433 assert!(!parse_bool("false").unwrap());
435 assert!(!parse_bool("FALSE").unwrap());
436 assert!(!parse_bool("0").unwrap());
437 assert!(!parse_bool("no").unwrap());
438
439 assert!(parse_bool("invalid").is_err());
441 }
442
443 #[test]
444 fn test_machine_name_fallback_to_hostname() {
445 let config = Config::default();
446 let name = config.get_machine_name();
447 assert!(!name.is_empty());
449 }
450
451 #[test]
452 fn test_machine_identity_yaml_serialization() {
453 let config = Config::default();
455 let yaml = serde_saphyr::to_string(&config).unwrap();
456 assert!(!yaml.contains("machine_id"));
457 assert!(!yaml.contains("machine_name"));
458
459 let config = Config {
461 machine_id: Some("uuid-1234".to_string()),
462 machine_name: Some("my-machine".to_string()),
463 ..Default::default()
464 };
465 let yaml = serde_saphyr::to_string(&config).unwrap();
466 assert!(yaml.contains("machine_id"));
467 assert!(yaml.contains("machine_name"));
468 }
469}