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