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}
280
281fn default_bind() -> std::net::IpAddr {
282 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
283}
284
285impl Default for GraphqlConfig {
286 fn default() -> Self {
287 Self {
288 enabled: true,
289 port: 4000,
290 bind: default_bind(),
291 playground: false,
292 subsonic_port: None,
293 auth_enabled: true,
294 access_token_ttl: "15m".into(),
295 refresh_token_ttl: "30d".into(),
296 }
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(default)]
303pub struct RadioConfig {
304 pub lookahead: usize,
306 pub batch_size: usize,
308 pub use_subsonic: bool,
310 pub history_window: usize,
312 pub seed_window: usize,
314 pub discovery_weight: f64,
317}
318
319impl Default for RadioConfig {
320 fn default() -> Self {
321 Self {
322 lookahead: 5,
323 batch_size: 5,
324 use_subsonic: true,
325 history_window: 200,
326 seed_window: 5,
327 discovery_weight: 0.3,
328 }
329 }
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
334#[serde(default)]
335pub struct DiscoveryConfig {
336 pub analysis_on_scan: bool,
338 pub acoustic_weight: f64,
340}
341
342impl Default for DiscoveryConfig {
343 fn default() -> Self {
344 Self {
345 analysis_on_scan: false,
346 acoustic_weight: 0.5,
347 }
348 }
349}
350
351impl Config {
352 fn figment() -> Figment {
358 let base_path = config_file_path();
359 let local_path = config_local_file_path();
360
361 Figment::from(Serialized::defaults(Config::default()))
362 .merge(Toml::file(&base_path))
363 .merge(Toml::file(&local_path))
364 .merge(Env::prefixed("KOAN_").split("__"))
365 }
366
367 pub fn load() -> Result<Self, ConfigError> {
369 let cfg: Self = Self::figment()
370 .extract()
371 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
372
373 check_secrets_in_git();
375
376 Ok(cfg)
377 }
378
379 pub fn load_or_default() -> Self {
381 Self::load().unwrap_or_else(|e| {
382 log::warn!("failed to load config, using defaults: {}", e);
383 Self::default()
384 })
385 }
386
387 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
389 let contents = fs::read_to_string(path)?;
390 let config: Config = toml::from_str(&contents)?;
391 Ok(config)
392 }
393
394 pub fn update_base<F>(mutate: F) -> Result<(), ConfigError>
398 where
399 F: FnOnce(&mut Config),
400 {
401 let path = config_file_path();
402 let mut cfg = if path.exists() {
403 Config::load_from(&path)?
404 } else {
405 Config::default()
406 };
407 mutate(&mut cfg);
408 cfg.write_to(&path)?;
409 Ok(())
410 }
411
412 fn write_to(&self, path: &Path) -> Result<(), ConfigError> {
414 if let Some(parent) = path.parent() {
415 fs::create_dir_all(parent)?;
416 }
417 let contents = toml::to_string_pretty(self)?;
418 fs::write(path, contents)?;
419 Ok(())
420 }
421
422 pub fn patch_local(
425 section: &str,
426 values: &toml::map::Map<String, toml::Value>,
427 ) -> Result<(), ConfigError> {
428 let path = config_local_file_path();
429 let mut doc: toml::Value = if path.exists() {
430 let contents = fs::read_to_string(&path)?;
431 toml::from_str(&contents).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new()))
432 } else {
433 toml::Value::Table(toml::map::Map::new())
434 };
435
436 let table = doc.as_table_mut().expect("root is always a table");
437 let section_table = table
438 .entry(section)
439 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
440 .as_table_mut()
441 .ok_or_else(|| {
442 ConfigError::Io(std::io::Error::new(
443 std::io::ErrorKind::InvalidData,
444 format!("[{}] is not a table", section),
445 ))
446 })?;
447
448 for (key, value) in values {
449 section_table.insert(key.clone(), value.clone());
450 }
451
452 let contents = toml::to_string_pretty(&doc)?;
453 fs::write(&path, contents)?;
454 #[cfg(unix)]
455 {
456 use std::os::unix::fs::PermissionsExt;
457 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
458 }
459 Ok(())
460 }
461
462 pub fn cache_dir(&self) -> PathBuf {
464 self.remote
465 .cache_dir
466 .clone()
467 .unwrap_or_else(|| config_dir().join("cache"))
468 }
469
470 pub fn cache_limit_bytes(&self) -> Option<u64> {
472 self.remote
473 .cache_limit
474 .as_deref()
475 .and_then(parse_size_bytes)
476 }
477}
478
479pub fn config_dir() -> PathBuf {
481 dirs::home_dir()
482 .unwrap_or_else(|| PathBuf::from("."))
483 .join(".config")
484 .join("koan")
485}
486
487pub fn config_file_path() -> PathBuf {
489 config_dir().join("config.toml")
490}
491
492pub fn config_local_file_path() -> PathBuf {
494 config_dir().join("config.local.toml")
495}
496
497pub fn db_path() -> PathBuf {
499 config_dir().join("koan.db")
500}
501
502fn check_secrets_in_git() {
506 let sensitive_fields = ["password"];
507
508 for (label, path) in [
509 ("config.toml", config_file_path()),
510 ("config.local.toml", config_local_file_path()),
511 ] {
512 let Ok(contents) = std::fs::read_to_string(&path) else {
513 continue;
514 };
515
516 let has_secrets = sensitive_fields.iter().any(|field| {
518 contents.lines().any(|line| {
519 let line = line.trim();
520 if let Some(rest) = line.strip_prefix(field) {
521 let rest = rest.trim_start();
522 if let Some(value) = rest.strip_prefix('=') {
523 let value = value.trim().trim_matches('"').trim_matches('\'');
524 return !value.is_empty();
525 }
526 }
527 false
528 })
529 });
530
531 if !has_secrets {
532 continue;
533 }
534
535 if is_tracked_by_git(&path) {
537 eprintln!();
538 eprintln!("╔══════════════════════════════════════════════════════════════╗");
539 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
540 eprintln!("╠══════════════════════════════════════════════════════════════╣");
541 eprintln!("║ ║");
542 eprintln!("║ File: {:<52} ║", path.display());
543 eprintln!("║ ║");
544 eprintln!("║ Your password is in version control. You should: ║");
545 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
546 eprintln!("║ 2. Add it to .gitignore ║");
547 eprintln!("║ 3. Rotate your credentials immediately ║");
548 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
549 eprintln!("║ or use `koan remote login` for keyring storage ║");
550 eprintln!("║ ║");
551 eprintln!("╚══════════════════════════════════════════════════════════════╝");
552 eprintln!();
553 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
554 }
555 }
556}
557
558fn is_tracked_by_git(path: &Path) -> bool {
560 let Some(parent) = path.parent() else {
561 return false;
562 };
563 std::process::Command::new("git")
565 .args(["ls-files", "--error-unmatch"])
566 .arg(path)
567 .current_dir(parent)
568 .stdout(std::process::Stdio::null())
569 .stderr(std::process::Stdio::null())
570 .status()
571 .is_ok_and(|s| s.success())
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use std::fs;
578
579 fn tmp_dir() -> PathBuf {
580 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
581 fs::create_dir_all(&dir).unwrap();
582 dir
583 }
584
585 #[test]
586 fn test_defaults() {
587 let cfg = Config::default();
588 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
589 assert!(!cfg.remote.enabled);
590 assert_eq!(cfg.remote.transcode_quality, "original");
591 }
592
593 #[test]
594 fn test_roundtrip_toml() {
595 let cfg = Config::default();
596 let serialized = toml::to_string_pretty(&cfg).unwrap();
597 let deserialized: Config = toml::from_str(&serialized).unwrap();
598 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
599 assert_eq!(
600 deserialized.remote.transcode_quality,
601 cfg.remote.transcode_quality
602 );
603 }
604
605 #[test]
606 fn test_load_from_file() {
607 let dir = tempfile::tempdir().unwrap();
608 let path = dir.path().join("config.toml");
609 fs::write(
610 &path,
611 r#"
612[library]
613folders = ["/tmp/music"]
614
615[playback]
616replaygain = "track"
617"#,
618 )
619 .unwrap();
620
621 let cfg = Config::load_from(&path).unwrap();
622 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
623 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
624 assert!(!cfg.remote.enabled);
625 }
626
627 #[test]
628 fn test_partial_toml_uses_defaults() {
629 let dir = tempfile::tempdir().unwrap();
630 let path = dir.path().join("partial.toml");
631 fs::write(&path, "[playback]\nsoftware_volume = true\n").unwrap();
632
633 let cfg = Config::load_from(&path).unwrap();
634 assert!(cfg.playback.software_volume);
635 }
636
637 #[test]
638 fn test_figment_layered_loading() {
639 let dir = tempfile::tempdir().unwrap();
640 let base_path = dir.path().join("config.toml");
641 let local_path = dir.path().join("config.local.toml");
642
643 fs::write(
644 &base_path,
645 r#"
646[remote]
647url = "https://base.example.com"
648"#,
649 )
650 .unwrap();
651 fs::write(
652 &local_path,
653 r#"
654[remote]
655enabled = true
656url = "https://local.example.com"
657username = "admin"
658password = "secret"
659"#,
660 )
661 .unwrap();
662
663 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
665 .merge(Toml::file(&base_path))
666 .merge(Toml::file(&local_path))
667 .extract()
668 .unwrap();
669
670 assert!(cfg.remote.enabled);
671 assert_eq!(cfg.remote.url, "https://local.example.com");
672 assert_eq!(cfg.remote.username, "admin");
673 assert_eq!(cfg.remote.password, "secret");
674 }
675
676 #[test]
677 fn test_figment_missing_keys_preserved() {
678 let dir = tempfile::tempdir().unwrap();
679 let base_path = dir.path().join("config.toml");
680 let local_path = dir.path().join("config.local.toml");
681
682 fs::write(
683 &base_path,
684 r#"
685[remote]
686url = "https://keep.me"
687username = "keepuser"
688"#,
689 )
690 .unwrap();
691 fs::write(
692 &local_path,
693 r#"
694[remote]
695password = "secret"
696"#,
697 )
698 .unwrap();
699
700 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
701 .merge(Toml::file(&base_path))
702 .merge(Toml::file(&local_path))
703 .extract()
704 .unwrap();
705
706 assert_eq!(cfg.remote.url, "https://keep.me");
707 assert_eq!(cfg.remote.username, "keepuser");
708 assert_eq!(cfg.remote.password, "secret");
709 }
710
711 #[test]
712 fn test_env_var_override() {
713 let dir = tempfile::tempdir().unwrap();
714 let base_path = dir.path().join("config.toml");
715
716 fs::write(
717 &base_path,
718 r#"
719[remote]
720url = "https://file.example.com"
721"#,
722 )
723 .unwrap();
724
725 unsafe {
727 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
728 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
729 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
730 }
731
732 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
733 .merge(Toml::file(&base_path))
734 .merge(Env::prefixed("KOAN_").split("__"))
735 .extract()
736 .unwrap();
737
738 assert_eq!(cfg.remote.url, "https://env.example.com");
739 assert_eq!(cfg.remote.password, "env-secret");
740 assert_eq!(cfg.graphql.port, 9999);
741
742 unsafe {
744 std::env::remove_var("KOAN_REMOTE__URL");
745 std::env::remove_var("KOAN_REMOTE__PASSWORD");
746 std::env::remove_var("KOAN_GRAPHQL__PORT");
747 }
748 }
749
750 #[test]
751 fn test_update_base_does_not_leak_secrets() {
752 let dir = tempfile::tempdir().unwrap();
753 let base_path = dir.path().join("config.toml");
754
755 fs::write(
757 &base_path,
758 r#"
759[playback]
760target_fps = 60
761
762[remote]
763url = "https://base.example.com"
764"#,
765 )
766 .unwrap();
767
768 let mut base_cfg = Config::load_from(&base_path).unwrap();
770 base_cfg.visualizer.enabled = false;
771 base_cfg.write_to(&base_path).unwrap();
772
773 let written = fs::read_to_string(&base_path).unwrap();
775 assert!(!written.contains("secret"));
776 assert!(!written.contains("password"));
777
778 let reloaded = Config::load_from(&base_path).unwrap();
780 assert!(!reloaded.visualizer.enabled);
781 assert_eq!(reloaded.remote.url, "https://base.example.com");
782 }
783
784 #[test]
785 fn test_cache_dir_default() {
786 let cfg = Config::default();
787 assert!(cfg.cache_dir().ends_with("cache"));
788 }
789
790 #[test]
791 fn test_cache_dir_explicit() {
792 let mut cfg = Config::default();
793 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
794 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
795 }
796
797 #[test]
798 fn test_organize_config_defaults() {
799 let cfg = Config::default();
800 assert!(cfg.organize.default.is_none());
801 assert!(cfg.organize.patterns.is_empty());
802 }
803
804 #[test]
805 fn test_organize_config_from_toml() {
806 let dir = tmp_dir();
807 let path = dir.join("organize.toml");
808 fs::write(
809 &path,
810 r#"
811[organize]
812default = "standard"
813
814[organize.patterns]
815standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
816va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
817"#,
818 )
819 .unwrap();
820
821 let cfg = Config::load_from(&path).unwrap();
822 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
823 assert_eq!(cfg.organize.patterns.len(), 2);
824 assert!(cfg.organize.patterns.contains_key("standard"));
825 assert!(cfg.organize.patterns.contains_key("va-aware"));
826
827 fs::remove_dir_all(&dir).ok();
828 }
829
830 #[test]
831 fn test_organize_resolve_named_pattern() {
832 let mut cfg = OrganizeConfig::default();
833 cfg.patterns
834 .insert("standard".into(), "%artist%/%title%".into());
835
836 assert_eq!(cfg.resolve_pattern("standard"), "%artist%/%title%");
837 assert_eq!(cfg.resolve_pattern("%raw%pattern%"), "%raw%pattern%");
839 }
840
841 #[test]
842 fn test_organize_default_pattern() {
843 let mut cfg = OrganizeConfig {
844 default: Some("standard".into()),
845 ..OrganizeConfig::default()
846 };
847 cfg.patterns
848 .insert("standard".into(), "%artist%/%title%".into());
849
850 assert_eq!(cfg.default_pattern(), Some("%artist%/%title%"));
851 }
852
853 #[test]
854 fn test_organize_default_pattern_missing_name() {
855 let cfg = OrganizeConfig {
856 default: Some("nonexistent".into()),
857 ..OrganizeConfig::default()
858 };
859 assert_eq!(cfg.default_pattern(), None);
861 }
862
863 #[test]
864 fn test_figment_organize_patterns_merge() {
865 let dir = tempfile::tempdir().unwrap();
866 let base_path = dir.path().join("config.toml");
867 let local_path = dir.path().join("config.local.toml");
868
869 fs::write(
870 &base_path,
871 r#"
872[organize]
873default = "standard"
874
875[organize.patterns]
876standard = "base-pattern"
877"#,
878 )
879 .unwrap();
880 fs::write(
881 &local_path,
882 r#"
883[organize]
884default = "custom"
885
886[organize.patterns]
887custom = "local-pattern"
888"#,
889 )
890 .unwrap();
891
892 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
893 .merge(Toml::file(&base_path))
894 .merge(Toml::file(&local_path))
895 .extract()
896 .unwrap();
897
898 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
900 assert_eq!(cfg.organize.patterns.len(), 2);
902 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
903 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
904 }
905
906 #[test]
907 fn test_output_device_config_roundtrip() {
908 let mut cfg = Config::default();
909 cfg.playback.output_device = Some("My DAC".into());
910
911 let serialized = toml::to_string_pretty(&cfg).unwrap();
912 let deserialized: Config = toml::from_str(&serialized).unwrap();
913 assert_eq!(
914 deserialized.playback.output_device.as_deref(),
915 Some("My DAC")
916 );
917 }
918
919 #[test]
920 fn test_output_device_config_default_is_none() {
921 let cfg = Config::default();
922 assert!(cfg.playback.output_device.is_none());
923
924 let serialized = toml::to_string_pretty(&cfg).unwrap();
926 assert!(!serialized.contains("output_device"));
927 let deserialized: Config = toml::from_str(&serialized).unwrap();
928 assert!(deserialized.playback.output_device.is_none());
929 }
930
931 #[test]
932 fn test_output_device_config_from_toml() {
933 let dir = tempfile::tempdir().unwrap();
934 let path = dir.path().join("config.toml");
935 fs::write(
936 &path,
937 r#"
938[playback]
939output_device = "External Speakers"
940"#,
941 )
942 .unwrap();
943
944 let cfg = Config::load_from(&path).unwrap();
945 assert_eq!(
946 cfg.playback.output_device.as_deref(),
947 Some("External Speakers")
948 );
949 }
950
951 #[test]
952 fn test_graphql_bind_defaults_to_localhost() {
953 let cfg = GraphqlConfig::default();
954 assert_eq!(
955 cfg.bind,
956 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
957 );
958 }
959
960 #[test]
961 fn test_graphql_bind_from_toml() {
962 let toml_str = r#"
963[graphql]
964bind = "0.0.0.0"
965port = 5000
966"#;
967 let cfg: Config = toml::from_str(toml_str).unwrap();
968 assert_eq!(
969 cfg.graphql.bind,
970 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
971 );
972 assert_eq!(cfg.graphql.port, 5000);
973 }
974
975 #[test]
976 fn test_graphql_bind_omitted_defaults_to_localhost() {
977 let toml_str = r#"
978[graphql]
979port = 4000
980"#;
981 let cfg: Config = toml::from_str(toml_str).unwrap();
982 assert_eq!(
983 cfg.graphql.bind,
984 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
985 );
986 }
987
988 #[test]
989 fn test_organize_config_roundtrip() {
990 let mut cfg = Config::default();
991 cfg.organize.default = Some("standard".into());
992 cfg.organize
993 .patterns
994 .insert("standard".into(), "%artist%/%title%".into());
995
996 let serialized = toml::to_string_pretty(&cfg).unwrap();
997 let deserialized: Config = toml::from_str(&serialized).unwrap();
998 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
999 assert_eq!(
1000 deserialized.organize.patterns["standard"],
1001 "%artist%/%title%"
1002 );
1003 }
1004
1005 #[test]
1006 fn test_parse_size_bytes() {
1007 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1008 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1009 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1010 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1011 assert_eq!(parse_size_bytes("1024B"), Some(1024));
1012 assert_eq!(parse_size_bytes("1024"), Some(1024));
1013
1014 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1016 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1017
1018 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1020 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1021
1022 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1024 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1025
1026 assert_eq!(
1028 parse_size_bytes("1.5GB"),
1029 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1030 );
1031
1032 assert_eq!(parse_size_bytes(""), None);
1034 assert_eq!(parse_size_bytes("abc"), None);
1035 assert_eq!(parse_size_bytes("50XB"), None);
1036 }
1037
1038 #[test]
1039 fn test_cache_limit_config_from_toml() {
1040 let toml_str = r#"
1041[remote]
1042cache_limit = "50GB"
1043"#;
1044 let cfg: Config = toml::from_str(toml_str).unwrap();
1045 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1046 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1047 }
1048
1049 #[test]
1050 fn test_cache_limit_none_by_default() {
1051 let cfg = Config::default();
1052 assert!(cfg.remote.cache_limit.is_none());
1053 assert!(cfg.cache_limit_bytes().is_none());
1054 }
1055
1056 #[test]
1057 fn test_cache_limit_not_serialized_when_none() {
1058 let cfg = Config::default();
1059 let serialized = toml::to_string_pretty(&cfg).unwrap();
1060 assert!(!serialized.contains("cache_limit"));
1061 }
1062
1063 #[test]
1064 fn player_uses_config_on_init() {
1065 let dir = tempfile::tempdir().unwrap();
1069 let path = dir.path().join("config.toml");
1070 fs::write(
1071 &path,
1072 r#"
1073[playback]
1074replaygain = "track"
1075output_device = "My Fancy DAC"
1076pre_amp_db = -3.5
1077software_volume = true
1078target_fps = 30
1079art_size = 32
1080
1081[visualizer]
1082enabled = false
1083mode = "oscilloscope"
1084fps = 30
1085"#,
1086 )
1087 .unwrap();
1088
1089 let cfg = Config::load_from(&path).unwrap();
1090
1091 assert_eq!(
1093 cfg.playback.replaygain,
1094 ReplayGainMode::Track,
1095 "replaygain should be 'track'"
1096 );
1097 assert_eq!(
1098 cfg.playback.output_device.as_deref(),
1099 Some("My Fancy DAC"),
1100 "output_device should match config"
1101 );
1102 assert!(
1103 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1104 "pre_amp_db should be -3.5"
1105 );
1106 assert!(
1107 cfg.playback.software_volume,
1108 "software_volume should be true"
1109 );
1110 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1111 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1112
1113 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1115 assert_eq!(cfg.visualizer.mode, "oscilloscope");
1116 assert_eq!(cfg.visualizer.fps, 30);
1117 }
1118}