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_saphyr::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_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    /// 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_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        // Nonexistent file returns default
320        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        // Empty file returns default
325        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        // Set watchers with whitespace trimming
359        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        // Set boolean values with different formats
372        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        // Set threshold
381        config.set("auto_link_threshold", "0.5").unwrap();
382        assert!((config.auto_link_threshold - 0.5).abs() < f64::EPSILON);
383
384        // Set machine name
385        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        // Valid boundary values
394        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        // Invalid values
400        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        // Unknown key
410        assert!(config.set("unknown_key", "value").is_err());
411
412        // Invalid boolean
413        assert!(config.set("auto_link", "maybe").is_err());
414
415        // machine_id cannot be set manually
416        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        // Truthy values
427        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        // Falsy values
434        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        // Invalid
440        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        // Should return hostname or "unknown", never empty
448        assert!(!name.is_empty());
449    }
450
451    #[test]
452    fn test_machine_identity_yaml_serialization() {
453        // When not set, machine_id and machine_name are omitted from YAML
454        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        // When set, they are included
460        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}