1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use figment::Figment;
6use figment::providers::{Env, Format, Serialized, Toml};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10#[derive(Debug, Error)]
11pub enum ConfigError {
12 #[error("io error: {0}")]
13 Io(#[from] std::io::Error),
14 #[error("parse error: {0}")]
15 Parse(#[from] toml::de::Error),
16 #[error("serialize error: {0}")]
17 Serialize(#[from] toml::ser::Error),
18 #[error("config error: {0}")]
19 Figment(#[from] Box<figment::Error>),
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23#[serde(default)]
24pub struct Config {
25 pub library: LibraryConfig,
26 pub playback: PlaybackConfig,
27 pub remote: RemoteConfig,
28 pub organize: OrganizeConfig,
29 #[serde(alias = "visualiser")]
30 pub visualizer: VisualizerConfig,
31 pub radio: RadioConfig,
32 pub graphql: GraphqlConfig,
33 pub discovery: DiscoveryConfig,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(default)]
38pub struct LibraryConfig {
39 pub folders: Vec<PathBuf>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(default)]
44pub struct PlaybackConfig {
45 pub software_volume: bool,
46 pub replaygain: ReplayGainMode,
47 pub ticker_fps: u8,
50 pub target_fps: u8,
53 pub show_fps: bool,
55 pub pre_amp_db: f64,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub output_device: Option<String>,
62 pub art_size: u16,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum ReplayGainMode {
70 Off,
71 Track,
72 Album,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(default)]
77pub struct RemoteConfig {
78 pub enabled: bool,
79 pub url: String,
80 pub username: String,
81 #[serde(default, skip_serializing_if = "String::is_empty")]
83 pub password: String,
84 pub transcode_quality: String,
86 pub cache_dir: Option<PathBuf>,
88 pub download_workers: usize,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub cache_limit: Option<String>,
94}
95
96impl Default for LibraryConfig {
97 fn default() -> Self {
98 let music_dir = dirs::audio_dir().unwrap_or_else(|| {
99 dirs::home_dir()
100 .map(|h| h.join("Music"))
101 .unwrap_or_else(|| PathBuf::from("/Music"))
102 });
103 Self {
104 folders: vec![music_dir],
105 }
106 }
107}
108
109impl Default for PlaybackConfig {
110 fn default() -> Self {
111 Self {
112 software_volume: false,
113 replaygain: ReplayGainMode::Off,
114 ticker_fps: 8,
115 target_fps: 60,
116 show_fps: false,
117 pre_amp_db: 0.0,
118 output_device: None,
119 art_size: 24,
120 }
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(default)]
126pub struct VisualizerConfig {
127 pub enabled: bool,
128 pub fps: u8,
129 pub mode: String,
131 pub scale: String,
133 pub amplitude_scale: String,
135 pub bar_decay_ms: u32,
137 pub peak_decay_ms: u32,
139 pub palette: String,
142 pub reactivity: f32,
146 pub bass_shake: bool,
149 pub matrix_overlay: bool,
152 pub reactive_bg: bool,
154}
155
156impl Default for VisualizerConfig {
157 fn default() -> Self {
158 Self {
159 enabled: true,
160 fps: 60,
161 mode: "bars".into(),
162 scale: "bark".into(),
163 amplitude_scale: "aweight".into(),
164 bar_decay_ms: 50,
165 peak_decay_ms: 180,
166 palette: "spectrum".into(),
167 reactivity: 1.0,
168 bass_shake: true,
169 matrix_overlay: false,
170 reactive_bg: false,
171 }
172 }
173}
174
175impl Default for RemoteConfig {
176 fn default() -> Self {
177 Self {
178 enabled: false,
179 url: String::new(),
180 username: String::new(),
181 password: String::new(),
182 transcode_quality: "original".into(),
183 cache_dir: None,
184 download_workers: 5,
185 cache_limit: None,
186 }
187 }
188}
189
190pub fn parse_size_bytes(s: &str) -> Option<u64> {
193 let s = s.trim();
194 if s.is_empty() {
195 return None;
196 }
197
198 let mut num_end = 0;
200 for (i, c) in s.char_indices() {
201 if c.is_ascii_digit() || c == '.' {
202 num_end = i + c.len_utf8();
203 } else if !c.is_whitespace() {
204 break;
205 }
206 }
207
208 let num_str = s[..num_end].trim();
209 let suffix = s[num_end..].trim().to_ascii_uppercase();
210
211 let value: f64 = num_str.parse().ok()?;
212 let multiplier: u64 = match suffix.as_str() {
213 "" | "B" => 1,
214 "KB" | "K" => 1024,
215 "MB" | "M" => 1024 * 1024,
216 "GB" | "G" => 1024 * 1024 * 1024,
217 "TB" | "T" => 1024 * 1024 * 1024 * 1024,
218 _ => return None,
219 };
220
221 Some((value * multiplier as f64) as u64)
222}
223
224#[derive(Debug, Clone, Default, Serialize, Deserialize)]
225#[serde(default)]
226pub struct OrganizeConfig {
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub default: Option<String>,
230 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
232 pub patterns: HashMap<String, String>,
233}
234
235impl OrganizeConfig {
236 pub fn resolve_pattern<'a>(&'a self, name_or_raw: &'a str) -> &'a str {
239 self.patterns
240 .get(name_or_raw)
241 .map(|s| s.as_str())
242 .unwrap_or(name_or_raw)
243 }
244
245 pub fn default_pattern(&self) -> Option<&str> {
247 self.default
248 .as_ref()
249 .and_then(|name| self.patterns.get(name))
250 .map(|s| s.as_str())
251 }
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(default)]
257pub struct GraphqlConfig {
258 pub enabled: bool,
261 pub port: u16,
263 #[serde(default = "default_bind")]
266 pub bind: std::net::IpAddr,
267 pub playground: bool,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub subsonic_port: Option<u16>,
272 pub auth_enabled: bool,
275 pub access_token_ttl: String,
277 pub refresh_token_ttl: String,
279 pub cors_origins: Vec<String>,
282}
283
284fn default_bind() -> std::net::IpAddr {
285 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
286}
287
288impl Default for GraphqlConfig {
289 fn default() -> Self {
290 Self {
291 enabled: true,
292 port: 4000,
293 bind: default_bind(),
294 playground: false,
295 subsonic_port: None,
296 auth_enabled: true,
297 access_token_ttl: "15m".into(),
298 refresh_token_ttl: "30d".into(),
299 cors_origins: Vec::new(),
300 }
301 }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
306#[serde(default)]
307pub struct RadioConfig {
308 pub lookahead: usize,
310 pub batch_size: usize,
312 pub use_subsonic: bool,
314 pub history_window: usize,
316 pub seed_window: usize,
318 pub discovery_weight: f64,
321}
322
323impl Default for RadioConfig {
324 fn default() -> Self {
325 Self {
326 lookahead: 5,
327 batch_size: 5,
328 use_subsonic: true,
329 history_window: 200,
330 seed_window: 5,
331 discovery_weight: 0.3,
332 }
333 }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
338#[serde(default)]
339pub struct DiscoveryConfig {
340 pub analysis_on_scan: bool,
342 pub acoustic_weight: f64,
344}
345
346impl Default for DiscoveryConfig {
347 fn default() -> Self {
348 Self {
349 analysis_on_scan: false,
350 acoustic_weight: 0.5,
351 }
352 }
353}
354
355impl Config {
356 fn figment() -> Figment {
362 let base_path = config_file_path();
363 let local_path = config_local_file_path();
364
365 Figment::from(Serialized::defaults(Config::default()))
366 .merge(Toml::file(&base_path))
367 .merge(Toml::file(&local_path))
368 .merge(Env::prefixed("KOAN_").split("__"))
369 }
370
371 pub fn load() -> Result<Self, ConfigError> {
373 let cfg: Self = Self::figment()
374 .extract()
375 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
376
377 check_secrets_in_git();
379
380 Ok(cfg)
381 }
382
383 pub fn load_or_default() -> Self {
385 Self::load().unwrap_or_else(|e| {
386 log::warn!("failed to load config, using defaults: {}", e);
387 Self::default()
388 })
389 }
390
391 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
393 let contents = fs::read_to_string(path)?;
394 let config: Config = toml::from_str(&contents)?;
395 Ok(config)
396 }
397
398 pub fn update_base<F>(mutate: F) -> Result<(), ConfigError>
402 where
403 F: FnOnce(&mut Config),
404 {
405 let path = config_file_path();
406 let mut cfg = if path.exists() {
407 Config::load_from(&path)?
408 } else {
409 Config::default()
410 };
411 mutate(&mut cfg);
412 cfg.write_to(&path)?;
413 Ok(())
414 }
415
416 fn write_to(&self, path: &Path) -> Result<(), ConfigError> {
418 if let Some(parent) = path.parent() {
419 fs::create_dir_all(parent)?;
420 }
421 let contents = toml::to_string_pretty(self)?;
422 fs::write(path, contents)?;
423 Ok(())
424 }
425
426 pub fn patch_local(
429 section: &str,
430 values: &toml::map::Map<String, toml::Value>,
431 ) -> Result<(), ConfigError> {
432 let path = config_local_file_path();
433 let mut doc: toml::Value = if path.exists() {
434 let contents = fs::read_to_string(&path)?;
435 toml::from_str(&contents).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new()))
436 } else {
437 toml::Value::Table(toml::map::Map::new())
438 };
439
440 let table = doc.as_table_mut().expect("root is always a table");
441 let section_table = table
442 .entry(section)
443 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
444 .as_table_mut()
445 .ok_or_else(|| {
446 ConfigError::Io(std::io::Error::new(
447 std::io::ErrorKind::InvalidData,
448 format!("[{}] is not a table", section),
449 ))
450 })?;
451
452 for (key, value) in values {
453 section_table.insert(key.clone(), value.clone());
454 }
455
456 let contents = toml::to_string_pretty(&doc)?;
457 fs::write(&path, contents)?;
458 #[cfg(unix)]
459 {
460 use std::os::unix::fs::PermissionsExt;
461 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
462 }
463 Ok(())
464 }
465
466 pub fn cache_dir(&self) -> PathBuf {
468 self.remote
469 .cache_dir
470 .clone()
471 .unwrap_or_else(|| config_dir().join("cache"))
472 }
473
474 pub fn cache_limit_bytes(&self) -> Option<u64> {
476 self.remote
477 .cache_limit
478 .as_deref()
479 .and_then(parse_size_bytes)
480 }
481}
482
483pub fn config_dir() -> PathBuf {
485 dirs::home_dir()
486 .unwrap_or_else(|| PathBuf::from("."))
487 .join(".config")
488 .join("koan")
489}
490
491pub fn config_file_path() -> PathBuf {
493 config_dir().join("config.toml")
494}
495
496pub fn config_local_file_path() -> PathBuf {
498 config_dir().join("config.local.toml")
499}
500
501pub fn db_path() -> PathBuf {
503 config_dir().join("koan.db")
504}
505
506fn check_secrets_in_git() {
510 let sensitive_fields = ["password"];
511
512 for (label, path) in [
513 ("config.toml", config_file_path()),
514 ("config.local.toml", config_local_file_path()),
515 ] {
516 let Ok(contents) = std::fs::read_to_string(&path) else {
517 continue;
518 };
519
520 let has_secrets = sensitive_fields.iter().any(|field| {
522 contents.lines().any(|line| {
523 let line = line.trim();
524 if let Some(rest) = line.strip_prefix(field) {
525 let rest = rest.trim_start();
526 if let Some(value) = rest.strip_prefix('=') {
527 let value = value.trim().trim_matches('"').trim_matches('\'');
528 return !value.is_empty();
529 }
530 }
531 false
532 })
533 });
534
535 if !has_secrets {
536 continue;
537 }
538
539 if is_tracked_by_git(&path) {
541 eprintln!();
542 eprintln!("╔══════════════════════════════════════════════════════════════╗");
543 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
544 eprintln!("╠══════════════════════════════════════════════════════════════╣");
545 eprintln!("║ ║");
546 eprintln!("║ File: {:<52} ║", path.display());
547 eprintln!("║ ║");
548 eprintln!("║ Your password is in version control. You should: ║");
549 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
550 eprintln!("║ 2. Add it to .gitignore ║");
551 eprintln!("║ 3. Rotate your credentials immediately ║");
552 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
553 eprintln!("║ or use `koan remote login` for keyring storage ║");
554 eprintln!("║ ║");
555 eprintln!("╚══════════════════════════════════════════════════════════════╝");
556 eprintln!();
557 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
558 }
559 }
560}
561
562fn is_tracked_by_git(path: &Path) -> bool {
564 let Some(parent) = path.parent() else {
565 return false;
566 };
567 std::process::Command::new("git")
569 .args(["ls-files", "--error-unmatch"])
570 .arg(path)
571 .current_dir(parent)
572 .stdout(std::process::Stdio::null())
573 .stderr(std::process::Stdio::null())
574 .status()
575 .is_ok_and(|s| s.success())
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581 use std::fs;
582
583 fn tmp_dir() -> PathBuf {
584 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
585 fs::create_dir_all(&dir).unwrap();
586 dir
587 }
588
589 #[test]
590 fn test_defaults() {
591 let cfg = Config::default();
592 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
593 assert!(!cfg.remote.enabled);
594 assert_eq!(cfg.remote.transcode_quality, "original");
595 }
596
597 #[test]
598 fn test_roundtrip_toml() {
599 let cfg = Config::default();
600 let serialized = toml::to_string_pretty(&cfg).unwrap();
601 let deserialized: Config = toml::from_str(&serialized).unwrap();
602 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
603 assert_eq!(
604 deserialized.remote.transcode_quality,
605 cfg.remote.transcode_quality
606 );
607 }
608
609 #[test]
610 fn test_load_from_file() {
611 let dir = tempfile::tempdir().unwrap();
612 let path = dir.path().join("config.toml");
613 fs::write(
614 &path,
615 r#"
616[library]
617folders = ["/tmp/music"]
618
619[playback]
620replaygain = "track"
621"#,
622 )
623 .unwrap();
624
625 let cfg = Config::load_from(&path).unwrap();
626 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
627 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
628 assert!(!cfg.remote.enabled);
629 }
630
631 #[test]
632 fn test_partial_toml_uses_defaults() {
633 let dir = tempfile::tempdir().unwrap();
634 let path = dir.path().join("partial.toml");
635 fs::write(&path, "[playback]\nsoftware_volume = true\n").unwrap();
636
637 let cfg = Config::load_from(&path).unwrap();
638 assert!(cfg.playback.software_volume);
639 }
640
641 #[test]
642 fn test_figment_layered_loading() {
643 let dir = tempfile::tempdir().unwrap();
644 let base_path = dir.path().join("config.toml");
645 let local_path = dir.path().join("config.local.toml");
646
647 fs::write(
648 &base_path,
649 r#"
650[remote]
651url = "https://base.example.com"
652"#,
653 )
654 .unwrap();
655 fs::write(
656 &local_path,
657 r#"
658[remote]
659enabled = true
660url = "https://local.example.com"
661username = "admin"
662password = "secret"
663"#,
664 )
665 .unwrap();
666
667 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
669 .merge(Toml::file(&base_path))
670 .merge(Toml::file(&local_path))
671 .extract()
672 .unwrap();
673
674 assert!(cfg.remote.enabled);
675 assert_eq!(cfg.remote.url, "https://local.example.com");
676 assert_eq!(cfg.remote.username, "admin");
677 assert_eq!(cfg.remote.password, "secret");
678 }
679
680 #[test]
681 fn test_figment_missing_keys_preserved() {
682 let dir = tempfile::tempdir().unwrap();
683 let base_path = dir.path().join("config.toml");
684 let local_path = dir.path().join("config.local.toml");
685
686 fs::write(
687 &base_path,
688 r#"
689[remote]
690url = "https://keep.me"
691username = "keepuser"
692"#,
693 )
694 .unwrap();
695 fs::write(
696 &local_path,
697 r#"
698[remote]
699password = "secret"
700"#,
701 )
702 .unwrap();
703
704 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
705 .merge(Toml::file(&base_path))
706 .merge(Toml::file(&local_path))
707 .extract()
708 .unwrap();
709
710 assert_eq!(cfg.remote.url, "https://keep.me");
711 assert_eq!(cfg.remote.username, "keepuser");
712 assert_eq!(cfg.remote.password, "secret");
713 }
714
715 #[test]
716 fn test_env_var_override() {
717 let dir = tempfile::tempdir().unwrap();
718 let base_path = dir.path().join("config.toml");
719
720 fs::write(
721 &base_path,
722 r#"
723[remote]
724url = "https://file.example.com"
725"#,
726 )
727 .unwrap();
728
729 unsafe {
731 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
732 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
733 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
734 }
735
736 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
737 .merge(Toml::file(&base_path))
738 .merge(Env::prefixed("KOAN_").split("__"))
739 .extract()
740 .unwrap();
741
742 assert_eq!(cfg.remote.url, "https://env.example.com");
743 assert_eq!(cfg.remote.password, "env-secret");
744 assert_eq!(cfg.graphql.port, 9999);
745
746 unsafe {
748 std::env::remove_var("KOAN_REMOTE__URL");
749 std::env::remove_var("KOAN_REMOTE__PASSWORD");
750 std::env::remove_var("KOAN_GRAPHQL__PORT");
751 }
752 }
753
754 #[test]
755 fn test_update_base_does_not_leak_secrets() {
756 let dir = tempfile::tempdir().unwrap();
757 let base_path = dir.path().join("config.toml");
758
759 fs::write(
761 &base_path,
762 r#"
763[playback]
764target_fps = 60
765
766[remote]
767url = "https://base.example.com"
768"#,
769 )
770 .unwrap();
771
772 let mut base_cfg = Config::load_from(&base_path).unwrap();
774 base_cfg.visualizer.enabled = false;
775 base_cfg.write_to(&base_path).unwrap();
776
777 let written = fs::read_to_string(&base_path).unwrap();
779 assert!(!written.contains("secret"));
780 assert!(!written.contains("password"));
781
782 let reloaded = Config::load_from(&base_path).unwrap();
784 assert!(!reloaded.visualizer.enabled);
785 assert_eq!(reloaded.remote.url, "https://base.example.com");
786 }
787
788 #[test]
789 fn test_cache_dir_default() {
790 let cfg = Config::default();
791 assert!(cfg.cache_dir().ends_with("cache"));
792 }
793
794 #[test]
795 fn test_cache_dir_explicit() {
796 let mut cfg = Config::default();
797 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
798 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
799 }
800
801 #[test]
802 fn test_organize_config_defaults() {
803 let cfg = Config::default();
804 assert!(cfg.organize.default.is_none());
805 assert!(cfg.organize.patterns.is_empty());
806 }
807
808 #[test]
809 fn test_organize_config_from_toml() {
810 let dir = tmp_dir();
811 let path = dir.join("organize.toml");
812 fs::write(
813 &path,
814 r#"
815[organize]
816default = "standard"
817
818[organize.patterns]
819standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
820va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
821"#,
822 )
823 .unwrap();
824
825 let cfg = Config::load_from(&path).unwrap();
826 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
827 assert_eq!(cfg.organize.patterns.len(), 2);
828 assert!(cfg.organize.patterns.contains_key("standard"));
829 assert!(cfg.organize.patterns.contains_key("va-aware"));
830
831 fs::remove_dir_all(&dir).ok();
832 }
833
834 #[test]
835 fn test_organize_resolve_named_pattern() {
836 let mut cfg = OrganizeConfig::default();
837 cfg.patterns
838 .insert("standard".into(), "%artist%/%title%".into());
839
840 assert_eq!(cfg.resolve_pattern("standard"), "%artist%/%title%");
841 assert_eq!(cfg.resolve_pattern("%raw%pattern%"), "%raw%pattern%");
843 }
844
845 #[test]
846 fn test_organize_default_pattern() {
847 let mut cfg = OrganizeConfig {
848 default: Some("standard".into()),
849 ..OrganizeConfig::default()
850 };
851 cfg.patterns
852 .insert("standard".into(), "%artist%/%title%".into());
853
854 assert_eq!(cfg.default_pattern(), Some("%artist%/%title%"));
855 }
856
857 #[test]
858 fn test_organize_default_pattern_missing_name() {
859 let cfg = OrganizeConfig {
860 default: Some("nonexistent".into()),
861 ..OrganizeConfig::default()
862 };
863 assert_eq!(cfg.default_pattern(), None);
865 }
866
867 #[test]
868 fn test_figment_organize_patterns_merge() {
869 let dir = tempfile::tempdir().unwrap();
870 let base_path = dir.path().join("config.toml");
871 let local_path = dir.path().join("config.local.toml");
872
873 fs::write(
874 &base_path,
875 r#"
876[organize]
877default = "standard"
878
879[organize.patterns]
880standard = "base-pattern"
881"#,
882 )
883 .unwrap();
884 fs::write(
885 &local_path,
886 r#"
887[organize]
888default = "custom"
889
890[organize.patterns]
891custom = "local-pattern"
892"#,
893 )
894 .unwrap();
895
896 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
897 .merge(Toml::file(&base_path))
898 .merge(Toml::file(&local_path))
899 .extract()
900 .unwrap();
901
902 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
904 assert_eq!(cfg.organize.patterns.len(), 2);
906 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
907 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
908 }
909
910 #[test]
911 fn test_output_device_config_roundtrip() {
912 let mut cfg = Config::default();
913 cfg.playback.output_device = Some("My DAC".into());
914
915 let serialized = toml::to_string_pretty(&cfg).unwrap();
916 let deserialized: Config = toml::from_str(&serialized).unwrap();
917 assert_eq!(
918 deserialized.playback.output_device.as_deref(),
919 Some("My DAC")
920 );
921 }
922
923 #[test]
924 fn test_output_device_config_default_is_none() {
925 let cfg = Config::default();
926 assert!(cfg.playback.output_device.is_none());
927
928 let serialized = toml::to_string_pretty(&cfg).unwrap();
930 assert!(!serialized.contains("output_device"));
931 let deserialized: Config = toml::from_str(&serialized).unwrap();
932 assert!(deserialized.playback.output_device.is_none());
933 }
934
935 #[test]
936 fn test_output_device_config_from_toml() {
937 let dir = tempfile::tempdir().unwrap();
938 let path = dir.path().join("config.toml");
939 fs::write(
940 &path,
941 r#"
942[playback]
943output_device = "External Speakers"
944"#,
945 )
946 .unwrap();
947
948 let cfg = Config::load_from(&path).unwrap();
949 assert_eq!(
950 cfg.playback.output_device.as_deref(),
951 Some("External Speakers")
952 );
953 }
954
955 #[test]
956 fn test_graphql_bind_defaults_to_localhost() {
957 let cfg = GraphqlConfig::default();
958 assert_eq!(
959 cfg.bind,
960 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
961 );
962 }
963
964 #[test]
965 fn test_graphql_bind_from_toml() {
966 let toml_str = r#"
967[graphql]
968bind = "0.0.0.0"
969port = 5000
970"#;
971 let cfg: Config = toml::from_str(toml_str).unwrap();
972 assert_eq!(
973 cfg.graphql.bind,
974 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
975 );
976 assert_eq!(cfg.graphql.port, 5000);
977 }
978
979 #[test]
980 fn test_graphql_bind_omitted_defaults_to_localhost() {
981 let toml_str = r#"
982[graphql]
983port = 4000
984"#;
985 let cfg: Config = toml::from_str(toml_str).unwrap();
986 assert_eq!(
987 cfg.graphql.bind,
988 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
989 );
990 }
991
992 #[test]
993 fn test_organize_config_roundtrip() {
994 let mut cfg = Config::default();
995 cfg.organize.default = Some("standard".into());
996 cfg.organize
997 .patterns
998 .insert("standard".into(), "%artist%/%title%".into());
999
1000 let serialized = toml::to_string_pretty(&cfg).unwrap();
1001 let deserialized: Config = toml::from_str(&serialized).unwrap();
1002 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
1003 assert_eq!(
1004 deserialized.organize.patterns["standard"],
1005 "%artist%/%title%"
1006 );
1007 }
1008
1009 #[test]
1010 fn test_parse_size_bytes() {
1011 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1012 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1013 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1014 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1015 assert_eq!(parse_size_bytes("1024B"), Some(1024));
1016 assert_eq!(parse_size_bytes("1024"), Some(1024));
1017
1018 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1020 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1021
1022 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1024 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1025
1026 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1028 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1029
1030 assert_eq!(
1032 parse_size_bytes("1.5GB"),
1033 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1034 );
1035
1036 assert_eq!(parse_size_bytes(""), None);
1038 assert_eq!(parse_size_bytes("abc"), None);
1039 assert_eq!(parse_size_bytes("50XB"), None);
1040 }
1041
1042 #[test]
1043 fn test_cache_limit_config_from_toml() {
1044 let toml_str = r#"
1045[remote]
1046cache_limit = "50GB"
1047"#;
1048 let cfg: Config = toml::from_str(toml_str).unwrap();
1049 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1050 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1051 }
1052
1053 #[test]
1054 fn test_cache_limit_none_by_default() {
1055 let cfg = Config::default();
1056 assert!(cfg.remote.cache_limit.is_none());
1057 assert!(cfg.cache_limit_bytes().is_none());
1058 }
1059
1060 #[test]
1061 fn test_cache_limit_not_serialized_when_none() {
1062 let cfg = Config::default();
1063 let serialized = toml::to_string_pretty(&cfg).unwrap();
1064 assert!(!serialized.contains("cache_limit"));
1065 }
1066
1067 #[test]
1068 fn player_uses_config_on_init() {
1069 let dir = tempfile::tempdir().unwrap();
1073 let path = dir.path().join("config.toml");
1074 fs::write(
1075 &path,
1076 r#"
1077[playback]
1078replaygain = "track"
1079output_device = "My Fancy DAC"
1080pre_amp_db = -3.5
1081software_volume = true
1082target_fps = 30
1083art_size = 32
1084
1085[visualizer]
1086enabled = false
1087mode = "oscilloscope"
1088fps = 30
1089"#,
1090 )
1091 .unwrap();
1092
1093 let cfg = Config::load_from(&path).unwrap();
1094
1095 assert_eq!(
1097 cfg.playback.replaygain,
1098 ReplayGainMode::Track,
1099 "replaygain should be 'track'"
1100 );
1101 assert_eq!(
1102 cfg.playback.output_device.as_deref(),
1103 Some("My Fancy DAC"),
1104 "output_device should match config"
1105 );
1106 assert!(
1107 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1108 "pre_amp_db should be -3.5"
1109 );
1110 assert!(
1111 cfg.playback.software_volume,
1112 "software_volume should be true"
1113 );
1114 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1115 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1116
1117 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1119 assert_eq!(cfg.visualizer.mode, "oscilloscope");
1120 assert_eq!(cfg.visualizer.fps, 30);
1121 }
1122}