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 encryption_salt: Option<String>,
51
52 #[serde(default)]
58 pub use_keychain: bool,
59
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub summary_provider: Option<String>,
63
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub summary_api_key_anthropic: Option<String>,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub summary_api_key_openai: Option<String>,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub summary_api_key_openrouter: Option<String>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub summary_model_anthropic: Option<String>,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub summary_model_openai: Option<String>,
83
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub summary_model_openrouter: Option<String>,
87
88 #[serde(default)]
90 pub summary_auto: bool,
91
92 #[serde(default = "default_summary_auto_threshold")]
94 pub summary_auto_threshold: usize,
95
96 #[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 pub fn load() -> Result<Self> {
136 let path = Self::config_path()?;
137 Self::load_from_path(&path)
138 }
139
140 pub fn save(&self) -> Result<()> {
144 let path = Self::config_path()?;
145 self.save_to_path(&path)
146 }
147
148 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 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 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 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 pub fn set_machine_name(&mut self, name: &str) -> Result<()> {
224 self.machine_name = Some(name.to_string());
225 self.save()
226 }
227
228 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 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 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 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 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 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
447fn default_summary_auto_threshold() -> usize {
449 4
450}
451
452fn 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 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 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 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 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 config.set("auto_link_threshold", "0.5").unwrap();
584 assert!((config.auto_link_threshold - 0.5).abs() < f64::EPSILON);
585
586 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 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 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 assert!(config.set("unknown_key", "value").is_err());
613
614 assert!(config.set("auto_link", "maybe").is_err());
616
617 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 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 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 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 assert!(!name.is_empty());
651 }
652
653 #[test]
654 fn test_machine_identity_yaml_serialization() {
655 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 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 assert_eq!(config.get("summary_provider"), None);
692
693 config.set("summary_provider", "anthropic").unwrap();
695 assert_eq!(
696 config.get("summary_provider"),
697 Some("anthropic".to_string())
698 );
699
700 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 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 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 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 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 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 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 assert_eq!(config.get("summary_auto"), Some("false".to_string()));
821
822 config.set("summary_auto", "true").unwrap();
824 assert!(config.summary_auto);
825 assert_eq!(config.get("summary_auto"), Some("true".to_string()));
826
827 config.set("summary_auto", "false").unwrap();
829 assert!(!config.summary_auto);
830 assert_eq!(config.get("summary_auto"), Some("false".to_string()));
831
832 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 assert_eq!(config.get("summary_auto_threshold"), Some("4".to_string()));
842
843 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 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 assert!(config.set("summary_auto_threshold", "-1").is_err());
858
859 assert!(config.set("summary_auto_threshold", "abc").is_err());
861 }
862
863 #[test]
864 fn test_summary_fields_yaml_serialization() {
865 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 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 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}