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