Skip to main content

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    /// Cloud service URL for sync operations.
46    ///
47    /// Defaults to the official Lore cloud service. Can be customized for
48    /// self-hosted deployments.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub cloud_url: Option<String>,
51
52    /// Salt for encryption key derivation (base64-encoded).
53    ///
54    /// Generated on first cloud push and stored for consistent key derivation.
55    /// This is NOT secret - only the passphrase needs to be kept private.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub encryption_salt: Option<String>,
58
59    /// Whether to use the OS keychain for credential storage.
60    ///
61    /// When false (default), credentials are stored in ~/.lore/credentials.json.
62    /// When true, uses macOS Keychain, GNOME Keyring, or Windows Credential Manager.
63    /// Note: Keychain may prompt for permission on first access.
64    #[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    /// Loads configuration from the default config file.
86    ///
87    /// Returns default configuration if the file does not exist.
88    pub fn load() -> Result<Self> {
89        let path = Self::config_path()?;
90        Self::load_from_path(&path)
91    }
92
93    /// Saves configuration to the default config file.
94    ///
95    /// Creates the `~/.lore` directory if it does not exist.
96    pub fn save(&self) -> Result<()> {
97        let path = Self::config_path()?;
98        self.save_to_path(&path)
99    }
100
101    /// Loads configuration from a specific path.
102    ///
103    /// Returns default configuration if the file does not exist.
104    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    /// Saves configuration to a specific path.
123    ///
124    /// Creates parent directories if they do not exist.
125    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    /// Returns the machine UUID, generating and saving a new one if needed.
141    ///
142    /// If no machine_id exists in config, generates a new UUIDv4 and saves
143    /// it to the config file. This ensures a consistent machine identifier
144    /// across sessions for cloud sync deduplication.
145    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    /// Returns the machine name.
157    ///
158    /// If a custom machine_name is set, returns that. Otherwise returns
159    /// the system hostname. Returns "unknown" if hostname cannot be determined.
160    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    /// Sets a custom machine name and saves the configuration.
172    ///
173    /// The machine name is a human-readable identifier for this machine,
174    /// displayed in session listings and useful for identifying which
175    /// machine created a session.
176    pub fn set_machine_name(&mut self, name: &str) -> Result<()> {
177        self.machine_name = Some(name.to_string());
178        self.save()
179    }
180
181    /// Returns the cloud service URL.
182    ///
183    /// If a custom cloud_url is set, returns that. Otherwise returns
184    /// the default Lore cloud service URL.
185    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    /// Sets the cloud service URL and saves the configuration.
192    #[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    /// Returns the encryption salt (base64-encoded), generating one if needed.
199    ///
200    /// The salt is stored in the config file and used for deriving the
201    /// encryption key from the user's passphrase.
202    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        // Generate a new random salt
208        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    /// Gets a configuration value by key.
221    ///
222    /// Supported keys:
223    /// - `watchers` - comma-separated list of enabled watchers
224    /// - `auto_link` - "true" or "false"
225    /// - `auto_link_threshold` - float between 0.0 and 1.0
226    /// - `commit_footer` - "true" or "false"
227    /// - `machine_id` - the machine UUID (read-only, auto-generated)
228    /// - `machine_name` - human-readable machine name
229    /// - `cloud_url` - cloud service URL
230    /// - `encryption_salt` - salt for encryption key derivation (read-only)
231    ///
232    /// Returns `None` if the key is not recognized.
233    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    /// Sets a configuration value by key.
249    ///
250    /// Supported keys:
251    /// - `watchers` - comma-separated list of enabled watchers
252    /// - `auto_link` - "true" or "false"
253    /// - `auto_link_threshold` - float between 0.0 and 1.0 (inclusive)
254    /// - `commit_footer` - "true" or "false"
255    /// - `machine_name` - human-readable machine name
256    /// - `cloud_url` - cloud service URL
257    ///
258    /// Note: `machine_id` and `encryption_salt` cannot be set manually.
259    ///
260    /// Returns an error if the key is not recognized or the value is invalid.
261    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    /// Returns the path to the configuration file.
312    ///
313    /// The configuration file is located at `~/.lore/config.yaml`.
314    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    /// Returns the list of valid configuration keys.
323    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    /// Checks if use_keychain was explicitly set in the config file.
338    ///
339    /// Returns true if the config file exists and contains a use_keychain key,
340    /// false if the file does not exist or does not contain the key (meaning
341    /// the default value is being used).
342    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        // Check if the YAML content contains use_keychain key
356        // We look for the key at the start of a line (not in comments)
357        Ok(content.lines().any(|line| {
358            let trimmed = line.trim();
359            trimmed.starts_with("use_keychain:")
360        }))
361    }
362}
363
364/// Parses a boolean value from a string.
365///
366/// Accepts "true", "false", "1", "0", "yes", "no" (case-insensitive).
367fn 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        // Nonexistent file returns default
430        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        // Empty file returns default
435        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        // Set watchers with whitespace trimming
473        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        // Set boolean values with different formats
486        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        // Set threshold
495        config.set("auto_link_threshold", "0.5").unwrap();
496        assert!((config.auto_link_threshold - 0.5).abs() < f64::EPSILON);
497
498        // Set machine name
499        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        // Valid boundary values
508        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        // Invalid values
514        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        // Unknown key
524        assert!(config.set("unknown_key", "value").is_err());
525
526        // Invalid boolean
527        assert!(config.set("auto_link", "maybe").is_err());
528
529        // machine_id cannot be set manually
530        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        // Truthy values
541        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        // Falsy values
548        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        // Invalid
554        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        // Should return hostname or "unknown", never empty
562        assert!(!name.is_empty());
563    }
564
565    #[test]
566    fn test_machine_identity_yaml_serialization() {
567        // When not set, machine_id and machine_name are omitted from YAML
568        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        // When set, they are included
574        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        // Test the detection logic by checking serialized config content.
587        // The function checks the default config path, not a custom one,
588        // so we test the behavior through the serialization logic.
589        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        // Read the saved content - default config should contain use_keychain
596        // since serde serializes all fields by default
597        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        // serde includes all fields by default, so this will be true
603        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        // Write config with explicit use_keychain: true
612        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        // Write empty file
632        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}