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 auth: AuthConfig,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(default)]
41pub struct LibraryConfig {
42 pub folders: Vec<PathBuf>,
43 pub analyze_on_scan: bool,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(default)]
50pub struct PlaybackConfig {
51 pub replaygain: ReplayGainMode,
52 pub target_fps: u8,
55 pub show_fps: bool,
57 pub pre_amp_db: f64,
60 pub fade_on_pause: bool,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub output_device: Option<String>,
66 pub art_size: u16,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "lowercase")]
73pub enum ReplayGainMode {
74 Off,
75 Track,
76 Album,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(default)]
81pub struct RemoteConfig {
82 pub enabled: bool,
83 pub url: String,
84 pub username: String,
85 #[serde(default, skip_serializing_if = "String::is_empty")]
87 pub password: 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 analyze_on_scan: false,
116 }
117 }
118}
119
120impl Default for PlaybackConfig {
121 fn default() -> Self {
122 Self {
123 replaygain: ReplayGainMode::Off,
124 target_fps: 60,
125 show_fps: false,
126 pre_amp_db: 0.0,
127 fade_on_pause: true,
128 output_device: None,
129 art_size: 24,
130 }
131 }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(default)]
136pub struct VisualizerConfig {
137 pub enabled: bool,
138 pub fps: u8,
139 pub mode: String,
141 pub scale: String,
143 pub amplitude_scale: String,
145 pub bar_decay_ms: u32,
147 pub peak_decay_ms: u32,
149 pub palette: String,
152 pub reactivity: f32,
156 pub bass_shake: bool,
159 pub matrix_overlay: bool,
162 pub reactive_bg: bool,
164}
165
166impl Default for VisualizerConfig {
167 fn default() -> Self {
168 Self {
169 enabled: true,
170 fps: 60,
171 mode: "bars".into(),
172 scale: "bark".into(),
173 amplitude_scale: "aweight".into(),
174 bar_decay_ms: 50,
175 peak_decay_ms: 180,
176 palette: "spectrum".into(),
177 reactivity: 1.0,
178 bass_shake: true,
179 matrix_overlay: false,
180 reactive_bg: false,
181 }
182 }
183}
184
185impl Default for RemoteConfig {
186 fn default() -> Self {
187 Self {
188 enabled: false,
189 url: String::new(),
190 username: String::new(),
191 password: String::new(),
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
261#[derive(Debug, Clone, Serialize, Deserialize)]
263#[serde(default)]
264pub struct GraphqlConfig {
265 pub enabled: bool,
268 pub port: u16,
270 #[serde(default = "default_bind")]
273 pub bind: std::net::IpAddr,
274 pub playground: bool,
276 pub auth_enabled: bool,
279 pub access_token_ttl: String,
281 pub refresh_token_ttl: String,
283 pub cors_origins: Vec<String>,
286 pub allowed_hosts: Vec<String>,
290 pub cookie_secure: bool,
294 pub allow_organize: bool,
296}
297
298fn default_true() -> bool {
299 true
300}
301
302fn default_bind() -> std::net::IpAddr {
303 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
304}
305
306impl Default for GraphqlConfig {
307 fn default() -> Self {
308 Self {
309 enabled: true,
310 port: 4000,
311 bind: default_bind(),
312 playground: false,
313 auth_enabled: true,
314 access_token_ttl: "15m".into(),
315 refresh_token_ttl: "30d".into(),
316 cors_origins: Vec::new(),
317 allowed_hosts: Vec::new(),
318 cookie_secure: false,
319 allow_organize: false,
320 }
321 }
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(default)]
333pub struct SubsonicConfig {
334 pub enabled: bool,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub port: Option<u16>,
342 pub username: String,
344 #[serde(default, skip_serializing_if = "String::is_empty")]
347 pub password: String,
348}
349
350impl Default for SubsonicConfig {
351 fn default() -> Self {
352 Self {
353 enabled: false,
354 port: None,
355 username: "koan".into(),
356 password: String::new(),
357 }
358 }
359}
360
361#[derive(Debug, Clone, Default, Serialize, Deserialize)]
366#[serde(default)]
367pub struct AuthConfig {
368 #[serde(skip_serializing_if = "String::is_empty")]
370 pub server: String,
371 #[serde(skip_serializing_if = "String::is_empty")]
374 pub refresh_token: String,
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 history_window: usize,
387 pub seed_window: usize,
389 pub discovery_weight: f64,
392}
393
394impl Default for RadioConfig {
395 fn default() -> Self {
396 Self {
397 lookahead: 5,
398 batch_size: 5,
399 history_window: 200,
400 seed_window: 5,
401 discovery_weight: 0.3,
402 }
403 }
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum Layer {
409 Shared,
411 Machine,
413}
414
415pub fn layer_of(path: &str) -> Layer {
425 match path {
426 "remote.password"
428 | "subsonic.password"
429 | "auth.refresh_token"
430 | "library.folders"
432 | "remote.enabled"
433 | "remote.url"
434 | "remote.username"
435 | "remote.cache_dir"
436 | "remote.cache_limit"
437 | "playback.output_device"
439 | "subsonic.enabled"
442 | "subsonic.port"
443 | "subsonic.username"
444 | "auth.server"
446 | "playback.art_size"
448 | "visualizer.enabled"
449 | "visualizer.mode"
450 | "visualizer.matrix_overlay"
451 | "visualizer.bass_shake" => Layer::Machine,
452 _ => Layer::Shared,
453 }
454}
455
456type ConfigStamp = (Option<SystemTime>, Option<SystemTime>);
460
461type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
462
463static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
464 LazyLock::new(|| parking_lot::RwLock::new(None));
465
466fn config_stamp() -> ConfigStamp {
467 stamp_of(&config_file_path(), &config_local_file_path())
468}
469
470fn stamp_of(base: &Path, local: &Path) -> ConfigStamp {
472 let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
473 (mtime(base), mtime(local))
474}
475
476impl Config {
477 fn figment() -> Figment {
483 let base_path = config_file_path();
484 let local_path = config_local_file_path();
485
486 Figment::from(Serialized::defaults(Config::default()))
487 .merge(Toml::file(&base_path))
488 .merge(Toml::file(&local_path))
489 .merge(Env::prefixed("KOAN_").split("__"))
490 }
491
492 pub fn load() -> Result<Self, ConfigError> {
494 let cfg: Self = Self::figment()
495 .extract()
496 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
497
498 check_secrets_in_git();
501
502 Ok(cfg)
503 }
504
505 pub fn load_or_default() -> Self {
510 (*Self::cached()).clone()
511 }
512
513 pub fn cached() -> Arc<Config> {
520 let stamp = config_stamp();
521 if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
522 && *seen == stamp
523 {
524 return cfg.clone();
525 }
526
527 let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
528 log::warn!("failed to load config, using defaults: {}", e);
529 Self::default()
530 }));
531 *CONFIG_CACHE.write() = Some((stamp, cfg.clone()));
532 cfg
533 }
534
535 pub fn invalidate_cache() {
539 *CONFIG_CACHE.write() = None;
540 }
541
542 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
544 let contents = fs::read_to_string(path)?;
545 let config: Config = toml::from_str(&contents)?;
546 Ok(config)
547 }
548
549 fn from_files() -> Result<Self, ConfigError> {
555 Figment::from(Serialized::defaults(Config::default()))
556 .merge(Toml::file(config_file_path()))
557 .merge(Toml::file(config_local_file_path()))
558 .extract()
559 .map_err(|e| ConfigError::Figment(Box::new(e)))
560 }
561
562 pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
574 where
575 F: FnOnce(&mut Config),
576 {
577 let before = Self::from_files()?;
578 let mut after = before.clone();
579 mutate(&mut after);
580
581 let mut changes = Vec::new();
582 diff_into(
583 "",
584 &toml::Value::try_from(&before)?,
585 &toml::Value::try_from(&after)?,
586 &mut changes,
587 );
588 if changes.is_empty() {
589 return Ok(());
590 }
591
592 let base_path = config_file_path();
593 let local_path = config_local_file_path();
594 let mut base = read_document(&base_path)?;
595 let mut local = read_document(&local_path)?;
596
597 for (path, value) in &changes {
598 let (target, other) = match layer_of(path) {
599 Layer::Shared => (&mut base, &mut local),
600 Layer::Machine => (&mut local, &mut base),
601 };
602 match value {
603 Some(v) => doc_set(target, path, v),
604 None => doc_remove(target, path),
605 }
606 doc_remove(other, path);
607 }
608
609 write_document(&base_path, &base, false)?;
610 write_document(&local_path, &local, true)?;
611 Self::invalidate_cache();
612 Ok(())
613 }
614
615 pub fn cache_dir(&self) -> PathBuf {
617 self.remote
618 .cache_dir
619 .clone()
620 .unwrap_or_else(|| config_dir().join("cache"))
621 }
622
623 pub fn cache_limit_bytes(&self) -> Option<u64> {
625 self.remote
626 .cache_limit
627 .as_deref()
628 .and_then(parse_size_bytes)
629 }
630}
631
632fn diff_into(
637 prefix: &str,
638 before: &toml::Value,
639 after: &toml::Value,
640 out: &mut Vec<(String, Option<toml::Value>)>,
641) {
642 let (b, a) = match (before.as_table(), after.as_table()) {
643 (Some(b), Some(a)) => (b, a),
644 _ => {
645 if before != after {
646 out.push((prefix.to_string(), Some(after.clone())));
647 }
648 return;
649 }
650 };
651
652 let empty = toml::Value::Table(toml::map::Map::new());
653 for key in b
654 .keys()
655 .chain(a.keys())
656 .collect::<std::collections::BTreeSet<_>>()
657 {
658 let path = if prefix.is_empty() {
659 key.clone()
660 } else {
661 format!("{prefix}.{key}")
662 };
663 match (b.get(key), a.get(key)) {
664 (Some(bv), Some(av)) => diff_into(&path, bv, av, out),
665 (None, Some(av)) => diff_into(&path, &empty, av, out),
668 (Some(_), None) => out.push((path, None)),
669 (None, None) => unreachable!("key came from one of the two tables"),
670 }
671 }
672}
673
674fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
675 let Ok(contents) = fs::read_to_string(path) else {
676 return Ok(toml_edit::DocumentMut::new());
677 };
678 contents
679 .parse::<toml_edit::DocumentMut>()
680 .map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
681}
682
683fn write_document(
686 path: &Path,
687 doc: &toml_edit::DocumentMut,
688 secret: bool,
689) -> Result<(), ConfigError> {
690 let contents = doc.to_string();
691 if contents.trim().is_empty() && !path.exists() {
692 return Ok(());
693 }
694 if let Some(parent) = path.parent() {
695 fs::create_dir_all(parent)?;
696 }
697 fs::write(path, contents)?;
698 #[cfg(unix)]
699 if secret {
700 use std::os::unix::fs::PermissionsExt;
701 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
702 }
703 #[cfg(not(unix))]
704 let _ = secret;
705 Ok(())
706}
707
708fn implicit_table() -> toml_edit::Item {
709 let mut table = toml_edit::Table::new();
710 table.set_implicit(true);
713 toml_edit::Item::Table(table)
714}
715
716fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
717 let segments: Vec<&str> = path.split('.').collect();
718 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
719
720 let mut table = doc.as_table_mut();
721 for segment in parents {
722 let item = table.entry(segment).or_insert_with(implicit_table);
723 if !item.is_table() {
726 *item = implicit_table();
727 }
728 table = item.as_table_mut().expect("just ensured it is a table");
729 }
730 match table.get_mut(last) {
733 Some(existing) => *existing = toml_edit::value(to_edit_value(value)),
734 None => {
735 table.insert(last, toml_edit::value(to_edit_value(value)));
736 }
737 }
738}
739
740fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
741 let segments: Vec<&str> = path.split('.').collect();
742 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
743
744 let mut table = doc.as_table_mut();
745 for segment in parents {
746 match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
747 Some(child) => table = child,
748 None => return,
749 }
750 }
751 table.remove(last);
754}
755
756fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
757 match value {
758 toml::Value::String(s) => s.as_str().into(),
759 toml::Value::Integer(i) => (*i).into(),
760 toml::Value::Float(f) => (*f).into(),
761 toml::Value::Boolean(b) => (*b).into(),
762 toml::Value::Datetime(d) => d.to_string().into(),
763 toml::Value::Array(items) => items
764 .iter()
765 .map(to_edit_value)
766 .collect::<toml_edit::Array>()
767 .into(),
768 toml::Value::Table(t) => {
769 let mut inline = toml_edit::InlineTable::new();
770 for (k, v) in t {
771 inline.insert(k, to_edit_value(v));
772 }
773 inline.into()
774 }
775 }
776}
777
778pub fn config_dir() -> PathBuf {
786 if let Some(dir) = CONFIG_DIR.read().clone() {
787 return dir;
788 }
789 if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
790 return PathBuf::from(dir);
791 }
792 dirs::home_dir()
793 .unwrap_or_else(|| PathBuf::from("."))
794 .join(".config")
795 .join("koan")
796}
797
798pub fn set_config_dir(dir: impl Into<PathBuf>) {
805 *CONFIG_DIR.write() = Some(dir.into());
806 Config::invalidate_cache();
807}
808
809pub fn isolate_config_for_tests() {
821 let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
822 let _ = fs::create_dir_all(&dir);
823 set_config_dir(dir);
824}
825
826static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
827 LazyLock::new(|| parking_lot::RwLock::new(None));
828
829pub fn config_file_path() -> PathBuf {
831 config_dir().join("config.toml")
832}
833
834pub fn config_local_file_path() -> PathBuf {
836 config_dir().join("config.local.toml")
837}
838
839pub fn db_path() -> PathBuf {
841 config_dir().join("koan.db")
842}
843
844pub fn open_log() -> Option<fs::File> {
849 let dir = config_dir();
850 fs::create_dir_all(&dir).ok()?;
851 fs::OpenOptions::new()
852 .create(true)
853 .append(true)
854 .open(dir.join("koan.log"))
855 .ok()
856}
857
858fn check_secrets_in_git() {
866 static ONCE: Once = Once::new();
867 ONCE.call_once(scan_for_tracked_secrets);
868}
869
870fn scan_for_tracked_secrets() {
871 let sensitive_fields = ["password", "refresh_token"];
872
873 for (label, path) in [
874 ("config.toml", config_file_path()),
875 ("config.local.toml", config_local_file_path()),
876 ] {
877 let Ok(contents) = std::fs::read_to_string(&path) else {
878 continue;
879 };
880
881 let has_secrets = sensitive_fields.iter().any(|field| {
883 contents.lines().any(|line| {
884 let line = line.trim();
885 if let Some(rest) = line.strip_prefix(field) {
886 let rest = rest.trim_start();
887 if let Some(value) = rest.strip_prefix('=') {
888 let value = value.trim().trim_matches('"').trim_matches('\'');
889 return !value.is_empty();
890 }
891 }
892 false
893 })
894 });
895
896 if !has_secrets {
897 continue;
898 }
899
900 if is_tracked_by_git(&path) {
902 eprintln!();
903 eprintln!("╔══════════════════════════════════════════════════════════════╗");
904 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
905 eprintln!("╠══════════════════════════════════════════════════════════════╣");
906 eprintln!("║ ║");
907 eprintln!("║ File: {:<52} ║", path.display());
908 eprintln!("║ ║");
909 eprintln!("║ Your password is in version control. You should: ║");
910 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
911 eprintln!("║ 2. Add it to .gitignore ║");
912 eprintln!("║ 3. Rotate your credentials immediately ║");
913 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
914 eprintln!("║ `koan remote login` writes there for you ║");
915 eprintln!("║ ║");
916 eprintln!("╚══════════════════════════════════════════════════════════════╝");
917 eprintln!();
918 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
919 }
920 }
921}
922
923fn is_tracked_by_git(path: &Path) -> bool {
925 let Some(parent) = path.parent() else {
926 return false;
927 };
928 std::process::Command::new("git")
930 .args(["ls-files", "--error-unmatch"])
931 .arg(path)
932 .current_dir(parent)
933 .stdout(std::process::Stdio::null())
934 .stderr(std::process::Stdio::null())
935 .status()
936 .is_ok_and(|s| s.success())
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942 use std::fs;
943
944 fn tmp_dir() -> PathBuf {
945 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
946 fs::create_dir_all(&dir).unwrap();
947 dir
948 }
949
950 #[test]
951 fn test_defaults() {
952 let cfg = Config::default();
953 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
954 assert!(!cfg.remote.enabled);
955 }
956
957 #[test]
958 fn test_roundtrip_toml() {
959 let cfg = Config::default();
960 let serialized = toml::to_string_pretty(&cfg).unwrap();
961 let deserialized: Config = toml::from_str(&serialized).unwrap();
962 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
963 assert_eq!(
964 deserialized.remote.download_workers,
965 cfg.remote.download_workers
966 );
967 }
968
969 #[test]
970 fn test_load_from_file() {
971 let dir = tempfile::tempdir().unwrap();
972 let path = dir.path().join("config.toml");
973 fs::write(
974 &path,
975 r#"
976[library]
977folders = ["/tmp/music"]
978
979[playback]
980replaygain = "track"
981"#,
982 )
983 .unwrap();
984
985 let cfg = Config::load_from(&path).unwrap();
986 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
987 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
988 assert!(!cfg.remote.enabled);
989 }
990
991 #[test]
992 fn test_partial_toml_uses_defaults() {
993 let dir = tempfile::tempdir().unwrap();
994 let path = dir.path().join("partial.toml");
995 fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
996
997 let cfg = Config::load_from(&path).unwrap();
998 assert_eq!(cfg.playback.target_fps, 30);
999 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1000 }
1001
1002 #[test]
1003 fn test_figment_layered_loading() {
1004 let dir = tempfile::tempdir().unwrap();
1005 let base_path = dir.path().join("config.toml");
1006 let local_path = dir.path().join("config.local.toml");
1007
1008 fs::write(
1009 &base_path,
1010 r#"
1011[remote]
1012url = "https://base.example.com"
1013"#,
1014 )
1015 .unwrap();
1016 fs::write(
1017 &local_path,
1018 r#"
1019[remote]
1020enabled = true
1021url = "https://local.example.com"
1022username = "admin"
1023password = "secret"
1024"#,
1025 )
1026 .unwrap();
1027
1028 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1030 .merge(Toml::file(&base_path))
1031 .merge(Toml::file(&local_path))
1032 .extract()
1033 .unwrap();
1034
1035 assert!(cfg.remote.enabled);
1036 assert_eq!(cfg.remote.url, "https://local.example.com");
1037 assert_eq!(cfg.remote.username, "admin");
1038 assert_eq!(cfg.remote.password, "secret");
1039 }
1040
1041 #[test]
1042 fn test_figment_missing_keys_preserved() {
1043 let dir = tempfile::tempdir().unwrap();
1044 let base_path = dir.path().join("config.toml");
1045 let local_path = dir.path().join("config.local.toml");
1046
1047 fs::write(
1048 &base_path,
1049 r#"
1050[remote]
1051url = "https://keep.me"
1052username = "keepuser"
1053"#,
1054 )
1055 .unwrap();
1056 fs::write(
1057 &local_path,
1058 r#"
1059[remote]
1060password = "secret"
1061"#,
1062 )
1063 .unwrap();
1064
1065 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1066 .merge(Toml::file(&base_path))
1067 .merge(Toml::file(&local_path))
1068 .extract()
1069 .unwrap();
1070
1071 assert_eq!(cfg.remote.url, "https://keep.me");
1072 assert_eq!(cfg.remote.username, "keepuser");
1073 assert_eq!(cfg.remote.password, "secret");
1074 }
1075
1076 #[test]
1077 fn test_env_var_override() {
1078 let dir = tempfile::tempdir().unwrap();
1079 let base_path = dir.path().join("config.toml");
1080
1081 fs::write(
1082 &base_path,
1083 r#"
1084[remote]
1085url = "https://file.example.com"
1086"#,
1087 )
1088 .unwrap();
1089
1090 unsafe {
1092 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
1093 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
1094 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
1095 }
1096
1097 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1098 .merge(Toml::file(&base_path))
1099 .merge(Env::prefixed("KOAN_").split("__"))
1100 .extract()
1101 .unwrap();
1102
1103 assert_eq!(cfg.remote.url, "https://env.example.com");
1104 assert_eq!(cfg.remote.password, "env-secret");
1105 assert_eq!(cfg.graphql.port, 9999);
1106
1107 unsafe {
1109 std::env::remove_var("KOAN_REMOTE__URL");
1110 std::env::remove_var("KOAN_REMOTE__PASSWORD");
1111 std::env::remove_var("KOAN_GRAPHQL__PORT");
1112 }
1113 }
1114
1115 #[test]
1116 fn test_cache_dir_default() {
1117 let cfg = Config::default();
1118 assert!(cfg.cache_dir().ends_with("cache"));
1119 }
1120
1121 #[test]
1122 fn test_cache_dir_explicit() {
1123 let mut cfg = Config::default();
1124 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
1125 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
1126 }
1127
1128 #[test]
1129 fn test_organize_config_defaults() {
1130 let cfg = Config::default();
1131 assert!(cfg.organize.default.is_none());
1132 assert!(cfg.organize.patterns.is_empty());
1133 }
1134
1135 #[test]
1136 fn test_organize_config_from_toml() {
1137 let dir = tmp_dir();
1138 let path = dir.join("organize.toml");
1139 fs::write(
1140 &path,
1141 r#"
1142[organize]
1143default = "standard"
1144
1145[organize.patterns]
1146standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
1147va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
1148"#,
1149 )
1150 .unwrap();
1151
1152 let cfg = Config::load_from(&path).unwrap();
1153 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
1154 assert_eq!(cfg.organize.patterns.len(), 2);
1155 assert!(cfg.organize.patterns.contains_key("standard"));
1156 assert!(cfg.organize.patterns.contains_key("va-aware"));
1157
1158 fs::remove_dir_all(&dir).ok();
1159 }
1160
1161 #[test]
1162 fn test_figment_organize_patterns_merge() {
1163 let dir = tempfile::tempdir().unwrap();
1164 let base_path = dir.path().join("config.toml");
1165 let local_path = dir.path().join("config.local.toml");
1166
1167 fs::write(
1168 &base_path,
1169 r#"
1170[organize]
1171default = "standard"
1172
1173[organize.patterns]
1174standard = "base-pattern"
1175"#,
1176 )
1177 .unwrap();
1178 fs::write(
1179 &local_path,
1180 r#"
1181[organize]
1182default = "custom"
1183
1184[organize.patterns]
1185custom = "local-pattern"
1186"#,
1187 )
1188 .unwrap();
1189
1190 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1191 .merge(Toml::file(&base_path))
1192 .merge(Toml::file(&local_path))
1193 .extract()
1194 .unwrap();
1195
1196 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
1198 assert_eq!(cfg.organize.patterns.len(), 2);
1200 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
1201 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
1202 }
1203
1204 #[test]
1205 fn test_output_device_config_roundtrip() {
1206 let mut cfg = Config::default();
1207 cfg.playback.output_device = Some("My DAC".into());
1208
1209 let serialized = toml::to_string_pretty(&cfg).unwrap();
1210 let deserialized: Config = toml::from_str(&serialized).unwrap();
1211 assert_eq!(
1212 deserialized.playback.output_device.as_deref(),
1213 Some("My DAC")
1214 );
1215 }
1216
1217 #[test]
1218 fn test_output_device_config_default_is_none() {
1219 let cfg = Config::default();
1220 assert!(cfg.playback.output_device.is_none());
1221
1222 let serialized = toml::to_string_pretty(&cfg).unwrap();
1224 assert!(!serialized.contains("output_device"));
1225 let deserialized: Config = toml::from_str(&serialized).unwrap();
1226 assert!(deserialized.playback.output_device.is_none());
1227 }
1228
1229 #[test]
1230 fn test_output_device_config_from_toml() {
1231 let dir = tempfile::tempdir().unwrap();
1232 let path = dir.path().join("config.toml");
1233 fs::write(
1234 &path,
1235 r#"
1236[playback]
1237output_device = "External Speakers"
1238"#,
1239 )
1240 .unwrap();
1241
1242 let cfg = Config::load_from(&path).unwrap();
1243 assert_eq!(
1244 cfg.playback.output_device.as_deref(),
1245 Some("External Speakers")
1246 );
1247 }
1248
1249 #[test]
1250 fn test_graphql_bind_defaults_to_localhost() {
1251 let cfg = GraphqlConfig::default();
1252 assert_eq!(
1253 cfg.bind,
1254 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1255 );
1256 }
1257
1258 #[test]
1259 fn test_graphql_bind_from_toml() {
1260 let toml_str = r#"
1261[graphql]
1262bind = "0.0.0.0"
1263port = 5000
1264"#;
1265 let cfg: Config = toml::from_str(toml_str).unwrap();
1266 assert_eq!(
1267 cfg.graphql.bind,
1268 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
1269 );
1270 assert_eq!(cfg.graphql.port, 5000);
1271 }
1272
1273 #[test]
1274 fn test_graphql_bind_omitted_defaults_to_localhost() {
1275 let toml_str = r#"
1276[graphql]
1277port = 4000
1278"#;
1279 let cfg: Config = toml::from_str(toml_str).unwrap();
1280 assert_eq!(
1281 cfg.graphql.bind,
1282 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1283 );
1284 }
1285
1286 #[test]
1287 fn test_organize_config_roundtrip() {
1288 let mut cfg = Config::default();
1289 cfg.organize.default = Some("standard".into());
1290 cfg.organize
1291 .patterns
1292 .insert("standard".into(), "%artist%/%title%".into());
1293
1294 let serialized = toml::to_string_pretty(&cfg).unwrap();
1295 let deserialized: Config = toml::from_str(&serialized).unwrap();
1296 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
1297 assert_eq!(
1298 deserialized.organize.patterns["standard"],
1299 "%artist%/%title%"
1300 );
1301 }
1302
1303 #[test]
1304 fn test_parse_size_bytes() {
1305 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1306 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1307 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1308 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1309 assert_eq!(parse_size_bytes("1024B"), Some(1024));
1310 assert_eq!(parse_size_bytes("1024"), Some(1024));
1311
1312 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1314 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1315
1316 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1318 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1319
1320 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1322 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1323
1324 assert_eq!(
1326 parse_size_bytes("1.5GB"),
1327 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1328 );
1329
1330 assert_eq!(parse_size_bytes(""), None);
1332 assert_eq!(parse_size_bytes("abc"), None);
1333 assert_eq!(parse_size_bytes("50XB"), None);
1334 }
1335
1336 #[test]
1337 fn test_cache_limit_config_from_toml() {
1338 let toml_str = r#"
1339[remote]
1340cache_limit = "50GB"
1341"#;
1342 let cfg: Config = toml::from_str(toml_str).unwrap();
1343 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1344 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1345 }
1346
1347 #[test]
1348 fn test_cache_limit_none_by_default() {
1349 let cfg = Config::default();
1350 assert!(cfg.remote.cache_limit.is_none());
1351 assert!(cfg.cache_limit_bytes().is_none());
1352 }
1353
1354 #[test]
1355 fn test_cache_limit_not_serialized_when_none() {
1356 let cfg = Config::default();
1357 let serialized = toml::to_string_pretty(&cfg).unwrap();
1358 assert!(!serialized.contains("cache_limit"));
1359 }
1360
1361 #[test]
1362 fn player_uses_config_on_init() {
1363 let dir = tempfile::tempdir().unwrap();
1367 let path = dir.path().join("config.toml");
1368 fs::write(
1369 &path,
1370 r#"
1371[playback]
1372replaygain = "track"
1373output_device = "My Fancy DAC"
1374pre_amp_db = -3.5
1375target_fps = 30
1376art_size = 32
1377
1378[visualizer]
1379enabled = false
1380mode = "oscilloscope"
1381fps = 30
1382"#,
1383 )
1384 .unwrap();
1385
1386 let cfg = Config::load_from(&path).unwrap();
1387
1388 assert_eq!(
1390 cfg.playback.replaygain,
1391 ReplayGainMode::Track,
1392 "replaygain should be 'track'"
1393 );
1394 assert_eq!(
1395 cfg.playback.output_device.as_deref(),
1396 Some("My Fancy DAC"),
1397 "output_device should match config"
1398 );
1399 assert!(
1400 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1401 "pre_amp_db should be -3.5"
1402 );
1403 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1404 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1405
1406 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1408 assert_eq!(cfg.visualizer.mode, "oscilloscope");
1409 assert_eq!(cfg.visualizer.fps, 30);
1410 }
1411
1412 #[test]
1413 fn a_missing_config_file_stamps_as_absent() {
1414 let dir = tempfile::tempdir().unwrap();
1415 let base = dir.path().join("config.toml");
1416 let local = dir.path().join("config.local.toml");
1417
1418 assert_eq!(stamp_of(&base, &local), (None, None));
1419
1420 fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
1421 let (base_stamp, local_stamp) = stamp_of(&base, &local);
1422 assert!(base_stamp.is_some(), "creating the file must be a change");
1423 assert!(local_stamp.is_none());
1424 }
1425
1426 #[test]
1427 fn editing_a_config_file_changes_its_stamp() {
1428 let dir = tempfile::tempdir().unwrap();
1429 let base = dir.path().join("config.toml");
1430 let local = dir.path().join("config.local.toml");
1431 fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
1432
1433 let before = stamp_of(&base, &local);
1434 std::thread::sleep(std::time::Duration::from_millis(20));
1436 fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
1437
1438 assert_ne!(
1439 before,
1440 stamp_of(&base, &local),
1441 "a config edited by hand has to be picked up"
1442 );
1443 }
1444
1445 #[test]
1446 fn invalidating_forces_a_reload() {
1447 let first = Config::cached();
1448 Config::invalidate_cache();
1449 assert!(
1450 !Arc::ptr_eq(&first, &Config::cached()),
1451 "koan's own writes invalidate explicitly; the next read must re-parse"
1452 );
1453 }
1454
1455 static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1460
1461 fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
1463 let dir =
1464 std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
1465 let _ = fs::remove_dir_all(&dir);
1466 fs::create_dir_all(&dir).unwrap();
1467 set_config_dir(&dir);
1468 (config_file_path(), config_local_file_path())
1469 }
1470
1471 #[test]
1472 fn persist_keeps_comments_and_untouched_keys() {
1473 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1474 let (base, _local) = persist_sandbox("comments");
1475 fs::write(
1476 &base,
1477 "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
1478 )
1479 .unwrap();
1480
1481 Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
1482
1483 let written = fs::read_to_string(&base).unwrap();
1484 assert!(
1485 written.contains("# koan — shareable defaults"),
1486 "the header comment must survive a write: {written}"
1487 );
1488 assert!(
1489 written.contains("# fps = 60"),
1490 "commented-out defaults are the template's whole point: {written}"
1491 );
1492 assert!(written.contains("palette = \"neon\""));
1493 assert!(
1494 !written.contains("[graphql]"),
1495 "an untouched section must not be invented: {written}"
1496 );
1497 }
1498
1499 #[test]
1500 fn persist_routes_machine_settings_to_the_local_file() {
1501 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1502 let (base, local) = persist_sandbox("routing");
1503
1504 Config::persist(|cfg| {
1505 cfg.playback.replaygain = ReplayGainMode::Album;
1506 cfg.playback.output_device = Some("My DAC".into());
1507 cfg.playback.art_size = 40;
1508 cfg.visualizer.mode = "starfield".into();
1509 })
1510 .unwrap();
1511
1512 let shared = fs::read_to_string(&base).unwrap();
1513 let machine = fs::read_to_string(&local).unwrap();
1514
1515 assert!(shared.contains("replaygain = \"album\""), "{shared}");
1516 for machine_only in ["output_device", "art_size", "starfield"] {
1517 assert!(
1518 !shared.contains(machine_only),
1519 "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
1520 );
1521 assert!(machine.contains(machine_only), "{machine}");
1522 }
1523 }
1524
1525 #[test]
1526 fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
1527 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1528 let (base, _local) = persist_sandbox("folders");
1529
1530 Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
1533
1534 let shared = fs::read_to_string(&base).unwrap_or_default();
1535 assert!(
1536 !shared.contains("folders"),
1537 "a visualiser toggle must not invent library folders: {shared}"
1538 );
1539 }
1540
1541 #[test]
1542 fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
1543 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1544 let (base, local) = persist_sandbox("drain");
1545 fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
1546
1547 Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
1548
1549 let shared = fs::read_to_string(&base).unwrap();
1550 assert!(
1551 !shared.contains("art_size"),
1552 "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
1553 );
1554 assert!(shared.contains("target_fps"), "{shared}");
1555 assert!(
1556 fs::read_to_string(&local)
1557 .unwrap()
1558 .contains("art_size = 48")
1559 );
1560 }
1561
1562 #[test]
1563 fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
1564 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1565 let (_base, local) = persist_sandbox("shadow");
1566 fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
1567
1568 Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
1569
1570 assert_eq!(
1571 Config::from_files().unwrap().playback.target_fps,
1572 120,
1573 "local wins the merge, so a shared write over a local copy would \
1574 otherwise be silently ignored: {}",
1575 fs::read_to_string(&local).unwrap()
1576 );
1577 }
1578
1579 #[test]
1580 fn persist_writes_nothing_when_the_mutation_changes_nothing() {
1581 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1582 let (base, _local) = persist_sandbox("noop");
1583 fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
1584
1585 Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
1586
1587 assert_eq!(
1588 fs::read_to_string(&base).unwrap(),
1589 "# untouched\n[playback]\ntarget_fps = 60\n"
1590 );
1591 }
1592
1593 #[test]
1594 fn persist_keeps_passwords_out_of_the_shared_file() {
1595 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1596 let (base, local) = persist_sandbox("secrets");
1597
1598 Config::persist(|cfg| {
1599 cfg.remote.password = "hunter2".into();
1600 cfg.subsonic.password = "s3cret".into();
1601 cfg.visualizer.palette = "mono".into();
1602 })
1603 .unwrap();
1604
1605 let shared = fs::read_to_string(&base).unwrap();
1606 assert!(!shared.contains("hunter2"), "{shared}");
1607 assert!(!shared.contains("s3cret"), "{shared}");
1608 assert!(shared.contains("mono"));
1609
1610 let machine = fs::read_to_string(&local).unwrap();
1611 assert!(machine.contains("hunter2") && machine.contains("s3cret"));
1612 }
1613
1614 #[test]
1615 fn persist_removes_a_cleared_password_rather_than_blanking_it() {
1616 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1617 let (_base, local) = persist_sandbox("clear-secret");
1618 fs::write(
1619 &local,
1620 "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
1621 )
1622 .unwrap();
1623
1624 Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
1625
1626 let machine = fs::read_to_string(&local).unwrap();
1627 assert!(
1628 !machine.contains("password"),
1629 "an emptied secret should leave no key behind: {machine}"
1630 );
1631 assert!(machine.contains("url"), "{machine}");
1632 }
1633
1634 #[test]
1635 fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
1636 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1637 let (base, _local) = persist_sandbox("patterns");
1638 fs::write(
1639 &base,
1640 "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
1641 )
1642 .unwrap();
1643
1644 Config::persist(|cfg| {
1645 cfg.organize
1646 .patterns
1647 .insert("standard".into(), "%album artist%/%album%".into());
1648 })
1649 .unwrap();
1650
1651 let cfg = Config::from_files().unwrap();
1652 assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
1653 assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
1654 }
1655}