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 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub cloud_url: Option<String>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub encryption_salt: Option<String>,
58
59 #[serde(default)]
65 pub use_keychain: bool,
66}
67
68impl Default for Config {
69 fn default() -> Self {
70 Self {
71 watchers: vec!["claude-code".to_string()],
72 auto_link: false,
73 auto_link_threshold: 0.7,
74 commit_footer: false,
75 machine_id: None,
76 machine_name: None,
77 cloud_url: None,
78 encryption_salt: None,
79 use_keychain: false,
80 }
81 }
82}
83
84impl Config {
85 pub fn load() -> Result<Self> {
89 let path = Self::config_path()?;
90 Self::load_from_path(&path)
91 }
92
93 pub fn save(&self) -> Result<()> {
97 let path = Self::config_path()?;
98 self.save_to_path(&path)
99 }
100
101 pub fn load_from_path(path: &Path) -> Result<Self> {
105 if !path.exists() {
106 return Ok(Self::default());
107 }
108
109 let content = fs::read_to_string(path)
110 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
111
112 if content.trim().is_empty() {
113 return Ok(Self::default());
114 }
115
116 let config: Config = serde_saphyr::from_str(&content)
117 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
118
119 Ok(config)
120 }
121
122 pub fn save_to_path(&self, path: &Path) -> Result<()> {
126 if let Some(parent) = path.parent() {
127 fs::create_dir_all(parent).with_context(|| {
128 format!("Failed to create config directory: {}", parent.display())
129 })?;
130 }
131
132 let content = serde_saphyr::to_string(self).context("Failed to serialize config")?;
133
134 fs::write(path, content)
135 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
136
137 Ok(())
138 }
139
140 pub fn get_or_create_machine_id(&mut self) -> Result<String> {
146 if let Some(ref id) = self.machine_id {
147 return Ok(id.clone());
148 }
149
150 let id = Uuid::new_v4().to_string();
151 self.machine_id = Some(id.clone());
152 self.save()?;
153 Ok(id)
154 }
155
156 pub fn get_machine_name(&self) -> String {
161 if let Some(ref name) = self.machine_name {
162 return name.clone();
163 }
164
165 hostname::get()
166 .ok()
167 .and_then(|h| h.into_string().ok())
168 .unwrap_or_else(|| "unknown".to_string())
169 }
170
171 pub fn set_machine_name(&mut self, name: &str) -> Result<()> {
177 self.machine_name = Some(name.to_string());
178 self.save()
179 }
180
181 pub fn get_cloud_url(&self) -> String {
186 self.cloud_url
187 .clone()
188 .unwrap_or_else(|| "https://app.lore.varalys.com".to_string())
189 }
190
191 #[allow(dead_code)]
193 pub fn set_cloud_url(&mut self, url: &str) -> Result<()> {
194 self.cloud_url = Some(url.to_string());
195 self.save()
196 }
197
198 pub fn get_or_create_encryption_salt(&mut self) -> Result<String> {
203 if let Some(ref salt) = self.encryption_salt {
204 return Ok(salt.clone());
205 }
206
207 use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
209 use rand::RngCore;
210
211 let mut salt_bytes = [0u8; 16];
212 rand::thread_rng().fill_bytes(&mut salt_bytes);
213 let salt_b64 = BASE64.encode(salt_bytes);
214
215 self.encryption_salt = Some(salt_b64.clone());
216 self.save()?;
217 Ok(salt_b64)
218 }
219
220 pub fn get(&self, key: &str) -> Option<String> {
234 match key {
235 "watchers" => Some(self.watchers.join(",")),
236 "auto_link" => Some(self.auto_link.to_string()),
237 "auto_link_threshold" => Some(self.auto_link_threshold.to_string()),
238 "commit_footer" => Some(self.commit_footer.to_string()),
239 "machine_id" => self.machine_id.clone(),
240 "machine_name" => Some(self.get_machine_name()),
241 "cloud_url" => Some(self.get_cloud_url()),
242 "encryption_salt" => self.encryption_salt.clone(),
243 "use_keychain" => Some(self.use_keychain.to_string()),
244 _ => None,
245 }
246 }
247
248 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
262 match key {
263 "watchers" => {
264 self.watchers = value
265 .split(',')
266 .map(|s| s.trim().to_string())
267 .filter(|s| !s.is_empty())
268 .collect();
269 }
270 "auto_link" => {
271 self.auto_link = parse_bool(value)
272 .with_context(|| format!("Invalid value for auto_link: '{value}'"))?;
273 }
274 "auto_link_threshold" => {
275 let threshold: f64 = value
276 .parse()
277 .with_context(|| format!("Invalid value for auto_link_threshold: '{value}'"))?;
278 if !(0.0..=1.0).contains(&threshold) {
279 bail!("auto_link_threshold must be between 0.0 and 1.0, got {threshold}");
280 }
281 self.auto_link_threshold = threshold;
282 }
283 "commit_footer" => {
284 self.commit_footer = parse_bool(value)
285 .with_context(|| format!("Invalid value for commit_footer: '{value}'"))?;
286 }
287 "machine_name" => {
288 self.machine_name = Some(value.to_string());
289 }
290 "cloud_url" => {
291 self.cloud_url = Some(value.to_string());
292 }
293 "machine_id" => {
294 bail!("machine_id cannot be set manually; it is auto-generated");
295 }
296 "encryption_salt" => {
297 bail!("encryption_salt cannot be set manually; it is auto-generated");
298 }
299 "use_keychain" => {
300 self.use_keychain = parse_bool(value).with_context(|| {
301 format!("Invalid boolean value for use_keychain: '{value}'")
302 })?;
303 }
304 _ => {
305 bail!("Unknown configuration key: '{key}'");
306 }
307 }
308 Ok(())
309 }
310
311 pub fn config_path() -> Result<PathBuf> {
315 let config_dir = dirs::home_dir()
316 .ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?
317 .join(".lore");
318
319 Ok(config_dir.join("config.yaml"))
320 }
321
322 pub fn valid_keys() -> &'static [&'static str] {
324 &[
325 "watchers",
326 "auto_link",
327 "auto_link_threshold",
328 "commit_footer",
329 "machine_id",
330 "machine_name",
331 "cloud_url",
332 "encryption_salt",
333 "use_keychain",
334 ]
335 }
336
337 pub fn is_use_keychain_configured() -> Result<bool> {
343 let path = Self::config_path()?;
344 if !path.exists() {
345 return Ok(false);
346 }
347
348 let content = fs::read_to_string(&path)
349 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
350
351 if content.trim().is_empty() {
352 return Ok(false);
353 }
354
355 Ok(content.lines().any(|line| {
358 let trimmed = line.trim();
359 trimmed.starts_with("use_keychain:")
360 }))
361 }
362}
363
364fn parse_bool(value: &str) -> Result<bool> {
368 match value.to_lowercase().as_str() {
369 "true" | "1" | "yes" => Ok(true),
370 "false" | "0" | "no" => Ok(false),
371 _ => bail!("Expected 'true' or 'false', got '{value}'"),
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use tempfile::TempDir;
379
380 #[test]
381 fn test_default_config() {
382 let config = Config::default();
383 assert_eq!(config.watchers, vec!["claude-code".to_string()]);
384 assert!(!config.auto_link);
385 assert!((config.auto_link_threshold - 0.7).abs() < f64::EPSILON);
386 assert!(!config.commit_footer);
387 assert!(config.machine_id.is_none());
388 assert!(config.machine_name.is_none());
389 }
390
391 #[test]
392 fn test_save_and_load_roundtrip() {
393 let temp_dir = TempDir::new().unwrap();
394 let path = temp_dir.path().join("config.yaml");
395
396 let config = Config {
397 auto_link: true,
398 auto_link_threshold: 0.8,
399 watchers: vec!["claude-code".to_string(), "cursor".to_string()],
400 machine_id: Some("test-uuid".to_string()),
401 machine_name: Some("test-name".to_string()),
402 ..Default::default()
403 };
404
405 config.save_to_path(&path).unwrap();
406 let loaded = Config::load_from_path(&path).unwrap();
407 assert_eq!(loaded, config);
408 }
409
410 #[test]
411 fn test_save_creates_parent_directories() {
412 let temp_dir = TempDir::new().unwrap();
413 let path = temp_dir
414 .path()
415 .join("nested")
416 .join("dir")
417 .join("config.yaml");
418
419 let config = Config::default();
420 config.save_to_path(&path).unwrap();
421
422 assert!(path.exists());
423 }
424
425 #[test]
426 fn test_load_returns_default_for_missing_or_empty_file() {
427 let temp_dir = TempDir::new().unwrap();
428
429 let nonexistent = temp_dir.path().join("nonexistent.yaml");
431 let config = Config::load_from_path(&nonexistent).unwrap();
432 assert_eq!(config, Config::default());
433
434 let empty = temp_dir.path().join("empty.yaml");
436 fs::write(&empty, "").unwrap();
437 let config = Config::load_from_path(&empty).unwrap();
438 assert_eq!(config, Config::default());
439 }
440
441 #[test]
442 fn test_get_returns_expected_values() {
443 let config = Config {
444 watchers: vec!["claude-code".to_string(), "cursor".to_string()],
445 auto_link: true,
446 auto_link_threshold: 0.85,
447 commit_footer: true,
448 machine_id: Some("test-uuid".to_string()),
449 machine_name: Some("test-machine".to_string()),
450 cloud_url: None,
451 encryption_salt: None,
452 use_keychain: false,
453 };
454
455 assert_eq!(
456 config.get("watchers"),
457 Some("claude-code,cursor".to_string())
458 );
459 assert_eq!(config.get("auto_link"), Some("true".to_string()));
460 assert_eq!(config.get("auto_link_threshold"), Some("0.85".to_string()));
461 assert_eq!(config.get("commit_footer"), Some("true".to_string()));
462 assert_eq!(config.get("machine_id"), Some("test-uuid".to_string()));
463 assert_eq!(config.get("machine_name"), Some("test-machine".to_string()));
464 assert_eq!(config.get("use_keychain"), Some("false".to_string()));
465 assert_eq!(config.get("unknown_key"), None);
466 }
467
468 #[test]
469 fn test_set_updates_values() {
470 let mut config = Config::default();
471
472 config
474 .set("watchers", "claude-code, cursor, copilot")
475 .unwrap();
476 assert_eq!(
477 config.watchers,
478 vec![
479 "claude-code".to_string(),
480 "cursor".to_string(),
481 "copilot".to_string()
482 ]
483 );
484
485 config.set("auto_link", "true").unwrap();
487 assert!(config.auto_link);
488 config.set("auto_link", "no").unwrap();
489 assert!(!config.auto_link);
490
491 config.set("commit_footer", "yes").unwrap();
492 assert!(config.commit_footer);
493
494 config.set("auto_link_threshold", "0.5").unwrap();
496 assert!((config.auto_link_threshold - 0.5).abs() < f64::EPSILON);
497
498 config.set("machine_name", "dev-workstation").unwrap();
500 assert_eq!(config.machine_name, Some("dev-workstation".to_string()));
501 }
502
503 #[test]
504 fn test_set_validates_threshold_range() {
505 let mut config = Config::default();
506
507 config.set("auto_link_threshold", "0.0").unwrap();
509 assert!((config.auto_link_threshold - 0.0).abs() < f64::EPSILON);
510 config.set("auto_link_threshold", "1.0").unwrap();
511 assert!((config.auto_link_threshold - 1.0).abs() < f64::EPSILON);
512
513 assert!(config.set("auto_link_threshold", "-0.1").is_err());
515 assert!(config.set("auto_link_threshold", "1.1").is_err());
516 assert!(config.set("auto_link_threshold", "not_a_number").is_err());
517 }
518
519 #[test]
520 fn test_set_rejects_invalid_input() {
521 let mut config = Config::default();
522
523 assert!(config.set("unknown_key", "value").is_err());
525
526 assert!(config.set("auto_link", "maybe").is_err());
528
529 let result = config.set("machine_id", "some-uuid");
531 assert!(result.is_err());
532 assert!(result
533 .unwrap_err()
534 .to_string()
535 .contains("cannot be set manually"));
536 }
537
538 #[test]
539 fn test_parse_bool_accepts_multiple_formats() {
540 assert!(parse_bool("true").unwrap());
542 assert!(parse_bool("TRUE").unwrap());
543 assert!(parse_bool("1").unwrap());
544 assert!(parse_bool("yes").unwrap());
545 assert!(parse_bool("YES").unwrap());
546
547 assert!(!parse_bool("false").unwrap());
549 assert!(!parse_bool("FALSE").unwrap());
550 assert!(!parse_bool("0").unwrap());
551 assert!(!parse_bool("no").unwrap());
552
553 assert!(parse_bool("invalid").is_err());
555 }
556
557 #[test]
558 fn test_machine_name_fallback_to_hostname() {
559 let config = Config::default();
560 let name = config.get_machine_name();
561 assert!(!name.is_empty());
563 }
564
565 #[test]
566 fn test_machine_identity_yaml_serialization() {
567 let config = Config::default();
569 let yaml = serde_saphyr::to_string(&config).unwrap();
570 assert!(!yaml.contains("machine_id"));
571 assert!(!yaml.contains("machine_name"));
572
573 let config = Config {
575 machine_id: Some("uuid-1234".to_string()),
576 machine_name: Some("my-machine".to_string()),
577 ..Default::default()
578 };
579 let yaml = serde_saphyr::to_string(&config).unwrap();
580 assert!(yaml.contains("machine_id"));
581 assert!(yaml.contains("machine_name"));
582 }
583
584 #[test]
585 fn test_is_use_keychain_configured_with_default_config() {
586 let temp_dir = TempDir::new().unwrap();
590
591 let config_path = temp_dir.path().join("config.yaml");
592 let config = Config::default();
593 config.save_to_path(&config_path).unwrap();
594
595 let content = fs::read_to_string(&config_path).unwrap();
598 let has_use_keychain = content.lines().any(|line| {
599 let trimmed = line.trim();
600 trimmed.starts_with("use_keychain:")
601 });
602 assert!(has_use_keychain);
604 }
605
606 #[test]
607 fn test_is_use_keychain_configured_detects_explicit_setting() {
608 let temp_dir = TempDir::new().unwrap();
609 let config_path = temp_dir.path().join("config.yaml");
610
611 let config = Config {
613 use_keychain: true,
614 ..Default::default()
615 };
616 config.save_to_path(&config_path).unwrap();
617
618 let content = fs::read_to_string(&config_path).unwrap();
619 let has_use_keychain = content.lines().any(|line| {
620 let trimmed = line.trim();
621 trimmed.starts_with("use_keychain:")
622 });
623 assert!(has_use_keychain);
624 }
625
626 #[test]
627 fn test_is_use_keychain_configured_returns_false_for_empty_file() {
628 let temp_dir = TempDir::new().unwrap();
629 let config_path = temp_dir.path().join("config.yaml");
630
631 fs::write(&config_path, "").unwrap();
633
634 let content = fs::read_to_string(&config_path).unwrap();
635 let has_use_keychain = content.lines().any(|line| {
636 let trimmed = line.trim();
637 trimmed.starts_with("use_keychain:")
638 });
639 assert!(!has_use_keychain);
640 }
641}