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 pub push: PushConfig,
39 pub devices: DevicesConfig,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44#[serde(default)]
45pub struct SharingConfig {
46 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub public_url: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(default)]
59pub struct PushConfig {
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub key_path: Option<PathBuf>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub key: Option<String>,
67 #[serde(skip_serializing_if = "String::is_empty")]
69 pub key_id: String,
70 #[serde(skip_serializing_if = "String::is_empty")]
72 pub team_id: String,
73 pub topic: String,
75}
76
77impl Default for PushConfig {
78 fn default() -> Self {
79 Self {
80 key_path: None,
81 key: None,
82 key_id: String::new(),
83 team_id: String::new(),
84 topic: "cc.blit.koan".into(),
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(default)]
91pub struct LibraryConfig {
92 pub folders: Vec<PathBuf>,
93 pub analyze_on_scan: bool,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(default)]
100pub struct PlaybackConfig {
101 pub replaygain: ReplayGainMode,
102 pub target_fps: u8,
105 pub show_fps: bool,
107 pub pre_amp_db: f64,
110 pub fade_on_pause: bool,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub output_device: Option<String>,
116 pub art_size: u16,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "lowercase")]
123pub enum ReplayGainMode {
124 Off,
125 Track,
126 Album,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(default)]
131pub struct RemoteConfig {
132 pub enabled: bool,
133 pub url: String,
134 pub username: String,
135 #[serde(default, skip_serializing_if = "String::is_empty")]
137 pub password: String,
138 pub cache_dir: Option<PathBuf>,
140 pub download_workers: usize,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub cache_limit: Option<String>,
147 pub auto_sync: bool,
153 pub auto_sync_interval_mins: u64,
155}
156
157impl Default for LibraryConfig {
158 fn default() -> Self {
159 let music_dir = dirs::audio_dir().unwrap_or_else(|| {
160 dirs::home_dir()
161 .map(|h| h.join("Music"))
162 .unwrap_or_else(|| PathBuf::from("/Music"))
163 });
164 Self {
165 folders: vec![music_dir],
166 analyze_on_scan: false,
167 }
168 }
169}
170
171impl Default for PlaybackConfig {
172 fn default() -> Self {
173 Self {
174 replaygain: ReplayGainMode::Off,
175 target_fps: 60,
176 show_fps: false,
177 pre_amp_db: 0.0,
178 fade_on_pause: true,
179 output_device: None,
180 art_size: 24,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[serde(default)]
187pub struct VisualizerConfig {
188 pub enabled: bool,
189 pub fps: u8,
190 pub mode: String,
193 pub scale: String,
195 pub amplitude_scale: String,
197 pub bar_decay_ms: u32,
199 pub peak_decay_ms: u32,
201 pub palette: String,
204 pub reactivity: f32,
208 pub bass_shake: bool,
211 pub matrix_overlay: bool,
214 pub reactive_bg: bool,
216}
217
218impl Default for VisualizerConfig {
219 fn default() -> Self {
220 Self {
221 enabled: true,
222 fps: 60,
223 mode: "bars".into(),
224 scale: "bark".into(),
225 amplitude_scale: "aweight".into(),
226 bar_decay_ms: 50,
227 peak_decay_ms: 180,
228 palette: "spectrum".into(),
229 reactivity: 1.0,
230 bass_shake: true,
231 matrix_overlay: false,
232 reactive_bg: false,
233 }
234 }
235}
236
237impl Default for RemoteConfig {
238 fn default() -> Self {
239 Self {
240 enabled: false,
241 url: String::new(),
242 username: String::new(),
243 password: String::new(),
244 cache_dir: None,
245 download_workers: 5,
246 cache_limit: None,
247 auto_sync: true,
248 auto_sync_interval_mins: 60,
249 }
250 }
251}
252
253pub fn parse_size_bytes(s: &str) -> Option<u64> {
256 let s = s.trim();
257 if s.is_empty() {
258 return None;
259 }
260
261 let mut num_end = 0;
263 for (i, c) in s.char_indices() {
264 if c.is_ascii_digit() || c == '.' {
265 num_end = i + c.len_utf8();
266 } else if !c.is_whitespace() {
267 break;
268 }
269 }
270
271 let num_str = s[..num_end].trim();
272 let suffix = s[num_end..].trim().to_ascii_uppercase();
273
274 let value: f64 = num_str.parse().ok()?;
275 let multiplier: u64 = match suffix.as_str() {
276 "" | "B" => 1,
277 "KB" | "K" => 1024,
278 "MB" | "M" => 1024 * 1024,
279 "GB" | "G" => 1024 * 1024 * 1024,
280 "TB" | "T" => 1024 * 1024 * 1024 * 1024,
281 _ => return None,
282 };
283
284 Some((value * multiplier as f64) as u64)
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
288#[serde(default)]
289pub struct OrganizeConfig {
290 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub default: Option<String>,
293 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
295 pub patterns: HashMap<String, String>,
296 #[serde(default = "default_true")]
300 pub move_ancillary: bool,
301}
302
303impl Default for OrganizeConfig {
304 fn default() -> Self {
305 Self {
306 default: None,
307 patterns: HashMap::new(),
308 move_ancillary: true,
309 }
310 }
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
315#[serde(default)]
316pub struct GraphqlConfig {
317 pub enabled: bool,
320 pub port: u16,
322 #[serde(default = "default_bind")]
325 pub bind: std::net::IpAddr,
326 pub playground: bool,
328 pub auth_enabled: bool,
331 pub access_token_ttl: String,
333 pub refresh_token_ttl: String,
335 pub cors_origins: Vec<String>,
338 pub allowed_hosts: Vec<String>,
342 pub cookie_secure: bool,
346 pub allow_organize: bool,
348}
349
350fn default_true() -> bool {
351 true
352}
353
354fn default_bind() -> std::net::IpAddr {
355 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
356}
357
358impl Default for GraphqlConfig {
359 fn default() -> Self {
360 Self {
361 enabled: true,
362 port: 4000,
363 bind: default_bind(),
364 playground: false,
365 auth_enabled: true,
366 access_token_ttl: "15m".into(),
367 refresh_token_ttl: "30d".into(),
368 cors_origins: Vec::new(),
369 allowed_hosts: Vec::new(),
370 cookie_secure: false,
371 allow_organize: false,
372 }
373 }
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
384#[serde(default)]
385pub struct SubsonicConfig {
386 pub enabled: bool,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub port: Option<u16>,
394 pub username: String,
396 #[serde(default, skip_serializing_if = "String::is_empty")]
399 pub password: String,
400}
401
402impl Default for SubsonicConfig {
403 fn default() -> Self {
404 Self {
405 enabled: false,
406 port: None,
407 username: "koan".into(),
408 password: String::new(),
409 }
410 }
411}
412
413#[derive(Debug, Clone, Default, Serialize, Deserialize)]
418#[serde(default)]
419pub struct AuthConfig {
420 #[serde(skip_serializing_if = "String::is_empty")]
422 pub server: String,
423 #[serde(skip_serializing_if = "String::is_empty")]
426 pub refresh_token: String,
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
433#[serde(default)]
434pub struct DevicesConfig {
435 pub discoverable: bool,
438 pub port: u16,
441 pub addresses: Vec<String>,
444}
445
446impl Default for DevicesConfig {
447 fn default() -> Self {
448 Self {
449 discoverable: true,
450 port: DEVICES_PORT,
451 addresses: Vec::new(),
452 }
453 }
454}
455
456pub const DEVICES_PORT: u16 = 5626;
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
461#[serde(default)]
462pub struct RadioConfig {
463 pub lookahead: usize,
465 pub batch_size: usize,
467 pub history_window: usize,
469 pub seed_window: usize,
471 pub discovery_weight: f64,
474}
475
476impl Default for RadioConfig {
477 fn default() -> Self {
478 Self {
479 lookahead: 5,
480 batch_size: 5,
481 history_window: 200,
482 seed_window: 5,
483 discovery_weight: 0.3,
484 }
485 }
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490pub enum Layer {
491 Shared,
493 Machine,
495}
496
497pub fn layer_of(path: &str) -> Layer {
507 match path {
508 "remote.password"
510 | "subsonic.password"
511 | "auth.refresh_token"
512 | "push.key"
513 | "push.key_path"
514 | "push.key_id"
515 | "push.team_id"
516 | "library.folders"
518 | "remote.enabled"
519 | "remote.url"
520 | "remote.username"
521 | "remote.cache_dir"
522 | "remote.cache_limit"
523 | "playback.output_device"
525 | "subsonic.enabled"
528 | "subsonic.port"
529 | "subsonic.username"
530 | "devices.discoverable"
532 | "devices.port"
533 | "devices.addresses"
534 | "auth.server"
536 | "playback.art_size"
538 | "visualizer.enabled"
539 | "visualizer.mode"
540 | "visualizer.matrix_overlay"
541 | "visualizer.bass_shake" => Layer::Machine,
542 _ => Layer::Shared,
543 }
544}
545
546type ConfigStamp = (Option<SystemTime>, Option<SystemTime>);
550
551type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
552
553static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
554 LazyLock::new(|| parking_lot::RwLock::new(None));
555
556fn config_stamp() -> ConfigStamp {
557 stamp_of(&config_file_path(), &config_local_file_path())
558}
559
560fn stamp_of(base: &Path, local: &Path) -> ConfigStamp {
562 let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
563 (mtime(base), mtime(local))
564}
565
566impl Config {
567 fn figment() -> Figment {
573 let base_path = config_file_path();
574 let local_path = config_local_file_path();
575
576 Figment::from(Serialized::defaults(Config::default()))
577 .merge(Toml::file(&base_path))
578 .merge(Toml::file(&local_path))
579 .merge(Env::prefixed("KOAN_").split("__"))
580 }
581
582 pub fn load() -> Result<Self, ConfigError> {
584 let cfg: Self = Self::figment()
585 .extract()
586 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
587
588 check_secrets_in_git();
591
592 Ok(cfg)
593 }
594
595 pub fn load_or_default() -> Self {
600 (*Self::cached()).clone()
601 }
602
603 pub fn cached() -> Arc<Config> {
610 let stamp = config_stamp();
611 if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
612 && *seen == stamp
613 {
614 return cfg.clone();
615 }
616
617 let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
618 log::warn!("failed to load config, using defaults: {}", e);
619 Self::default()
620 }));
621 *CONFIG_CACHE.write() = Some((stamp, cfg.clone()));
622 cfg
623 }
624
625 pub fn invalidate_cache() {
629 *CONFIG_CACHE.write() = None;
630 }
631
632 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
634 let contents = fs::read_to_string(path)?;
635 let config: Config = toml::from_str(&contents)?;
636 Ok(config)
637 }
638
639 fn from_files() -> Result<Self, ConfigError> {
645 Figment::from(Serialized::defaults(Config::default()))
646 .merge(Toml::file(config_file_path()))
647 .merge(Toml::file(config_local_file_path()))
648 .extract()
649 .map_err(|e| ConfigError::Figment(Box::new(e)))
650 }
651
652 pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
664 where
665 F: FnOnce(&mut Config),
666 {
667 let before = Self::from_files()?;
668 let mut after = before.clone();
669 mutate(&mut after);
670
671 let mut changes = Vec::new();
672 diff_into(
673 "",
674 &toml::Value::try_from(&before)?,
675 &toml::Value::try_from(&after)?,
676 &mut changes,
677 );
678 if changes.is_empty() {
679 return Ok(());
680 }
681
682 let base_path = config_file_path();
683 let local_path = config_local_file_path();
684 let mut base = read_document(&base_path)?;
685 let mut local = read_document(&local_path)?;
686
687 for (path, value) in &changes {
688 let (target, other) = match layer_of(path) {
689 Layer::Shared => (&mut base, &mut local),
690 Layer::Machine => (&mut local, &mut base),
691 };
692 match value {
693 Some(v) => doc_set(target, path, v),
694 None => doc_remove(target, path),
695 }
696 doc_remove(other, path);
697 }
698
699 write_document(&base_path, &base, false)?;
700 write_document(&local_path, &local, true)?;
701 Self::invalidate_cache();
702 Ok(())
703 }
704
705 pub fn cache_dir(&self) -> PathBuf {
707 self.remote
708 .cache_dir
709 .clone()
710 .unwrap_or_else(default_cache_dir)
711 }
712
713 pub fn cache_limit_bytes(&self) -> Option<u64> {
715 self.remote
716 .cache_limit
717 .as_deref()
718 .and_then(parse_size_bytes)
719 }
720}
721
722fn diff_into(
727 prefix: &str,
728 before: &toml::Value,
729 after: &toml::Value,
730 out: &mut Vec<(String, Option<toml::Value>)>,
731) {
732 let (b, a) = match (before.as_table(), after.as_table()) {
733 (Some(b), Some(a)) => (b, a),
734 _ => {
735 if before != after {
736 out.push((prefix.to_string(), Some(after.clone())));
737 }
738 return;
739 }
740 };
741
742 let empty = toml::Value::Table(toml::map::Map::new());
743 for key in b
744 .keys()
745 .chain(a.keys())
746 .collect::<std::collections::BTreeSet<_>>()
747 {
748 let path = if prefix.is_empty() {
749 key.clone()
750 } else {
751 format!("{prefix}.{key}")
752 };
753 match (b.get(key), a.get(key)) {
754 (Some(bv), Some(av)) => diff_into(&path, bv, av, out),
755 (None, Some(av)) => diff_into(&path, &empty, av, out),
758 (Some(_), None) => out.push((path, None)),
759 (None, None) => unreachable!("key came from one of the two tables"),
760 }
761 }
762}
763
764fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
765 let Ok(contents) = fs::read_to_string(path) else {
766 return Ok(toml_edit::DocumentMut::new());
767 };
768 contents
769 .parse::<toml_edit::DocumentMut>()
770 .map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
771}
772
773fn write_document(
776 path: &Path,
777 doc: &toml_edit::DocumentMut,
778 secret: bool,
779) -> Result<(), ConfigError> {
780 let contents = doc.to_string();
781 if contents.trim().is_empty() && !path.exists() {
782 return Ok(());
783 }
784 if let Some(parent) = path.parent() {
785 fs::create_dir_all(parent)?;
786 }
787 fs::write(path, contents)?;
788 #[cfg(unix)]
789 if secret {
790 use std::os::unix::fs::PermissionsExt;
791 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
792 }
793 #[cfg(not(unix))]
794 let _ = secret;
795 Ok(())
796}
797
798fn implicit_table() -> toml_edit::Item {
799 let mut table = toml_edit::Table::new();
800 table.set_implicit(true);
803 toml_edit::Item::Table(table)
804}
805
806fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
807 let segments: Vec<&str> = path.split('.').collect();
808 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
809
810 let mut table = doc.as_table_mut();
811 for segment in parents {
812 let item = table.entry(segment).or_insert_with(implicit_table);
813 if !item.is_table() {
816 *item = implicit_table();
817 }
818 table = item.as_table_mut().expect("just ensured it is a table");
819 }
820 match table.get_mut(last) {
823 Some(existing) => *existing = toml_edit::value(to_edit_value(value)),
824 None => {
825 table.insert(last, toml_edit::value(to_edit_value(value)));
826 }
827 }
828}
829
830fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
831 let segments: Vec<&str> = path.split('.').collect();
832 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
833
834 let mut table = doc.as_table_mut();
835 for segment in parents {
836 match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
837 Some(child) => table = child,
838 None => return,
839 }
840 }
841 table.remove(last);
844}
845
846fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
847 match value {
848 toml::Value::String(s) => s.as_str().into(),
849 toml::Value::Integer(i) => (*i).into(),
850 toml::Value::Float(f) => (*f).into(),
851 toml::Value::Boolean(b) => (*b).into(),
852 toml::Value::Datetime(d) => d.to_string().into(),
853 toml::Value::Array(items) => items
854 .iter()
855 .map(to_edit_value)
856 .collect::<toml_edit::Array>()
857 .into(),
858 toml::Value::Table(t) => {
859 let mut inline = toml_edit::InlineTable::new();
860 for (k, v) in t {
861 inline.insert(k, to_edit_value(v));
862 }
863 inline.into()
864 }
865 }
866}
867
868pub fn config_dir() -> PathBuf {
877 if let Some(dir) = CONFIG_DIR.read().clone() {
878 return dir;
879 }
880 if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
881 return PathBuf::from(dir);
882 }
883 platform_config_dir()
884}
885
886#[cfg(not(target_os = "ios"))]
887fn platform_config_dir() -> PathBuf {
888 dirs::home_dir()
889 .unwrap_or_else(|| PathBuf::from("."))
890 .join(".config")
891 .join("koan")
892}
893
894#[cfg(target_os = "ios")]
898fn platform_config_dir() -> PathBuf {
899 ios_library().join("Application Support").join("koan")
900}
901
902#[cfg(target_os = "ios")]
903fn ios_library() -> PathBuf {
904 dirs::home_dir()
905 .unwrap_or_else(|| PathBuf::from("."))
906 .join("Library")
907}
908
909fn default_cache_dir() -> PathBuf {
916 #[cfg(target_os = "ios")]
917 if CONFIG_DIR.read().is_none() && std::env::var_os("KOAN_CONFIG_DIR").is_none() {
918 return ios_library().join("Caches").join("koan");
919 }
920 config_dir().join("cache")
921}
922
923pub fn set_config_dir(dir: impl Into<PathBuf>) {
930 *CONFIG_DIR.write() = Some(dir.into());
931 Config::invalidate_cache();
932}
933
934pub fn isolate_config_for_tests() {
946 let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
947 let _ = fs::create_dir_all(&dir);
948 set_config_dir(dir);
949}
950
951static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
952 LazyLock::new(|| parking_lot::RwLock::new(None));
953
954pub fn config_file_path() -> PathBuf {
956 config_dir().join("config.toml")
957}
958
959pub fn config_local_file_path() -> PathBuf {
961 config_dir().join("config.local.toml")
962}
963
964pub fn db_path() -> PathBuf {
966 config_dir().join("koan.db")
967}
968
969const LOG_LIMIT: u64 = 16 * 1024 * 1024;
972const LOG_CHECK_EVERY: u32 = 4096;
975
976#[derive(Default)]
978pub struct LogFile {
979 file: Option<fs::File>,
980 lines: u32,
981}
982
983impl LogFile {
984 pub fn write(&mut self, line: std::fmt::Arguments) {
985 use std::io::Write as _;
986 if self.file.is_none() {
987 self.file = open_log();
988 }
989 let Some(file) = self.file.as_mut() else {
990 return;
991 };
992 let _ = writeln!(file, "{line}");
993 self.lines = self.lines.wrapping_add(1);
994 if self.lines.is_multiple_of(LOG_CHECK_EVERY)
995 && file.metadata().is_ok_and(|m| m.len() > LOG_LIMIT)
996 {
997 self.file = open_log();
998 }
999 }
1000
1001 pub fn flush(&mut self) {
1002 if let Some(file) = self.file.as_mut() {
1003 let _ = std::io::Write::flush(file);
1004 }
1005 }
1006}
1007
1008fn open_log() -> Option<fs::File> {
1014 let dir = config_dir();
1015 fs::create_dir_all(&dir).ok()?;
1016 let path = dir.join("koan.log");
1017 if fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) {
1018 let _ = fs::rename(&path, dir.join("koan.log.1"));
1019 }
1020 fs::OpenOptions::new()
1021 .create(true)
1022 .append(true)
1023 .open(path)
1024 .ok()
1025}
1026
1027fn check_secrets_in_git() {
1034 static ONCE: Once = Once::new();
1035 ONCE.call_once(scan_for_tracked_secrets);
1036}
1037
1038fn scan_for_tracked_secrets() {
1039 let sensitive_fields = ["password", "refresh_token"];
1040
1041 for (label, path) in [
1042 ("config.toml", config_file_path()),
1043 ("config.local.toml", config_local_file_path()),
1044 ] {
1045 let Ok(contents) = std::fs::read_to_string(&path) else {
1046 continue;
1047 };
1048
1049 let has_secrets = sensitive_fields.iter().any(|field| {
1051 contents.lines().any(|line| {
1052 let line = line.trim();
1053 if let Some(rest) = line.strip_prefix(field) {
1054 let rest = rest.trim_start();
1055 if let Some(value) = rest.strip_prefix('=') {
1056 let value = value.trim().trim_matches('"').trim_matches('\'');
1057 return !value.is_empty();
1058 }
1059 }
1060 false
1061 })
1062 });
1063
1064 if !has_secrets {
1065 continue;
1066 }
1067
1068 if is_tracked_by_git(&path) {
1070 eprintln!();
1071 eprintln!("╔══════════════════════════════════════════════════════════════╗");
1072 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
1073 eprintln!("╠══════════════════════════════════════════════════════════════╣");
1074 eprintln!("║ ║");
1075 eprintln!("║ File: {:<52} ║", path.display());
1076 eprintln!("║ ║");
1077 eprintln!("║ Your password is in version control. You should: ║");
1078 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
1079 eprintln!("║ 2. Add it to .gitignore ║");
1080 eprintln!("║ 3. Rotate your credentials immediately ║");
1081 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
1082 eprintln!("║ `koan remote login` writes there for you ║");
1083 eprintln!("║ ║");
1084 eprintln!("╚══════════════════════════════════════════════════════════════╝");
1085 eprintln!();
1086 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
1087 }
1088 }
1089}
1090
1091fn is_tracked_by_git(path: &Path) -> bool {
1093 let Some(parent) = path.parent() else {
1094 return false;
1095 };
1096 std::process::Command::new("git")
1098 .args(["ls-files", "--error-unmatch"])
1099 .arg(path)
1100 .current_dir(parent)
1101 .stdout(std::process::Stdio::null())
1102 .stderr(std::process::Stdio::null())
1103 .status()
1104 .is_ok_and(|s| s.success())
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109 use super::*;
1110 use std::fs;
1111
1112 fn tmp_dir() -> PathBuf {
1113 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
1114 fs::create_dir_all(&dir).unwrap();
1115 dir
1116 }
1117
1118 #[test]
1119 fn test_defaults() {
1120 let cfg = Config::default();
1121 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1122 assert!(!cfg.remote.enabled);
1123 }
1124
1125 #[test]
1126 fn test_roundtrip_toml() {
1127 let cfg = Config::default();
1128 let serialized = toml::to_string_pretty(&cfg).unwrap();
1129 let deserialized: Config = toml::from_str(&serialized).unwrap();
1130 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
1131 assert_eq!(
1132 deserialized.remote.download_workers,
1133 cfg.remote.download_workers
1134 );
1135 }
1136
1137 #[test]
1138 fn test_load_from_file() {
1139 let dir = tempfile::tempdir().unwrap();
1140 let path = dir.path().join("config.toml");
1141 fs::write(
1142 &path,
1143 r#"
1144[library]
1145folders = ["/tmp/music"]
1146
1147[playback]
1148replaygain = "track"
1149"#,
1150 )
1151 .unwrap();
1152
1153 let cfg = Config::load_from(&path).unwrap();
1154 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
1155 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
1156 assert!(!cfg.remote.enabled);
1157 }
1158
1159 #[test]
1160 fn test_partial_toml_uses_defaults() {
1161 let dir = tempfile::tempdir().unwrap();
1162 let path = dir.path().join("partial.toml");
1163 fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
1164
1165 let cfg = Config::load_from(&path).unwrap();
1166 assert_eq!(cfg.playback.target_fps, 30);
1167 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1168 }
1169
1170 #[test]
1171 fn test_figment_layered_loading() {
1172 let dir = tempfile::tempdir().unwrap();
1173 let base_path = dir.path().join("config.toml");
1174 let local_path = dir.path().join("config.local.toml");
1175
1176 fs::write(
1177 &base_path,
1178 r#"
1179[remote]
1180url = "https://base.example.com"
1181"#,
1182 )
1183 .unwrap();
1184 fs::write(
1185 &local_path,
1186 r#"
1187[remote]
1188enabled = true
1189url = "https://local.example.com"
1190username = "admin"
1191password = "secret"
1192"#,
1193 )
1194 .unwrap();
1195
1196 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1198 .merge(Toml::file(&base_path))
1199 .merge(Toml::file(&local_path))
1200 .extract()
1201 .unwrap();
1202
1203 assert!(cfg.remote.enabled);
1204 assert_eq!(cfg.remote.url, "https://local.example.com");
1205 assert_eq!(cfg.remote.username, "admin");
1206 assert_eq!(cfg.remote.password, "secret");
1207 }
1208
1209 #[test]
1210 fn test_figment_missing_keys_preserved() {
1211 let dir = tempfile::tempdir().unwrap();
1212 let base_path = dir.path().join("config.toml");
1213 let local_path = dir.path().join("config.local.toml");
1214
1215 fs::write(
1216 &base_path,
1217 r#"
1218[remote]
1219url = "https://keep.me"
1220username = "keepuser"
1221"#,
1222 )
1223 .unwrap();
1224 fs::write(
1225 &local_path,
1226 r#"
1227[remote]
1228password = "secret"
1229"#,
1230 )
1231 .unwrap();
1232
1233 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1234 .merge(Toml::file(&base_path))
1235 .merge(Toml::file(&local_path))
1236 .extract()
1237 .unwrap();
1238
1239 assert_eq!(cfg.remote.url, "https://keep.me");
1240 assert_eq!(cfg.remote.username, "keepuser");
1241 assert_eq!(cfg.remote.password, "secret");
1242 }
1243
1244 #[test]
1245 fn test_env_var_override() {
1246 let dir = tempfile::tempdir().unwrap();
1247 let base_path = dir.path().join("config.toml");
1248
1249 fs::write(
1250 &base_path,
1251 r#"
1252[remote]
1253url = "https://file.example.com"
1254"#,
1255 )
1256 .unwrap();
1257
1258 unsafe {
1260 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
1261 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
1262 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
1263 }
1264
1265 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1266 .merge(Toml::file(&base_path))
1267 .merge(Env::prefixed("KOAN_").split("__"))
1268 .extract()
1269 .unwrap();
1270
1271 assert_eq!(cfg.remote.url, "https://env.example.com");
1272 assert_eq!(cfg.remote.password, "env-secret");
1273 assert_eq!(cfg.graphql.port, 9999);
1274
1275 unsafe {
1277 std::env::remove_var("KOAN_REMOTE__URL");
1278 std::env::remove_var("KOAN_REMOTE__PASSWORD");
1279 std::env::remove_var("KOAN_GRAPHQL__PORT");
1280 }
1281 }
1282
1283 #[test]
1284 fn test_cache_dir_default() {
1285 let cfg = Config::default();
1286 assert!(cfg.cache_dir().ends_with("cache"));
1287 }
1288
1289 #[test]
1290 fn test_cache_dir_explicit() {
1291 let mut cfg = Config::default();
1292 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
1293 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
1294 }
1295
1296 #[test]
1297 fn test_organize_config_defaults() {
1298 let cfg = Config::default();
1299 assert!(cfg.organize.default.is_none());
1300 assert!(cfg.organize.patterns.is_empty());
1301 }
1302
1303 #[test]
1304 fn test_organize_config_from_toml() {
1305 let dir = tmp_dir();
1306 let path = dir.join("organize.toml");
1307 fs::write(
1308 &path,
1309 r#"
1310[organize]
1311default = "standard"
1312
1313[organize.patterns]
1314standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
1315va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
1316"#,
1317 )
1318 .unwrap();
1319
1320 let cfg = Config::load_from(&path).unwrap();
1321 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
1322 assert_eq!(cfg.organize.patterns.len(), 2);
1323 assert!(cfg.organize.patterns.contains_key("standard"));
1324 assert!(cfg.organize.patterns.contains_key("va-aware"));
1325
1326 fs::remove_dir_all(&dir).ok();
1327 }
1328
1329 #[test]
1330 fn test_figment_organize_patterns_merge() {
1331 let dir = tempfile::tempdir().unwrap();
1332 let base_path = dir.path().join("config.toml");
1333 let local_path = dir.path().join("config.local.toml");
1334
1335 fs::write(
1336 &base_path,
1337 r#"
1338[organize]
1339default = "standard"
1340
1341[organize.patterns]
1342standard = "base-pattern"
1343"#,
1344 )
1345 .unwrap();
1346 fs::write(
1347 &local_path,
1348 r#"
1349[organize]
1350default = "custom"
1351
1352[organize.patterns]
1353custom = "local-pattern"
1354"#,
1355 )
1356 .unwrap();
1357
1358 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1359 .merge(Toml::file(&base_path))
1360 .merge(Toml::file(&local_path))
1361 .extract()
1362 .unwrap();
1363
1364 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
1366 assert_eq!(cfg.organize.patterns.len(), 2);
1368 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
1369 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
1370 }
1371
1372 #[test]
1373 fn test_output_device_config_roundtrip() {
1374 let mut cfg = Config::default();
1375 cfg.playback.output_device = Some("My DAC".into());
1376
1377 let serialized = toml::to_string_pretty(&cfg).unwrap();
1378 let deserialized: Config = toml::from_str(&serialized).unwrap();
1379 assert_eq!(
1380 deserialized.playback.output_device.as_deref(),
1381 Some("My DAC")
1382 );
1383 }
1384
1385 #[test]
1386 fn test_output_device_config_default_is_none() {
1387 let cfg = Config::default();
1388 assert!(cfg.playback.output_device.is_none());
1389
1390 let serialized = toml::to_string_pretty(&cfg).unwrap();
1392 assert!(!serialized.contains("output_device"));
1393 let deserialized: Config = toml::from_str(&serialized).unwrap();
1394 assert!(deserialized.playback.output_device.is_none());
1395 }
1396
1397 #[test]
1398 fn test_output_device_config_from_toml() {
1399 let dir = tempfile::tempdir().unwrap();
1400 let path = dir.path().join("config.toml");
1401 fs::write(
1402 &path,
1403 r#"
1404[playback]
1405output_device = "External Speakers"
1406"#,
1407 )
1408 .unwrap();
1409
1410 let cfg = Config::load_from(&path).unwrap();
1411 assert_eq!(
1412 cfg.playback.output_device.as_deref(),
1413 Some("External Speakers")
1414 );
1415 }
1416
1417 #[test]
1418 fn test_graphql_bind_defaults_to_localhost() {
1419 let cfg = GraphqlConfig::default();
1420 assert_eq!(
1421 cfg.bind,
1422 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1423 );
1424 }
1425
1426 #[test]
1427 fn test_graphql_bind_from_toml() {
1428 let toml_str = r#"
1429[graphql]
1430bind = "0.0.0.0"
1431port = 5000
1432"#;
1433 let cfg: Config = toml::from_str(toml_str).unwrap();
1434 assert_eq!(
1435 cfg.graphql.bind,
1436 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
1437 );
1438 assert_eq!(cfg.graphql.port, 5000);
1439 }
1440
1441 #[test]
1442 fn test_graphql_bind_omitted_defaults_to_localhost() {
1443 let toml_str = r#"
1444[graphql]
1445port = 4000
1446"#;
1447 let cfg: Config = toml::from_str(toml_str).unwrap();
1448 assert_eq!(
1449 cfg.graphql.bind,
1450 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1451 );
1452 }
1453
1454 #[test]
1455 fn test_organize_config_roundtrip() {
1456 let mut cfg = Config::default();
1457 cfg.organize.default = Some("standard".into());
1458 cfg.organize
1459 .patterns
1460 .insert("standard".into(), "%artist%/%title%".into());
1461
1462 let serialized = toml::to_string_pretty(&cfg).unwrap();
1463 let deserialized: Config = toml::from_str(&serialized).unwrap();
1464 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
1465 assert_eq!(
1466 deserialized.organize.patterns["standard"],
1467 "%artist%/%title%"
1468 );
1469 }
1470
1471 #[test]
1472 fn test_parse_size_bytes() {
1473 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1474 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1475 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1476 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1477 assert_eq!(parse_size_bytes("1024B"), Some(1024));
1478 assert_eq!(parse_size_bytes("1024"), Some(1024));
1479
1480 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1482 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1483
1484 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1486 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1487
1488 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1490 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1491
1492 assert_eq!(
1494 parse_size_bytes("1.5GB"),
1495 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1496 );
1497
1498 assert_eq!(parse_size_bytes(""), None);
1500 assert_eq!(parse_size_bytes("abc"), None);
1501 assert_eq!(parse_size_bytes("50XB"), None);
1502 }
1503
1504 #[test]
1505 fn test_cache_limit_config_from_toml() {
1506 let toml_str = r#"
1507[remote]
1508cache_limit = "50GB"
1509"#;
1510 let cfg: Config = toml::from_str(toml_str).unwrap();
1511 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1512 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1513 }
1514
1515 #[test]
1516 fn test_cache_limit_none_by_default() {
1517 let cfg = Config::default();
1518 assert!(cfg.remote.cache_limit.is_none());
1519 assert!(cfg.cache_limit_bytes().is_none());
1520 }
1521
1522 #[test]
1523 fn test_cache_limit_not_serialized_when_none() {
1524 let cfg = Config::default();
1525 let serialized = toml::to_string_pretty(&cfg).unwrap();
1526 assert!(!serialized.contains("cache_limit"));
1527 }
1528
1529 #[test]
1530 fn player_uses_config_on_init() {
1531 let dir = tempfile::tempdir().unwrap();
1535 let path = dir.path().join("config.toml");
1536 fs::write(
1537 &path,
1538 r#"
1539[playback]
1540replaygain = "track"
1541output_device = "My Fancy DAC"
1542pre_amp_db = -3.5
1543target_fps = 30
1544art_size = 32
1545
1546[visualizer]
1547enabled = false
1548mode = "oscilloscope"
1549fps = 30
1550"#,
1551 )
1552 .unwrap();
1553
1554 let cfg = Config::load_from(&path).unwrap();
1555
1556 assert_eq!(
1558 cfg.playback.replaygain,
1559 ReplayGainMode::Track,
1560 "replaygain should be 'track'"
1561 );
1562 assert_eq!(
1563 cfg.playback.output_device.as_deref(),
1564 Some("My Fancy DAC"),
1565 "output_device should match config"
1566 );
1567 assert!(
1568 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1569 "pre_amp_db should be -3.5"
1570 );
1571 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1572 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1573
1574 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1576 assert_eq!(cfg.visualizer.mode, "oscilloscope");
1577 assert_eq!(cfg.visualizer.fps, 30);
1578 }
1579
1580 #[test]
1581 fn a_missing_config_file_stamps_as_absent() {
1582 let dir = tempfile::tempdir().unwrap();
1583 let base = dir.path().join("config.toml");
1584 let local = dir.path().join("config.local.toml");
1585
1586 assert_eq!(stamp_of(&base, &local), (None, None));
1587
1588 fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
1589 let (base_stamp, local_stamp) = stamp_of(&base, &local);
1590 assert!(base_stamp.is_some(), "creating the file must be a change");
1591 assert!(local_stamp.is_none());
1592 }
1593
1594 #[test]
1595 fn editing_a_config_file_changes_its_stamp() {
1596 let dir = tempfile::tempdir().unwrap();
1597 let base = dir.path().join("config.toml");
1598 let local = dir.path().join("config.local.toml");
1599 fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
1600
1601 let before = stamp_of(&base, &local);
1602 std::thread::sleep(std::time::Duration::from_millis(20));
1604 fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
1605
1606 assert_ne!(
1607 before,
1608 stamp_of(&base, &local),
1609 "a config edited by hand has to be picked up"
1610 );
1611 }
1612
1613 #[test]
1614 fn invalidating_forces_a_reload() {
1615 let first = Config::cached();
1616 Config::invalidate_cache();
1617 assert!(
1618 !Arc::ptr_eq(&first, &Config::cached()),
1619 "koan's own writes invalidate explicitly; the next read must re-parse"
1620 );
1621 }
1622
1623 static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1628
1629 fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
1631 let dir =
1632 std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
1633 let _ = fs::remove_dir_all(&dir);
1634 fs::create_dir_all(&dir).unwrap();
1635 set_config_dir(&dir);
1636 (config_file_path(), config_local_file_path())
1637 }
1638
1639 #[test]
1640 fn persist_keeps_comments_and_untouched_keys() {
1641 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1642 let (base, _local) = persist_sandbox("comments");
1643 fs::write(
1644 &base,
1645 "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
1646 )
1647 .unwrap();
1648
1649 Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
1650
1651 let written = fs::read_to_string(&base).unwrap();
1652 assert!(
1653 written.contains("# koan — shareable defaults"),
1654 "the header comment must survive a write: {written}"
1655 );
1656 assert!(
1657 written.contains("# fps = 60"),
1658 "commented-out defaults are the template's whole point: {written}"
1659 );
1660 assert!(written.contains("palette = \"neon\""));
1661 assert!(
1662 !written.contains("[graphql]"),
1663 "an untouched section must not be invented: {written}"
1664 );
1665 }
1666
1667 #[test]
1668 fn persist_routes_machine_settings_to_the_local_file() {
1669 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1670 let (base, local) = persist_sandbox("routing");
1671
1672 Config::persist(|cfg| {
1673 cfg.playback.replaygain = ReplayGainMode::Album;
1674 cfg.playback.output_device = Some("My DAC".into());
1675 cfg.playback.art_size = 40;
1676 cfg.visualizer.mode = "starfield".into();
1677 })
1678 .unwrap();
1679
1680 let shared = fs::read_to_string(&base).unwrap();
1681 let machine = fs::read_to_string(&local).unwrap();
1682
1683 assert!(shared.contains("replaygain = \"album\""), "{shared}");
1684 for machine_only in ["output_device", "art_size", "starfield"] {
1685 assert!(
1686 !shared.contains(machine_only),
1687 "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
1688 );
1689 assert!(machine.contains(machine_only), "{machine}");
1690 }
1691 }
1692
1693 #[test]
1694 fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
1695 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1696 let (base, _local) = persist_sandbox("folders");
1697
1698 Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
1701
1702 let shared = fs::read_to_string(&base).unwrap_or_default();
1703 assert!(
1704 !shared.contains("folders"),
1705 "a visualiser toggle must not invent library folders: {shared}"
1706 );
1707 }
1708
1709 #[test]
1710 fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
1711 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1712 let (base, local) = persist_sandbox("drain");
1713 fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
1714
1715 Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
1716
1717 let shared = fs::read_to_string(&base).unwrap();
1718 assert!(
1719 !shared.contains("art_size"),
1720 "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
1721 );
1722 assert!(shared.contains("target_fps"), "{shared}");
1723 assert!(
1724 fs::read_to_string(&local)
1725 .unwrap()
1726 .contains("art_size = 48")
1727 );
1728 }
1729
1730 #[test]
1731 fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
1732 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1733 let (_base, local) = persist_sandbox("shadow");
1734 fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
1735
1736 Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
1737
1738 assert_eq!(
1739 Config::from_files().unwrap().playback.target_fps,
1740 120,
1741 "local wins the merge, so a shared write over a local copy would \
1742 otherwise be silently ignored: {}",
1743 fs::read_to_string(&local).unwrap()
1744 );
1745 }
1746
1747 #[test]
1748 fn persist_writes_nothing_when_the_mutation_changes_nothing() {
1749 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1750 let (base, _local) = persist_sandbox("noop");
1751 fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
1752
1753 Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
1754
1755 assert_eq!(
1756 fs::read_to_string(&base).unwrap(),
1757 "# untouched\n[playback]\ntarget_fps = 60\n"
1758 );
1759 }
1760
1761 #[test]
1762 fn persist_keeps_passwords_out_of_the_shared_file() {
1763 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1764 let (base, local) = persist_sandbox("secrets");
1765
1766 Config::persist(|cfg| {
1767 cfg.remote.password = "hunter2".into();
1768 cfg.subsonic.password = "s3cret".into();
1769 cfg.visualizer.palette = "mono".into();
1770 })
1771 .unwrap();
1772
1773 let shared = fs::read_to_string(&base).unwrap();
1774 assert!(!shared.contains("hunter2"), "{shared}");
1775 assert!(!shared.contains("s3cret"), "{shared}");
1776 assert!(shared.contains("mono"));
1777
1778 let machine = fs::read_to_string(&local).unwrap();
1779 assert!(machine.contains("hunter2") && machine.contains("s3cret"));
1780 }
1781
1782 #[test]
1783 fn persist_removes_a_cleared_password_rather_than_blanking_it() {
1784 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1785 let (_base, local) = persist_sandbox("clear-secret");
1786 fs::write(
1787 &local,
1788 "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
1789 )
1790 .unwrap();
1791
1792 Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
1793
1794 let machine = fs::read_to_string(&local).unwrap();
1795 assert!(
1796 !machine.contains("password"),
1797 "an emptied secret should leave no key behind: {machine}"
1798 );
1799 assert!(machine.contains("url"), "{machine}");
1800 }
1801
1802 #[test]
1803 fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
1804 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1805 let (base, _local) = persist_sandbox("patterns");
1806 fs::write(
1807 &base,
1808 "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
1809 )
1810 .unwrap();
1811
1812 Config::persist(|cfg| {
1813 cfg.organize
1814 .patterns
1815 .insert("standard".into(), "%album artist%/%album%".into());
1816 })
1817 .unwrap();
1818
1819 let cfg = Config::from_files().unwrap();
1820 assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
1821 assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
1822 }
1823}