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