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}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(default)]
40pub struct LibraryConfig {
41 pub folders: Vec<PathBuf>,
42 pub analyze_on_scan: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(default)]
49pub struct PlaybackConfig {
50 pub replaygain: ReplayGainMode,
51 pub target_fps: u8,
54 pub show_fps: bool,
56 pub pre_amp_db: f64,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub output_device: Option<String>,
63 pub art_size: u16,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum ReplayGainMode {
71 Off,
72 Track,
73 Album,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(default)]
78pub struct RemoteConfig {
79 pub enabled: bool,
80 pub url: String,
81 pub username: String,
82 #[serde(default, skip_serializing_if = "String::is_empty")]
84 pub password: String,
85 pub cache_dir: Option<PathBuf>,
87 pub download_workers: usize,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub cache_limit: Option<String>,
93 pub auto_sync: bool,
99 pub auto_sync_interval_mins: u64,
101}
102
103impl Default for LibraryConfig {
104 fn default() -> Self {
105 let music_dir = dirs::audio_dir().unwrap_or_else(|| {
106 dirs::home_dir()
107 .map(|h| h.join("Music"))
108 .unwrap_or_else(|| PathBuf::from("/Music"))
109 });
110 Self {
111 folders: vec![music_dir],
112 analyze_on_scan: false,
113 }
114 }
115}
116
117impl Default for PlaybackConfig {
118 fn default() -> Self {
119 Self {
120 replaygain: ReplayGainMode::Off,
121 target_fps: 60,
122 show_fps: false,
123 pre_amp_db: 0.0,
124 output_device: None,
125 art_size: 24,
126 }
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(default)]
132pub struct VisualizerConfig {
133 pub enabled: bool,
134 pub fps: u8,
135 pub mode: String,
137 pub scale: String,
139 pub amplitude_scale: String,
141 pub bar_decay_ms: u32,
143 pub peak_decay_ms: u32,
145 pub palette: String,
148 pub reactivity: f32,
152 pub bass_shake: bool,
155 pub matrix_overlay: bool,
158 pub reactive_bg: bool,
160}
161
162impl Default for VisualizerConfig {
163 fn default() -> Self {
164 Self {
165 enabled: true,
166 fps: 60,
167 mode: "bars".into(),
168 scale: "bark".into(),
169 amplitude_scale: "aweight".into(),
170 bar_decay_ms: 50,
171 peak_decay_ms: 180,
172 palette: "spectrum".into(),
173 reactivity: 1.0,
174 bass_shake: true,
175 matrix_overlay: false,
176 reactive_bg: false,
177 }
178 }
179}
180
181impl Default for RemoteConfig {
182 fn default() -> Self {
183 Self {
184 enabled: false,
185 url: String::new(),
186 username: String::new(),
187 password: String::new(),
188 cache_dir: None,
189 download_workers: 5,
190 cache_limit: None,
191 auto_sync: true,
192 auto_sync_interval_mins: 60,
193 }
194 }
195}
196
197pub fn parse_size_bytes(s: &str) -> Option<u64> {
200 let s = s.trim();
201 if s.is_empty() {
202 return None;
203 }
204
205 let mut num_end = 0;
207 for (i, c) in s.char_indices() {
208 if c.is_ascii_digit() || c == '.' {
209 num_end = i + c.len_utf8();
210 } else if !c.is_whitespace() {
211 break;
212 }
213 }
214
215 let num_str = s[..num_end].trim();
216 let suffix = s[num_end..].trim().to_ascii_uppercase();
217
218 let value: f64 = num_str.parse().ok()?;
219 let multiplier: u64 = match suffix.as_str() {
220 "" | "B" => 1,
221 "KB" | "K" => 1024,
222 "MB" | "M" => 1024 * 1024,
223 "GB" | "G" => 1024 * 1024 * 1024,
224 "TB" | "T" => 1024 * 1024 * 1024 * 1024,
225 _ => return None,
226 };
227
228 Some((value * multiplier as f64) as u64)
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(default)]
233pub struct OrganizeConfig {
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub default: Option<String>,
237 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
239 pub patterns: HashMap<String, String>,
240 #[serde(default = "default_true")]
244 pub move_ancillary: bool,
245}
246
247impl Default for OrganizeConfig {
248 fn default() -> Self {
249 Self {
250 default: None,
251 patterns: HashMap::new(),
252 move_ancillary: true,
253 }
254 }
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(default)]
260pub struct GraphqlConfig {
261 pub enabled: bool,
264 pub port: u16,
266 #[serde(default = "default_bind")]
269 pub bind: std::net::IpAddr,
270 pub playground: bool,
272 pub auth_enabled: bool,
275 pub access_token_ttl: String,
277 pub refresh_token_ttl: String,
279 pub cors_origins: Vec<String>,
282 pub allowed_hosts: Vec<String>,
286 pub cookie_secure: bool,
290 pub allow_organize: bool,
292}
293
294fn default_true() -> bool {
295 true
296}
297
298fn default_bind() -> std::net::IpAddr {
299 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
300}
301
302impl Default for GraphqlConfig {
303 fn default() -> Self {
304 Self {
305 enabled: true,
306 port: 4000,
307 bind: default_bind(),
308 playground: false,
309 auth_enabled: true,
310 access_token_ttl: "15m".into(),
311 refresh_token_ttl: "30d".into(),
312 cors_origins: Vec::new(),
313 allowed_hosts: Vec::new(),
314 cookie_secure: false,
315 allow_organize: false,
316 }
317 }
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
328#[serde(default)]
329pub struct SubsonicConfig {
330 pub enabled: bool,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub port: Option<u16>,
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 port: None,
351 username: "koan".into(),
352 password: String::new(),
353 }
354 }
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
359#[serde(default)]
360pub struct RadioConfig {
361 pub lookahead: usize,
363 pub batch_size: usize,
365 pub history_window: usize,
367 pub seed_window: usize,
369 pub discovery_weight: f64,
372}
373
374impl Default for RadioConfig {
375 fn default() -> Self {
376 Self {
377 lookahead: 5,
378 batch_size: 5,
379 history_window: 200,
380 seed_window: 5,
381 discovery_weight: 0.3,
382 }
383 }
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum Layer {
389 Shared,
391 Machine,
393}
394
395pub fn layer_of(path: &str) -> Layer {
405 match path {
406 "remote.password"
408 | "subsonic.password"
409 | "library.folders"
411 | "remote.enabled"
412 | "remote.url"
413 | "remote.username"
414 | "remote.cache_dir"
415 | "remote.cache_limit"
416 | "playback.output_device"
418 | "subsonic.enabled"
421 | "subsonic.port"
422 | "subsonic.username"
423 | "playback.art_size"
425 | "visualizer.enabled"
426 | "visualizer.mode"
427 | "visualizer.matrix_overlay"
428 | "visualizer.bass_shake" => Layer::Machine,
429 _ => Layer::Shared,
430 }
431}
432
433type ConfigStamp = (Option<SystemTime>, Option<SystemTime>);
437
438type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
439
440static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
441 LazyLock::new(|| parking_lot::RwLock::new(None));
442
443fn config_stamp() -> ConfigStamp {
444 stamp_of(&config_file_path(), &config_local_file_path())
445}
446
447fn stamp_of(base: &Path, local: &Path) -> ConfigStamp {
449 let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
450 (mtime(base), mtime(local))
451}
452
453impl Config {
454 fn figment() -> Figment {
460 let base_path = config_file_path();
461 let local_path = config_local_file_path();
462
463 Figment::from(Serialized::defaults(Config::default()))
464 .merge(Toml::file(&base_path))
465 .merge(Toml::file(&local_path))
466 .merge(Env::prefixed("KOAN_").split("__"))
467 }
468
469 pub fn load() -> Result<Self, ConfigError> {
471 let cfg: Self = Self::figment()
472 .extract()
473 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
474
475 check_secrets_in_git();
478
479 Ok(cfg)
480 }
481
482 pub fn load_or_default() -> Self {
487 (*Self::cached()).clone()
488 }
489
490 pub fn cached() -> Arc<Config> {
497 let stamp = config_stamp();
498 if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
499 && *seen == stamp
500 {
501 return cfg.clone();
502 }
503
504 let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
505 log::warn!("failed to load config, using defaults: {}", e);
506 Self::default()
507 }));
508 *CONFIG_CACHE.write() = Some((stamp, cfg.clone()));
509 cfg
510 }
511
512 pub fn invalidate_cache() {
516 *CONFIG_CACHE.write() = None;
517 }
518
519 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
521 let contents = fs::read_to_string(path)?;
522 let config: Config = toml::from_str(&contents)?;
523 Ok(config)
524 }
525
526 fn from_files() -> Result<Self, ConfigError> {
532 Figment::from(Serialized::defaults(Config::default()))
533 .merge(Toml::file(config_file_path()))
534 .merge(Toml::file(config_local_file_path()))
535 .extract()
536 .map_err(|e| ConfigError::Figment(Box::new(e)))
537 }
538
539 pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
551 where
552 F: FnOnce(&mut Config),
553 {
554 let before = Self::from_files()?;
555 let mut after = before.clone();
556 mutate(&mut after);
557
558 let mut changes = Vec::new();
559 diff_into(
560 "",
561 &toml::Value::try_from(&before)?,
562 &toml::Value::try_from(&after)?,
563 &mut changes,
564 );
565 if changes.is_empty() {
566 return Ok(());
567 }
568
569 let base_path = config_file_path();
570 let local_path = config_local_file_path();
571 let mut base = read_document(&base_path)?;
572 let mut local = read_document(&local_path)?;
573
574 for (path, value) in &changes {
575 let (target, other) = match layer_of(path) {
576 Layer::Shared => (&mut base, &mut local),
577 Layer::Machine => (&mut local, &mut base),
578 };
579 match value {
580 Some(v) => doc_set(target, path, v),
581 None => doc_remove(target, path),
582 }
583 doc_remove(other, path);
584 }
585
586 write_document(&base_path, &base, false)?;
587 write_document(&local_path, &local, true)?;
588 Self::invalidate_cache();
589 Ok(())
590 }
591
592 pub fn cache_dir(&self) -> PathBuf {
594 self.remote
595 .cache_dir
596 .clone()
597 .unwrap_or_else(|| config_dir().join("cache"))
598 }
599
600 pub fn cache_limit_bytes(&self) -> Option<u64> {
602 self.remote
603 .cache_limit
604 .as_deref()
605 .and_then(parse_size_bytes)
606 }
607}
608
609fn diff_into(
614 prefix: &str,
615 before: &toml::Value,
616 after: &toml::Value,
617 out: &mut Vec<(String, Option<toml::Value>)>,
618) {
619 let (b, a) = match (before.as_table(), after.as_table()) {
620 (Some(b), Some(a)) => (b, a),
621 _ => {
622 if before != after {
623 out.push((prefix.to_string(), Some(after.clone())));
624 }
625 return;
626 }
627 };
628
629 let empty = toml::Value::Table(toml::map::Map::new());
630 for key in b
631 .keys()
632 .chain(a.keys())
633 .collect::<std::collections::BTreeSet<_>>()
634 {
635 let path = if prefix.is_empty() {
636 key.clone()
637 } else {
638 format!("{prefix}.{key}")
639 };
640 match (b.get(key), a.get(key)) {
641 (Some(bv), Some(av)) => diff_into(&path, bv, av, out),
642 (None, Some(av)) => diff_into(&path, &empty, av, out),
645 (Some(_), None) => out.push((path, None)),
646 (None, None) => unreachable!("key came from one of the two tables"),
647 }
648 }
649}
650
651fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
652 let Ok(contents) = fs::read_to_string(path) else {
653 return Ok(toml_edit::DocumentMut::new());
654 };
655 contents
656 .parse::<toml_edit::DocumentMut>()
657 .map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
658}
659
660fn write_document(
663 path: &Path,
664 doc: &toml_edit::DocumentMut,
665 secret: bool,
666) -> Result<(), ConfigError> {
667 let contents = doc.to_string();
668 if contents.trim().is_empty() && !path.exists() {
669 return Ok(());
670 }
671 if let Some(parent) = path.parent() {
672 fs::create_dir_all(parent)?;
673 }
674 fs::write(path, contents)?;
675 #[cfg(unix)]
676 if secret {
677 use std::os::unix::fs::PermissionsExt;
678 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
679 }
680 #[cfg(not(unix))]
681 let _ = secret;
682 Ok(())
683}
684
685fn implicit_table() -> toml_edit::Item {
686 let mut table = toml_edit::Table::new();
687 table.set_implicit(true);
690 toml_edit::Item::Table(table)
691}
692
693fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
694 let segments: Vec<&str> = path.split('.').collect();
695 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
696
697 let mut table = doc.as_table_mut();
698 for segment in parents {
699 let item = table.entry(segment).or_insert_with(implicit_table);
700 if !item.is_table() {
703 *item = implicit_table();
704 }
705 table = item.as_table_mut().expect("just ensured it is a table");
706 }
707 match table.get_mut(last) {
710 Some(existing) => *existing = toml_edit::value(to_edit_value(value)),
711 None => {
712 table.insert(last, toml_edit::value(to_edit_value(value)));
713 }
714 }
715}
716
717fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
718 let segments: Vec<&str> = path.split('.').collect();
719 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
720
721 let mut table = doc.as_table_mut();
722 for segment in parents {
723 match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
724 Some(child) => table = child,
725 None => return,
726 }
727 }
728 table.remove(last);
731}
732
733fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
734 match value {
735 toml::Value::String(s) => s.as_str().into(),
736 toml::Value::Integer(i) => (*i).into(),
737 toml::Value::Float(f) => (*f).into(),
738 toml::Value::Boolean(b) => (*b).into(),
739 toml::Value::Datetime(d) => d.to_string().into(),
740 toml::Value::Array(items) => items
741 .iter()
742 .map(to_edit_value)
743 .collect::<toml_edit::Array>()
744 .into(),
745 toml::Value::Table(t) => {
746 let mut inline = toml_edit::InlineTable::new();
747 for (k, v) in t {
748 inline.insert(k, to_edit_value(v));
749 }
750 inline.into()
751 }
752 }
753}
754
755pub fn config_dir() -> PathBuf {
763 if let Some(dir) = CONFIG_DIR.read().clone() {
764 return dir;
765 }
766 if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
767 return PathBuf::from(dir);
768 }
769 dirs::home_dir()
770 .unwrap_or_else(|| PathBuf::from("."))
771 .join(".config")
772 .join("koan")
773}
774
775pub fn set_config_dir(dir: impl Into<PathBuf>) {
782 *CONFIG_DIR.write() = Some(dir.into());
783 Config::invalidate_cache();
784}
785
786pub fn isolate_config_for_tests() {
798 let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
799 let _ = fs::create_dir_all(&dir);
800 set_config_dir(dir);
801}
802
803static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
804 LazyLock::new(|| parking_lot::RwLock::new(None));
805
806pub fn config_file_path() -> PathBuf {
808 config_dir().join("config.toml")
809}
810
811pub fn config_local_file_path() -> PathBuf {
813 config_dir().join("config.local.toml")
814}
815
816pub fn db_path() -> PathBuf {
818 config_dir().join("koan.db")
819}
820
821fn check_secrets_in_git() {
829 static ONCE: Once = Once::new();
830 ONCE.call_once(scan_for_tracked_secrets);
831}
832
833fn scan_for_tracked_secrets() {
834 let sensitive_fields = ["password"];
835
836 for (label, path) in [
837 ("config.toml", config_file_path()),
838 ("config.local.toml", config_local_file_path()),
839 ] {
840 let Ok(contents) = std::fs::read_to_string(&path) else {
841 continue;
842 };
843
844 let has_secrets = sensitive_fields.iter().any(|field| {
846 contents.lines().any(|line| {
847 let line = line.trim();
848 if let Some(rest) = line.strip_prefix(field) {
849 let rest = rest.trim_start();
850 if let Some(value) = rest.strip_prefix('=') {
851 let value = value.trim().trim_matches('"').trim_matches('\'');
852 return !value.is_empty();
853 }
854 }
855 false
856 })
857 });
858
859 if !has_secrets {
860 continue;
861 }
862
863 if is_tracked_by_git(&path) {
865 eprintln!();
866 eprintln!("╔══════════════════════════════════════════════════════════════╗");
867 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
868 eprintln!("╠══════════════════════════════════════════════════════════════╣");
869 eprintln!("║ ║");
870 eprintln!("║ File: {:<52} ║", path.display());
871 eprintln!("║ ║");
872 eprintln!("║ Your password is in version control. You should: ║");
873 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
874 eprintln!("║ 2. Add it to .gitignore ║");
875 eprintln!("║ 3. Rotate your credentials immediately ║");
876 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
877 eprintln!("║ or use `koan remote login` for keyring storage ║");
878 eprintln!("║ ║");
879 eprintln!("╚══════════════════════════════════════════════════════════════╝");
880 eprintln!();
881 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
882 }
883 }
884}
885
886fn is_tracked_by_git(path: &Path) -> bool {
888 let Some(parent) = path.parent() else {
889 return false;
890 };
891 std::process::Command::new("git")
893 .args(["ls-files", "--error-unmatch"])
894 .arg(path)
895 .current_dir(parent)
896 .stdout(std::process::Stdio::null())
897 .stderr(std::process::Stdio::null())
898 .status()
899 .is_ok_and(|s| s.success())
900}
901
902#[cfg(test)]
903mod tests {
904 use super::*;
905 use std::fs;
906
907 fn tmp_dir() -> PathBuf {
908 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
909 fs::create_dir_all(&dir).unwrap();
910 dir
911 }
912
913 #[test]
914 fn test_defaults() {
915 let cfg = Config::default();
916 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
917 assert!(!cfg.remote.enabled);
918 }
919
920 #[test]
921 fn test_roundtrip_toml() {
922 let cfg = Config::default();
923 let serialized = toml::to_string_pretty(&cfg).unwrap();
924 let deserialized: Config = toml::from_str(&serialized).unwrap();
925 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
926 assert_eq!(
927 deserialized.remote.download_workers,
928 cfg.remote.download_workers
929 );
930 }
931
932 #[test]
933 fn test_load_from_file() {
934 let dir = tempfile::tempdir().unwrap();
935 let path = dir.path().join("config.toml");
936 fs::write(
937 &path,
938 r#"
939[library]
940folders = ["/tmp/music"]
941
942[playback]
943replaygain = "track"
944"#,
945 )
946 .unwrap();
947
948 let cfg = Config::load_from(&path).unwrap();
949 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
950 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
951 assert!(!cfg.remote.enabled);
952 }
953
954 #[test]
955 fn test_partial_toml_uses_defaults() {
956 let dir = tempfile::tempdir().unwrap();
957 let path = dir.path().join("partial.toml");
958 fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
959
960 let cfg = Config::load_from(&path).unwrap();
961 assert_eq!(cfg.playback.target_fps, 30);
962 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
963 }
964
965 #[test]
966 fn test_figment_layered_loading() {
967 let dir = tempfile::tempdir().unwrap();
968 let base_path = dir.path().join("config.toml");
969 let local_path = dir.path().join("config.local.toml");
970
971 fs::write(
972 &base_path,
973 r#"
974[remote]
975url = "https://base.example.com"
976"#,
977 )
978 .unwrap();
979 fs::write(
980 &local_path,
981 r#"
982[remote]
983enabled = true
984url = "https://local.example.com"
985username = "admin"
986password = "secret"
987"#,
988 )
989 .unwrap();
990
991 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
993 .merge(Toml::file(&base_path))
994 .merge(Toml::file(&local_path))
995 .extract()
996 .unwrap();
997
998 assert!(cfg.remote.enabled);
999 assert_eq!(cfg.remote.url, "https://local.example.com");
1000 assert_eq!(cfg.remote.username, "admin");
1001 assert_eq!(cfg.remote.password, "secret");
1002 }
1003
1004 #[test]
1005 fn test_figment_missing_keys_preserved() {
1006 let dir = tempfile::tempdir().unwrap();
1007 let base_path = dir.path().join("config.toml");
1008 let local_path = dir.path().join("config.local.toml");
1009
1010 fs::write(
1011 &base_path,
1012 r#"
1013[remote]
1014url = "https://keep.me"
1015username = "keepuser"
1016"#,
1017 )
1018 .unwrap();
1019 fs::write(
1020 &local_path,
1021 r#"
1022[remote]
1023password = "secret"
1024"#,
1025 )
1026 .unwrap();
1027
1028 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1029 .merge(Toml::file(&base_path))
1030 .merge(Toml::file(&local_path))
1031 .extract()
1032 .unwrap();
1033
1034 assert_eq!(cfg.remote.url, "https://keep.me");
1035 assert_eq!(cfg.remote.username, "keepuser");
1036 assert_eq!(cfg.remote.password, "secret");
1037 }
1038
1039 #[test]
1040 fn test_env_var_override() {
1041 let dir = tempfile::tempdir().unwrap();
1042 let base_path = dir.path().join("config.toml");
1043
1044 fs::write(
1045 &base_path,
1046 r#"
1047[remote]
1048url = "https://file.example.com"
1049"#,
1050 )
1051 .unwrap();
1052
1053 unsafe {
1055 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
1056 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
1057 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
1058 }
1059
1060 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1061 .merge(Toml::file(&base_path))
1062 .merge(Env::prefixed("KOAN_").split("__"))
1063 .extract()
1064 .unwrap();
1065
1066 assert_eq!(cfg.remote.url, "https://env.example.com");
1067 assert_eq!(cfg.remote.password, "env-secret");
1068 assert_eq!(cfg.graphql.port, 9999);
1069
1070 unsafe {
1072 std::env::remove_var("KOAN_REMOTE__URL");
1073 std::env::remove_var("KOAN_REMOTE__PASSWORD");
1074 std::env::remove_var("KOAN_GRAPHQL__PORT");
1075 }
1076 }
1077
1078 #[test]
1079 fn test_cache_dir_default() {
1080 let cfg = Config::default();
1081 assert!(cfg.cache_dir().ends_with("cache"));
1082 }
1083
1084 #[test]
1085 fn test_cache_dir_explicit() {
1086 let mut cfg = Config::default();
1087 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
1088 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
1089 }
1090
1091 #[test]
1092 fn test_organize_config_defaults() {
1093 let cfg = Config::default();
1094 assert!(cfg.organize.default.is_none());
1095 assert!(cfg.organize.patterns.is_empty());
1096 }
1097
1098 #[test]
1099 fn test_organize_config_from_toml() {
1100 let dir = tmp_dir();
1101 let path = dir.join("organize.toml");
1102 fs::write(
1103 &path,
1104 r#"
1105[organize]
1106default = "standard"
1107
1108[organize.patterns]
1109standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
1110va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
1111"#,
1112 )
1113 .unwrap();
1114
1115 let cfg = Config::load_from(&path).unwrap();
1116 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
1117 assert_eq!(cfg.organize.patterns.len(), 2);
1118 assert!(cfg.organize.patterns.contains_key("standard"));
1119 assert!(cfg.organize.patterns.contains_key("va-aware"));
1120
1121 fs::remove_dir_all(&dir).ok();
1122 }
1123
1124 #[test]
1125 fn test_figment_organize_patterns_merge() {
1126 let dir = tempfile::tempdir().unwrap();
1127 let base_path = dir.path().join("config.toml");
1128 let local_path = dir.path().join("config.local.toml");
1129
1130 fs::write(
1131 &base_path,
1132 r#"
1133[organize]
1134default = "standard"
1135
1136[organize.patterns]
1137standard = "base-pattern"
1138"#,
1139 )
1140 .unwrap();
1141 fs::write(
1142 &local_path,
1143 r#"
1144[organize]
1145default = "custom"
1146
1147[organize.patterns]
1148custom = "local-pattern"
1149"#,
1150 )
1151 .unwrap();
1152
1153 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1154 .merge(Toml::file(&base_path))
1155 .merge(Toml::file(&local_path))
1156 .extract()
1157 .unwrap();
1158
1159 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
1161 assert_eq!(cfg.organize.patterns.len(), 2);
1163 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
1164 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
1165 }
1166
1167 #[test]
1168 fn test_output_device_config_roundtrip() {
1169 let mut cfg = Config::default();
1170 cfg.playback.output_device = Some("My DAC".into());
1171
1172 let serialized = toml::to_string_pretty(&cfg).unwrap();
1173 let deserialized: Config = toml::from_str(&serialized).unwrap();
1174 assert_eq!(
1175 deserialized.playback.output_device.as_deref(),
1176 Some("My DAC")
1177 );
1178 }
1179
1180 #[test]
1181 fn test_output_device_config_default_is_none() {
1182 let cfg = Config::default();
1183 assert!(cfg.playback.output_device.is_none());
1184
1185 let serialized = toml::to_string_pretty(&cfg).unwrap();
1187 assert!(!serialized.contains("output_device"));
1188 let deserialized: Config = toml::from_str(&serialized).unwrap();
1189 assert!(deserialized.playback.output_device.is_none());
1190 }
1191
1192 #[test]
1193 fn test_output_device_config_from_toml() {
1194 let dir = tempfile::tempdir().unwrap();
1195 let path = dir.path().join("config.toml");
1196 fs::write(
1197 &path,
1198 r#"
1199[playback]
1200output_device = "External Speakers"
1201"#,
1202 )
1203 .unwrap();
1204
1205 let cfg = Config::load_from(&path).unwrap();
1206 assert_eq!(
1207 cfg.playback.output_device.as_deref(),
1208 Some("External Speakers")
1209 );
1210 }
1211
1212 #[test]
1213 fn test_graphql_bind_defaults_to_localhost() {
1214 let cfg = GraphqlConfig::default();
1215 assert_eq!(
1216 cfg.bind,
1217 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1218 );
1219 }
1220
1221 #[test]
1222 fn test_graphql_bind_from_toml() {
1223 let toml_str = r#"
1224[graphql]
1225bind = "0.0.0.0"
1226port = 5000
1227"#;
1228 let cfg: Config = toml::from_str(toml_str).unwrap();
1229 assert_eq!(
1230 cfg.graphql.bind,
1231 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
1232 );
1233 assert_eq!(cfg.graphql.port, 5000);
1234 }
1235
1236 #[test]
1237 fn test_graphql_bind_omitted_defaults_to_localhost() {
1238 let toml_str = r#"
1239[graphql]
1240port = 4000
1241"#;
1242 let cfg: Config = toml::from_str(toml_str).unwrap();
1243 assert_eq!(
1244 cfg.graphql.bind,
1245 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1246 );
1247 }
1248
1249 #[test]
1250 fn test_organize_config_roundtrip() {
1251 let mut cfg = Config::default();
1252 cfg.organize.default = Some("standard".into());
1253 cfg.organize
1254 .patterns
1255 .insert("standard".into(), "%artist%/%title%".into());
1256
1257 let serialized = toml::to_string_pretty(&cfg).unwrap();
1258 let deserialized: Config = toml::from_str(&serialized).unwrap();
1259 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
1260 assert_eq!(
1261 deserialized.organize.patterns["standard"],
1262 "%artist%/%title%"
1263 );
1264 }
1265
1266 #[test]
1267 fn test_parse_size_bytes() {
1268 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1269 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1270 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1271 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1272 assert_eq!(parse_size_bytes("1024B"), Some(1024));
1273 assert_eq!(parse_size_bytes("1024"), Some(1024));
1274
1275 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1277 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1278
1279 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1281 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1282
1283 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1285 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1286
1287 assert_eq!(
1289 parse_size_bytes("1.5GB"),
1290 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1291 );
1292
1293 assert_eq!(parse_size_bytes(""), None);
1295 assert_eq!(parse_size_bytes("abc"), None);
1296 assert_eq!(parse_size_bytes("50XB"), None);
1297 }
1298
1299 #[test]
1300 fn test_cache_limit_config_from_toml() {
1301 let toml_str = r#"
1302[remote]
1303cache_limit = "50GB"
1304"#;
1305 let cfg: Config = toml::from_str(toml_str).unwrap();
1306 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1307 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1308 }
1309
1310 #[test]
1311 fn test_cache_limit_none_by_default() {
1312 let cfg = Config::default();
1313 assert!(cfg.remote.cache_limit.is_none());
1314 assert!(cfg.cache_limit_bytes().is_none());
1315 }
1316
1317 #[test]
1318 fn test_cache_limit_not_serialized_when_none() {
1319 let cfg = Config::default();
1320 let serialized = toml::to_string_pretty(&cfg).unwrap();
1321 assert!(!serialized.contains("cache_limit"));
1322 }
1323
1324 #[test]
1325 fn player_uses_config_on_init() {
1326 let dir = tempfile::tempdir().unwrap();
1330 let path = dir.path().join("config.toml");
1331 fs::write(
1332 &path,
1333 r#"
1334[playback]
1335replaygain = "track"
1336output_device = "My Fancy DAC"
1337pre_amp_db = -3.5
1338target_fps = 30
1339art_size = 32
1340
1341[visualizer]
1342enabled = false
1343mode = "oscilloscope"
1344fps = 30
1345"#,
1346 )
1347 .unwrap();
1348
1349 let cfg = Config::load_from(&path).unwrap();
1350
1351 assert_eq!(
1353 cfg.playback.replaygain,
1354 ReplayGainMode::Track,
1355 "replaygain should be 'track'"
1356 );
1357 assert_eq!(
1358 cfg.playback.output_device.as_deref(),
1359 Some("My Fancy DAC"),
1360 "output_device should match config"
1361 );
1362 assert!(
1363 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1364 "pre_amp_db should be -3.5"
1365 );
1366 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1367 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1368
1369 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1371 assert_eq!(cfg.visualizer.mode, "oscilloscope");
1372 assert_eq!(cfg.visualizer.fps, 30);
1373 }
1374
1375 #[test]
1376 fn a_missing_config_file_stamps_as_absent() {
1377 let dir = tempfile::tempdir().unwrap();
1378 let base = dir.path().join("config.toml");
1379 let local = dir.path().join("config.local.toml");
1380
1381 assert_eq!(stamp_of(&base, &local), (None, None));
1382
1383 fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
1384 let (base_stamp, local_stamp) = stamp_of(&base, &local);
1385 assert!(base_stamp.is_some(), "creating the file must be a change");
1386 assert!(local_stamp.is_none());
1387 }
1388
1389 #[test]
1390 fn editing_a_config_file_changes_its_stamp() {
1391 let dir = tempfile::tempdir().unwrap();
1392 let base = dir.path().join("config.toml");
1393 let local = dir.path().join("config.local.toml");
1394 fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
1395
1396 let before = stamp_of(&base, &local);
1397 std::thread::sleep(std::time::Duration::from_millis(20));
1399 fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
1400
1401 assert_ne!(
1402 before,
1403 stamp_of(&base, &local),
1404 "a config edited by hand has to be picked up"
1405 );
1406 }
1407
1408 #[test]
1409 fn invalidating_forces_a_reload() {
1410 let first = Config::cached();
1411 Config::invalidate_cache();
1412 assert!(
1413 !Arc::ptr_eq(&first, &Config::cached()),
1414 "koan's own writes invalidate explicitly; the next read must re-parse"
1415 );
1416 }
1417
1418 static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1423
1424 fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
1426 let dir =
1427 std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
1428 let _ = fs::remove_dir_all(&dir);
1429 fs::create_dir_all(&dir).unwrap();
1430 set_config_dir(&dir);
1431 (config_file_path(), config_local_file_path())
1432 }
1433
1434 #[test]
1435 fn persist_keeps_comments_and_untouched_keys() {
1436 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1437 let (base, _local) = persist_sandbox("comments");
1438 fs::write(
1439 &base,
1440 "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
1441 )
1442 .unwrap();
1443
1444 Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
1445
1446 let written = fs::read_to_string(&base).unwrap();
1447 assert!(
1448 written.contains("# koan — shareable defaults"),
1449 "the header comment must survive a write: {written}"
1450 );
1451 assert!(
1452 written.contains("# fps = 60"),
1453 "commented-out defaults are the template's whole point: {written}"
1454 );
1455 assert!(written.contains("palette = \"neon\""));
1456 assert!(
1457 !written.contains("[graphql]"),
1458 "an untouched section must not be invented: {written}"
1459 );
1460 }
1461
1462 #[test]
1463 fn persist_routes_machine_settings_to_the_local_file() {
1464 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1465 let (base, local) = persist_sandbox("routing");
1466
1467 Config::persist(|cfg| {
1468 cfg.playback.replaygain = ReplayGainMode::Album;
1469 cfg.playback.output_device = Some("My DAC".into());
1470 cfg.playback.art_size = 40;
1471 cfg.visualizer.mode = "starfield".into();
1472 })
1473 .unwrap();
1474
1475 let shared = fs::read_to_string(&base).unwrap();
1476 let machine = fs::read_to_string(&local).unwrap();
1477
1478 assert!(shared.contains("replaygain = \"album\""), "{shared}");
1479 for machine_only in ["output_device", "art_size", "starfield"] {
1480 assert!(
1481 !shared.contains(machine_only),
1482 "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
1483 );
1484 assert!(machine.contains(machine_only), "{machine}");
1485 }
1486 }
1487
1488 #[test]
1489 fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
1490 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1491 let (base, _local) = persist_sandbox("folders");
1492
1493 Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
1496
1497 let shared = fs::read_to_string(&base).unwrap_or_default();
1498 assert!(
1499 !shared.contains("folders"),
1500 "a visualiser toggle must not invent library folders: {shared}"
1501 );
1502 }
1503
1504 #[test]
1505 fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
1506 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1507 let (base, local) = persist_sandbox("drain");
1508 fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
1509
1510 Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
1511
1512 let shared = fs::read_to_string(&base).unwrap();
1513 assert!(
1514 !shared.contains("art_size"),
1515 "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
1516 );
1517 assert!(shared.contains("target_fps"), "{shared}");
1518 assert!(
1519 fs::read_to_string(&local)
1520 .unwrap()
1521 .contains("art_size = 48")
1522 );
1523 }
1524
1525 #[test]
1526 fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
1527 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1528 let (_base, local) = persist_sandbox("shadow");
1529 fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
1530
1531 Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
1532
1533 assert_eq!(
1534 Config::from_files().unwrap().playback.target_fps,
1535 120,
1536 "local wins the merge, so a shared write over a local copy would \
1537 otherwise be silently ignored: {}",
1538 fs::read_to_string(&local).unwrap()
1539 );
1540 }
1541
1542 #[test]
1543 fn persist_writes_nothing_when_the_mutation_changes_nothing() {
1544 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1545 let (base, _local) = persist_sandbox("noop");
1546 fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
1547
1548 Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
1549
1550 assert_eq!(
1551 fs::read_to_string(&base).unwrap(),
1552 "# untouched\n[playback]\ntarget_fps = 60\n"
1553 );
1554 }
1555
1556 #[test]
1557 fn persist_keeps_passwords_out_of_the_shared_file() {
1558 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1559 let (base, local) = persist_sandbox("secrets");
1560
1561 Config::persist(|cfg| {
1562 cfg.remote.password = "hunter2".into();
1563 cfg.subsonic.password = "s3cret".into();
1564 cfg.visualizer.palette = "mono".into();
1565 })
1566 .unwrap();
1567
1568 let shared = fs::read_to_string(&base).unwrap();
1569 assert!(!shared.contains("hunter2"), "{shared}");
1570 assert!(!shared.contains("s3cret"), "{shared}");
1571 assert!(shared.contains("mono"));
1572
1573 let machine = fs::read_to_string(&local).unwrap();
1574 assert!(machine.contains("hunter2") && machine.contains("s3cret"));
1575 }
1576
1577 #[test]
1578 fn persist_removes_a_cleared_password_rather_than_blanking_it() {
1579 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1580 let (_base, local) = persist_sandbox("clear-secret");
1581 fs::write(
1582 &local,
1583 "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
1584 )
1585 .unwrap();
1586
1587 Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
1588
1589 let machine = fs::read_to_string(&local).unwrap();
1590 assert!(
1591 !machine.contains("password"),
1592 "an emptied secret should leave no key behind: {machine}"
1593 );
1594 assert!(machine.contains("url"), "{machine}");
1595 }
1596
1597 #[test]
1598 fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
1599 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1600 let (base, _local) = persist_sandbox("patterns");
1601 fs::write(
1602 &base,
1603 "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
1604 )
1605 .unwrap();
1606
1607 Config::persist(|cfg| {
1608 cfg.organize
1609 .patterns
1610 .insert("standard".into(), "%album artist%/%album%".into());
1611 })
1612 .unwrap();
1613
1614 let cfg = Config::from_files().unwrap();
1615 assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
1616 assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
1617 }
1618}