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