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 pub sharing: SharingConfig,
38}
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42#[serde(default)]
43pub struct SharingConfig {
44 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub public_url: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(default)]
53pub struct LibraryConfig {
54 pub folders: Vec<PathBuf>,
55 pub analyze_on_scan: bool,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(default)]
62pub struct PlaybackConfig {
63 pub replaygain: ReplayGainMode,
64 pub target_fps: u8,
67 pub show_fps: bool,
69 pub pre_amp_db: f64,
72 pub fade_on_pause: bool,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub output_device: Option<String>,
78 pub art_size: u16,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum ReplayGainMode {
86 Off,
87 Track,
88 Album,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(default)]
93pub struct RemoteConfig {
94 pub enabled: bool,
95 pub url: String,
96 pub username: String,
97 #[serde(default, skip_serializing_if = "String::is_empty")]
99 pub password: String,
100 pub cache_dir: Option<PathBuf>,
102 pub download_workers: usize,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub cache_limit: Option<String>,
108 pub auto_sync: bool,
114 pub auto_sync_interval_mins: u64,
116}
117
118impl Default for LibraryConfig {
119 fn default() -> Self {
120 let music_dir = dirs::audio_dir().unwrap_or_else(|| {
121 dirs::home_dir()
122 .map(|h| h.join("Music"))
123 .unwrap_or_else(|| PathBuf::from("/Music"))
124 });
125 Self {
126 folders: vec![music_dir],
127 analyze_on_scan: false,
128 }
129 }
130}
131
132impl Default for PlaybackConfig {
133 fn default() -> Self {
134 Self {
135 replaygain: ReplayGainMode::Off,
136 target_fps: 60,
137 show_fps: false,
138 pre_amp_db: 0.0,
139 fade_on_pause: true,
140 output_device: None,
141 art_size: 24,
142 }
143 }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(default)]
148pub struct VisualizerConfig {
149 pub enabled: bool,
150 pub fps: u8,
151 pub mode: String,
153 pub scale: String,
155 pub amplitude_scale: String,
157 pub bar_decay_ms: u32,
159 pub peak_decay_ms: u32,
161 pub palette: String,
164 pub reactivity: f32,
168 pub bass_shake: bool,
171 pub matrix_overlay: bool,
174 pub reactive_bg: bool,
176}
177
178impl Default for VisualizerConfig {
179 fn default() -> Self {
180 Self {
181 enabled: true,
182 fps: 60,
183 mode: "bars".into(),
184 scale: "bark".into(),
185 amplitude_scale: "aweight".into(),
186 bar_decay_ms: 50,
187 peak_decay_ms: 180,
188 palette: "spectrum".into(),
189 reactivity: 1.0,
190 bass_shake: true,
191 matrix_overlay: false,
192 reactive_bg: false,
193 }
194 }
195}
196
197impl Default for RemoteConfig {
198 fn default() -> Self {
199 Self {
200 enabled: false,
201 url: String::new(),
202 username: String::new(),
203 password: String::new(),
204 cache_dir: None,
205 download_workers: 5,
206 cache_limit: None,
207 auto_sync: true,
208 auto_sync_interval_mins: 60,
209 }
210 }
211}
212
213pub fn parse_size_bytes(s: &str) -> Option<u64> {
216 let s = s.trim();
217 if s.is_empty() {
218 return None;
219 }
220
221 let mut num_end = 0;
223 for (i, c) in s.char_indices() {
224 if c.is_ascii_digit() || c == '.' {
225 num_end = i + c.len_utf8();
226 } else if !c.is_whitespace() {
227 break;
228 }
229 }
230
231 let num_str = s[..num_end].trim();
232 let suffix = s[num_end..].trim().to_ascii_uppercase();
233
234 let value: f64 = num_str.parse().ok()?;
235 let multiplier: u64 = match suffix.as_str() {
236 "" | "B" => 1,
237 "KB" | "K" => 1024,
238 "MB" | "M" => 1024 * 1024,
239 "GB" | "G" => 1024 * 1024 * 1024,
240 "TB" | "T" => 1024 * 1024 * 1024 * 1024,
241 _ => return None,
242 };
243
244 Some((value * multiplier as f64) as u64)
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[serde(default)]
249pub struct OrganizeConfig {
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub default: Option<String>,
253 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
255 pub patterns: HashMap<String, String>,
256 #[serde(default = "default_true")]
260 pub move_ancillary: bool,
261}
262
263impl Default for OrganizeConfig {
264 fn default() -> Self {
265 Self {
266 default: None,
267 patterns: HashMap::new(),
268 move_ancillary: true,
269 }
270 }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(default)]
276pub struct GraphqlConfig {
277 pub enabled: bool,
280 pub port: u16,
282 #[serde(default = "default_bind")]
285 pub bind: std::net::IpAddr,
286 pub playground: bool,
288 pub auth_enabled: bool,
291 pub access_token_ttl: String,
293 pub refresh_token_ttl: String,
295 pub cors_origins: Vec<String>,
298 pub allowed_hosts: Vec<String>,
302 pub cookie_secure: bool,
306 pub allow_organize: bool,
308}
309
310fn default_true() -> bool {
311 true
312}
313
314fn default_bind() -> std::net::IpAddr {
315 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
316}
317
318impl Default for GraphqlConfig {
319 fn default() -> Self {
320 Self {
321 enabled: true,
322 port: 4000,
323 bind: default_bind(),
324 playground: false,
325 auth_enabled: true,
326 access_token_ttl: "15m".into(),
327 refresh_token_ttl: "30d".into(),
328 cors_origins: Vec::new(),
329 allowed_hosts: Vec::new(),
330 cookie_secure: false,
331 allow_organize: false,
332 }
333 }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
344#[serde(default)]
345pub struct SubsonicConfig {
346 pub enabled: bool,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub port: Option<u16>,
354 pub username: String,
356 #[serde(default, skip_serializing_if = "String::is_empty")]
359 pub password: String,
360}
361
362impl Default for SubsonicConfig {
363 fn default() -> Self {
364 Self {
365 enabled: false,
366 port: None,
367 username: "koan".into(),
368 password: String::new(),
369 }
370 }
371}
372
373#[derive(Debug, Clone, Default, Serialize, Deserialize)]
378#[serde(default)]
379pub struct AuthConfig {
380 #[serde(skip_serializing_if = "String::is_empty")]
382 pub server: String,
383 #[serde(skip_serializing_if = "String::is_empty")]
386 pub refresh_token: String,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391#[serde(default)]
392pub struct RadioConfig {
393 pub lookahead: usize,
395 pub batch_size: usize,
397 pub history_window: usize,
399 pub seed_window: usize,
401 pub discovery_weight: f64,
404}
405
406impl Default for RadioConfig {
407 fn default() -> Self {
408 Self {
409 lookahead: 5,
410 batch_size: 5,
411 history_window: 200,
412 seed_window: 5,
413 discovery_weight: 0.3,
414 }
415 }
416}
417
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum Layer {
421 Shared,
423 Machine,
425}
426
427pub fn layer_of(path: &str) -> Layer {
437 match path {
438 "remote.password"
440 | "subsonic.password"
441 | "auth.refresh_token"
442 | "library.folders"
444 | "remote.enabled"
445 | "remote.url"
446 | "remote.username"
447 | "remote.cache_dir"
448 | "remote.cache_limit"
449 | "playback.output_device"
451 | "subsonic.enabled"
454 | "subsonic.port"
455 | "subsonic.username"
456 | "auth.server"
458 | "playback.art_size"
460 | "visualizer.enabled"
461 | "visualizer.mode"
462 | "visualizer.matrix_overlay"
463 | "visualizer.bass_shake" => Layer::Machine,
464 _ => Layer::Shared,
465 }
466}
467
468type ConfigStamp = (Option<SystemTime>, Option<SystemTime>);
472
473type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
474
475static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
476 LazyLock::new(|| parking_lot::RwLock::new(None));
477
478fn config_stamp() -> ConfigStamp {
479 stamp_of(&config_file_path(), &config_local_file_path())
480}
481
482fn stamp_of(base: &Path, local: &Path) -> ConfigStamp {
484 let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
485 (mtime(base), mtime(local))
486}
487
488impl Config {
489 fn figment() -> Figment {
495 let base_path = config_file_path();
496 let local_path = config_local_file_path();
497
498 Figment::from(Serialized::defaults(Config::default()))
499 .merge(Toml::file(&base_path))
500 .merge(Toml::file(&local_path))
501 .merge(Env::prefixed("KOAN_").split("__"))
502 }
503
504 pub fn load() -> Result<Self, ConfigError> {
506 let cfg: Self = Self::figment()
507 .extract()
508 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
509
510 check_secrets_in_git();
513
514 Ok(cfg)
515 }
516
517 pub fn load_or_default() -> Self {
522 (*Self::cached()).clone()
523 }
524
525 pub fn cached() -> Arc<Config> {
532 let stamp = config_stamp();
533 if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
534 && *seen == stamp
535 {
536 return cfg.clone();
537 }
538
539 let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
540 log::warn!("failed to load config, using defaults: {}", e);
541 Self::default()
542 }));
543 *CONFIG_CACHE.write() = Some((stamp, cfg.clone()));
544 cfg
545 }
546
547 pub fn invalidate_cache() {
551 *CONFIG_CACHE.write() = None;
552 }
553
554 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
556 let contents = fs::read_to_string(path)?;
557 let config: Config = toml::from_str(&contents)?;
558 Ok(config)
559 }
560
561 fn from_files() -> Result<Self, ConfigError> {
567 Figment::from(Serialized::defaults(Config::default()))
568 .merge(Toml::file(config_file_path()))
569 .merge(Toml::file(config_local_file_path()))
570 .extract()
571 .map_err(|e| ConfigError::Figment(Box::new(e)))
572 }
573
574 pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
586 where
587 F: FnOnce(&mut Config),
588 {
589 let before = Self::from_files()?;
590 let mut after = before.clone();
591 mutate(&mut after);
592
593 let mut changes = Vec::new();
594 diff_into(
595 "",
596 &toml::Value::try_from(&before)?,
597 &toml::Value::try_from(&after)?,
598 &mut changes,
599 );
600 if changes.is_empty() {
601 return Ok(());
602 }
603
604 let base_path = config_file_path();
605 let local_path = config_local_file_path();
606 let mut base = read_document(&base_path)?;
607 let mut local = read_document(&local_path)?;
608
609 for (path, value) in &changes {
610 let (target, other) = match layer_of(path) {
611 Layer::Shared => (&mut base, &mut local),
612 Layer::Machine => (&mut local, &mut base),
613 };
614 match value {
615 Some(v) => doc_set(target, path, v),
616 None => doc_remove(target, path),
617 }
618 doc_remove(other, path);
619 }
620
621 write_document(&base_path, &base, false)?;
622 write_document(&local_path, &local, true)?;
623 Self::invalidate_cache();
624 Ok(())
625 }
626
627 pub fn cache_dir(&self) -> PathBuf {
629 self.remote
630 .cache_dir
631 .clone()
632 .unwrap_or_else(default_cache_dir)
633 }
634
635 pub fn cache_limit_bytes(&self) -> Option<u64> {
637 self.remote
638 .cache_limit
639 .as_deref()
640 .and_then(parse_size_bytes)
641 }
642}
643
644fn diff_into(
649 prefix: &str,
650 before: &toml::Value,
651 after: &toml::Value,
652 out: &mut Vec<(String, Option<toml::Value>)>,
653) {
654 let (b, a) = match (before.as_table(), after.as_table()) {
655 (Some(b), Some(a)) => (b, a),
656 _ => {
657 if before != after {
658 out.push((prefix.to_string(), Some(after.clone())));
659 }
660 return;
661 }
662 };
663
664 let empty = toml::Value::Table(toml::map::Map::new());
665 for key in b
666 .keys()
667 .chain(a.keys())
668 .collect::<std::collections::BTreeSet<_>>()
669 {
670 let path = if prefix.is_empty() {
671 key.clone()
672 } else {
673 format!("{prefix}.{key}")
674 };
675 match (b.get(key), a.get(key)) {
676 (Some(bv), Some(av)) => diff_into(&path, bv, av, out),
677 (None, Some(av)) => diff_into(&path, &empty, av, out),
680 (Some(_), None) => out.push((path, None)),
681 (None, None) => unreachable!("key came from one of the two tables"),
682 }
683 }
684}
685
686fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
687 let Ok(contents) = fs::read_to_string(path) else {
688 return Ok(toml_edit::DocumentMut::new());
689 };
690 contents
691 .parse::<toml_edit::DocumentMut>()
692 .map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
693}
694
695fn write_document(
698 path: &Path,
699 doc: &toml_edit::DocumentMut,
700 secret: bool,
701) -> Result<(), ConfigError> {
702 let contents = doc.to_string();
703 if contents.trim().is_empty() && !path.exists() {
704 return Ok(());
705 }
706 if let Some(parent) = path.parent() {
707 fs::create_dir_all(parent)?;
708 }
709 fs::write(path, contents)?;
710 #[cfg(unix)]
711 if secret {
712 use std::os::unix::fs::PermissionsExt;
713 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
714 }
715 #[cfg(not(unix))]
716 let _ = secret;
717 Ok(())
718}
719
720fn implicit_table() -> toml_edit::Item {
721 let mut table = toml_edit::Table::new();
722 table.set_implicit(true);
725 toml_edit::Item::Table(table)
726}
727
728fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
729 let segments: Vec<&str> = path.split('.').collect();
730 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
731
732 let mut table = doc.as_table_mut();
733 for segment in parents {
734 let item = table.entry(segment).or_insert_with(implicit_table);
735 if !item.is_table() {
738 *item = implicit_table();
739 }
740 table = item.as_table_mut().expect("just ensured it is a table");
741 }
742 match table.get_mut(last) {
745 Some(existing) => *existing = toml_edit::value(to_edit_value(value)),
746 None => {
747 table.insert(last, toml_edit::value(to_edit_value(value)));
748 }
749 }
750}
751
752fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
753 let segments: Vec<&str> = path.split('.').collect();
754 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
755
756 let mut table = doc.as_table_mut();
757 for segment in parents {
758 match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
759 Some(child) => table = child,
760 None => return,
761 }
762 }
763 table.remove(last);
766}
767
768fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
769 match value {
770 toml::Value::String(s) => s.as_str().into(),
771 toml::Value::Integer(i) => (*i).into(),
772 toml::Value::Float(f) => (*f).into(),
773 toml::Value::Boolean(b) => (*b).into(),
774 toml::Value::Datetime(d) => d.to_string().into(),
775 toml::Value::Array(items) => items
776 .iter()
777 .map(to_edit_value)
778 .collect::<toml_edit::Array>()
779 .into(),
780 toml::Value::Table(t) => {
781 let mut inline = toml_edit::InlineTable::new();
782 for (k, v) in t {
783 inline.insert(k, to_edit_value(v));
784 }
785 inline.into()
786 }
787 }
788}
789
790pub fn config_dir() -> PathBuf {
799 if let Some(dir) = CONFIG_DIR.read().clone() {
800 return dir;
801 }
802 if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
803 return PathBuf::from(dir);
804 }
805 platform_config_dir()
806}
807
808#[cfg(not(target_os = "ios"))]
809fn platform_config_dir() -> PathBuf {
810 dirs::home_dir()
811 .unwrap_or_else(|| PathBuf::from("."))
812 .join(".config")
813 .join("koan")
814}
815
816#[cfg(target_os = "ios")]
820fn platform_config_dir() -> PathBuf {
821 ios_library().join("Application Support").join("koan")
822}
823
824#[cfg(target_os = "ios")]
825fn ios_library() -> PathBuf {
826 dirs::home_dir()
827 .unwrap_or_else(|| PathBuf::from("."))
828 .join("Library")
829}
830
831fn default_cache_dir() -> PathBuf {
838 #[cfg(target_os = "ios")]
839 if CONFIG_DIR.read().is_none() && std::env::var_os("KOAN_CONFIG_DIR").is_none() {
840 return ios_library().join("Caches").join("koan");
841 }
842 config_dir().join("cache")
843}
844
845pub fn set_config_dir(dir: impl Into<PathBuf>) {
852 *CONFIG_DIR.write() = Some(dir.into());
853 Config::invalidate_cache();
854}
855
856pub fn isolate_config_for_tests() {
868 let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
869 let _ = fs::create_dir_all(&dir);
870 set_config_dir(dir);
871}
872
873static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
874 LazyLock::new(|| parking_lot::RwLock::new(None));
875
876pub fn config_file_path() -> PathBuf {
878 config_dir().join("config.toml")
879}
880
881pub fn config_local_file_path() -> PathBuf {
883 config_dir().join("config.local.toml")
884}
885
886pub fn db_path() -> PathBuf {
888 config_dir().join("koan.db")
889}
890
891pub fn open_log() -> Option<fs::File> {
896 let dir = config_dir();
897 fs::create_dir_all(&dir).ok()?;
898 fs::OpenOptions::new()
899 .create(true)
900 .append(true)
901 .open(dir.join("koan.log"))
902 .ok()
903}
904
905fn check_secrets_in_git() {
913 static ONCE: Once = Once::new();
914 ONCE.call_once(scan_for_tracked_secrets);
915}
916
917fn scan_for_tracked_secrets() {
918 let sensitive_fields = ["password", "refresh_token"];
919
920 for (label, path) in [
921 ("config.toml", config_file_path()),
922 ("config.local.toml", config_local_file_path()),
923 ] {
924 let Ok(contents) = std::fs::read_to_string(&path) else {
925 continue;
926 };
927
928 let has_secrets = sensitive_fields.iter().any(|field| {
930 contents.lines().any(|line| {
931 let line = line.trim();
932 if let Some(rest) = line.strip_prefix(field) {
933 let rest = rest.trim_start();
934 if let Some(value) = rest.strip_prefix('=') {
935 let value = value.trim().trim_matches('"').trim_matches('\'');
936 return !value.is_empty();
937 }
938 }
939 false
940 })
941 });
942
943 if !has_secrets {
944 continue;
945 }
946
947 if is_tracked_by_git(&path) {
949 eprintln!();
950 eprintln!("╔══════════════════════════════════════════════════════════════╗");
951 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
952 eprintln!("╠══════════════════════════════════════════════════════════════╣");
953 eprintln!("║ ║");
954 eprintln!("║ File: {:<52} ║", path.display());
955 eprintln!("║ ║");
956 eprintln!("║ Your password is in version control. You should: ║");
957 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
958 eprintln!("║ 2. Add it to .gitignore ║");
959 eprintln!("║ 3. Rotate your credentials immediately ║");
960 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
961 eprintln!("║ `koan remote login` writes there for you ║");
962 eprintln!("║ ║");
963 eprintln!("╚══════════════════════════════════════════════════════════════╝");
964 eprintln!();
965 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
966 }
967 }
968}
969
970fn is_tracked_by_git(path: &Path) -> bool {
972 let Some(parent) = path.parent() else {
973 return false;
974 };
975 std::process::Command::new("git")
977 .args(["ls-files", "--error-unmatch"])
978 .arg(path)
979 .current_dir(parent)
980 .stdout(std::process::Stdio::null())
981 .stderr(std::process::Stdio::null())
982 .status()
983 .is_ok_and(|s| s.success())
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989 use std::fs;
990
991 fn tmp_dir() -> PathBuf {
992 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
993 fs::create_dir_all(&dir).unwrap();
994 dir
995 }
996
997 #[test]
998 fn test_defaults() {
999 let cfg = Config::default();
1000 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1001 assert!(!cfg.remote.enabled);
1002 }
1003
1004 #[test]
1005 fn test_roundtrip_toml() {
1006 let cfg = Config::default();
1007 let serialized = toml::to_string_pretty(&cfg).unwrap();
1008 let deserialized: Config = toml::from_str(&serialized).unwrap();
1009 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
1010 assert_eq!(
1011 deserialized.remote.download_workers,
1012 cfg.remote.download_workers
1013 );
1014 }
1015
1016 #[test]
1017 fn test_load_from_file() {
1018 let dir = tempfile::tempdir().unwrap();
1019 let path = dir.path().join("config.toml");
1020 fs::write(
1021 &path,
1022 r#"
1023[library]
1024folders = ["/tmp/music"]
1025
1026[playback]
1027replaygain = "track"
1028"#,
1029 )
1030 .unwrap();
1031
1032 let cfg = Config::load_from(&path).unwrap();
1033 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
1034 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
1035 assert!(!cfg.remote.enabled);
1036 }
1037
1038 #[test]
1039 fn test_partial_toml_uses_defaults() {
1040 let dir = tempfile::tempdir().unwrap();
1041 let path = dir.path().join("partial.toml");
1042 fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
1043
1044 let cfg = Config::load_from(&path).unwrap();
1045 assert_eq!(cfg.playback.target_fps, 30);
1046 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1047 }
1048
1049 #[test]
1050 fn test_figment_layered_loading() {
1051 let dir = tempfile::tempdir().unwrap();
1052 let base_path = dir.path().join("config.toml");
1053 let local_path = dir.path().join("config.local.toml");
1054
1055 fs::write(
1056 &base_path,
1057 r#"
1058[remote]
1059url = "https://base.example.com"
1060"#,
1061 )
1062 .unwrap();
1063 fs::write(
1064 &local_path,
1065 r#"
1066[remote]
1067enabled = true
1068url = "https://local.example.com"
1069username = "admin"
1070password = "secret"
1071"#,
1072 )
1073 .unwrap();
1074
1075 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1077 .merge(Toml::file(&base_path))
1078 .merge(Toml::file(&local_path))
1079 .extract()
1080 .unwrap();
1081
1082 assert!(cfg.remote.enabled);
1083 assert_eq!(cfg.remote.url, "https://local.example.com");
1084 assert_eq!(cfg.remote.username, "admin");
1085 assert_eq!(cfg.remote.password, "secret");
1086 }
1087
1088 #[test]
1089 fn test_figment_missing_keys_preserved() {
1090 let dir = tempfile::tempdir().unwrap();
1091 let base_path = dir.path().join("config.toml");
1092 let local_path = dir.path().join("config.local.toml");
1093
1094 fs::write(
1095 &base_path,
1096 r#"
1097[remote]
1098url = "https://keep.me"
1099username = "keepuser"
1100"#,
1101 )
1102 .unwrap();
1103 fs::write(
1104 &local_path,
1105 r#"
1106[remote]
1107password = "secret"
1108"#,
1109 )
1110 .unwrap();
1111
1112 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1113 .merge(Toml::file(&base_path))
1114 .merge(Toml::file(&local_path))
1115 .extract()
1116 .unwrap();
1117
1118 assert_eq!(cfg.remote.url, "https://keep.me");
1119 assert_eq!(cfg.remote.username, "keepuser");
1120 assert_eq!(cfg.remote.password, "secret");
1121 }
1122
1123 #[test]
1124 fn test_env_var_override() {
1125 let dir = tempfile::tempdir().unwrap();
1126 let base_path = dir.path().join("config.toml");
1127
1128 fs::write(
1129 &base_path,
1130 r#"
1131[remote]
1132url = "https://file.example.com"
1133"#,
1134 )
1135 .unwrap();
1136
1137 unsafe {
1139 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
1140 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
1141 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
1142 }
1143
1144 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1145 .merge(Toml::file(&base_path))
1146 .merge(Env::prefixed("KOAN_").split("__"))
1147 .extract()
1148 .unwrap();
1149
1150 assert_eq!(cfg.remote.url, "https://env.example.com");
1151 assert_eq!(cfg.remote.password, "env-secret");
1152 assert_eq!(cfg.graphql.port, 9999);
1153
1154 unsafe {
1156 std::env::remove_var("KOAN_REMOTE__URL");
1157 std::env::remove_var("KOAN_REMOTE__PASSWORD");
1158 std::env::remove_var("KOAN_GRAPHQL__PORT");
1159 }
1160 }
1161
1162 #[test]
1163 fn test_cache_dir_default() {
1164 let cfg = Config::default();
1165 assert!(cfg.cache_dir().ends_with("cache"));
1166 }
1167
1168 #[test]
1169 fn test_cache_dir_explicit() {
1170 let mut cfg = Config::default();
1171 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
1172 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
1173 }
1174
1175 #[test]
1176 fn test_organize_config_defaults() {
1177 let cfg = Config::default();
1178 assert!(cfg.organize.default.is_none());
1179 assert!(cfg.organize.patterns.is_empty());
1180 }
1181
1182 #[test]
1183 fn test_organize_config_from_toml() {
1184 let dir = tmp_dir();
1185 let path = dir.join("organize.toml");
1186 fs::write(
1187 &path,
1188 r#"
1189[organize]
1190default = "standard"
1191
1192[organize.patterns]
1193standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
1194va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
1195"#,
1196 )
1197 .unwrap();
1198
1199 let cfg = Config::load_from(&path).unwrap();
1200 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
1201 assert_eq!(cfg.organize.patterns.len(), 2);
1202 assert!(cfg.organize.patterns.contains_key("standard"));
1203 assert!(cfg.organize.patterns.contains_key("va-aware"));
1204
1205 fs::remove_dir_all(&dir).ok();
1206 }
1207
1208 #[test]
1209 fn test_figment_organize_patterns_merge() {
1210 let dir = tempfile::tempdir().unwrap();
1211 let base_path = dir.path().join("config.toml");
1212 let local_path = dir.path().join("config.local.toml");
1213
1214 fs::write(
1215 &base_path,
1216 r#"
1217[organize]
1218default = "standard"
1219
1220[organize.patterns]
1221standard = "base-pattern"
1222"#,
1223 )
1224 .unwrap();
1225 fs::write(
1226 &local_path,
1227 r#"
1228[organize]
1229default = "custom"
1230
1231[organize.patterns]
1232custom = "local-pattern"
1233"#,
1234 )
1235 .unwrap();
1236
1237 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1238 .merge(Toml::file(&base_path))
1239 .merge(Toml::file(&local_path))
1240 .extract()
1241 .unwrap();
1242
1243 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
1245 assert_eq!(cfg.organize.patterns.len(), 2);
1247 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
1248 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
1249 }
1250
1251 #[test]
1252 fn test_output_device_config_roundtrip() {
1253 let mut cfg = Config::default();
1254 cfg.playback.output_device = Some("My DAC".into());
1255
1256 let serialized = toml::to_string_pretty(&cfg).unwrap();
1257 let deserialized: Config = toml::from_str(&serialized).unwrap();
1258 assert_eq!(
1259 deserialized.playback.output_device.as_deref(),
1260 Some("My DAC")
1261 );
1262 }
1263
1264 #[test]
1265 fn test_output_device_config_default_is_none() {
1266 let cfg = Config::default();
1267 assert!(cfg.playback.output_device.is_none());
1268
1269 let serialized = toml::to_string_pretty(&cfg).unwrap();
1271 assert!(!serialized.contains("output_device"));
1272 let deserialized: Config = toml::from_str(&serialized).unwrap();
1273 assert!(deserialized.playback.output_device.is_none());
1274 }
1275
1276 #[test]
1277 fn test_output_device_config_from_toml() {
1278 let dir = tempfile::tempdir().unwrap();
1279 let path = dir.path().join("config.toml");
1280 fs::write(
1281 &path,
1282 r#"
1283[playback]
1284output_device = "External Speakers"
1285"#,
1286 )
1287 .unwrap();
1288
1289 let cfg = Config::load_from(&path).unwrap();
1290 assert_eq!(
1291 cfg.playback.output_device.as_deref(),
1292 Some("External Speakers")
1293 );
1294 }
1295
1296 #[test]
1297 fn test_graphql_bind_defaults_to_localhost() {
1298 let cfg = GraphqlConfig::default();
1299 assert_eq!(
1300 cfg.bind,
1301 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1302 );
1303 }
1304
1305 #[test]
1306 fn test_graphql_bind_from_toml() {
1307 let toml_str = r#"
1308[graphql]
1309bind = "0.0.0.0"
1310port = 5000
1311"#;
1312 let cfg: Config = toml::from_str(toml_str).unwrap();
1313 assert_eq!(
1314 cfg.graphql.bind,
1315 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
1316 );
1317 assert_eq!(cfg.graphql.port, 5000);
1318 }
1319
1320 #[test]
1321 fn test_graphql_bind_omitted_defaults_to_localhost() {
1322 let toml_str = r#"
1323[graphql]
1324port = 4000
1325"#;
1326 let cfg: Config = toml::from_str(toml_str).unwrap();
1327 assert_eq!(
1328 cfg.graphql.bind,
1329 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1330 );
1331 }
1332
1333 #[test]
1334 fn test_organize_config_roundtrip() {
1335 let mut cfg = Config::default();
1336 cfg.organize.default = Some("standard".into());
1337 cfg.organize
1338 .patterns
1339 .insert("standard".into(), "%artist%/%title%".into());
1340
1341 let serialized = toml::to_string_pretty(&cfg).unwrap();
1342 let deserialized: Config = toml::from_str(&serialized).unwrap();
1343 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
1344 assert_eq!(
1345 deserialized.organize.patterns["standard"],
1346 "%artist%/%title%"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_parse_size_bytes() {
1352 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1353 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1354 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1355 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1356 assert_eq!(parse_size_bytes("1024B"), Some(1024));
1357 assert_eq!(parse_size_bytes("1024"), Some(1024));
1358
1359 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1361 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1362
1363 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1365 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1366
1367 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1369 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1370
1371 assert_eq!(
1373 parse_size_bytes("1.5GB"),
1374 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1375 );
1376
1377 assert_eq!(parse_size_bytes(""), None);
1379 assert_eq!(parse_size_bytes("abc"), None);
1380 assert_eq!(parse_size_bytes("50XB"), None);
1381 }
1382
1383 #[test]
1384 fn test_cache_limit_config_from_toml() {
1385 let toml_str = r#"
1386[remote]
1387cache_limit = "50GB"
1388"#;
1389 let cfg: Config = toml::from_str(toml_str).unwrap();
1390 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1391 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1392 }
1393
1394 #[test]
1395 fn test_cache_limit_none_by_default() {
1396 let cfg = Config::default();
1397 assert!(cfg.remote.cache_limit.is_none());
1398 assert!(cfg.cache_limit_bytes().is_none());
1399 }
1400
1401 #[test]
1402 fn test_cache_limit_not_serialized_when_none() {
1403 let cfg = Config::default();
1404 let serialized = toml::to_string_pretty(&cfg).unwrap();
1405 assert!(!serialized.contains("cache_limit"));
1406 }
1407
1408 #[test]
1409 fn player_uses_config_on_init() {
1410 let dir = tempfile::tempdir().unwrap();
1414 let path = dir.path().join("config.toml");
1415 fs::write(
1416 &path,
1417 r#"
1418[playback]
1419replaygain = "track"
1420output_device = "My Fancy DAC"
1421pre_amp_db = -3.5
1422target_fps = 30
1423art_size = 32
1424
1425[visualizer]
1426enabled = false
1427mode = "oscilloscope"
1428fps = 30
1429"#,
1430 )
1431 .unwrap();
1432
1433 let cfg = Config::load_from(&path).unwrap();
1434
1435 assert_eq!(
1437 cfg.playback.replaygain,
1438 ReplayGainMode::Track,
1439 "replaygain should be 'track'"
1440 );
1441 assert_eq!(
1442 cfg.playback.output_device.as_deref(),
1443 Some("My Fancy DAC"),
1444 "output_device should match config"
1445 );
1446 assert!(
1447 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1448 "pre_amp_db should be -3.5"
1449 );
1450 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1451 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1452
1453 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1455 assert_eq!(cfg.visualizer.mode, "oscilloscope");
1456 assert_eq!(cfg.visualizer.fps, 30);
1457 }
1458
1459 #[test]
1460 fn a_missing_config_file_stamps_as_absent() {
1461 let dir = tempfile::tempdir().unwrap();
1462 let base = dir.path().join("config.toml");
1463 let local = dir.path().join("config.local.toml");
1464
1465 assert_eq!(stamp_of(&base, &local), (None, None));
1466
1467 fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
1468 let (base_stamp, local_stamp) = stamp_of(&base, &local);
1469 assert!(base_stamp.is_some(), "creating the file must be a change");
1470 assert!(local_stamp.is_none());
1471 }
1472
1473 #[test]
1474 fn editing_a_config_file_changes_its_stamp() {
1475 let dir = tempfile::tempdir().unwrap();
1476 let base = dir.path().join("config.toml");
1477 let local = dir.path().join("config.local.toml");
1478 fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
1479
1480 let before = stamp_of(&base, &local);
1481 std::thread::sleep(std::time::Duration::from_millis(20));
1483 fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
1484
1485 assert_ne!(
1486 before,
1487 stamp_of(&base, &local),
1488 "a config edited by hand has to be picked up"
1489 );
1490 }
1491
1492 #[test]
1493 fn invalidating_forces_a_reload() {
1494 let first = Config::cached();
1495 Config::invalidate_cache();
1496 assert!(
1497 !Arc::ptr_eq(&first, &Config::cached()),
1498 "koan's own writes invalidate explicitly; the next read must re-parse"
1499 );
1500 }
1501
1502 static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1507
1508 fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
1510 let dir =
1511 std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
1512 let _ = fs::remove_dir_all(&dir);
1513 fs::create_dir_all(&dir).unwrap();
1514 set_config_dir(&dir);
1515 (config_file_path(), config_local_file_path())
1516 }
1517
1518 #[test]
1519 fn persist_keeps_comments_and_untouched_keys() {
1520 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1521 let (base, _local) = persist_sandbox("comments");
1522 fs::write(
1523 &base,
1524 "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
1525 )
1526 .unwrap();
1527
1528 Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
1529
1530 let written = fs::read_to_string(&base).unwrap();
1531 assert!(
1532 written.contains("# koan — shareable defaults"),
1533 "the header comment must survive a write: {written}"
1534 );
1535 assert!(
1536 written.contains("# fps = 60"),
1537 "commented-out defaults are the template's whole point: {written}"
1538 );
1539 assert!(written.contains("palette = \"neon\""));
1540 assert!(
1541 !written.contains("[graphql]"),
1542 "an untouched section must not be invented: {written}"
1543 );
1544 }
1545
1546 #[test]
1547 fn persist_routes_machine_settings_to_the_local_file() {
1548 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1549 let (base, local) = persist_sandbox("routing");
1550
1551 Config::persist(|cfg| {
1552 cfg.playback.replaygain = ReplayGainMode::Album;
1553 cfg.playback.output_device = Some("My DAC".into());
1554 cfg.playback.art_size = 40;
1555 cfg.visualizer.mode = "starfield".into();
1556 })
1557 .unwrap();
1558
1559 let shared = fs::read_to_string(&base).unwrap();
1560 let machine = fs::read_to_string(&local).unwrap();
1561
1562 assert!(shared.contains("replaygain = \"album\""), "{shared}");
1563 for machine_only in ["output_device", "art_size", "starfield"] {
1564 assert!(
1565 !shared.contains(machine_only),
1566 "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
1567 );
1568 assert!(machine.contains(machine_only), "{machine}");
1569 }
1570 }
1571
1572 #[test]
1573 fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
1574 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1575 let (base, _local) = persist_sandbox("folders");
1576
1577 Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
1580
1581 let shared = fs::read_to_string(&base).unwrap_or_default();
1582 assert!(
1583 !shared.contains("folders"),
1584 "a visualiser toggle must not invent library folders: {shared}"
1585 );
1586 }
1587
1588 #[test]
1589 fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
1590 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1591 let (base, local) = persist_sandbox("drain");
1592 fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
1593
1594 Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
1595
1596 let shared = fs::read_to_string(&base).unwrap();
1597 assert!(
1598 !shared.contains("art_size"),
1599 "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
1600 );
1601 assert!(shared.contains("target_fps"), "{shared}");
1602 assert!(
1603 fs::read_to_string(&local)
1604 .unwrap()
1605 .contains("art_size = 48")
1606 );
1607 }
1608
1609 #[test]
1610 fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
1611 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1612 let (_base, local) = persist_sandbox("shadow");
1613 fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
1614
1615 Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
1616
1617 assert_eq!(
1618 Config::from_files().unwrap().playback.target_fps,
1619 120,
1620 "local wins the merge, so a shared write over a local copy would \
1621 otherwise be silently ignored: {}",
1622 fs::read_to_string(&local).unwrap()
1623 );
1624 }
1625
1626 #[test]
1627 fn persist_writes_nothing_when_the_mutation_changes_nothing() {
1628 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1629 let (base, _local) = persist_sandbox("noop");
1630 fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
1631
1632 Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
1633
1634 assert_eq!(
1635 fs::read_to_string(&base).unwrap(),
1636 "# untouched\n[playback]\ntarget_fps = 60\n"
1637 );
1638 }
1639
1640 #[test]
1641 fn persist_keeps_passwords_out_of_the_shared_file() {
1642 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1643 let (base, local) = persist_sandbox("secrets");
1644
1645 Config::persist(|cfg| {
1646 cfg.remote.password = "hunter2".into();
1647 cfg.subsonic.password = "s3cret".into();
1648 cfg.visualizer.palette = "mono".into();
1649 })
1650 .unwrap();
1651
1652 let shared = fs::read_to_string(&base).unwrap();
1653 assert!(!shared.contains("hunter2"), "{shared}");
1654 assert!(!shared.contains("s3cret"), "{shared}");
1655 assert!(shared.contains("mono"));
1656
1657 let machine = fs::read_to_string(&local).unwrap();
1658 assert!(machine.contains("hunter2") && machine.contains("s3cret"));
1659 }
1660
1661 #[test]
1662 fn persist_removes_a_cleared_password_rather_than_blanking_it() {
1663 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1664 let (_base, local) = persist_sandbox("clear-secret");
1665 fs::write(
1666 &local,
1667 "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
1668 )
1669 .unwrap();
1670
1671 Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
1672
1673 let machine = fs::read_to_string(&local).unwrap();
1674 assert!(
1675 !machine.contains("password"),
1676 "an emptied secret should leave no key behind: {machine}"
1677 );
1678 assert!(machine.contains("url"), "{machine}");
1679 }
1680
1681 #[test]
1682 fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
1683 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1684 let (base, _local) = persist_sandbox("patterns");
1685 fs::write(
1686 &base,
1687 "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
1688 )
1689 .unwrap();
1690
1691 Config::persist(|cfg| {
1692 cfg.organize
1693 .patterns
1694 .insert("standard".into(), "%album artist%/%album%".into());
1695 })
1696 .unwrap();
1697
1698 let cfg = Config::from_files().unwrap();
1699 assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
1700 assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
1701 }
1702}