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