1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, LazyLock, Once};
5use std::time::SystemTime;
6
7use figment::Figment;
8use figment::providers::{Env, Format, Serialized, Toml};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12#[derive(Debug, Error)]
13pub enum ConfigError {
14 #[error("io error: {0}")]
15 Io(#[from] std::io::Error),
16 #[error("parse error: {0}")]
17 Parse(#[from] toml::de::Error),
18 #[error("serialize error: {0}")]
19 Serialize(#[from] toml::ser::Error),
20 #[error("config error: {0}")]
21 Figment(#[from] Box<figment::Error>),
22}
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25#[serde(default)]
26pub struct Config {
27 pub library: LibraryConfig,
28 pub playback: PlaybackConfig,
29 pub remote: RemoteConfig,
30 pub organize: OrganizeConfig,
31 #[serde(alias = "visualiser")]
32 pub visualizer: VisualizerConfig,
33 pub radio: RadioConfig,
34 pub graphql: GraphqlConfig,
35 pub subsonic: SubsonicConfig,
36 pub discovery: DiscoveryConfig,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(default)]
41pub struct LibraryConfig {
42 pub folders: Vec<PathBuf>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(default)]
47pub struct PlaybackConfig {
48 pub replaygain: ReplayGainMode,
49 pub ticker_fps: u8,
52 pub target_fps: u8,
55 pub show_fps: bool,
57 pub pre_amp_db: f64,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub output_device: Option<String>,
64 pub art_size: u16,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "lowercase")]
71pub enum ReplayGainMode {
72 Off,
73 Track,
74 Album,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(default)]
79pub struct RemoteConfig {
80 pub enabled: bool,
81 pub url: String,
82 pub username: String,
83 #[serde(default, skip_serializing_if = "String::is_empty")]
85 pub password: String,
86 pub transcode_quality: String,
88 pub cache_dir: Option<PathBuf>,
90 pub download_workers: usize,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub cache_limit: Option<String>,
96 pub auto_sync: bool,
102 pub auto_sync_interval_mins: u64,
104}
105
106impl Default for LibraryConfig {
107 fn default() -> Self {
108 let music_dir = dirs::audio_dir().unwrap_or_else(|| {
109 dirs::home_dir()
110 .map(|h| h.join("Music"))
111 .unwrap_or_else(|| PathBuf::from("/Music"))
112 });
113 Self {
114 folders: vec![music_dir],
115 }
116 }
117}
118
119impl Default for PlaybackConfig {
120 fn default() -> Self {
121 Self {
122 replaygain: ReplayGainMode::Off,
123 ticker_fps: 8,
124 target_fps: 60,
125 show_fps: false,
126 pre_amp_db: 0.0,
127 output_device: None,
128 art_size: 24,
129 }
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(default)]
135pub struct VisualizerConfig {
136 pub enabled: bool,
137 pub fps: u8,
138 pub mode: String,
140 pub scale: String,
142 pub amplitude_scale: String,
144 pub bar_decay_ms: u32,
146 pub peak_decay_ms: u32,
148 pub palette: String,
151 pub reactivity: f32,
155 pub bass_shake: bool,
158 pub matrix_overlay: bool,
161 pub reactive_bg: bool,
163}
164
165impl Default for VisualizerConfig {
166 fn default() -> Self {
167 Self {
168 enabled: true,
169 fps: 60,
170 mode: "bars".into(),
171 scale: "bark".into(),
172 amplitude_scale: "aweight".into(),
173 bar_decay_ms: 50,
174 peak_decay_ms: 180,
175 palette: "spectrum".into(),
176 reactivity: 1.0,
177 bass_shake: true,
178 matrix_overlay: false,
179 reactive_bg: false,
180 }
181 }
182}
183
184impl Default for RemoteConfig {
185 fn default() -> Self {
186 Self {
187 enabled: false,
188 url: String::new(),
189 username: String::new(),
190 password: String::new(),
191 transcode_quality: "original".into(),
192 cache_dir: None,
193 download_workers: 5,
194 cache_limit: None,
195 auto_sync: true,
196 auto_sync_interval_mins: 60,
197 }
198 }
199}
200
201pub fn parse_size_bytes(s: &str) -> Option<u64> {
204 let s = s.trim();
205 if s.is_empty() {
206 return None;
207 }
208
209 let mut num_end = 0;
211 for (i, c) in s.char_indices() {
212 if c.is_ascii_digit() || c == '.' {
213 num_end = i + c.len_utf8();
214 } else if !c.is_whitespace() {
215 break;
216 }
217 }
218
219 let num_str = s[..num_end].trim();
220 let suffix = s[num_end..].trim().to_ascii_uppercase();
221
222 let value: f64 = num_str.parse().ok()?;
223 let multiplier: u64 = match suffix.as_str() {
224 "" | "B" => 1,
225 "KB" | "K" => 1024,
226 "MB" | "M" => 1024 * 1024,
227 "GB" | "G" => 1024 * 1024 * 1024,
228 "TB" | "T" => 1024 * 1024 * 1024 * 1024,
229 _ => return None,
230 };
231
232 Some((value * multiplier as f64) as u64)
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(default)]
237pub struct OrganizeConfig {
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub default: Option<String>,
241 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
243 pub patterns: HashMap<String, String>,
244 #[serde(default = "default_true")]
248 pub move_ancillary: bool,
249}
250
251impl Default for OrganizeConfig {
252 fn default() -> Self {
253 Self {
254 default: None,
255 patterns: HashMap::new(),
256 move_ancillary: true,
257 }
258 }
259}
260
261impl OrganizeConfig {
262 pub fn resolve_pattern<'a>(&'a self, name_or_raw: &'a str) -> &'a str {
265 self.patterns
266 .get(name_or_raw)
267 .map(|s| s.as_str())
268 .unwrap_or(name_or_raw)
269 }
270
271 pub fn default_pattern(&self) -> Option<&str> {
273 self.default
274 .as_ref()
275 .and_then(|name| self.patterns.get(name))
276 .map(|s| s.as_str())
277 }
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282#[serde(default)]
283pub struct GraphqlConfig {
284 pub enabled: bool,
287 pub port: u16,
289 #[serde(default = "default_bind")]
292 pub bind: std::net::IpAddr,
293 pub playground: bool,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub subsonic_port: Option<u16>,
298 pub auth_enabled: bool,
301 pub access_token_ttl: String,
303 pub refresh_token_ttl: String,
305 pub cors_origins: Vec<String>,
308 pub allowed_hosts: Vec<String>,
312 pub cookie_secure: bool,
316 pub allow_organize: bool,
318}
319
320fn default_true() -> bool {
321 true
322}
323
324fn default_bind() -> std::net::IpAddr {
325 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
326}
327
328impl Default for GraphqlConfig {
329 fn default() -> Self {
330 Self {
331 enabled: true,
332 port: 4000,
333 bind: default_bind(),
334 playground: false,
335 subsonic_port: None,
336 auth_enabled: true,
337 access_token_ttl: "15m".into(),
338 refresh_token_ttl: "30d".into(),
339 cors_origins: Vec::new(),
340 allowed_hosts: Vec::new(),
341 cookie_secure: false,
342 allow_organize: false,
343 }
344 }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
355#[serde(default)]
356pub struct SubsonicConfig {
357 pub enabled: bool,
359 pub username: String,
361 #[serde(default, skip_serializing_if = "String::is_empty")]
364 pub password: String,
365}
366
367impl Default for SubsonicConfig {
368 fn default() -> Self {
369 Self {
370 enabled: false,
371 username: "koan".into(),
372 password: String::new(),
373 }
374 }
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
379#[serde(default)]
380pub struct RadioConfig {
381 pub lookahead: usize,
383 pub batch_size: usize,
385 pub use_subsonic: bool,
387 pub history_window: usize,
389 pub seed_window: usize,
391 pub discovery_weight: f64,
394}
395
396impl Default for RadioConfig {
397 fn default() -> Self {
398 Self {
399 lookahead: 5,
400 batch_size: 5,
401 use_subsonic: true,
402 history_window: 200,
403 seed_window: 5,
404 discovery_weight: 0.3,
405 }
406 }
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
411#[serde(default)]
412pub struct DiscoveryConfig {
413 pub analysis_on_scan: bool,
415 pub acoustic_weight: f64,
417}
418
419impl Default for DiscoveryConfig {
420 fn default() -> Self {
421 Self {
422 analysis_on_scan: false,
423 acoustic_weight: 0.5,
424 }
425 }
426}
427
428type ConfigStamp = (Option<SystemTime>, Option<SystemTime>);
432
433type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
434
435static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
436 LazyLock::new(|| parking_lot::RwLock::new(None));
437
438fn config_stamp() -> ConfigStamp {
439 stamp_of(&config_file_path(), &config_local_file_path())
440}
441
442fn stamp_of(base: &Path, local: &Path) -> ConfigStamp {
444 let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
445 (mtime(base), mtime(local))
446}
447
448impl Config {
449 fn figment() -> Figment {
455 let base_path = config_file_path();
456 let local_path = config_local_file_path();
457
458 Figment::from(Serialized::defaults(Config::default()))
459 .merge(Toml::file(&base_path))
460 .merge(Toml::file(&local_path))
461 .merge(Env::prefixed("KOAN_").split("__"))
462 }
463
464 pub fn load() -> Result<Self, ConfigError> {
466 let cfg: Self = Self::figment()
467 .extract()
468 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
469
470 check_secrets_in_git();
473
474 Ok(cfg)
475 }
476
477 pub fn load_or_default() -> Self {
482 (*Self::cached()).clone()
483 }
484
485 pub fn cached() -> Arc<Config> {
492 let stamp = config_stamp();
493 if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
494 && *seen == stamp
495 {
496 return cfg.clone();
497 }
498
499 let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
500 log::warn!("failed to load config, using defaults: {}", e);
501 Self::default()
502 }));
503 *CONFIG_CACHE.write() = Some((stamp, cfg.clone()));
504 cfg
505 }
506
507 pub fn invalidate_cache() {
511 *CONFIG_CACHE.write() = None;
512 }
513
514 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
516 let contents = fs::read_to_string(path)?;
517 let config: Config = toml::from_str(&contents)?;
518 Ok(config)
519 }
520
521 pub fn update_base<F>(mutate: F) -> Result<(), ConfigError>
525 where
526 F: FnOnce(&mut Config),
527 {
528 let path = config_file_path();
529 let mut cfg = if path.exists() {
530 Config::load_from(&path)?
531 } else {
532 Config::default()
533 };
534 mutate(&mut cfg);
535 cfg.write_to(&path)?;
536 Self::invalidate_cache();
537 Ok(())
538 }
539
540 fn write_to(&self, path: &Path) -> Result<(), ConfigError> {
542 if let Some(parent) = path.parent() {
543 fs::create_dir_all(parent)?;
544 }
545 let contents = toml::to_string_pretty(self)?;
546 fs::write(path, contents)?;
547 Ok(())
548 }
549
550 pub fn patch_local(
553 section: &str,
554 values: &toml::map::Map<String, toml::Value>,
555 ) -> Result<(), ConfigError> {
556 let path = config_local_file_path();
557 let mut doc: toml::Value = if path.exists() {
558 let contents = fs::read_to_string(&path)?;
559 toml::from_str(&contents).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new()))
560 } else {
561 toml::Value::Table(toml::map::Map::new())
562 };
563
564 let table = doc.as_table_mut().expect("root is always a table");
565 let section_table = table
566 .entry(section)
567 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
568 .as_table_mut()
569 .ok_or_else(|| {
570 ConfigError::Io(std::io::Error::new(
571 std::io::ErrorKind::InvalidData,
572 format!("[{}] is not a table", section),
573 ))
574 })?;
575
576 for (key, value) in values {
577 section_table.insert(key.clone(), value.clone());
578 }
579
580 let contents = toml::to_string_pretty(&doc)?;
581 fs::write(&path, contents)?;
582 #[cfg(unix)]
583 {
584 use std::os::unix::fs::PermissionsExt;
585 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
586 }
587 Self::invalidate_cache();
588 Ok(())
589 }
590
591 pub fn cache_dir(&self) -> PathBuf {
593 self.remote
594 .cache_dir
595 .clone()
596 .unwrap_or_else(|| config_dir().join("cache"))
597 }
598
599 pub fn cache_limit_bytes(&self) -> Option<u64> {
601 self.remote
602 .cache_limit
603 .as_deref()
604 .and_then(parse_size_bytes)
605 }
606}
607
608pub fn config_dir() -> PathBuf {
616 if let Some(dir) = CONFIG_DIR.read().clone() {
617 return dir;
618 }
619 if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
620 return PathBuf::from(dir);
621 }
622 dirs::home_dir()
623 .unwrap_or_else(|| PathBuf::from("."))
624 .join(".config")
625 .join("koan")
626}
627
628pub fn set_config_dir(dir: impl Into<PathBuf>) {
635 *CONFIG_DIR.write() = Some(dir.into());
636 Config::invalidate_cache();
637}
638
639pub fn isolate_config_for_tests() {
651 let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
652 let _ = fs::create_dir_all(&dir);
653 set_config_dir(dir);
654}
655
656static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
657 LazyLock::new(|| parking_lot::RwLock::new(None));
658
659pub fn config_file_path() -> PathBuf {
661 config_dir().join("config.toml")
662}
663
664pub fn config_local_file_path() -> PathBuf {
666 config_dir().join("config.local.toml")
667}
668
669pub fn db_path() -> PathBuf {
671 config_dir().join("koan.db")
672}
673
674fn check_secrets_in_git() {
682 static ONCE: Once = Once::new();
683 ONCE.call_once(scan_for_tracked_secrets);
684}
685
686fn scan_for_tracked_secrets() {
687 let sensitive_fields = ["password"];
688
689 for (label, path) in [
690 ("config.toml", config_file_path()),
691 ("config.local.toml", config_local_file_path()),
692 ] {
693 let Ok(contents) = std::fs::read_to_string(&path) else {
694 continue;
695 };
696
697 let has_secrets = sensitive_fields.iter().any(|field| {
699 contents.lines().any(|line| {
700 let line = line.trim();
701 if let Some(rest) = line.strip_prefix(field) {
702 let rest = rest.trim_start();
703 if let Some(value) = rest.strip_prefix('=') {
704 let value = value.trim().trim_matches('"').trim_matches('\'');
705 return !value.is_empty();
706 }
707 }
708 false
709 })
710 });
711
712 if !has_secrets {
713 continue;
714 }
715
716 if is_tracked_by_git(&path) {
718 eprintln!();
719 eprintln!("╔══════════════════════════════════════════════════════════════╗");
720 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
721 eprintln!("╠══════════════════════════════════════════════════════════════╣");
722 eprintln!("║ ║");
723 eprintln!("║ File: {:<52} ║", path.display());
724 eprintln!("║ ║");
725 eprintln!("║ Your password is in version control. You should: ║");
726 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
727 eprintln!("║ 2. Add it to .gitignore ║");
728 eprintln!("║ 3. Rotate your credentials immediately ║");
729 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
730 eprintln!("║ or use `koan remote login` for keyring storage ║");
731 eprintln!("║ ║");
732 eprintln!("╚══════════════════════════════════════════════════════════════╝");
733 eprintln!();
734 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
735 }
736 }
737}
738
739fn is_tracked_by_git(path: &Path) -> bool {
741 let Some(parent) = path.parent() else {
742 return false;
743 };
744 std::process::Command::new("git")
746 .args(["ls-files", "--error-unmatch"])
747 .arg(path)
748 .current_dir(parent)
749 .stdout(std::process::Stdio::null())
750 .stderr(std::process::Stdio::null())
751 .status()
752 .is_ok_and(|s| s.success())
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758 use std::fs;
759
760 fn tmp_dir() -> PathBuf {
761 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
762 fs::create_dir_all(&dir).unwrap();
763 dir
764 }
765
766 #[test]
767 fn test_defaults() {
768 let cfg = Config::default();
769 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
770 assert!(!cfg.remote.enabled);
771 assert_eq!(cfg.remote.transcode_quality, "original");
772 }
773
774 #[test]
775 fn test_roundtrip_toml() {
776 let cfg = Config::default();
777 let serialized = toml::to_string_pretty(&cfg).unwrap();
778 let deserialized: Config = toml::from_str(&serialized).unwrap();
779 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
780 assert_eq!(
781 deserialized.remote.transcode_quality,
782 cfg.remote.transcode_quality
783 );
784 }
785
786 #[test]
787 fn test_load_from_file() {
788 let dir = tempfile::tempdir().unwrap();
789 let path = dir.path().join("config.toml");
790 fs::write(
791 &path,
792 r#"
793[library]
794folders = ["/tmp/music"]
795
796[playback]
797replaygain = "track"
798"#,
799 )
800 .unwrap();
801
802 let cfg = Config::load_from(&path).unwrap();
803 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
804 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
805 assert!(!cfg.remote.enabled);
806 }
807
808 #[test]
809 fn test_partial_toml_uses_defaults() {
810 let dir = tempfile::tempdir().unwrap();
811 let path = dir.path().join("partial.toml");
812 fs::write(&path, "[playback]\nticker_fps = 12\n").unwrap();
813
814 let cfg = Config::load_from(&path).unwrap();
815 assert_eq!(cfg.playback.ticker_fps, 12);
816 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
817 }
818
819 #[test]
820 fn test_figment_layered_loading() {
821 let dir = tempfile::tempdir().unwrap();
822 let base_path = dir.path().join("config.toml");
823 let local_path = dir.path().join("config.local.toml");
824
825 fs::write(
826 &base_path,
827 r#"
828[remote]
829url = "https://base.example.com"
830"#,
831 )
832 .unwrap();
833 fs::write(
834 &local_path,
835 r#"
836[remote]
837enabled = true
838url = "https://local.example.com"
839username = "admin"
840password = "secret"
841"#,
842 )
843 .unwrap();
844
845 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
847 .merge(Toml::file(&base_path))
848 .merge(Toml::file(&local_path))
849 .extract()
850 .unwrap();
851
852 assert!(cfg.remote.enabled);
853 assert_eq!(cfg.remote.url, "https://local.example.com");
854 assert_eq!(cfg.remote.username, "admin");
855 assert_eq!(cfg.remote.password, "secret");
856 }
857
858 #[test]
859 fn test_figment_missing_keys_preserved() {
860 let dir = tempfile::tempdir().unwrap();
861 let base_path = dir.path().join("config.toml");
862 let local_path = dir.path().join("config.local.toml");
863
864 fs::write(
865 &base_path,
866 r#"
867[remote]
868url = "https://keep.me"
869username = "keepuser"
870"#,
871 )
872 .unwrap();
873 fs::write(
874 &local_path,
875 r#"
876[remote]
877password = "secret"
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.remote.url, "https://keep.me");
889 assert_eq!(cfg.remote.username, "keepuser");
890 assert_eq!(cfg.remote.password, "secret");
891 }
892
893 #[test]
894 fn test_env_var_override() {
895 let dir = tempfile::tempdir().unwrap();
896 let base_path = dir.path().join("config.toml");
897
898 fs::write(
899 &base_path,
900 r#"
901[remote]
902url = "https://file.example.com"
903"#,
904 )
905 .unwrap();
906
907 unsafe {
909 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
910 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
911 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
912 }
913
914 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
915 .merge(Toml::file(&base_path))
916 .merge(Env::prefixed("KOAN_").split("__"))
917 .extract()
918 .unwrap();
919
920 assert_eq!(cfg.remote.url, "https://env.example.com");
921 assert_eq!(cfg.remote.password, "env-secret");
922 assert_eq!(cfg.graphql.port, 9999);
923
924 unsafe {
926 std::env::remove_var("KOAN_REMOTE__URL");
927 std::env::remove_var("KOAN_REMOTE__PASSWORD");
928 std::env::remove_var("KOAN_GRAPHQL__PORT");
929 }
930 }
931
932 #[test]
933 fn test_update_base_does_not_leak_secrets() {
934 let dir = tempfile::tempdir().unwrap();
935 let base_path = dir.path().join("config.toml");
936
937 fs::write(
939 &base_path,
940 r#"
941[playback]
942target_fps = 60
943
944[remote]
945url = "https://base.example.com"
946"#,
947 )
948 .unwrap();
949
950 let mut base_cfg = Config::load_from(&base_path).unwrap();
952 base_cfg.visualizer.enabled = false;
953 base_cfg.write_to(&base_path).unwrap();
954
955 let written = fs::read_to_string(&base_path).unwrap();
957 assert!(!written.contains("secret"));
958 assert!(!written.contains("password"));
959
960 let reloaded = Config::load_from(&base_path).unwrap();
962 assert!(!reloaded.visualizer.enabled);
963 assert_eq!(reloaded.remote.url, "https://base.example.com");
964 }
965
966 #[test]
967 fn test_subsonic_defaults_off_and_keeps_secret_out_of_base_config() {
968 let cfg = Config::default();
969 assert!(!cfg.subsonic.enabled);
970 assert!(cfg.subsonic.password.is_empty());
971
972 let dir = tempfile::tempdir().unwrap();
975 let base_path = dir.path().join("config.toml");
976 cfg.write_to(&base_path).unwrap();
977 let written = fs::read_to_string(&base_path).unwrap();
978 assert!(written.contains("[subsonic]"));
979 assert!(!written.contains("password"));
980 }
981
982 #[test]
983 fn test_cache_dir_default() {
984 let cfg = Config::default();
985 assert!(cfg.cache_dir().ends_with("cache"));
986 }
987
988 #[test]
989 fn test_cache_dir_explicit() {
990 let mut cfg = Config::default();
991 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
992 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
993 }
994
995 #[test]
996 fn test_organize_config_defaults() {
997 let cfg = Config::default();
998 assert!(cfg.organize.default.is_none());
999 assert!(cfg.organize.patterns.is_empty());
1000 }
1001
1002 #[test]
1003 fn test_organize_config_from_toml() {
1004 let dir = tmp_dir();
1005 let path = dir.join("organize.toml");
1006 fs::write(
1007 &path,
1008 r#"
1009[organize]
1010default = "standard"
1011
1012[organize.patterns]
1013standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
1014va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
1015"#,
1016 )
1017 .unwrap();
1018
1019 let cfg = Config::load_from(&path).unwrap();
1020 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
1021 assert_eq!(cfg.organize.patterns.len(), 2);
1022 assert!(cfg.organize.patterns.contains_key("standard"));
1023 assert!(cfg.organize.patterns.contains_key("va-aware"));
1024
1025 fs::remove_dir_all(&dir).ok();
1026 }
1027
1028 #[test]
1029 fn test_organize_resolve_named_pattern() {
1030 let mut cfg = OrganizeConfig::default();
1031 cfg.patterns
1032 .insert("standard".into(), "%artist%/%title%".into());
1033
1034 assert_eq!(cfg.resolve_pattern("standard"), "%artist%/%title%");
1035 assert_eq!(cfg.resolve_pattern("%raw%pattern%"), "%raw%pattern%");
1037 }
1038
1039 #[test]
1040 fn test_organize_default_pattern() {
1041 let mut cfg = OrganizeConfig {
1042 default: Some("standard".into()),
1043 ..OrganizeConfig::default()
1044 };
1045 cfg.patterns
1046 .insert("standard".into(), "%artist%/%title%".into());
1047
1048 assert_eq!(cfg.default_pattern(), Some("%artist%/%title%"));
1049 }
1050
1051 #[test]
1052 fn test_organize_default_pattern_missing_name() {
1053 let cfg = OrganizeConfig {
1054 default: Some("nonexistent".into()),
1055 ..OrganizeConfig::default()
1056 };
1057 assert_eq!(cfg.default_pattern(), None);
1059 }
1060
1061 #[test]
1062 fn test_figment_organize_patterns_merge() {
1063 let dir = tempfile::tempdir().unwrap();
1064 let base_path = dir.path().join("config.toml");
1065 let local_path = dir.path().join("config.local.toml");
1066
1067 fs::write(
1068 &base_path,
1069 r#"
1070[organize]
1071default = "standard"
1072
1073[organize.patterns]
1074standard = "base-pattern"
1075"#,
1076 )
1077 .unwrap();
1078 fs::write(
1079 &local_path,
1080 r#"
1081[organize]
1082default = "custom"
1083
1084[organize.patterns]
1085custom = "local-pattern"
1086"#,
1087 )
1088 .unwrap();
1089
1090 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1091 .merge(Toml::file(&base_path))
1092 .merge(Toml::file(&local_path))
1093 .extract()
1094 .unwrap();
1095
1096 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
1098 assert_eq!(cfg.organize.patterns.len(), 2);
1100 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
1101 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
1102 }
1103
1104 #[test]
1105 fn test_output_device_config_roundtrip() {
1106 let mut cfg = Config::default();
1107 cfg.playback.output_device = Some("My DAC".into());
1108
1109 let serialized = toml::to_string_pretty(&cfg).unwrap();
1110 let deserialized: Config = toml::from_str(&serialized).unwrap();
1111 assert_eq!(
1112 deserialized.playback.output_device.as_deref(),
1113 Some("My DAC")
1114 );
1115 }
1116
1117 #[test]
1118 fn test_output_device_config_default_is_none() {
1119 let cfg = Config::default();
1120 assert!(cfg.playback.output_device.is_none());
1121
1122 let serialized = toml::to_string_pretty(&cfg).unwrap();
1124 assert!(!serialized.contains("output_device"));
1125 let deserialized: Config = toml::from_str(&serialized).unwrap();
1126 assert!(deserialized.playback.output_device.is_none());
1127 }
1128
1129 #[test]
1130 fn test_output_device_config_from_toml() {
1131 let dir = tempfile::tempdir().unwrap();
1132 let path = dir.path().join("config.toml");
1133 fs::write(
1134 &path,
1135 r#"
1136[playback]
1137output_device = "External Speakers"
1138"#,
1139 )
1140 .unwrap();
1141
1142 let cfg = Config::load_from(&path).unwrap();
1143 assert_eq!(
1144 cfg.playback.output_device.as_deref(),
1145 Some("External Speakers")
1146 );
1147 }
1148
1149 #[test]
1150 fn test_graphql_bind_defaults_to_localhost() {
1151 let cfg = GraphqlConfig::default();
1152 assert_eq!(
1153 cfg.bind,
1154 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1155 );
1156 }
1157
1158 #[test]
1159 fn test_graphql_bind_from_toml() {
1160 let toml_str = r#"
1161[graphql]
1162bind = "0.0.0.0"
1163port = 5000
1164"#;
1165 let cfg: Config = toml::from_str(toml_str).unwrap();
1166 assert_eq!(
1167 cfg.graphql.bind,
1168 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
1169 );
1170 assert_eq!(cfg.graphql.port, 5000);
1171 }
1172
1173 #[test]
1174 fn test_graphql_bind_omitted_defaults_to_localhost() {
1175 let toml_str = r#"
1176[graphql]
1177port = 4000
1178"#;
1179 let cfg: Config = toml::from_str(toml_str).unwrap();
1180 assert_eq!(
1181 cfg.graphql.bind,
1182 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1183 );
1184 }
1185
1186 #[test]
1187 fn test_organize_config_roundtrip() {
1188 let mut cfg = Config::default();
1189 cfg.organize.default = Some("standard".into());
1190 cfg.organize
1191 .patterns
1192 .insert("standard".into(), "%artist%/%title%".into());
1193
1194 let serialized = toml::to_string_pretty(&cfg).unwrap();
1195 let deserialized: Config = toml::from_str(&serialized).unwrap();
1196 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
1197 assert_eq!(
1198 deserialized.organize.patterns["standard"],
1199 "%artist%/%title%"
1200 );
1201 }
1202
1203 #[test]
1204 fn test_parse_size_bytes() {
1205 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1206 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1207 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1208 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1209 assert_eq!(parse_size_bytes("1024B"), Some(1024));
1210 assert_eq!(parse_size_bytes("1024"), Some(1024));
1211
1212 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1214 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1215
1216 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1218 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1219
1220 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1222 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1223
1224 assert_eq!(
1226 parse_size_bytes("1.5GB"),
1227 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1228 );
1229
1230 assert_eq!(parse_size_bytes(""), None);
1232 assert_eq!(parse_size_bytes("abc"), None);
1233 assert_eq!(parse_size_bytes("50XB"), None);
1234 }
1235
1236 #[test]
1237 fn test_cache_limit_config_from_toml() {
1238 let toml_str = r#"
1239[remote]
1240cache_limit = "50GB"
1241"#;
1242 let cfg: Config = toml::from_str(toml_str).unwrap();
1243 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1244 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1245 }
1246
1247 #[test]
1248 fn test_cache_limit_none_by_default() {
1249 let cfg = Config::default();
1250 assert!(cfg.remote.cache_limit.is_none());
1251 assert!(cfg.cache_limit_bytes().is_none());
1252 }
1253
1254 #[test]
1255 fn test_cache_limit_not_serialized_when_none() {
1256 let cfg = Config::default();
1257 let serialized = toml::to_string_pretty(&cfg).unwrap();
1258 assert!(!serialized.contains("cache_limit"));
1259 }
1260
1261 #[test]
1262 fn player_uses_config_on_init() {
1263 let dir = tempfile::tempdir().unwrap();
1267 let path = dir.path().join("config.toml");
1268 fs::write(
1269 &path,
1270 r#"
1271[playback]
1272replaygain = "track"
1273output_device = "My Fancy DAC"
1274pre_amp_db = -3.5
1275target_fps = 30
1276art_size = 32
1277
1278[visualizer]
1279enabled = false
1280mode = "oscilloscope"
1281fps = 30
1282"#,
1283 )
1284 .unwrap();
1285
1286 let cfg = Config::load_from(&path).unwrap();
1287
1288 assert_eq!(
1290 cfg.playback.replaygain,
1291 ReplayGainMode::Track,
1292 "replaygain should be 'track'"
1293 );
1294 assert_eq!(
1295 cfg.playback.output_device.as_deref(),
1296 Some("My Fancy DAC"),
1297 "output_device should match config"
1298 );
1299 assert!(
1300 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1301 "pre_amp_db should be -3.5"
1302 );
1303 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1304 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1305
1306 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1308 assert_eq!(cfg.visualizer.mode, "oscilloscope");
1309 assert_eq!(cfg.visualizer.fps, 30);
1310 }
1311
1312 #[test]
1313 fn a_missing_config_file_stamps_as_absent() {
1314 let dir = tempfile::tempdir().unwrap();
1315 let base = dir.path().join("config.toml");
1316 let local = dir.path().join("config.local.toml");
1317
1318 assert_eq!(stamp_of(&base, &local), (None, None));
1319
1320 fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
1321 let (base_stamp, local_stamp) = stamp_of(&base, &local);
1322 assert!(base_stamp.is_some(), "creating the file must be a change");
1323 assert!(local_stamp.is_none());
1324 }
1325
1326 #[test]
1327 fn editing_a_config_file_changes_its_stamp() {
1328 let dir = tempfile::tempdir().unwrap();
1329 let base = dir.path().join("config.toml");
1330 let local = dir.path().join("config.local.toml");
1331 fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
1332
1333 let before = stamp_of(&base, &local);
1334 std::thread::sleep(std::time::Duration::from_millis(20));
1336 fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
1337
1338 assert_ne!(
1339 before,
1340 stamp_of(&base, &local),
1341 "a config edited by hand has to be picked up"
1342 );
1343 }
1344
1345 #[test]
1346 fn invalidating_forces_a_reload() {
1347 let first = Config::cached();
1348 Config::invalidate_cache();
1349 assert!(
1350 !Arc::ptr_eq(&first, &Config::cached()),
1351 "koan's own writes invalidate explicitly; the next read must re-parse"
1352 );
1353 }
1354}