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