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 graphql: GraphqlConfig,
34 pub subsonic: SubsonicConfig,
35 pub auth: AuthConfig,
36 pub sharing: SharingConfig,
37 pub mcp: McpConfig,
38 pub push: PushConfig,
39 pub devices: DevicesConfig,
40 pub dsp: DspConfig,
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45#[serde(default)]
46pub struct SharingConfig {
47 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub public_url: Option<String>,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56#[serde(default)]
57pub struct McpConfig {
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub redirect_hosts: Vec<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(default)]
71pub struct PushConfig {
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub key_path: Option<PathBuf>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub key: Option<String>,
79 #[serde(skip_serializing_if = "String::is_empty")]
81 pub key_id: String,
82 #[serde(skip_serializing_if = "String::is_empty")]
84 pub team_id: String,
85 pub topic: String,
87}
88
89impl Default for PushConfig {
90 fn default() -> Self {
91 Self {
92 key_path: None,
93 key: None,
94 key_id: String::new(),
95 team_id: String::new(),
96 topic: "cc.blit.koan".into(),
97 }
98 }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(default)]
103pub struct LibraryConfig {
104 pub folders: Vec<PathBuf>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(default)]
109pub struct PlaybackConfig {
110 pub replaygain: ReplayGainMode,
111 pub target_fps: u8,
114 pub show_fps: bool,
116 pub pre_amp_db: f64,
119 pub fade_on_pause: bool,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub output_device: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub renderer: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub renderer_name: Option<String>,
132 pub art_size: u16,
135 pub rate_switch_lead_in_ms: u32,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "lowercase")]
143pub enum ReplayGainMode {
144 Off,
145 Track,
146 Album,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(default)]
151pub struct RemoteConfig {
152 pub enabled: bool,
153 pub url: String,
154 pub username: String,
155 #[serde(default, skip_serializing_if = "String::is_empty")]
157 pub password: String,
158 #[serde(default, skip_serializing_if = "String::is_empty")]
161 pub api_key: String,
162 pub cache_dir: Option<PathBuf>,
164 pub download_workers: usize,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub cache_limit: Option<String>,
171 pub auto_sync: bool,
177 pub auto_sync_interval_mins: u64,
179}
180
181impl Default for LibraryConfig {
182 fn default() -> Self {
183 let music_dir = dirs::audio_dir().unwrap_or_else(|| {
184 dirs::home_dir()
185 .map(|h| h.join("Music"))
186 .unwrap_or_else(|| PathBuf::from("/Music"))
187 });
188 Self {
189 folders: vec![music_dir],
190 }
191 }
192}
193
194impl Default for PlaybackConfig {
195 fn default() -> Self {
196 Self {
197 replaygain: ReplayGainMode::Off,
198 target_fps: 60,
199 show_fps: false,
200 pre_amp_db: 0.0,
201 fade_on_pause: true,
202 output_device: None,
203 renderer: None,
204 renderer_name: None,
205 art_size: 24,
206 rate_switch_lead_in_ms: 1000,
207 }
208 }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(default)]
213pub struct VisualizerConfig {
214 pub enabled: bool,
215 pub fps: u8,
216 pub mode: String,
219 pub scale: String,
221 pub amplitude_scale: String,
223 pub bar_decay_ms: u32,
225 pub peak_decay_ms: u32,
227 pub palette: String,
230 pub reactivity: f32,
234 pub bass_shake: bool,
237 pub matrix_overlay: bool,
240 pub reactive_bg: bool,
242}
243
244impl Default for VisualizerConfig {
245 fn default() -> Self {
246 Self {
247 enabled: true,
248 fps: 60,
249 mode: "bars".into(),
250 scale: "bark".into(),
251 amplitude_scale: "aweight".into(),
252 bar_decay_ms: 50,
253 peak_decay_ms: 180,
254 palette: "spectrum".into(),
255 reactivity: 1.0,
256 bass_shake: true,
257 matrix_overlay: false,
258 reactive_bg: false,
259 }
260 }
261}
262
263impl Default for RemoteConfig {
264 fn default() -> Self {
265 Self {
266 enabled: false,
267 url: String::new(),
268 username: String::new(),
269 password: String::new(),
270 api_key: String::new(),
271 cache_dir: None,
272 download_workers: 5,
273 cache_limit: None,
274 auto_sync: true,
275 auto_sync_interval_mins: 60,
276 }
277 }
278}
279
280pub fn parse_size_bytes(s: &str) -> Option<u64> {
283 let s = s.trim();
284 if s.is_empty() {
285 return None;
286 }
287
288 let mut num_end = 0;
290 for (i, c) in s.char_indices() {
291 if c.is_ascii_digit() || c == '.' {
292 num_end = i + c.len_utf8();
293 } else if !c.is_whitespace() {
294 break;
295 }
296 }
297
298 let num_str = s[..num_end].trim();
299 let suffix = s[num_end..].trim().to_ascii_uppercase();
300
301 let value: f64 = num_str.parse().ok()?;
302 let multiplier: u64 = match suffix.as_str() {
303 "" | "B" => 1,
304 "KB" | "K" => 1024,
305 "MB" | "M" => 1024 * 1024,
306 "GB" | "G" => 1024 * 1024 * 1024,
307 "TB" | "T" => 1024 * 1024 * 1024 * 1024,
308 _ => return None,
309 };
310
311 Some((value * multiplier as f64) as u64)
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
315#[serde(default)]
316pub struct OrganizeConfig {
317 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub default: Option<String>,
320 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
322 pub patterns: HashMap<String, String>,
323 #[serde(default = "default_true")]
327 pub move_ancillary: bool,
328}
329
330impl Default for OrganizeConfig {
331 fn default() -> Self {
332 Self {
333 default: None,
334 patterns: HashMap::new(),
335 move_ancillary: true,
336 }
337 }
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
342#[serde(default)]
343pub struct GraphqlConfig {
344 pub enabled: bool,
347 pub port: u16,
349 #[serde(default = "default_bind")]
352 pub bind: std::net::IpAddr,
353 pub playground: bool,
355 pub auth_enabled: bool,
358 pub access_token_ttl: String,
360 pub refresh_token_ttl: String,
362 pub cors_origins: Vec<String>,
365 pub allowed_hosts: Vec<String>,
369 pub cookie_secure: bool,
373 pub allow_organize: bool,
375}
376
377fn default_true() -> bool {
378 true
379}
380
381fn default_bind() -> std::net::IpAddr {
382 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
383}
384
385impl Default for GraphqlConfig {
386 fn default() -> Self {
387 Self {
388 enabled: true,
389 port: 4000,
390 bind: default_bind(),
391 playground: false,
392 auth_enabled: true,
393 access_token_ttl: "15m".into(),
394 refresh_token_ttl: "30d".into(),
395 cors_origins: Vec::new(),
396 allowed_hosts: Vec::new(),
397 cookie_secure: false,
398 allow_organize: false,
399 }
400 }
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
411#[serde(default)]
412pub struct SubsonicConfig {
413 pub enabled: bool,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub port: Option<u16>,
421 pub username: String,
423 #[serde(default, skip_serializing_if = "String::is_empty")]
426 pub password: String,
427}
428
429impl Default for SubsonicConfig {
430 fn default() -> Self {
431 Self {
432 enabled: false,
433 port: None,
434 username: "koan".into(),
435 password: String::new(),
436 }
437 }
438}
439
440#[derive(Debug, Clone, Default, Serialize, Deserialize)]
445#[serde(default)]
446pub struct AuthConfig {
447 #[serde(skip_serializing_if = "String::is_empty")]
449 pub server: String,
450 #[serde(skip_serializing_if = "String::is_empty")]
453 pub refresh_token: String,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
460#[serde(default)]
461pub struct DevicesConfig {
462 pub discoverable: bool,
465 pub port: u16,
468 pub addresses: Vec<String>,
471}
472
473impl Default for DevicesConfig {
474 fn default() -> Self {
475 Self {
476 discoverable: true,
477 port: DEVICES_PORT,
478 addresses: Vec::new(),
479 }
480 }
481}
482
483pub const DEVICES_PORT: u16 = 5626;
485
486#[derive(Debug, Clone, Serialize, Deserialize)]
491#[serde(default)]
492pub struct DspConfig {
493 pub enabled: bool,
495 pub profiles: Vec<DspProfile>,
496}
497
498impl Default for DspConfig {
499 fn default() -> Self {
500 Self {
501 enabled: true,
502 profiles: Vec::new(),
503 }
504 }
505}
506
507impl DspConfig {
508 pub fn profile_for(&self, device: &str) -> Option<&DspProfile> {
511 if !self.enabled {
512 return None;
513 }
514 self.profiles
515 .iter()
516 .find(|p| p.devices.iter().any(|d| d == device))
517 }
518}
519
520#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
521#[serde(default)]
522pub struct DspProfile {
523 pub name: String,
524 pub devices: Vec<String>,
526 #[serde(skip_serializing_if = "Option::is_none")]
529 pub preamp_db: Option<f64>,
530 pub filters: Vec<DspFilter>,
532 pub impulses: Vec<PathBuf>,
536 #[serde(default, skip_serializing_if = "Vec::is_empty")]
539 pub source: Vec<String>,
540}
541
542#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
545#[serde(tag = "type", rename_all = "snake_case")]
546pub enum DspFilter {
547 Delay(Delay),
548 Mix(Mix),
549 Graphic(GraphicEq),
550 #[serde(untagged)]
551 Band(EqFilter),
552}
553
554impl DspFilter {
555 pub fn channels(&self) -> &[u16] {
558 match self {
559 Self::Band(f) => &f.channels,
560 Self::Delay(d) => &d.channels,
561 Self::Graphic(g) => &g.channels,
562 Self::Mix(_) => &[],
563 }
564 }
565
566 pub fn channels_mut(&mut self) -> Option<&mut Vec<u16>> {
567 match self {
568 Self::Band(f) => Some(&mut f.channels),
569 Self::Delay(d) => Some(&mut d.channels),
570 Self::Graphic(g) => Some(&mut g.channels),
571 Self::Mix(_) => None,
572 }
573 }
574}
575
576impl From<EqFilter> for DspFilter {
577 fn from(f: EqFilter) -> Self {
578 Self::Band(f)
579 }
580}
581
582#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
584#[serde(default)]
585pub struct Delay {
586 #[serde(skip_serializing_if = "is_zero")]
587 pub ms: f64,
588 #[serde(skip_serializing_if = "is_zero")]
589 pub samples: f64,
590 #[serde(skip_serializing_if = "std::ops::Not::not")]
593 pub subsample: bool,
594 #[serde(skip_serializing_if = "Vec::is_empty")]
595 pub channels: Vec<u16>,
596}
597
598fn is_zero(v: &f64) -> bool {
599 *v == 0.0
600}
601
602#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
606pub struct Mix {
607 pub outputs: Vec<Vec<(u16, f64)>>,
608}
609
610#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
614pub struct GraphicEq {
615 pub points: Vec<(f64, f64)>,
616 #[serde(default, skip_serializing_if = "Vec::is_empty")]
617 pub channels: Vec<u16>,
618}
619
620#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
622pub struct EqFilter {
623 #[serde(rename = "type")]
624 pub kind: EqFilterKind,
625 pub freq: f64,
627 #[serde(default)]
628 pub gain_db: f64,
629 #[serde(default = "default_q")]
630 pub q: f64,
631 #[serde(default, skip_serializing_if = "Vec::is_empty")]
633 pub channels: Vec<u16>,
634}
635
636fn default_q() -> f64 {
637 std::f64::consts::FRAC_1_SQRT_2
638}
639
640#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
641#[serde(rename_all = "snake_case")]
642pub enum EqFilterKind {
643 Peaking,
644 LowShelf,
645 HighShelf,
646 LowPass,
647 HighPass,
648 Notch,
649 BandPass,
650 AllPass,
651 LowShelfFirstOrder,
654 HighShelfFirstOrder,
655 LowPassFirstOrder,
656 HighPassFirstOrder,
657 AllPassFirstOrder,
658 Gain,
660}
661
662impl EqFilterKind {
663 pub fn name(self) -> &'static str {
665 match self {
666 Self::Peaking => "peaking",
667 Self::LowShelf => "low_shelf",
668 Self::HighShelf => "high_shelf",
669 Self::LowPass => "low_pass",
670 Self::HighPass => "high_pass",
671 Self::Notch => "notch",
672 Self::BandPass => "band_pass",
673 Self::AllPass => "all_pass",
674 Self::LowShelfFirstOrder => "low_shelf_first_order",
675 Self::HighShelfFirstOrder => "high_shelf_first_order",
676 Self::LowPassFirstOrder => "low_pass_first_order",
677 Self::HighPassFirstOrder => "high_pass_first_order",
678 Self::AllPassFirstOrder => "all_pass_first_order",
679 Self::Gain => "gain",
680 }
681 }
682}
683
684#[derive(Debug, Clone, Copy, PartialEq, Eq)]
686pub enum Layer {
687 Shared,
689 Machine,
691}
692
693pub fn layer_of(path: &str) -> Layer {
703 match path {
704 "remote.password"
706 | "remote.api_key"
707 | "subsonic.password"
708 | "auth.refresh_token"
709 | "push.key"
710 | "push.key_path"
711 | "push.key_id"
712 | "push.team_id"
713 | "library.folders"
715 | "remote.enabled"
716 | "remote.url"
717 | "remote.username"
718 | "remote.cache_dir"
719 | "remote.cache_limit"
720 | "playback.output_device"
722 | "playback.renderer"
723 | "playback.renderer_name"
724 | "playback.rate_switch_lead_in_ms"
725 | "subsonic.enabled"
728 | "subsonic.port"
729 | "subsonic.username"
730 | "devices.discoverable"
732 | "devices.port"
733 | "devices.addresses"
734 | "auth.server"
736 | "playback.art_size"
738 | "visualizer.enabled"
739 | "visualizer.mode"
740 | "visualizer.matrix_overlay"
741 | "visualizer.bass_shake" => Layer::Machine,
742 p if p == "dsp" || p.starts_with("dsp.") => Layer::Machine,
744 _ => Layer::Shared,
745 }
746}
747
748type ConfigStamp = (Option<SystemTime>, Option<SystemTime>);
752
753type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
754
755static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
756 LazyLock::new(|| parking_lot::RwLock::new(None));
757
758fn config_stamp() -> ConfigStamp {
759 stamp_of(&config_file_path(), &config_local_file_path())
760}
761
762fn stamp_of(base: &Path, local: &Path) -> ConfigStamp {
764 let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
765 (mtime(base), mtime(local))
766}
767
768impl Config {
769 fn figment() -> Figment {
775 let base_path = config_file_path();
776 let local_path = config_local_file_path();
777
778 Figment::from(Serialized::defaults(Config::default()))
779 .merge(Toml::file(&base_path))
780 .merge(Toml::file(&local_path))
781 .merge(Env::prefixed("KOAN_").split("__"))
782 }
783
784 pub fn load() -> Result<Self, ConfigError> {
786 let cfg: Self = Self::figment()
787 .extract()
788 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
789
790 check_secrets_in_git();
793
794 Ok(cfg)
795 }
796
797 pub fn load_or_default() -> Self {
802 (*Self::cached()).clone()
803 }
804
805 pub fn cached() -> Arc<Config> {
812 let stamp = config_stamp();
813 if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
814 && *seen == stamp
815 {
816 return cfg.clone();
817 }
818
819 let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
820 log::warn!("failed to load config, using defaults: {}", e);
821 Self::default()
822 }));
823 *CONFIG_CACHE.write() = Some((stamp, cfg.clone()));
824 cfg
825 }
826
827 pub fn invalidate_cache() {
831 *CONFIG_CACHE.write() = None;
832 }
833
834 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
836 let contents = fs::read_to_string(path)?;
837 let config: Config = toml::from_str(&contents)?;
838 Ok(config)
839 }
840
841 fn from_files() -> Result<Self, ConfigError> {
847 Figment::from(Serialized::defaults(Config::default()))
848 .merge(Toml::file(config_file_path()))
849 .merge(Toml::file(config_local_file_path()))
850 .extract()
851 .map_err(|e| ConfigError::Figment(Box::new(e)))
852 }
853
854 pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
866 where
867 F: FnOnce(&mut Config),
868 {
869 let before = Self::from_files()?;
870 let mut after = before.clone();
871 mutate(&mut after);
872
873 let mut changes = Vec::new();
874 diff_into(
875 "",
876 &toml::Value::try_from(&before)?,
877 &toml::Value::try_from(&after)?,
878 &mut changes,
879 );
880 if changes.is_empty() {
881 return Ok(());
882 }
883
884 let base_path = config_file_path();
885 let local_path = config_local_file_path();
886 let mut base = read_document(&base_path)?;
887 let mut local = read_document(&local_path)?;
888
889 for (path, value) in &changes {
890 let (target, other) = match layer_of(path) {
891 Layer::Shared => (&mut base, &mut local),
892 Layer::Machine => (&mut local, &mut base),
893 };
894 match value {
895 Some(v) => doc_set(target, path, v),
896 None => doc_remove(target, path),
897 }
898 doc_remove(other, path);
899 }
900
901 write_document(&base_path, &base, false)?;
902 write_document(&local_path, &local, true)?;
903 Self::invalidate_cache();
904 Ok(())
905 }
906
907 pub fn cache_dir(&self) -> PathBuf {
909 self.remote
910 .cache_dir
911 .clone()
912 .unwrap_or_else(default_cache_dir)
913 }
914
915 pub fn cache_limit_bytes(&self) -> Option<u64> {
917 self.remote
918 .cache_limit
919 .as_deref()
920 .and_then(parse_size_bytes)
921 }
922}
923
924fn diff_into(
929 prefix: &str,
930 before: &toml::Value,
931 after: &toml::Value,
932 out: &mut Vec<(String, Option<toml::Value>)>,
933) {
934 let (b, a) = match (before.as_table(), after.as_table()) {
935 (Some(b), Some(a)) => (b, a),
936 _ => {
937 if before != after {
938 out.push((prefix.to_string(), Some(after.clone())));
939 }
940 return;
941 }
942 };
943
944 let empty = toml::Value::Table(toml::map::Map::new());
945 for key in b
946 .keys()
947 .chain(a.keys())
948 .collect::<std::collections::BTreeSet<_>>()
949 {
950 let path = if prefix.is_empty() {
951 key.clone()
952 } else {
953 format!("{prefix}.{key}")
954 };
955 match (b.get(key), a.get(key)) {
956 (Some(bv), Some(av)) => diff_into(&path, bv, av, out),
957 (None, Some(av)) => diff_into(&path, &empty, av, out),
960 (Some(_), None) => out.push((path, None)),
961 (None, None) => unreachable!("key came from one of the two tables"),
962 }
963 }
964}
965
966fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
967 let Ok(contents) = fs::read_to_string(path) else {
968 return Ok(toml_edit::DocumentMut::new());
969 };
970 contents
971 .parse::<toml_edit::DocumentMut>()
972 .map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
973}
974
975fn write_document(
978 path: &Path,
979 doc: &toml_edit::DocumentMut,
980 secret: bool,
981) -> Result<(), ConfigError> {
982 let contents = doc.to_string();
983 if contents.trim().is_empty() && !path.exists() {
984 return Ok(());
985 }
986 if let Some(parent) = path.parent() {
987 fs::create_dir_all(parent)?;
988 }
989 fs::write(path, contents)?;
990 #[cfg(unix)]
991 if secret {
992 use std::os::unix::fs::PermissionsExt;
993 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
994 }
995 #[cfg(not(unix))]
996 let _ = secret;
997 Ok(())
998}
999
1000fn implicit_table() -> toml_edit::Item {
1001 let mut table = toml_edit::Table::new();
1002 table.set_implicit(true);
1005 toml_edit::Item::Table(table)
1006}
1007
1008fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
1009 let segments: Vec<&str> = path.split('.').collect();
1010 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
1011
1012 let mut table = doc.as_table_mut();
1013 for segment in parents {
1014 let item = table.entry(segment).or_insert_with(implicit_table);
1015 if !item.is_table() {
1018 *item = implicit_table();
1019 }
1020 table = item.as_table_mut().expect("just ensured it is a table");
1021 }
1022 match table.get_mut(last) {
1025 Some(existing) => *existing = to_edit_item(value),
1026 None => {
1027 table.insert(last, to_edit_item(value));
1028 }
1029 }
1030}
1031
1032fn to_edit_item(value: &toml::Value) -> toml_edit::Item {
1035 let toml::Value::Array(items) = value else {
1036 return toml_edit::value(to_edit_value(value));
1037 };
1038 if items.is_empty() || !items.iter().all(toml::Value::is_table) {
1039 return toml_edit::value(to_edit_value(value));
1040 }
1041 let mut sections = toml_edit::ArrayOfTables::new();
1042 for item in items {
1043 let mut section = toml_edit::Table::new();
1044 for (k, v) in item.as_table().expect("checked above") {
1045 let mut v = to_edit_value(v);
1046 if let toml_edit::Value::Array(list) = &mut v
1047 && list.iter().any(toml_edit::Value::is_inline_table)
1048 {
1049 for entry in list.iter_mut() {
1050 entry.decor_mut().set_prefix("\n ");
1051 if let Some(t) = entry.as_inline_table_mut() {
1052 t.sort_values_by(|a, _, b, _| key_rank(a).cmp(&key_rank(b)));
1053 }
1054 }
1055 list.set_trailing("\n");
1056 list.set_trailing_comma(true);
1057 }
1058 section.insert(k, toml_edit::value(v));
1059 }
1060 section.sort_values_by(|a, _, b, _| key_rank(a).cmp(&key_rank(b)));
1061 sections.push(section);
1062 }
1063 toml_edit::Item::ArrayOfTables(sections)
1064}
1065
1066fn key_rank(key: &toml_edit::Key) -> (u8, &str) {
1068 let k = key.get();
1069 (if k == "name" || k == "type" { 0 } else { 1 }, k)
1070}
1071
1072fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
1073 let segments: Vec<&str> = path.split('.').collect();
1074 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
1075
1076 let mut table = doc.as_table_mut();
1077 for segment in parents {
1078 match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
1079 Some(child) => table = child,
1080 None => return,
1081 }
1082 }
1083 table.remove(last);
1086}
1087
1088fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
1089 match value {
1090 toml::Value::String(s) => s.as_str().into(),
1091 toml::Value::Integer(i) => (*i).into(),
1092 toml::Value::Float(f) => (*f).into(),
1093 toml::Value::Boolean(b) => (*b).into(),
1094 toml::Value::Datetime(d) => d.to_string().into(),
1095 toml::Value::Array(items) => items
1096 .iter()
1097 .map(to_edit_value)
1098 .collect::<toml_edit::Array>()
1099 .into(),
1100 toml::Value::Table(t) => {
1101 let mut inline = toml_edit::InlineTable::new();
1102 for (k, v) in t {
1103 inline.insert(k, to_edit_value(v));
1104 }
1105 inline.into()
1106 }
1107 }
1108}
1109
1110pub fn config_dir() -> PathBuf {
1119 if let Some(dir) = CONFIG_DIR.read().clone() {
1120 return dir;
1121 }
1122 if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
1123 return PathBuf::from(dir);
1124 }
1125 platform_config_dir()
1126}
1127
1128#[cfg(not(target_os = "ios"))]
1129fn platform_config_dir() -> PathBuf {
1130 dirs::home_dir()
1131 .unwrap_or_else(|| PathBuf::from("."))
1132 .join(".config")
1133 .join("koan")
1134}
1135
1136#[cfg(target_os = "ios")]
1140fn platform_config_dir() -> PathBuf {
1141 ios_library().join("Application Support").join("koan")
1142}
1143
1144#[cfg(target_os = "ios")]
1145fn ios_library() -> PathBuf {
1146 dirs::home_dir()
1147 .unwrap_or_else(|| PathBuf::from("."))
1148 .join("Library")
1149}
1150
1151fn default_cache_dir() -> PathBuf {
1158 #[cfg(target_os = "ios")]
1159 if CONFIG_DIR.read().is_none() && std::env::var_os("KOAN_CONFIG_DIR").is_none() {
1160 return ios_library().join("Caches").join("koan");
1161 }
1162 config_dir().join("cache")
1163}
1164
1165pub fn set_config_dir(dir: impl Into<PathBuf>) {
1172 *CONFIG_DIR.write() = Some(dir.into());
1173 Config::invalidate_cache();
1174}
1175
1176pub fn isolate_config_for_tests() {
1188 let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
1189 let _ = fs::create_dir_all(&dir);
1190 set_config_dir(dir);
1191}
1192
1193static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
1194 LazyLock::new(|| parking_lot::RwLock::new(None));
1195
1196pub fn config_file_path() -> PathBuf {
1198 config_dir().join("config.toml")
1199}
1200
1201pub fn config_local_file_path() -> PathBuf {
1203 config_dir().join("config.local.toml")
1204}
1205
1206pub fn db_path() -> PathBuf {
1208 config_dir().join("koan.db")
1209}
1210
1211const LOG_LIMIT: u64 = 16 * 1024 * 1024;
1214const LOG_CHECK_EVERY: u32 = 4096;
1217
1218#[derive(Default)]
1220pub struct LogFile {
1221 file: Option<fs::File>,
1222 lines: u32,
1223}
1224
1225impl LogFile {
1226 pub fn write(&mut self, line: std::fmt::Arguments) {
1227 use std::io::Write as _;
1228 if self.file.is_none() {
1229 self.file = open_log();
1230 }
1231 let Some(file) = self.file.as_mut() else {
1232 return;
1233 };
1234 let _ = writeln!(file, "{line}");
1235 self.lines = self.lines.wrapping_add(1);
1236 if self.lines.is_multiple_of(LOG_CHECK_EVERY)
1237 && file.metadata().is_ok_and(|m| m.len() > LOG_LIMIT)
1238 {
1239 self.file = open_log();
1240 }
1241 }
1242
1243 pub fn flush(&mut self) {
1244 if let Some(file) = self.file.as_mut() {
1245 let _ = std::io::Write::flush(file);
1246 }
1247 }
1248}
1249
1250fn open_log() -> Option<fs::File> {
1256 let dir = config_dir();
1257 fs::create_dir_all(&dir).ok()?;
1258 let path = dir.join("koan.log");
1259 if fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) {
1260 let _ = fs::rename(&path, dir.join("koan.log.1"));
1261 }
1262 fs::OpenOptions::new()
1263 .create(true)
1264 .append(true)
1265 .open(path)
1266 .ok()
1267}
1268
1269fn check_secrets_in_git() {
1276 static ONCE: Once = Once::new();
1277 ONCE.call_once(scan_for_tracked_secrets);
1278}
1279
1280fn scan_for_tracked_secrets() {
1281 let sensitive_fields = ["password", "api_key", "refresh_token"];
1282
1283 for (label, path) in [
1284 ("config.toml", config_file_path()),
1285 ("config.local.toml", config_local_file_path()),
1286 ] {
1287 let Ok(contents) = std::fs::read_to_string(&path) else {
1288 continue;
1289 };
1290
1291 let has_secrets = sensitive_fields.iter().any(|field| {
1293 contents.lines().any(|line| {
1294 let line = line.trim();
1295 if let Some(rest) = line.strip_prefix(field) {
1296 let rest = rest.trim_start();
1297 if let Some(value) = rest.strip_prefix('=') {
1298 let value = value.trim().trim_matches('"').trim_matches('\'');
1299 return !value.is_empty();
1300 }
1301 }
1302 false
1303 })
1304 });
1305
1306 if !has_secrets {
1307 continue;
1308 }
1309
1310 if is_tracked_by_git(&path) {
1312 eprintln!();
1313 eprintln!("╔══════════════════════════════════════════════════════════════╗");
1314 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
1315 eprintln!("╠══════════════════════════════════════════════════════════════╣");
1316 eprintln!("║ ║");
1317 eprintln!("║ File: {:<52} ║", path.display());
1318 eprintln!("║ ║");
1319 eprintln!("║ Your password is in version control. You should: ║");
1320 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
1321 eprintln!("║ 2. Add it to .gitignore ║");
1322 eprintln!("║ 3. Rotate your credentials immediately ║");
1323 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
1324 eprintln!("║ `koan remote login` writes there for you ║");
1325 eprintln!("║ ║");
1326 eprintln!("╚══════════════════════════════════════════════════════════════╝");
1327 eprintln!();
1328 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
1329 }
1330 }
1331}
1332
1333fn is_tracked_by_git(path: &Path) -> bool {
1335 let Some(parent) = path.parent() else {
1336 return false;
1337 };
1338 std::process::Command::new("git")
1340 .args(["ls-files", "--error-unmatch"])
1341 .arg(path)
1342 .current_dir(parent)
1343 .stdout(std::process::Stdio::null())
1344 .stderr(std::process::Stdio::null())
1345 .status()
1346 .is_ok_and(|s| s.success())
1347}
1348
1349#[cfg(test)]
1350pub(crate) mod tests {
1351 use super::*;
1352 use std::fs;
1353
1354 fn tmp_dir() -> PathBuf {
1355 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
1356 fs::create_dir_all(&dir).unwrap();
1357 dir
1358 }
1359
1360 #[test]
1361 fn test_defaults() {
1362 let cfg = Config::default();
1363 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1364 assert!(!cfg.remote.enabled);
1365 }
1366
1367 #[test]
1368 fn test_roundtrip_toml() {
1369 let cfg = Config::default();
1370 let serialized = toml::to_string_pretty(&cfg).unwrap();
1371 let deserialized: Config = toml::from_str(&serialized).unwrap();
1372 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
1373 assert_eq!(
1374 deserialized.remote.download_workers,
1375 cfg.remote.download_workers
1376 );
1377 }
1378
1379 #[test]
1380 fn test_load_from_file() {
1381 let dir = tempfile::tempdir().unwrap();
1382 let path = dir.path().join("config.toml");
1383 fs::write(
1384 &path,
1385 r#"
1386[library]
1387folders = ["/tmp/music"]
1388
1389[playback]
1390replaygain = "track"
1391"#,
1392 )
1393 .unwrap();
1394
1395 let cfg = Config::load_from(&path).unwrap();
1396 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
1397 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
1398 assert!(!cfg.remote.enabled);
1399 }
1400
1401 #[test]
1402 fn test_partial_toml_uses_defaults() {
1403 let dir = tempfile::tempdir().unwrap();
1404 let path = dir.path().join("partial.toml");
1405 fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
1406
1407 let cfg = Config::load_from(&path).unwrap();
1408 assert_eq!(cfg.playback.target_fps, 30);
1409 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1410 }
1411
1412 #[test]
1413 fn test_figment_layered_loading() {
1414 let dir = tempfile::tempdir().unwrap();
1415 let base_path = dir.path().join("config.toml");
1416 let local_path = dir.path().join("config.local.toml");
1417
1418 fs::write(
1419 &base_path,
1420 r#"
1421[remote]
1422url = "https://base.example.com"
1423"#,
1424 )
1425 .unwrap();
1426 fs::write(
1427 &local_path,
1428 r#"
1429[remote]
1430enabled = true
1431url = "https://local.example.com"
1432username = "admin"
1433password = "secret"
1434"#,
1435 )
1436 .unwrap();
1437
1438 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1440 .merge(Toml::file(&base_path))
1441 .merge(Toml::file(&local_path))
1442 .extract()
1443 .unwrap();
1444
1445 assert!(cfg.remote.enabled);
1446 assert_eq!(cfg.remote.url, "https://local.example.com");
1447 assert_eq!(cfg.remote.username, "admin");
1448 assert_eq!(cfg.remote.password, "secret");
1449 }
1450
1451 #[test]
1452 fn test_figment_missing_keys_preserved() {
1453 let dir = tempfile::tempdir().unwrap();
1454 let base_path = dir.path().join("config.toml");
1455 let local_path = dir.path().join("config.local.toml");
1456
1457 fs::write(
1458 &base_path,
1459 r#"
1460[remote]
1461url = "https://keep.me"
1462username = "keepuser"
1463"#,
1464 )
1465 .unwrap();
1466 fs::write(
1467 &local_path,
1468 r#"
1469[remote]
1470password = "secret"
1471"#,
1472 )
1473 .unwrap();
1474
1475 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1476 .merge(Toml::file(&base_path))
1477 .merge(Toml::file(&local_path))
1478 .extract()
1479 .unwrap();
1480
1481 assert_eq!(cfg.remote.url, "https://keep.me");
1482 assert_eq!(cfg.remote.username, "keepuser");
1483 assert_eq!(cfg.remote.password, "secret");
1484 }
1485
1486 #[test]
1487 fn test_env_var_override() {
1488 let dir = tempfile::tempdir().unwrap();
1489 let base_path = dir.path().join("config.toml");
1490
1491 fs::write(
1492 &base_path,
1493 r#"
1494[remote]
1495url = "https://file.example.com"
1496"#,
1497 )
1498 .unwrap();
1499
1500 unsafe {
1502 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
1503 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
1504 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
1505 }
1506
1507 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1508 .merge(Toml::file(&base_path))
1509 .merge(Env::prefixed("KOAN_").split("__"))
1510 .extract()
1511 .unwrap();
1512
1513 assert_eq!(cfg.remote.url, "https://env.example.com");
1514 assert_eq!(cfg.remote.password, "env-secret");
1515 assert_eq!(cfg.graphql.port, 9999);
1516
1517 unsafe {
1519 std::env::remove_var("KOAN_REMOTE__URL");
1520 std::env::remove_var("KOAN_REMOTE__PASSWORD");
1521 std::env::remove_var("KOAN_GRAPHQL__PORT");
1522 }
1523 }
1524
1525 #[test]
1526 fn test_cache_dir_default() {
1527 let cfg = Config::default();
1528 assert!(cfg.cache_dir().ends_with("cache"));
1529 }
1530
1531 #[test]
1532 fn test_cache_dir_explicit() {
1533 let mut cfg = Config::default();
1534 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
1535 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
1536 }
1537
1538 #[test]
1539 fn test_organize_config_defaults() {
1540 let cfg = Config::default();
1541 assert!(cfg.organize.default.is_none());
1542 assert!(cfg.organize.patterns.is_empty());
1543 }
1544
1545 #[test]
1546 fn test_organize_config_from_toml() {
1547 let dir = tmp_dir();
1548 let path = dir.join("organize.toml");
1549 fs::write(
1550 &path,
1551 r#"
1552[organize]
1553default = "standard"
1554
1555[organize.patterns]
1556standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
1557va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
1558"#,
1559 )
1560 .unwrap();
1561
1562 let cfg = Config::load_from(&path).unwrap();
1563 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
1564 assert_eq!(cfg.organize.patterns.len(), 2);
1565 assert!(cfg.organize.patterns.contains_key("standard"));
1566 assert!(cfg.organize.patterns.contains_key("va-aware"));
1567
1568 fs::remove_dir_all(&dir).ok();
1569 }
1570
1571 #[test]
1572 fn test_figment_organize_patterns_merge() {
1573 let dir = tempfile::tempdir().unwrap();
1574 let base_path = dir.path().join("config.toml");
1575 let local_path = dir.path().join("config.local.toml");
1576
1577 fs::write(
1578 &base_path,
1579 r#"
1580[organize]
1581default = "standard"
1582
1583[organize.patterns]
1584standard = "base-pattern"
1585"#,
1586 )
1587 .unwrap();
1588 fs::write(
1589 &local_path,
1590 r#"
1591[organize]
1592default = "custom"
1593
1594[organize.patterns]
1595custom = "local-pattern"
1596"#,
1597 )
1598 .unwrap();
1599
1600 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1601 .merge(Toml::file(&base_path))
1602 .merge(Toml::file(&local_path))
1603 .extract()
1604 .unwrap();
1605
1606 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
1608 assert_eq!(cfg.organize.patterns.len(), 2);
1610 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
1611 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
1612 }
1613
1614 #[test]
1615 fn test_output_device_config_roundtrip() {
1616 let mut cfg = Config::default();
1617 cfg.playback.output_device = Some("My DAC".into());
1618
1619 let serialized = toml::to_string_pretty(&cfg).unwrap();
1620 let deserialized: Config = toml::from_str(&serialized).unwrap();
1621 assert_eq!(
1622 deserialized.playback.output_device.as_deref(),
1623 Some("My DAC")
1624 );
1625 }
1626
1627 #[test]
1628 fn test_output_device_config_default_is_none() {
1629 let cfg = Config::default();
1630 assert!(cfg.playback.output_device.is_none());
1631
1632 let serialized = toml::to_string_pretty(&cfg).unwrap();
1634 assert!(!serialized.contains("output_device"));
1635 let deserialized: Config = toml::from_str(&serialized).unwrap();
1636 assert!(deserialized.playback.output_device.is_none());
1637 }
1638
1639 #[test]
1640 fn test_output_device_config_from_toml() {
1641 let dir = tempfile::tempdir().unwrap();
1642 let path = dir.path().join("config.toml");
1643 fs::write(
1644 &path,
1645 r#"
1646[playback]
1647output_device = "External Speakers"
1648"#,
1649 )
1650 .unwrap();
1651
1652 let cfg = Config::load_from(&path).unwrap();
1653 assert_eq!(
1654 cfg.playback.output_device.as_deref(),
1655 Some("External Speakers")
1656 );
1657 }
1658
1659 #[test]
1660 fn test_graphql_bind_defaults_to_localhost() {
1661 let cfg = GraphqlConfig::default();
1662 assert_eq!(
1663 cfg.bind,
1664 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1665 );
1666 }
1667
1668 #[test]
1669 fn test_graphql_bind_from_toml() {
1670 let toml_str = r#"
1671[graphql]
1672bind = "0.0.0.0"
1673port = 5000
1674"#;
1675 let cfg: Config = toml::from_str(toml_str).unwrap();
1676 assert_eq!(
1677 cfg.graphql.bind,
1678 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
1679 );
1680 assert_eq!(cfg.graphql.port, 5000);
1681 }
1682
1683 #[test]
1684 fn test_graphql_bind_omitted_defaults_to_localhost() {
1685 let toml_str = r#"
1686[graphql]
1687port = 4000
1688"#;
1689 let cfg: Config = toml::from_str(toml_str).unwrap();
1690 assert_eq!(
1691 cfg.graphql.bind,
1692 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1693 );
1694 }
1695
1696 #[test]
1697 fn test_organize_config_roundtrip() {
1698 let mut cfg = Config::default();
1699 cfg.organize.default = Some("standard".into());
1700 cfg.organize
1701 .patterns
1702 .insert("standard".into(), "%artist%/%title%".into());
1703
1704 let serialized = toml::to_string_pretty(&cfg).unwrap();
1705 let deserialized: Config = toml::from_str(&serialized).unwrap();
1706 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
1707 assert_eq!(
1708 deserialized.organize.patterns["standard"],
1709 "%artist%/%title%"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_parse_size_bytes() {
1715 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1716 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1717 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1718 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1719 assert_eq!(parse_size_bytes("1024B"), Some(1024));
1720 assert_eq!(parse_size_bytes("1024"), Some(1024));
1721
1722 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1724 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1725
1726 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1728 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1729
1730 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1732 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1733
1734 assert_eq!(
1736 parse_size_bytes("1.5GB"),
1737 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1738 );
1739
1740 assert_eq!(parse_size_bytes(""), None);
1742 assert_eq!(parse_size_bytes("abc"), None);
1743 assert_eq!(parse_size_bytes("50XB"), None);
1744 }
1745
1746 #[test]
1747 fn test_cache_limit_config_from_toml() {
1748 let toml_str = r#"
1749[remote]
1750cache_limit = "50GB"
1751"#;
1752 let cfg: Config = toml::from_str(toml_str).unwrap();
1753 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1754 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1755 }
1756
1757 #[test]
1758 fn test_cache_limit_none_by_default() {
1759 let cfg = Config::default();
1760 assert!(cfg.remote.cache_limit.is_none());
1761 assert!(cfg.cache_limit_bytes().is_none());
1762 }
1763
1764 #[test]
1765 fn test_cache_limit_not_serialized_when_none() {
1766 let cfg = Config::default();
1767 let serialized = toml::to_string_pretty(&cfg).unwrap();
1768 assert!(!serialized.contains("cache_limit"));
1769 }
1770
1771 #[test]
1772 fn player_uses_config_on_init() {
1773 let dir = tempfile::tempdir().unwrap();
1777 let path = dir.path().join("config.toml");
1778 fs::write(
1779 &path,
1780 r#"
1781[playback]
1782replaygain = "track"
1783output_device = "My Fancy DAC"
1784pre_amp_db = -3.5
1785target_fps = 30
1786art_size = 32
1787
1788[visualizer]
1789enabled = false
1790mode = "oscilloscope"
1791fps = 30
1792"#,
1793 )
1794 .unwrap();
1795
1796 let cfg = Config::load_from(&path).unwrap();
1797
1798 assert_eq!(
1800 cfg.playback.replaygain,
1801 ReplayGainMode::Track,
1802 "replaygain should be 'track'"
1803 );
1804 assert_eq!(
1805 cfg.playback.output_device.as_deref(),
1806 Some("My Fancy DAC"),
1807 "output_device should match config"
1808 );
1809 assert!(
1810 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1811 "pre_amp_db should be -3.5"
1812 );
1813 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1814 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1815
1816 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1818 assert_eq!(cfg.visualizer.mode, "oscilloscope");
1819 assert_eq!(cfg.visualizer.fps, 30);
1820 }
1821
1822 #[test]
1823 fn a_missing_config_file_stamps_as_absent() {
1824 let dir = tempfile::tempdir().unwrap();
1825 let base = dir.path().join("config.toml");
1826 let local = dir.path().join("config.local.toml");
1827
1828 assert_eq!(stamp_of(&base, &local), (None, None));
1829
1830 fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
1831 let (base_stamp, local_stamp) = stamp_of(&base, &local);
1832 assert!(base_stamp.is_some(), "creating the file must be a change");
1833 assert!(local_stamp.is_none());
1834 }
1835
1836 #[test]
1837 fn editing_a_config_file_changes_its_stamp() {
1838 let dir = tempfile::tempdir().unwrap();
1839 let base = dir.path().join("config.toml");
1840 let local = dir.path().join("config.local.toml");
1841 fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
1842
1843 let before = stamp_of(&base, &local);
1844 std::thread::sleep(std::time::Duration::from_millis(20));
1846 fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
1847
1848 assert_ne!(
1849 before,
1850 stamp_of(&base, &local),
1851 "a config edited by hand has to be picked up"
1852 );
1853 }
1854
1855 #[test]
1856 fn invalidating_forces_a_reload() {
1857 let first = Config::cached();
1858 Config::invalidate_cache();
1859 assert!(
1860 !Arc::ptr_eq(&first, &Config::cached()),
1861 "koan's own writes invalidate explicitly; the next read must re-parse"
1862 );
1863 }
1864
1865 pub(crate) static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1870
1871 fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
1873 let dir =
1874 std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
1875 let _ = fs::remove_dir_all(&dir);
1876 fs::create_dir_all(&dir).unwrap();
1877 set_config_dir(&dir);
1878 (config_file_path(), config_local_file_path())
1879 }
1880
1881 #[test]
1882 fn persist_keeps_comments_and_untouched_keys() {
1883 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1884 let (base, _local) = persist_sandbox("comments");
1885 fs::write(
1886 &base,
1887 "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
1888 )
1889 .unwrap();
1890
1891 Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
1892
1893 let written = fs::read_to_string(&base).unwrap();
1894 assert!(
1895 written.contains("# koan — shareable defaults"),
1896 "the header comment must survive a write: {written}"
1897 );
1898 assert!(
1899 written.contains("# fps = 60"),
1900 "commented-out defaults are the template's whole point: {written}"
1901 );
1902 assert!(written.contains("palette = \"neon\""));
1903 assert!(
1904 !written.contains("[graphql]"),
1905 "an untouched section must not be invented: {written}"
1906 );
1907 }
1908
1909 #[test]
1910 fn persist_routes_machine_settings_to_the_local_file() {
1911 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1912 let (base, local) = persist_sandbox("routing");
1913
1914 Config::persist(|cfg| {
1915 cfg.playback.replaygain = ReplayGainMode::Album;
1916 cfg.playback.output_device = Some("My DAC".into());
1917 cfg.playback.art_size = 40;
1918 cfg.visualizer.mode = "starfield".into();
1919 })
1920 .unwrap();
1921
1922 let shared = fs::read_to_string(&base).unwrap();
1923 let machine = fs::read_to_string(&local).unwrap();
1924
1925 assert!(shared.contains("replaygain = \"album\""), "{shared}");
1926 for machine_only in ["output_device", "art_size", "starfield"] {
1927 assert!(
1928 !shared.contains(machine_only),
1929 "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
1930 );
1931 assert!(machine.contains(machine_only), "{machine}");
1932 }
1933 }
1934
1935 #[test]
1936 fn persist_writes_dsp_profiles_to_the_local_file_as_sections() {
1937 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1938 let (base, local) = persist_sandbox("dsp");
1939
1940 let profile = DspProfile {
1941 name: "HD 600".into(),
1942 devices: vec!["Topping E30".into()],
1943 preamp_db: None,
1944 filters: vec![
1945 DspFilter::Band(EqFilter {
1946 kind: EqFilterKind::Peaking,
1947 freq: 20.0,
1948 gain_db: -1.3,
1949 q: 2.0,
1950 channels: vec![],
1951 }),
1952 DspFilter::Band(EqFilter {
1953 kind: EqFilterKind::HighShelfFirstOrder,
1954 freq: 10000.0,
1955 gain_db: 2.5,
1956 q: 0.7,
1957 channels: vec![],
1958 }),
1959 DspFilter::Delay(Delay {
1960 ms: 1.5,
1961 channels: vec![1],
1962 ..Default::default()
1963 }),
1964 DspFilter::Mix(Mix {
1965 outputs: vec![vec![(0, 0.5), (1, 0.5)], vec![]],
1966 }),
1967 DspFilter::Graphic(GraphicEq {
1968 points: vec![(20.0, -1.0), (1000.0, 0.0)],
1969 channels: vec![],
1970 }),
1971 ],
1972 impulses: vec![],
1973 source: vec![],
1974 };
1975 Config::persist(|cfg| cfg.dsp.profiles.push(profile.clone())).unwrap();
1976
1977 assert!(!base.exists() || !fs::read_to_string(&base).unwrap().contains("dsp"));
1978 let machine = fs::read_to_string(&local).unwrap();
1979 assert!(machine.contains("[[dsp.profiles]]"), "{machine}");
1980 assert!(
1981 machine.contains("\n { type = \"peaking\""),
1982 "one band per line: {machine}"
1983 );
1984 assert_eq!(Config::from_files().unwrap().dsp.profiles, vec![profile]);
1985 assert_eq!(
1986 Config::from_files()
1987 .unwrap()
1988 .dsp
1989 .profile_for("Topping E30")
1990 .map(|p| p.name.as_str()),
1991 Some("HD 600")
1992 );
1993 }
1994
1995 #[test]
1996 fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
1997 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1998 let (base, _local) = persist_sandbox("folders");
1999
2000 Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
2003
2004 let shared = fs::read_to_string(&base).unwrap_or_default();
2005 assert!(
2006 !shared.contains("folders"),
2007 "a visualiser toggle must not invent library folders: {shared}"
2008 );
2009 }
2010
2011 #[test]
2012 fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
2013 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2014 let (base, local) = persist_sandbox("drain");
2015 fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
2016
2017 Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
2018
2019 let shared = fs::read_to_string(&base).unwrap();
2020 assert!(
2021 !shared.contains("art_size"),
2022 "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
2023 );
2024 assert!(shared.contains("target_fps"), "{shared}");
2025 assert!(
2026 fs::read_to_string(&local)
2027 .unwrap()
2028 .contains("art_size = 48")
2029 );
2030 }
2031
2032 #[test]
2033 fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
2034 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2035 let (_base, local) = persist_sandbox("shadow");
2036 fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
2037
2038 Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
2039
2040 assert_eq!(
2041 Config::from_files().unwrap().playback.target_fps,
2042 120,
2043 "local wins the merge, so a shared write over a local copy would \
2044 otherwise be silently ignored: {}",
2045 fs::read_to_string(&local).unwrap()
2046 );
2047 }
2048
2049 #[test]
2050 fn persist_writes_nothing_when_the_mutation_changes_nothing() {
2051 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2052 let (base, _local) = persist_sandbox("noop");
2053 fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
2054
2055 Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
2056
2057 assert_eq!(
2058 fs::read_to_string(&base).unwrap(),
2059 "# untouched\n[playback]\ntarget_fps = 60\n"
2060 );
2061 }
2062
2063 #[test]
2064 fn persist_keeps_passwords_out_of_the_shared_file() {
2065 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2066 let (base, local) = persist_sandbox("secrets");
2067
2068 Config::persist(|cfg| {
2069 cfg.remote.password = "hunter2".into();
2070 cfg.subsonic.password = "s3cret".into();
2071 cfg.visualizer.palette = "mono".into();
2072 })
2073 .unwrap();
2074
2075 let shared = fs::read_to_string(&base).unwrap();
2076 assert!(!shared.contains("hunter2"), "{shared}");
2077 assert!(!shared.contains("s3cret"), "{shared}");
2078 assert!(shared.contains("mono"));
2079
2080 let machine = fs::read_to_string(&local).unwrap();
2081 assert!(machine.contains("hunter2") && machine.contains("s3cret"));
2082 }
2083
2084 #[test]
2085 fn persist_removes_a_cleared_password_rather_than_blanking_it() {
2086 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2087 let (_base, local) = persist_sandbox("clear-secret");
2088 fs::write(
2089 &local,
2090 "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
2091 )
2092 .unwrap();
2093
2094 Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
2095
2096 let machine = fs::read_to_string(&local).unwrap();
2097 assert!(
2098 !machine.contains("password"),
2099 "an emptied secret should leave no key behind: {machine}"
2100 );
2101 assert!(machine.contains("url"), "{machine}");
2102 }
2103
2104 #[test]
2105 fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
2106 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2107 let (base, _local) = persist_sandbox("patterns");
2108 fs::write(
2109 &base,
2110 "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
2111 )
2112 .unwrap();
2113
2114 Config::persist(|cfg| {
2115 cfg.organize
2116 .patterns
2117 .insert("standard".into(), "%album artist%/%album%".into());
2118 })
2119 .unwrap();
2120
2121 let cfg = Config::from_files().unwrap();
2122 assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
2123 assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
2124 }
2125}