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
969pub fn open_log() -> Option<fs::File> {
974 let dir = config_dir();
975 fs::create_dir_all(&dir).ok()?;
976 fs::OpenOptions::new()
977 .create(true)
978 .append(true)
979 .open(dir.join("koan.log"))
980 .ok()
981}
982
983fn check_secrets_in_git() {
990 static ONCE: Once = Once::new();
991 ONCE.call_once(scan_for_tracked_secrets);
992}
993
994fn scan_for_tracked_secrets() {
995 let sensitive_fields = ["password", "refresh_token"];
996
997 for (label, path) in [
998 ("config.toml", config_file_path()),
999 ("config.local.toml", config_local_file_path()),
1000 ] {
1001 let Ok(contents) = std::fs::read_to_string(&path) else {
1002 continue;
1003 };
1004
1005 let has_secrets = sensitive_fields.iter().any(|field| {
1007 contents.lines().any(|line| {
1008 let line = line.trim();
1009 if let Some(rest) = line.strip_prefix(field) {
1010 let rest = rest.trim_start();
1011 if let Some(value) = rest.strip_prefix('=') {
1012 let value = value.trim().trim_matches('"').trim_matches('\'');
1013 return !value.is_empty();
1014 }
1015 }
1016 false
1017 })
1018 });
1019
1020 if !has_secrets {
1021 continue;
1022 }
1023
1024 if is_tracked_by_git(&path) {
1026 eprintln!();
1027 eprintln!("╔══════════════════════════════════════════════════════════════╗");
1028 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
1029 eprintln!("╠══════════════════════════════════════════════════════════════╣");
1030 eprintln!("║ ║");
1031 eprintln!("║ File: {:<52} ║", path.display());
1032 eprintln!("║ ║");
1033 eprintln!("║ Your password is in version control. You should: ║");
1034 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
1035 eprintln!("║ 2. Add it to .gitignore ║");
1036 eprintln!("║ 3. Rotate your credentials immediately ║");
1037 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
1038 eprintln!("║ `koan remote login` writes there for you ║");
1039 eprintln!("║ ║");
1040 eprintln!("╚══════════════════════════════════════════════════════════════╝");
1041 eprintln!();
1042 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
1043 }
1044 }
1045}
1046
1047fn is_tracked_by_git(path: &Path) -> bool {
1049 let Some(parent) = path.parent() else {
1050 return false;
1051 };
1052 std::process::Command::new("git")
1054 .args(["ls-files", "--error-unmatch"])
1055 .arg(path)
1056 .current_dir(parent)
1057 .stdout(std::process::Stdio::null())
1058 .stderr(std::process::Stdio::null())
1059 .status()
1060 .is_ok_and(|s| s.success())
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065 use super::*;
1066 use std::fs;
1067
1068 fn tmp_dir() -> PathBuf {
1069 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
1070 fs::create_dir_all(&dir).unwrap();
1071 dir
1072 }
1073
1074 #[test]
1075 fn test_defaults() {
1076 let cfg = Config::default();
1077 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1078 assert!(!cfg.remote.enabled);
1079 }
1080
1081 #[test]
1082 fn test_roundtrip_toml() {
1083 let cfg = Config::default();
1084 let serialized = toml::to_string_pretty(&cfg).unwrap();
1085 let deserialized: Config = toml::from_str(&serialized).unwrap();
1086 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
1087 assert_eq!(
1088 deserialized.remote.download_workers,
1089 cfg.remote.download_workers
1090 );
1091 }
1092
1093 #[test]
1094 fn test_load_from_file() {
1095 let dir = tempfile::tempdir().unwrap();
1096 let path = dir.path().join("config.toml");
1097 fs::write(
1098 &path,
1099 r#"
1100[library]
1101folders = ["/tmp/music"]
1102
1103[playback]
1104replaygain = "track"
1105"#,
1106 )
1107 .unwrap();
1108
1109 let cfg = Config::load_from(&path).unwrap();
1110 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
1111 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
1112 assert!(!cfg.remote.enabled);
1113 }
1114
1115 #[test]
1116 fn test_partial_toml_uses_defaults() {
1117 let dir = tempfile::tempdir().unwrap();
1118 let path = dir.path().join("partial.toml");
1119 fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
1120
1121 let cfg = Config::load_from(&path).unwrap();
1122 assert_eq!(cfg.playback.target_fps, 30);
1123 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1124 }
1125
1126 #[test]
1127 fn test_figment_layered_loading() {
1128 let dir = tempfile::tempdir().unwrap();
1129 let base_path = dir.path().join("config.toml");
1130 let local_path = dir.path().join("config.local.toml");
1131
1132 fs::write(
1133 &base_path,
1134 r#"
1135[remote]
1136url = "https://base.example.com"
1137"#,
1138 )
1139 .unwrap();
1140 fs::write(
1141 &local_path,
1142 r#"
1143[remote]
1144enabled = true
1145url = "https://local.example.com"
1146username = "admin"
1147password = "secret"
1148"#,
1149 )
1150 .unwrap();
1151
1152 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1154 .merge(Toml::file(&base_path))
1155 .merge(Toml::file(&local_path))
1156 .extract()
1157 .unwrap();
1158
1159 assert!(cfg.remote.enabled);
1160 assert_eq!(cfg.remote.url, "https://local.example.com");
1161 assert_eq!(cfg.remote.username, "admin");
1162 assert_eq!(cfg.remote.password, "secret");
1163 }
1164
1165 #[test]
1166 fn test_figment_missing_keys_preserved() {
1167 let dir = tempfile::tempdir().unwrap();
1168 let base_path = dir.path().join("config.toml");
1169 let local_path = dir.path().join("config.local.toml");
1170
1171 fs::write(
1172 &base_path,
1173 r#"
1174[remote]
1175url = "https://keep.me"
1176username = "keepuser"
1177"#,
1178 )
1179 .unwrap();
1180 fs::write(
1181 &local_path,
1182 r#"
1183[remote]
1184password = "secret"
1185"#,
1186 )
1187 .unwrap();
1188
1189 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1190 .merge(Toml::file(&base_path))
1191 .merge(Toml::file(&local_path))
1192 .extract()
1193 .unwrap();
1194
1195 assert_eq!(cfg.remote.url, "https://keep.me");
1196 assert_eq!(cfg.remote.username, "keepuser");
1197 assert_eq!(cfg.remote.password, "secret");
1198 }
1199
1200 #[test]
1201 fn test_env_var_override() {
1202 let dir = tempfile::tempdir().unwrap();
1203 let base_path = dir.path().join("config.toml");
1204
1205 fs::write(
1206 &base_path,
1207 r#"
1208[remote]
1209url = "https://file.example.com"
1210"#,
1211 )
1212 .unwrap();
1213
1214 unsafe {
1216 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
1217 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
1218 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
1219 }
1220
1221 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1222 .merge(Toml::file(&base_path))
1223 .merge(Env::prefixed("KOAN_").split("__"))
1224 .extract()
1225 .unwrap();
1226
1227 assert_eq!(cfg.remote.url, "https://env.example.com");
1228 assert_eq!(cfg.remote.password, "env-secret");
1229 assert_eq!(cfg.graphql.port, 9999);
1230
1231 unsafe {
1233 std::env::remove_var("KOAN_REMOTE__URL");
1234 std::env::remove_var("KOAN_REMOTE__PASSWORD");
1235 std::env::remove_var("KOAN_GRAPHQL__PORT");
1236 }
1237 }
1238
1239 #[test]
1240 fn test_cache_dir_default() {
1241 let cfg = Config::default();
1242 assert!(cfg.cache_dir().ends_with("cache"));
1243 }
1244
1245 #[test]
1246 fn test_cache_dir_explicit() {
1247 let mut cfg = Config::default();
1248 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
1249 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
1250 }
1251
1252 #[test]
1253 fn test_organize_config_defaults() {
1254 let cfg = Config::default();
1255 assert!(cfg.organize.default.is_none());
1256 assert!(cfg.organize.patterns.is_empty());
1257 }
1258
1259 #[test]
1260 fn test_organize_config_from_toml() {
1261 let dir = tmp_dir();
1262 let path = dir.join("organize.toml");
1263 fs::write(
1264 &path,
1265 r#"
1266[organize]
1267default = "standard"
1268
1269[organize.patterns]
1270standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
1271va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
1272"#,
1273 )
1274 .unwrap();
1275
1276 let cfg = Config::load_from(&path).unwrap();
1277 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
1278 assert_eq!(cfg.organize.patterns.len(), 2);
1279 assert!(cfg.organize.patterns.contains_key("standard"));
1280 assert!(cfg.organize.patterns.contains_key("va-aware"));
1281
1282 fs::remove_dir_all(&dir).ok();
1283 }
1284
1285 #[test]
1286 fn test_figment_organize_patterns_merge() {
1287 let dir = tempfile::tempdir().unwrap();
1288 let base_path = dir.path().join("config.toml");
1289 let local_path = dir.path().join("config.local.toml");
1290
1291 fs::write(
1292 &base_path,
1293 r#"
1294[organize]
1295default = "standard"
1296
1297[organize.patterns]
1298standard = "base-pattern"
1299"#,
1300 )
1301 .unwrap();
1302 fs::write(
1303 &local_path,
1304 r#"
1305[organize]
1306default = "custom"
1307
1308[organize.patterns]
1309custom = "local-pattern"
1310"#,
1311 )
1312 .unwrap();
1313
1314 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1315 .merge(Toml::file(&base_path))
1316 .merge(Toml::file(&local_path))
1317 .extract()
1318 .unwrap();
1319
1320 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
1322 assert_eq!(cfg.organize.patterns.len(), 2);
1324 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
1325 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
1326 }
1327
1328 #[test]
1329 fn test_output_device_config_roundtrip() {
1330 let mut cfg = Config::default();
1331 cfg.playback.output_device = Some("My DAC".into());
1332
1333 let serialized = toml::to_string_pretty(&cfg).unwrap();
1334 let deserialized: Config = toml::from_str(&serialized).unwrap();
1335 assert_eq!(
1336 deserialized.playback.output_device.as_deref(),
1337 Some("My DAC")
1338 );
1339 }
1340
1341 #[test]
1342 fn test_output_device_config_default_is_none() {
1343 let cfg = Config::default();
1344 assert!(cfg.playback.output_device.is_none());
1345
1346 let serialized = toml::to_string_pretty(&cfg).unwrap();
1348 assert!(!serialized.contains("output_device"));
1349 let deserialized: Config = toml::from_str(&serialized).unwrap();
1350 assert!(deserialized.playback.output_device.is_none());
1351 }
1352
1353 #[test]
1354 fn test_output_device_config_from_toml() {
1355 let dir = tempfile::tempdir().unwrap();
1356 let path = dir.path().join("config.toml");
1357 fs::write(
1358 &path,
1359 r#"
1360[playback]
1361output_device = "External Speakers"
1362"#,
1363 )
1364 .unwrap();
1365
1366 let cfg = Config::load_from(&path).unwrap();
1367 assert_eq!(
1368 cfg.playback.output_device.as_deref(),
1369 Some("External Speakers")
1370 );
1371 }
1372
1373 #[test]
1374 fn test_graphql_bind_defaults_to_localhost() {
1375 let cfg = GraphqlConfig::default();
1376 assert_eq!(
1377 cfg.bind,
1378 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1379 );
1380 }
1381
1382 #[test]
1383 fn test_graphql_bind_from_toml() {
1384 let toml_str = r#"
1385[graphql]
1386bind = "0.0.0.0"
1387port = 5000
1388"#;
1389 let cfg: Config = toml::from_str(toml_str).unwrap();
1390 assert_eq!(
1391 cfg.graphql.bind,
1392 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
1393 );
1394 assert_eq!(cfg.graphql.port, 5000);
1395 }
1396
1397 #[test]
1398 fn test_graphql_bind_omitted_defaults_to_localhost() {
1399 let toml_str = r#"
1400[graphql]
1401port = 4000
1402"#;
1403 let cfg: Config = toml::from_str(toml_str).unwrap();
1404 assert_eq!(
1405 cfg.graphql.bind,
1406 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1407 );
1408 }
1409
1410 #[test]
1411 fn test_organize_config_roundtrip() {
1412 let mut cfg = Config::default();
1413 cfg.organize.default = Some("standard".into());
1414 cfg.organize
1415 .patterns
1416 .insert("standard".into(), "%artist%/%title%".into());
1417
1418 let serialized = toml::to_string_pretty(&cfg).unwrap();
1419 let deserialized: Config = toml::from_str(&serialized).unwrap();
1420 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
1421 assert_eq!(
1422 deserialized.organize.patterns["standard"],
1423 "%artist%/%title%"
1424 );
1425 }
1426
1427 #[test]
1428 fn test_parse_size_bytes() {
1429 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1430 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1431 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1432 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1433 assert_eq!(parse_size_bytes("1024B"), Some(1024));
1434 assert_eq!(parse_size_bytes("1024"), Some(1024));
1435
1436 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1438 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1439
1440 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1442 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1443
1444 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1446 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1447
1448 assert_eq!(
1450 parse_size_bytes("1.5GB"),
1451 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1452 );
1453
1454 assert_eq!(parse_size_bytes(""), None);
1456 assert_eq!(parse_size_bytes("abc"), None);
1457 assert_eq!(parse_size_bytes("50XB"), None);
1458 }
1459
1460 #[test]
1461 fn test_cache_limit_config_from_toml() {
1462 let toml_str = r#"
1463[remote]
1464cache_limit = "50GB"
1465"#;
1466 let cfg: Config = toml::from_str(toml_str).unwrap();
1467 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1468 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1469 }
1470
1471 #[test]
1472 fn test_cache_limit_none_by_default() {
1473 let cfg = Config::default();
1474 assert!(cfg.remote.cache_limit.is_none());
1475 assert!(cfg.cache_limit_bytes().is_none());
1476 }
1477
1478 #[test]
1479 fn test_cache_limit_not_serialized_when_none() {
1480 let cfg = Config::default();
1481 let serialized = toml::to_string_pretty(&cfg).unwrap();
1482 assert!(!serialized.contains("cache_limit"));
1483 }
1484
1485 #[test]
1486 fn player_uses_config_on_init() {
1487 let dir = tempfile::tempdir().unwrap();
1491 let path = dir.path().join("config.toml");
1492 fs::write(
1493 &path,
1494 r#"
1495[playback]
1496replaygain = "track"
1497output_device = "My Fancy DAC"
1498pre_amp_db = -3.5
1499target_fps = 30
1500art_size = 32
1501
1502[visualizer]
1503enabled = false
1504mode = "oscilloscope"
1505fps = 30
1506"#,
1507 )
1508 .unwrap();
1509
1510 let cfg = Config::load_from(&path).unwrap();
1511
1512 assert_eq!(
1514 cfg.playback.replaygain,
1515 ReplayGainMode::Track,
1516 "replaygain should be 'track'"
1517 );
1518 assert_eq!(
1519 cfg.playback.output_device.as_deref(),
1520 Some("My Fancy DAC"),
1521 "output_device should match config"
1522 );
1523 assert!(
1524 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1525 "pre_amp_db should be -3.5"
1526 );
1527 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1528 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1529
1530 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1532 assert_eq!(cfg.visualizer.mode, "oscilloscope");
1533 assert_eq!(cfg.visualizer.fps, 30);
1534 }
1535
1536 #[test]
1537 fn a_missing_config_file_stamps_as_absent() {
1538 let dir = tempfile::tempdir().unwrap();
1539 let base = dir.path().join("config.toml");
1540 let local = dir.path().join("config.local.toml");
1541
1542 assert_eq!(stamp_of(&base, &local), (None, None));
1543
1544 fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
1545 let (base_stamp, local_stamp) = stamp_of(&base, &local);
1546 assert!(base_stamp.is_some(), "creating the file must be a change");
1547 assert!(local_stamp.is_none());
1548 }
1549
1550 #[test]
1551 fn editing_a_config_file_changes_its_stamp() {
1552 let dir = tempfile::tempdir().unwrap();
1553 let base = dir.path().join("config.toml");
1554 let local = dir.path().join("config.local.toml");
1555 fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
1556
1557 let before = stamp_of(&base, &local);
1558 std::thread::sleep(std::time::Duration::from_millis(20));
1560 fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
1561
1562 assert_ne!(
1563 before,
1564 stamp_of(&base, &local),
1565 "a config edited by hand has to be picked up"
1566 );
1567 }
1568
1569 #[test]
1570 fn invalidating_forces_a_reload() {
1571 let first = Config::cached();
1572 Config::invalidate_cache();
1573 assert!(
1574 !Arc::ptr_eq(&first, &Config::cached()),
1575 "koan's own writes invalidate explicitly; the next read must re-parse"
1576 );
1577 }
1578
1579 static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1584
1585 fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
1587 let dir =
1588 std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
1589 let _ = fs::remove_dir_all(&dir);
1590 fs::create_dir_all(&dir).unwrap();
1591 set_config_dir(&dir);
1592 (config_file_path(), config_local_file_path())
1593 }
1594
1595 #[test]
1596 fn persist_keeps_comments_and_untouched_keys() {
1597 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1598 let (base, _local) = persist_sandbox("comments");
1599 fs::write(
1600 &base,
1601 "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
1602 )
1603 .unwrap();
1604
1605 Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
1606
1607 let written = fs::read_to_string(&base).unwrap();
1608 assert!(
1609 written.contains("# koan — shareable defaults"),
1610 "the header comment must survive a write: {written}"
1611 );
1612 assert!(
1613 written.contains("# fps = 60"),
1614 "commented-out defaults are the template's whole point: {written}"
1615 );
1616 assert!(written.contains("palette = \"neon\""));
1617 assert!(
1618 !written.contains("[graphql]"),
1619 "an untouched section must not be invented: {written}"
1620 );
1621 }
1622
1623 #[test]
1624 fn persist_routes_machine_settings_to_the_local_file() {
1625 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1626 let (base, local) = persist_sandbox("routing");
1627
1628 Config::persist(|cfg| {
1629 cfg.playback.replaygain = ReplayGainMode::Album;
1630 cfg.playback.output_device = Some("My DAC".into());
1631 cfg.playback.art_size = 40;
1632 cfg.visualizer.mode = "starfield".into();
1633 })
1634 .unwrap();
1635
1636 let shared = fs::read_to_string(&base).unwrap();
1637 let machine = fs::read_to_string(&local).unwrap();
1638
1639 assert!(shared.contains("replaygain = \"album\""), "{shared}");
1640 for machine_only in ["output_device", "art_size", "starfield"] {
1641 assert!(
1642 !shared.contains(machine_only),
1643 "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
1644 );
1645 assert!(machine.contains(machine_only), "{machine}");
1646 }
1647 }
1648
1649 #[test]
1650 fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
1651 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1652 let (base, _local) = persist_sandbox("folders");
1653
1654 Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
1657
1658 let shared = fs::read_to_string(&base).unwrap_or_default();
1659 assert!(
1660 !shared.contains("folders"),
1661 "a visualiser toggle must not invent library folders: {shared}"
1662 );
1663 }
1664
1665 #[test]
1666 fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
1667 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1668 let (base, local) = persist_sandbox("drain");
1669 fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
1670
1671 Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
1672
1673 let shared = fs::read_to_string(&base).unwrap();
1674 assert!(
1675 !shared.contains("art_size"),
1676 "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
1677 );
1678 assert!(shared.contains("target_fps"), "{shared}");
1679 assert!(
1680 fs::read_to_string(&local)
1681 .unwrap()
1682 .contains("art_size = 48")
1683 );
1684 }
1685
1686 #[test]
1687 fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
1688 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1689 let (_base, local) = persist_sandbox("shadow");
1690 fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
1691
1692 Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
1693
1694 assert_eq!(
1695 Config::from_files().unwrap().playback.target_fps,
1696 120,
1697 "local wins the merge, so a shared write over a local copy would \
1698 otherwise be silently ignored: {}",
1699 fs::read_to_string(&local).unwrap()
1700 );
1701 }
1702
1703 #[test]
1704 fn persist_writes_nothing_when_the_mutation_changes_nothing() {
1705 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1706 let (base, _local) = persist_sandbox("noop");
1707 fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
1708
1709 Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
1710
1711 assert_eq!(
1712 fs::read_to_string(&base).unwrap(),
1713 "# untouched\n[playback]\ntarget_fps = 60\n"
1714 );
1715 }
1716
1717 #[test]
1718 fn persist_keeps_passwords_out_of_the_shared_file() {
1719 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1720 let (base, local) = persist_sandbox("secrets");
1721
1722 Config::persist(|cfg| {
1723 cfg.remote.password = "hunter2".into();
1724 cfg.subsonic.password = "s3cret".into();
1725 cfg.visualizer.palette = "mono".into();
1726 })
1727 .unwrap();
1728
1729 let shared = fs::read_to_string(&base).unwrap();
1730 assert!(!shared.contains("hunter2"), "{shared}");
1731 assert!(!shared.contains("s3cret"), "{shared}");
1732 assert!(shared.contains("mono"));
1733
1734 let machine = fs::read_to_string(&local).unwrap();
1735 assert!(machine.contains("hunter2") && machine.contains("s3cret"));
1736 }
1737
1738 #[test]
1739 fn persist_removes_a_cleared_password_rather_than_blanking_it() {
1740 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1741 let (_base, local) = persist_sandbox("clear-secret");
1742 fs::write(
1743 &local,
1744 "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
1745 )
1746 .unwrap();
1747
1748 Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
1749
1750 let machine = fs::read_to_string(&local).unwrap();
1751 assert!(
1752 !machine.contains("password"),
1753 "an emptied secret should leave no key behind: {machine}"
1754 );
1755 assert!(machine.contains("url"), "{machine}");
1756 }
1757
1758 #[test]
1759 fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
1760 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1761 let (base, _local) = persist_sandbox("patterns");
1762 fs::write(
1763 &base,
1764 "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
1765 )
1766 .unwrap();
1767
1768 Config::persist(|cfg| {
1769 cfg.organize
1770 .patterns
1771 .insert("standard".into(), "%album artist%/%album%".into());
1772 })
1773 .unwrap();
1774
1775 let cfg = Config::from_files().unwrap();
1776 assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
1777 assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
1778 }
1779}