lore_cli/config/
mod.rs

1//! Configuration management.
2//!
3//! Handles loading and saving Lore configuration from `~/.lore/config.yaml`.
4//!
5//! Note: Configuration options are planned for a future release. Currently
6//! this module provides path information only. The Config struct and its
7//! methods are preserved for future use.
8
9use anyhow::{bail, Context, Result};
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::{Path, PathBuf};
13use uuid::Uuid;
14
15/// Lore configuration settings.
16///
17/// Controls watcher behavior, auto-linking, and commit integration.
18/// Loaded from `~/.lore/config.yaml` when available.
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct Config {
21    /// List of enabled watcher names (e.g., "claude-code", "cursor").
22    pub watchers: Vec<String>,
23
24    /// Whether to automatically link sessions to commits.
25    pub auto_link: bool,
26
27    /// Minimum confidence score (0.0-1.0) required for auto-linking.
28    pub auto_link_threshold: f64,
29
30    /// Whether to append session references to commit messages.
31    pub commit_footer: bool,
32
33    /// Unique machine identifier (UUID) for cloud sync deduplication.
34    ///
35    /// Auto-generated on first access via `get_or_create_machine_id()`.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub machine_id: Option<String>,
38
39    /// Human-readable machine name.
40    ///
41    /// Defaults to hostname if not set. Can be customized by the user.
42    #[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    /// Loads configuration from the default config file.
61    ///
62    /// Returns default configuration if the file does not exist.
63    pub fn load() -> Result<Self> {
64        let path = Self::config_path()?;
65        Self::load_from_path(&path)
66    }
67
68    /// Saves configuration to the default config file.
69    ///
70    /// Creates the `~/.lore` directory if it does not exist.
71    pub fn save(&self) -> Result<()> {
72        let path = Self::config_path()?;
73        self.save_to_path(&path)
74    }
75
76    /// Loads configuration from a specific path.
77    ///
78    /// Returns default configuration if the file does not exist.
79    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    /// Saves configuration to a specific path.
98    ///
99    /// Creates parent directories if they do not exist.
100    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    /// Returns the machine UUID, generating and saving a new one if needed.
116    ///
117    /// If no machine_id exists in config, generates a new UUIDv4 and saves
118    /// it to the config file. This ensures a consistent machine identifier
119    /// across sessions for cloud sync deduplication.
120    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    /// Returns the machine name.
132    ///
133    /// If a custom machine_name is set, returns that. Otherwise returns
134    /// the system hostname. Returns "unknown" if hostname cannot be determined.
135    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    /// Sets a custom machine name and saves the configuration.
147    ///
148    /// The machine name is a human-readable identifier for this machine,
149    /// displayed in session listings and useful for identifying which
150    /// machine created a session.
151    pub fn set_machine_name(&mut self, name: &str) -> Result<()> {
152        self.machine_name = Some(name.to_string());
153        self.save()
154    }
155
156    /// Gets a configuration value by key.
157    ///
158    /// Supported keys:
159    /// - `watchers` - comma-separated list of enabled watchers
160    /// - `auto_link` - "true" or "false"
161    /// - `auto_link_threshold` - float between 0.0 and 1.0
162    /// - `commit_footer` - "true" or "false"
163    /// - `machine_id` - the machine UUID (read-only, auto-generated)
164    /// - `machine_name` - human-readable machine name
165    ///
166    /// Returns `None` if the key is not recognized.
167    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    /// Sets a configuration value by key.
180    ///
181    /// Supported keys:
182    /// - `watchers` - comma-separated list of enabled watchers
183    /// - `auto_link` - "true" or "false"
184    /// - `auto_link_threshold` - float between 0.0 and 1.0 (inclusive)
185    /// - `commit_footer` - "true" or "false"
186    /// - `machine_name` - human-readable machine name
187    ///
188    /// Note: `machine_id` cannot be set manually; it is auto-generated.
189    ///
190    /// Returns an error if the key is not recognized or the value is invalid.
191    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    /// Returns the path to the configuration file.
231    ///
232    /// The configuration file is located at `~/.lore/config.yaml`.
233    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    /// Returns the list of valid configuration keys.
242    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
254/// Parses a boolean value from a string.
255///
256/// Accepts "true", "false", "1", "0", "yes", "no" (case-insensitive).
257fn 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        // Should return some non-empty string (hostname or "unknown")
508        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}