1use anyhow::{bail, Context, Result};
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::{Path, PathBuf};
13use uuid::Uuid;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct Config {
21 pub watchers: Vec<String>,
23
24 pub auto_link: bool,
26
27 pub auto_link_threshold: f64,
29
30 pub commit_footer: bool,
32
33 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub machine_id: Option<String>,
38
39 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub machine_name: Option<String>,
44
45 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub cloud_url: Option<String>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub encryption_salt: Option<String>,
58
59 #[serde(default)]
65 pub use_keychain: bool,
66
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub summary_provider: Option<String>,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub summary_api_key_anthropic: Option<String>,
74
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub summary_api_key_openai: Option<String>,
78
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub summary_api_key_openrouter: Option<String>,
82
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub summary_model_anthropic: Option<String>,
86
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub summary_model_openai: Option<String>,
90
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub summary_model_openrouter: Option<String>,
94
95 #[serde(default)]
97 pub summary_auto: bool,
98
99 #[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 pub fn load() -> Result<Self> {
134 let path = Self::config_path()?;
135 Self::load_from_path(&path)
136 }
137
138 pub fn save(&self) -> Result<()> {
142 let path = Self::config_path()?;
143 self.save_to_path(&path)
144 }
145
146 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 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 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 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 pub fn set_machine_name(&mut self, name: &str) -> Result<()> {
222 self.machine_name = Some(name.to_string());
223 self.save()
224 }
225
226 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 #[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 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 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 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 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 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 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 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 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 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 Ok(content.lines().any(|line| {
504 let trimmed = line.trim();
505 trimmed.starts_with("use_keychain:")
506 }))
507 }
508}
509
510fn default_summary_auto_threshold() -> usize {
512 4
513}
514
515fn 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 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 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 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 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 config.set("auto_link_threshold", "0.5").unwrap();
648 assert!((config.auto_link_threshold - 0.5).abs() < f64::EPSILON);
649
650 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 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 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 assert!(config.set("unknown_key", "value").is_err());
677
678 assert!(config.set("auto_link", "maybe").is_err());
680
681 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 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 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 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 assert!(!name.is_empty());
715 }
716
717 #[test]
718 fn test_machine_identity_yaml_serialization() {
719 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 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 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 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 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 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 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 assert_eq!(config.get("summary_provider"), None);
814
815 config.set("summary_provider", "anthropic").unwrap();
817 assert_eq!(
818 config.get("summary_provider"),
819 Some("anthropic".to_string())
820 );
821
822 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 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 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 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 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 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 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 assert_eq!(config.get("summary_auto"), Some("false".to_string()));
943
944 config.set("summary_auto", "true").unwrap();
946 assert!(config.summary_auto);
947 assert_eq!(config.get("summary_auto"), Some("true".to_string()));
948
949 config.set("summary_auto", "false").unwrap();
951 assert!(!config.summary_auto);
952 assert_eq!(config.get("summary_auto"), Some("false".to_string()));
953
954 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 assert_eq!(config.get("summary_auto_threshold"), Some("4".to_string()));
964
965 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 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 assert!(config.set("summary_auto_threshold", "-1").is_err());
980
981 assert!(config.set("summary_auto_threshold", "abc").is_err());
983 }
984
985 #[test]
986 fn test_summary_fields_yaml_serialization() {
987 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 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 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}