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    /// LLM provider for summary generation ("anthropic", "openai", "openrouter").
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub summary_provider: Option<String>,
70
71    /// API key for Anthropic summary provider.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub summary_api_key_anthropic: Option<String>,
74
75    /// API key for OpenAI summary provider.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub summary_api_key_openai: Option<String>,
78
79    /// API key for OpenRouter summary provider.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub summary_api_key_openrouter: Option<String>,
82
83    /// Model override for Anthropic summary provider.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub summary_model_anthropic: Option<String>,
86
87    /// Model override for OpenAI summary provider.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub summary_model_openai: Option<String>,
90
91    /// Model override for OpenRouter summary provider.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub summary_model_openrouter: Option<String>,
94
95    /// Whether to automatically generate summaries when sessions end.
96    #[serde(default)]
97    pub summary_auto: bool,
98
99    /// Minimum message count to trigger auto-summary generation.
100    #[serde(default = "default_summary_auto_threshold")]
101    pub summary_auto_threshold: usize,
102}
103
104impl Default for Config {
105    fn default() -> Self {
106        Self {
107            watchers: vec!["claude-code".to_string()],
108            auto_link: false,
109            auto_link_threshold: 0.7,
110            commit_footer: false,
111            machine_id: None,
112            machine_name: None,
113            cloud_url: None,
114            encryption_salt: None,
115            use_keychain: false,
116            summary_provider: None,
117            summary_api_key_anthropic: None,
118            summary_api_key_openai: None,
119            summary_api_key_openrouter: None,
120            summary_model_anthropic: None,
121            summary_model_openai: None,
122            summary_model_openrouter: None,
123            summary_auto: false,
124            summary_auto_threshold: 4,
125        }
126    }
127}
128
129impl Config {
130    /// Loads configuration from the default config file.
131    ///
132    /// Returns default configuration if the file does not exist.
133    pub fn load() -> Result<Self> {
134        let path = Self::config_path()?;
135        Self::load_from_path(&path)
136    }
137
138    /// Saves configuration to the default config file.
139    ///
140    /// Creates the `~/.lore` directory if it does not exist.
141    pub fn save(&self) -> Result<()> {
142        let path = Self::config_path()?;
143        self.save_to_path(&path)
144    }
145
146    /// Loads configuration from a specific path.
147    ///
148    /// Returns default configuration if the file does not exist.
149    pub fn load_from_path(path: &Path) -> Result<Self> {
150        if !path.exists() {
151            return Ok(Self::default());
152        }
153
154        let content = fs::read_to_string(path)
155            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
156
157        if content.trim().is_empty() {
158            return Ok(Self::default());
159        }
160
161        let config: Config = serde_saphyr::from_str(&content)
162            .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
163
164        Ok(config)
165    }
166
167    /// Saves configuration to a specific path.
168    ///
169    /// Creates parent directories if they do not exist.
170    pub fn save_to_path(&self, path: &Path) -> Result<()> {
171        if let Some(parent) = path.parent() {
172            fs::create_dir_all(parent).with_context(|| {
173                format!("Failed to create config directory: {}", parent.display())
174            })?;
175        }
176
177        let content = serde_saphyr::to_string(self).context("Failed to serialize config")?;
178
179        fs::write(path, content)
180            .with_context(|| format!("Failed to write config file: {}", path.display()))?;
181
182        Ok(())
183    }
184
185    /// Returns the machine UUID, generating and saving a new one if needed.
186    ///
187    /// If no machine_id exists in config, generates a new UUIDv4 and saves
188    /// it to the config file. This ensures a consistent machine identifier
189    /// across sessions for cloud sync deduplication.
190    pub fn get_or_create_machine_id(&mut self) -> Result<String> {
191        if let Some(ref id) = self.machine_id {
192            return Ok(id.clone());
193        }
194
195        let id = Uuid::new_v4().to_string();
196        self.machine_id = Some(id.clone());
197        self.save()?;
198        Ok(id)
199    }
200
201    /// Returns the machine name.
202    ///
203    /// If a custom machine_name is set, returns that. Otherwise returns
204    /// the system hostname. Returns "unknown" if hostname cannot be determined.
205    pub fn get_machine_name(&self) -> String {
206        if let Some(ref name) = self.machine_name {
207            return name.clone();
208        }
209
210        hostname::get()
211            .ok()
212            .and_then(|h| h.into_string().ok())
213            .unwrap_or_else(|| "unknown".to_string())
214    }
215
216    /// Sets a custom machine name and saves the configuration.
217    ///
218    /// The machine name is a human-readable identifier for this machine,
219    /// displayed in session listings and useful for identifying which
220    /// machine created a session.
221    pub fn set_machine_name(&mut self, name: &str) -> Result<()> {
222        self.machine_name = Some(name.to_string());
223        self.save()
224    }
225
226    /// Returns the cloud service URL.
227    ///
228    /// If a custom cloud_url is set, returns that. Otherwise returns
229    /// the default Lore cloud service URL.
230    pub fn get_cloud_url(&self) -> String {
231        self.cloud_url
232            .clone()
233            .unwrap_or_else(|| "https://app.lore.varalys.com".to_string())
234    }
235
236    /// Sets the cloud service URL and saves the configuration.
237    #[allow(dead_code)]
238    pub fn set_cloud_url(&mut self, url: &str) -> Result<()> {
239        self.cloud_url = Some(url.to_string());
240        self.save()
241    }
242
243    /// Returns the encryption salt (base64-encoded), generating one if needed.
244    ///
245    /// The salt is stored in the config file and used for deriving the
246    /// encryption key from the user's passphrase.
247    pub fn get_or_create_encryption_salt(&mut self) -> Result<String> {
248        if let Some(ref salt) = self.encryption_salt {
249            return Ok(salt.clone());
250        }
251
252        // Generate a new random salt
253        use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
254        use rand::RngCore;
255
256        let mut salt_bytes = [0u8; 16];
257        rand::thread_rng().fill_bytes(&mut salt_bytes);
258        let salt_b64 = BASE64.encode(salt_bytes);
259
260        self.encryption_salt = Some(salt_b64.clone());
261        self.save()?;
262        Ok(salt_b64)
263    }
264
265    /// Gets a configuration value by key.
266    ///
267    /// Supported keys:
268    /// - `watchers` - comma-separated list of enabled watchers
269    /// - `auto_link` - "true" or "false"
270    /// - `auto_link_threshold` - float between 0.0 and 1.0
271    /// - `commit_footer` - "true" or "false"
272    /// - `machine_id` - the machine UUID (read-only, auto-generated)
273    /// - `machine_name` - human-readable machine name
274    /// - `cloud_url` - cloud service URL
275    /// - `encryption_salt` - salt for encryption key derivation (read-only)
276    /// - `summary_provider` - LLM provider for summaries
277    /// - `summary_api_key_anthropic` - Anthropic API key
278    /// - `summary_api_key_openai` - OpenAI API key
279    /// - `summary_api_key_openrouter` - OpenRouter API key
280    /// - `summary_model_anthropic` - Anthropic model override
281    /// - `summary_model_openai` - OpenAI model override
282    /// - `summary_model_openrouter` - OpenRouter model override
283    /// - `summary_auto` - "true" or "false"
284    /// - `summary_auto_threshold` - minimum messages for auto-summary
285    ///
286    /// Returns `None` if the key is not recognized.
287    pub fn get(&self, key: &str) -> Option<String> {
288        match key {
289            "watchers" => Some(self.watchers.join(",")),
290            "auto_link" => Some(self.auto_link.to_string()),
291            "auto_link_threshold" => Some(self.auto_link_threshold.to_string()),
292            "commit_footer" => Some(self.commit_footer.to_string()),
293            "machine_id" => self.machine_id.clone(),
294            "machine_name" => Some(self.get_machine_name()),
295            "cloud_url" => Some(self.get_cloud_url()),
296            "encryption_salt" => self.encryption_salt.clone(),
297            "use_keychain" => Some(self.use_keychain.to_string()),
298            "summary_provider" => self.summary_provider.clone(),
299            "summary_api_key_anthropic" => self.summary_api_key_anthropic.clone(),
300            "summary_api_key_openai" => self.summary_api_key_openai.clone(),
301            "summary_api_key_openrouter" => self.summary_api_key_openrouter.clone(),
302            "summary_model_anthropic" => self.summary_model_anthropic.clone(),
303            "summary_model_openai" => self.summary_model_openai.clone(),
304            "summary_model_openrouter" => self.summary_model_openrouter.clone(),
305            "summary_auto" => Some(self.summary_auto.to_string()),
306            "summary_auto_threshold" => Some(self.summary_auto_threshold.to_string()),
307            _ => None,
308        }
309    }
310
311    /// Sets a configuration value by key.
312    ///
313    /// Supported keys:
314    /// - `watchers` - comma-separated list of enabled watchers
315    /// - `auto_link` - "true" or "false"
316    /// - `auto_link_threshold` - float between 0.0 and 1.0 (inclusive)
317    /// - `commit_footer` - "true" or "false"
318    /// - `machine_name` - human-readable machine name
319    /// - `cloud_url` - cloud service URL
320    /// - `summary_provider` - "anthropic", "openai", or "openrouter"
321    /// - `summary_api_key_anthropic` - Anthropic API key
322    /// - `summary_api_key_openai` - OpenAI API key
323    /// - `summary_api_key_openrouter` - OpenRouter API key
324    /// - `summary_model_anthropic` - Anthropic model override
325    /// - `summary_model_openai` - OpenAI model override
326    /// - `summary_model_openrouter` - OpenRouter model override
327    /// - `summary_auto` - "true" or "false"
328    /// - `summary_auto_threshold` - positive integer
329    ///
330    /// Note: `machine_id` and `encryption_salt` cannot be set manually.
331    ///
332    /// Returns an error if the key is not recognized or the value is invalid.
333    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
334        match key {
335            "watchers" => {
336                self.watchers = value
337                    .split(',')
338                    .map(|s| s.trim().to_string())
339                    .filter(|s| !s.is_empty())
340                    .collect();
341            }
342            "auto_link" => {
343                self.auto_link = parse_bool(value)
344                    .with_context(|| format!("Invalid value for auto_link: '{value}'"))?;
345            }
346            "auto_link_threshold" => {
347                let threshold: f64 = value
348                    .parse()
349                    .with_context(|| format!("Invalid value for auto_link_threshold: '{value}'"))?;
350                if !(0.0..=1.0).contains(&threshold) {
351                    bail!("auto_link_threshold must be between 0.0 and 1.0, got {threshold}");
352                }
353                self.auto_link_threshold = threshold;
354            }
355            "commit_footer" => {
356                self.commit_footer = parse_bool(value)
357                    .with_context(|| format!("Invalid value for commit_footer: '{value}'"))?;
358            }
359            "machine_name" => {
360                self.machine_name = Some(value.to_string());
361            }
362            "cloud_url" => {
363                self.cloud_url = Some(value.to_string());
364            }
365            "machine_id" => {
366                bail!("machine_id cannot be set manually; it is auto-generated");
367            }
368            "encryption_salt" => {
369                bail!("encryption_salt cannot be set manually; it is auto-generated");
370            }
371            "use_keychain" => {
372                self.use_keychain = parse_bool(value).with_context(|| {
373                    format!("Invalid boolean value for use_keychain: '{value}'")
374                })?;
375            }
376            "summary_provider" => {
377                let lower = value.to_lowercase();
378                match lower.as_str() {
379                    "anthropic" | "openai" | "openrouter" => {
380                        self.summary_provider = Some(lower);
381                    }
382                    _ => {
383                        bail!(
384                            "Invalid summary_provider: '{value}'. \
385                             Must be one of: anthropic, openai, openrouter"
386                        );
387                    }
388                }
389            }
390            "summary_api_key_anthropic" => {
391                self.summary_api_key_anthropic = Some(value.to_string());
392            }
393            "summary_api_key_openai" => {
394                self.summary_api_key_openai = Some(value.to_string());
395            }
396            "summary_api_key_openrouter" => {
397                self.summary_api_key_openrouter = Some(value.to_string());
398            }
399            "summary_model_anthropic" => {
400                self.summary_model_anthropic = Some(value.to_string());
401            }
402            "summary_model_openai" => {
403                self.summary_model_openai = Some(value.to_string());
404            }
405            "summary_model_openrouter" => {
406                self.summary_model_openrouter = Some(value.to_string());
407            }
408            "summary_auto" => {
409                self.summary_auto = parse_bool(value)
410                    .with_context(|| format!("Invalid value for summary_auto: '{value}'"))?;
411            }
412            "summary_auto_threshold" => {
413                let threshold: usize = value.parse().with_context(|| {
414                    format!("Invalid value for summary_auto_threshold: '{value}'")
415                })?;
416                if threshold == 0 {
417                    bail!("summary_auto_threshold must be greater than 0, got {threshold}");
418                }
419                self.summary_auto_threshold = threshold;
420            }
421            _ => {
422                bail!("Unknown configuration key: '{key}'");
423            }
424        }
425        Ok(())
426    }
427
428    /// Returns the path to the configuration file.
429    ///
430    /// The configuration file is located at `~/.lore/config.yaml`.
431    pub fn config_path() -> Result<PathBuf> {
432        let config_dir = dirs::home_dir()
433            .ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?
434            .join(".lore");
435
436        Ok(config_dir.join("config.yaml"))
437    }
438
439    /// Returns the list of valid configuration keys.
440    pub fn valid_keys() -> &'static [&'static str] {
441        &[
442            "watchers",
443            "auto_link",
444            "auto_link_threshold",
445            "commit_footer",
446            "machine_id",
447            "machine_name",
448            "cloud_url",
449            "encryption_salt",
450            "use_keychain",
451            "summary_provider",
452            "summary_api_key_anthropic",
453            "summary_api_key_openai",
454            "summary_api_key_openrouter",
455            "summary_model_anthropic",
456            "summary_model_openai",
457            "summary_model_openrouter",
458            "summary_auto",
459            "summary_auto_threshold",
460        ]
461    }
462
463    /// Returns the API key for the given summary provider.
464    pub fn summary_api_key_for_provider(&self, provider: &str) -> Option<String> {
465        match provider {
466            "anthropic" => self.summary_api_key_anthropic.clone(),
467            "openai" => self.summary_api_key_openai.clone(),
468            "openrouter" => self.summary_api_key_openrouter.clone(),
469            _ => None,
470        }
471    }
472
473    /// Returns the model override for the given summary provider.
474    pub fn summary_model_for_provider(&self, provider: &str) -> Option<String> {
475        match provider {
476            "anthropic" => self.summary_model_anthropic.clone(),
477            "openai" => self.summary_model_openai.clone(),
478            "openrouter" => self.summary_model_openrouter.clone(),
479            _ => None,
480        }
481    }
482
483    /// Checks if use_keychain was explicitly set in the config file.
484    ///
485    /// Returns true if the config file exists and contains a use_keychain key,
486    /// false if the file does not exist or does not contain the key (meaning
487    /// the default value is being used).
488    pub fn is_use_keychain_configured() -> Result<bool> {
489        let path = Self::config_path()?;
490        if !path.exists() {
491            return Ok(false);
492        }
493
494        let content = fs::read_to_string(&path)
495            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
496
497        if content.trim().is_empty() {
498            return Ok(false);
499        }
500
501        // Check if the YAML content contains use_keychain key
502        // We look for the key at the start of a line (not in comments)
503        Ok(content.lines().any(|line| {
504            let trimmed = line.trim();
505            trimmed.starts_with("use_keychain:")
506        }))
507    }
508}
509
510/// Returns the default minimum message count for auto-summary generation.
511fn default_summary_auto_threshold() -> usize {
512    4
513}
514
515/// Parses a boolean value from a string.
516///
517/// Accepts "true", "false", "1", "0", "yes", "no" (case-insensitive).
518fn parse_bool(value: &str) -> Result<bool> {
519    match value.to_lowercase().as_str() {
520        "true" | "1" | "yes" => Ok(true),
521        "false" | "0" | "no" => Ok(false),
522        _ => bail!("Expected 'true' or 'false', got '{value}'"),
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use tempfile::TempDir;
530
531    #[test]
532    fn test_default_config() {
533        let config = Config::default();
534        assert_eq!(config.watchers, vec!["claude-code".to_string()]);
535        assert!(!config.auto_link);
536        assert!((config.auto_link_threshold - 0.7).abs() < f64::EPSILON);
537        assert!(!config.commit_footer);
538        assert!(config.machine_id.is_none());
539        assert!(config.machine_name.is_none());
540    }
541
542    #[test]
543    fn test_save_and_load_roundtrip() {
544        let temp_dir = TempDir::new().unwrap();
545        let path = temp_dir.path().join("config.yaml");
546
547        let config = Config {
548            auto_link: true,
549            auto_link_threshold: 0.8,
550            watchers: vec!["claude-code".to_string(), "cursor".to_string()],
551            machine_id: Some("test-uuid".to_string()),
552            machine_name: Some("test-name".to_string()),
553            ..Default::default()
554        };
555
556        config.save_to_path(&path).unwrap();
557        let loaded = Config::load_from_path(&path).unwrap();
558        assert_eq!(loaded, config);
559    }
560
561    #[test]
562    fn test_save_creates_parent_directories() {
563        let temp_dir = TempDir::new().unwrap();
564        let path = temp_dir
565            .path()
566            .join("nested")
567            .join("dir")
568            .join("config.yaml");
569
570        let config = Config::default();
571        config.save_to_path(&path).unwrap();
572
573        assert!(path.exists());
574    }
575
576    #[test]
577    fn test_load_returns_default_for_missing_or_empty_file() {
578        let temp_dir = TempDir::new().unwrap();
579
580        // Nonexistent file returns default
581        let nonexistent = temp_dir.path().join("nonexistent.yaml");
582        let config = Config::load_from_path(&nonexistent).unwrap();
583        assert_eq!(config, Config::default());
584
585        // Empty file returns default
586        let empty = temp_dir.path().join("empty.yaml");
587        fs::write(&empty, "").unwrap();
588        let config = Config::load_from_path(&empty).unwrap();
589        assert_eq!(config, Config::default());
590    }
591
592    #[test]
593    fn test_get_returns_expected_values() {
594        let config = Config {
595            watchers: vec!["claude-code".to_string(), "cursor".to_string()],
596            auto_link: true,
597            auto_link_threshold: 0.85,
598            commit_footer: true,
599            machine_id: Some("test-uuid".to_string()),
600            machine_name: Some("test-machine".to_string()),
601            cloud_url: None,
602            encryption_salt: None,
603            use_keychain: false,
604            ..Default::default()
605        };
606
607        assert_eq!(
608            config.get("watchers"),
609            Some("claude-code,cursor".to_string())
610        );
611        assert_eq!(config.get("auto_link"), Some("true".to_string()));
612        assert_eq!(config.get("auto_link_threshold"), Some("0.85".to_string()));
613        assert_eq!(config.get("commit_footer"), Some("true".to_string()));
614        assert_eq!(config.get("machine_id"), Some("test-uuid".to_string()));
615        assert_eq!(config.get("machine_name"), Some("test-machine".to_string()));
616        assert_eq!(config.get("use_keychain"), Some("false".to_string()));
617        assert_eq!(config.get("unknown_key"), None);
618    }
619
620    #[test]
621    fn test_set_updates_values() {
622        let mut config = Config::default();
623
624        // Set watchers with whitespace trimming
625        config
626            .set("watchers", "claude-code, cursor, copilot")
627            .unwrap();
628        assert_eq!(
629            config.watchers,
630            vec![
631                "claude-code".to_string(),
632                "cursor".to_string(),
633                "copilot".to_string()
634            ]
635        );
636
637        // Set boolean values with different formats
638        config.set("auto_link", "true").unwrap();
639        assert!(config.auto_link);
640        config.set("auto_link", "no").unwrap();
641        assert!(!config.auto_link);
642
643        config.set("commit_footer", "yes").unwrap();
644        assert!(config.commit_footer);
645
646        // Set threshold
647        config.set("auto_link_threshold", "0.5").unwrap();
648        assert!((config.auto_link_threshold - 0.5).abs() < f64::EPSILON);
649
650        // Set machine name
651        config.set("machine_name", "dev-workstation").unwrap();
652        assert_eq!(config.machine_name, Some("dev-workstation".to_string()));
653    }
654
655    #[test]
656    fn test_set_validates_threshold_range() {
657        let mut config = Config::default();
658
659        // Valid boundary values
660        config.set("auto_link_threshold", "0.0").unwrap();
661        assert!((config.auto_link_threshold - 0.0).abs() < f64::EPSILON);
662        config.set("auto_link_threshold", "1.0").unwrap();
663        assert!((config.auto_link_threshold - 1.0).abs() < f64::EPSILON);
664
665        // Invalid values
666        assert!(config.set("auto_link_threshold", "-0.1").is_err());
667        assert!(config.set("auto_link_threshold", "1.1").is_err());
668        assert!(config.set("auto_link_threshold", "not_a_number").is_err());
669    }
670
671    #[test]
672    fn test_set_rejects_invalid_input() {
673        let mut config = Config::default();
674
675        // Unknown key
676        assert!(config.set("unknown_key", "value").is_err());
677
678        // Invalid boolean
679        assert!(config.set("auto_link", "maybe").is_err());
680
681        // machine_id cannot be set manually
682        let result = config.set("machine_id", "some-uuid");
683        assert!(result.is_err());
684        assert!(result
685            .unwrap_err()
686            .to_string()
687            .contains("cannot be set manually"));
688    }
689
690    #[test]
691    fn test_parse_bool_accepts_multiple_formats() {
692        // Truthy values
693        assert!(parse_bool("true").unwrap());
694        assert!(parse_bool("TRUE").unwrap());
695        assert!(parse_bool("1").unwrap());
696        assert!(parse_bool("yes").unwrap());
697        assert!(parse_bool("YES").unwrap());
698
699        // Falsy values
700        assert!(!parse_bool("false").unwrap());
701        assert!(!parse_bool("FALSE").unwrap());
702        assert!(!parse_bool("0").unwrap());
703        assert!(!parse_bool("no").unwrap());
704
705        // Invalid
706        assert!(parse_bool("invalid").is_err());
707    }
708
709    #[test]
710    fn test_machine_name_fallback_to_hostname() {
711        let config = Config::default();
712        let name = config.get_machine_name();
713        // Should return hostname or "unknown", never empty
714        assert!(!name.is_empty());
715    }
716
717    #[test]
718    fn test_machine_identity_yaml_serialization() {
719        // When not set, machine_id and machine_name are omitted from YAML
720        let config = Config::default();
721        let yaml = serde_saphyr::to_string(&config).unwrap();
722        assert!(!yaml.contains("machine_id"));
723        assert!(!yaml.contains("machine_name"));
724
725        // When set, they are included
726        let config = Config {
727            machine_id: Some("uuid-1234".to_string()),
728            machine_name: Some("my-machine".to_string()),
729            ..Default::default()
730        };
731        let yaml = serde_saphyr::to_string(&config).unwrap();
732        assert!(yaml.contains("machine_id"));
733        assert!(yaml.contains("machine_name"));
734    }
735
736    #[test]
737    fn test_is_use_keychain_configured_with_default_config() {
738        // Test the detection logic by checking serialized config content.
739        // The function checks the default config path, not a custom one,
740        // so we test the behavior through the serialization logic.
741        let temp_dir = TempDir::new().unwrap();
742
743        let config_path = temp_dir.path().join("config.yaml");
744        let config = Config::default();
745        config.save_to_path(&config_path).unwrap();
746
747        // Read the saved content - default config should contain use_keychain
748        // since serde serializes all fields by default
749        let content = fs::read_to_string(&config_path).unwrap();
750        let has_use_keychain = content.lines().any(|line| {
751            let trimmed = line.trim();
752            trimmed.starts_with("use_keychain:")
753        });
754        // serde includes all fields by default, so this will be true
755        assert!(has_use_keychain);
756    }
757
758    #[test]
759    fn test_is_use_keychain_configured_detects_explicit_setting() {
760        let temp_dir = TempDir::new().unwrap();
761        let config_path = temp_dir.path().join("config.yaml");
762
763        // Write config with explicit use_keychain: true
764        let config = Config {
765            use_keychain: true,
766            ..Default::default()
767        };
768        config.save_to_path(&config_path).unwrap();
769
770        let content = fs::read_to_string(&config_path).unwrap();
771        let has_use_keychain = content.lines().any(|line| {
772            let trimmed = line.trim();
773            trimmed.starts_with("use_keychain:")
774        });
775        assert!(has_use_keychain);
776    }
777
778    #[test]
779    fn test_is_use_keychain_configured_returns_false_for_empty_file() {
780        let temp_dir = TempDir::new().unwrap();
781        let config_path = temp_dir.path().join("config.yaml");
782
783        // Write empty file
784        fs::write(&config_path, "").unwrap();
785
786        let content = fs::read_to_string(&config_path).unwrap();
787        let has_use_keychain = content.lines().any(|line| {
788            let trimmed = line.trim();
789            trimmed.starts_with("use_keychain:")
790        });
791        assert!(!has_use_keychain);
792    }
793
794    #[test]
795    fn test_default_config_summary_fields() {
796        let config = Config::default();
797        assert!(config.summary_provider.is_none());
798        assert!(config.summary_api_key_anthropic.is_none());
799        assert!(config.summary_api_key_openai.is_none());
800        assert!(config.summary_api_key_openrouter.is_none());
801        assert!(config.summary_model_anthropic.is_none());
802        assert!(config.summary_model_openai.is_none());
803        assert!(config.summary_model_openrouter.is_none());
804        assert!(!config.summary_auto);
805        assert_eq!(config.summary_auto_threshold, 4);
806    }
807
808    #[test]
809    fn test_get_set_summary_provider() {
810        let mut config = Config::default();
811
812        // Default is None
813        assert_eq!(config.get("summary_provider"), None);
814
815        // Set with lowercase
816        config.set("summary_provider", "anthropic").unwrap();
817        assert_eq!(
818            config.get("summary_provider"),
819            Some("anthropic".to_string())
820        );
821
822        // Set with mixed case is normalized to lowercase
823        config.set("summary_provider", "OpenAI").unwrap();
824        assert_eq!(config.get("summary_provider"), Some("openai".to_string()));
825
826        config.set("summary_provider", "OPENROUTER").unwrap();
827        assert_eq!(
828            config.get("summary_provider"),
829            Some("openrouter".to_string())
830        );
831    }
832
833    #[test]
834    fn test_set_summary_provider_validates() {
835        let mut config = Config::default();
836
837        let result = config.set("summary_provider", "invalid-provider");
838        assert!(result.is_err());
839        let err_msg = result.unwrap_err().to_string();
840        assert!(err_msg.contains("Invalid summary_provider"));
841        assert!(err_msg.contains("invalid-provider"));
842    }
843
844    #[test]
845    fn test_get_set_summary_api_keys_per_provider() {
846        let mut config = Config::default();
847
848        // All default to None
849        assert_eq!(config.get("summary_api_key_anthropic"), None);
850        assert_eq!(config.get("summary_api_key_openai"), None);
851        assert_eq!(config.get("summary_api_key_openrouter"), None);
852
853        // Set and retrieve each independently
854        config
855            .set("summary_api_key_anthropic", "sk-ant-123")
856            .unwrap();
857        config.set("summary_api_key_openai", "sk-oai-456").unwrap();
858        config
859            .set("summary_api_key_openrouter", "sk-or-789")
860            .unwrap();
861
862        assert_eq!(
863            config.get("summary_api_key_anthropic"),
864            Some("sk-ant-123".to_string())
865        );
866        assert_eq!(
867            config.get("summary_api_key_openai"),
868            Some("sk-oai-456".to_string())
869        );
870        assert_eq!(
871            config.get("summary_api_key_openrouter"),
872            Some("sk-or-789".to_string())
873        );
874
875        // Helper method returns the right key for each provider
876        assert_eq!(
877            config.summary_api_key_for_provider("anthropic"),
878            Some("sk-ant-123".to_string())
879        );
880        assert_eq!(
881            config.summary_api_key_for_provider("openai"),
882            Some("sk-oai-456".to_string())
883        );
884        assert_eq!(
885            config.summary_api_key_for_provider("openrouter"),
886            Some("sk-or-789".to_string())
887        );
888        assert_eq!(config.summary_api_key_for_provider("unknown"), None);
889    }
890
891    #[test]
892    fn test_get_set_summary_models_per_provider() {
893        let mut config = Config::default();
894
895        // All default to None
896        assert_eq!(config.get("summary_model_anthropic"), None);
897        assert_eq!(config.get("summary_model_openai"), None);
898        assert_eq!(config.get("summary_model_openrouter"), None);
899
900        // Set and retrieve each
901        config
902            .set("summary_model_anthropic", "claude-sonnet-4-20250514")
903            .unwrap();
904        config.set("summary_model_openai", "gpt-4o").unwrap();
905        config
906            .set(
907                "summary_model_openrouter",
908                "meta-llama/llama-3.1-8b-instruct:free",
909            )
910            .unwrap();
911
912        assert_eq!(
913            config.get("summary_model_anthropic"),
914            Some("claude-sonnet-4-20250514".to_string())
915        );
916        assert_eq!(
917            config.get("summary_model_openai"),
918            Some("gpt-4o".to_string())
919        );
920        assert_eq!(
921            config.get("summary_model_openrouter"),
922            Some("meta-llama/llama-3.1-8b-instruct:free".to_string())
923        );
924
925        // Helper returns the right model for each provider
926        assert_eq!(
927            config.summary_model_for_provider("anthropic"),
928            Some("claude-sonnet-4-20250514".to_string())
929        );
930        assert_eq!(
931            config.summary_model_for_provider("openai"),
932            Some("gpt-4o".to_string())
933        );
934        assert_eq!(config.summary_model_for_provider("unknown"), None);
935    }
936
937    #[test]
938    fn test_get_set_summary_auto() {
939        let mut config = Config::default();
940
941        // Default is false
942        assert_eq!(config.get("summary_auto"), Some("false".to_string()));
943
944        // Set to true
945        config.set("summary_auto", "true").unwrap();
946        assert!(config.summary_auto);
947        assert_eq!(config.get("summary_auto"), Some("true".to_string()));
948
949        // Set back to false
950        config.set("summary_auto", "false").unwrap();
951        assert!(!config.summary_auto);
952        assert_eq!(config.get("summary_auto"), Some("false".to_string()));
953
954        // Invalid value rejected
955        assert!(config.set("summary_auto", "maybe").is_err());
956    }
957
958    #[test]
959    fn test_get_set_summary_auto_threshold() {
960        let mut config = Config::default();
961
962        // Default is 4
963        assert_eq!(config.get("summary_auto_threshold"), Some("4".to_string()));
964
965        // Set valid value
966        config.set("summary_auto_threshold", "10").unwrap();
967        assert_eq!(config.summary_auto_threshold, 10);
968        assert_eq!(config.get("summary_auto_threshold"), Some("10".to_string()));
969
970        // Reject 0
971        let result = config.set("summary_auto_threshold", "0");
972        assert!(result.is_err());
973        assert!(result
974            .unwrap_err()
975            .to_string()
976            .contains("must be greater than 0"));
977
978        // Reject negative (parsing fails since usize cannot be negative)
979        assert!(config.set("summary_auto_threshold", "-1").is_err());
980
981        // Reject non-numeric
982        assert!(config.set("summary_auto_threshold", "abc").is_err());
983    }
984
985    #[test]
986    fn test_summary_fields_yaml_serialization() {
987        // When None, summary fields are omitted from YAML
988        let config = Config::default();
989        let yaml = serde_saphyr::to_string(&config).unwrap();
990        assert!(!yaml.contains("summary_provider"));
991        assert!(!yaml.contains("summary_api_key"));
992        assert!(!yaml.contains("summary_model"));
993
994        // When set, they appear in the output
995        let config = Config {
996            summary_provider: Some("anthropic".to_string()),
997            summary_api_key_anthropic: Some("sk-ant-test".to_string()),
998            summary_api_key_openai: Some("sk-oai-test".to_string()),
999            summary_model_anthropic: Some("claude-sonnet-4-20250514".to_string()),
1000            summary_auto: true,
1001            summary_auto_threshold: 8,
1002            ..Default::default()
1003        };
1004        let yaml = serde_saphyr::to_string(&config).unwrap();
1005        assert!(yaml.contains("summary_provider"));
1006        assert!(yaml.contains("anthropic"));
1007        assert!(yaml.contains("summary_api_key_anthropic"));
1008        assert!(yaml.contains("sk-ant-test"));
1009        assert!(yaml.contains("summary_api_key_openai"));
1010        assert!(yaml.contains("sk-oai-test"));
1011        assert!(yaml.contains("summary_model_anthropic"));
1012        assert!(yaml.contains("claude-sonnet-4-20250514"));
1013        assert!(yaml.contains("summary_auto"));
1014        assert!(yaml.contains("summary_auto_threshold"));
1015
1016        // Verify roundtrip through YAML serialization/deserialization
1017        let temp_dir = TempDir::new().unwrap();
1018        let path = temp_dir.path().join("config.yaml");
1019        config.save_to_path(&path).unwrap();
1020        let loaded = Config::load_from_path(&path).unwrap();
1021        assert_eq!(loaded.summary_provider, Some("anthropic".to_string()));
1022        assert_eq!(
1023            loaded.summary_api_key_anthropic,
1024            Some("sk-ant-test".to_string())
1025        );
1026        assert_eq!(
1027            loaded.summary_api_key_openai,
1028            Some("sk-oai-test".to_string())
1029        );
1030        assert_eq!(
1031            loaded.summary_model_anthropic,
1032            Some("claude-sonnet-4-20250514".to_string())
1033        );
1034        assert!(loaded.summary_auto);
1035        assert_eq!(loaded.summary_auto_threshold, 8);
1036    }
1037}