1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, LazyLock, Once};
5use std::time::SystemTime;
6
7use figment::Figment;
8use figment::providers::{Env, Format, Serialized, Toml};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12#[derive(Debug, Error)]
13pub enum ConfigError {
14 #[error("io error: {0}")]
15 Io(#[from] std::io::Error),
16 #[error("parse error: {0}")]
17 Parse(#[from] toml::de::Error),
18 #[error("serialize error: {0}")]
19 Serialize(#[from] toml::ser::Error),
20 #[error("config error: {0}")]
21 Figment(#[from] Box<figment::Error>),
22}
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25#[serde(default)]
26pub struct Config {
27 pub library: LibraryConfig,
28 pub playback: PlaybackConfig,
29 pub remote: RemoteConfig,
30 pub organize: OrganizeConfig,
31 #[serde(alias = "visualiser")]
32 pub visualizer: VisualizerConfig,
33 pub graphql: GraphqlConfig,
34 pub subsonic: SubsonicConfig,
35 pub auth: AuthConfig,
36 pub sharing: SharingConfig,
37 pub push: PushConfig,
38 pub devices: DevicesConfig,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43#[serde(default)]
44pub struct SharingConfig {
45 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub public_url: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(default)]
58pub struct PushConfig {
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub key_path: Option<PathBuf>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub key: Option<String>,
66 #[serde(skip_serializing_if = "String::is_empty")]
68 pub key_id: String,
69 #[serde(skip_serializing_if = "String::is_empty")]
71 pub team_id: String,
72 pub topic: String,
74}
75
76impl Default for PushConfig {
77 fn default() -> Self {
78 Self {
79 key_path: None,
80 key: None,
81 key_id: String::new(),
82 team_id: String::new(),
83 topic: "cc.blit.koan".into(),
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(default)]
90pub struct LibraryConfig {
91 pub folders: Vec<PathBuf>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(default)]
96pub struct PlaybackConfig {
97 pub replaygain: ReplayGainMode,
98 pub target_fps: u8,
101 pub show_fps: bool,
103 pub pre_amp_db: f64,
106 pub fade_on_pause: bool,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub output_device: Option<String>,
112 pub art_size: u16,
115 pub rate_switch_lead_in_ms: u32,
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 }
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 rate_switch_lead_in_ms: 1000,
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, Copy, PartialEq, Eq)]
461pub enum Layer {
462 Shared,
464 Machine,
466}
467
468pub fn layer_of(path: &str) -> Layer {
478 match path {
479 "remote.password"
481 | "subsonic.password"
482 | "auth.refresh_token"
483 | "push.key"
484 | "push.key_path"
485 | "push.key_id"
486 | "push.team_id"
487 | "library.folders"
489 | "remote.enabled"
490 | "remote.url"
491 | "remote.username"
492 | "remote.cache_dir"
493 | "remote.cache_limit"
494 | "playback.output_device"
496 | "playback.rate_switch_lead_in_ms"
497 | "subsonic.enabled"
500 | "subsonic.port"
501 | "subsonic.username"
502 | "devices.discoverable"
504 | "devices.port"
505 | "devices.addresses"
506 | "auth.server"
508 | "playback.art_size"
510 | "visualizer.enabled"
511 | "visualizer.mode"
512 | "visualizer.matrix_overlay"
513 | "visualizer.bass_shake" => Layer::Machine,
514 _ => Layer::Shared,
515 }
516}
517
518type ConfigStamp = (Option<SystemTime>, Option<SystemTime>);
522
523type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
524
525static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
526 LazyLock::new(|| parking_lot::RwLock::new(None));
527
528fn config_stamp() -> ConfigStamp {
529 stamp_of(&config_file_path(), &config_local_file_path())
530}
531
532fn stamp_of(base: &Path, local: &Path) -> ConfigStamp {
534 let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
535 (mtime(base), mtime(local))
536}
537
538impl Config {
539 fn figment() -> Figment {
545 let base_path = config_file_path();
546 let local_path = config_local_file_path();
547
548 Figment::from(Serialized::defaults(Config::default()))
549 .merge(Toml::file(&base_path))
550 .merge(Toml::file(&local_path))
551 .merge(Env::prefixed("KOAN_").split("__"))
552 }
553
554 pub fn load() -> Result<Self, ConfigError> {
556 let cfg: Self = Self::figment()
557 .extract()
558 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
559
560 check_secrets_in_git();
563
564 Ok(cfg)
565 }
566
567 pub fn load_or_default() -> Self {
572 (*Self::cached()).clone()
573 }
574
575 pub fn cached() -> Arc<Config> {
582 let stamp = config_stamp();
583 if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
584 && *seen == stamp
585 {
586 return cfg.clone();
587 }
588
589 let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
590 log::warn!("failed to load config, using defaults: {}", e);
591 Self::default()
592 }));
593 *CONFIG_CACHE.write() = Some((stamp, cfg.clone()));
594 cfg
595 }
596
597 pub fn invalidate_cache() {
601 *CONFIG_CACHE.write() = None;
602 }
603
604 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
606 let contents = fs::read_to_string(path)?;
607 let config: Config = toml::from_str(&contents)?;
608 Ok(config)
609 }
610
611 fn from_files() -> Result<Self, ConfigError> {
617 Figment::from(Serialized::defaults(Config::default()))
618 .merge(Toml::file(config_file_path()))
619 .merge(Toml::file(config_local_file_path()))
620 .extract()
621 .map_err(|e| ConfigError::Figment(Box::new(e)))
622 }
623
624 pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
636 where
637 F: FnOnce(&mut Config),
638 {
639 let before = Self::from_files()?;
640 let mut after = before.clone();
641 mutate(&mut after);
642
643 let mut changes = Vec::new();
644 diff_into(
645 "",
646 &toml::Value::try_from(&before)?,
647 &toml::Value::try_from(&after)?,
648 &mut changes,
649 );
650 if changes.is_empty() {
651 return Ok(());
652 }
653
654 let base_path = config_file_path();
655 let local_path = config_local_file_path();
656 let mut base = read_document(&base_path)?;
657 let mut local = read_document(&local_path)?;
658
659 for (path, value) in &changes {
660 let (target, other) = match layer_of(path) {
661 Layer::Shared => (&mut base, &mut local),
662 Layer::Machine => (&mut local, &mut base),
663 };
664 match value {
665 Some(v) => doc_set(target, path, v),
666 None => doc_remove(target, path),
667 }
668 doc_remove(other, path);
669 }
670
671 write_document(&base_path, &base, false)?;
672 write_document(&local_path, &local, true)?;
673 Self::invalidate_cache();
674 Ok(())
675 }
676
677 pub fn cache_dir(&self) -> PathBuf {
679 self.remote
680 .cache_dir
681 .clone()
682 .unwrap_or_else(default_cache_dir)
683 }
684
685 pub fn cache_limit_bytes(&self) -> Option<u64> {
687 self.remote
688 .cache_limit
689 .as_deref()
690 .and_then(parse_size_bytes)
691 }
692}
693
694fn diff_into(
699 prefix: &str,
700 before: &toml::Value,
701 after: &toml::Value,
702 out: &mut Vec<(String, Option<toml::Value>)>,
703) {
704 let (b, a) = match (before.as_table(), after.as_table()) {
705 (Some(b), Some(a)) => (b, a),
706 _ => {
707 if before != after {
708 out.push((prefix.to_string(), Some(after.clone())));
709 }
710 return;
711 }
712 };
713
714 let empty = toml::Value::Table(toml::map::Map::new());
715 for key in b
716 .keys()
717 .chain(a.keys())
718 .collect::<std::collections::BTreeSet<_>>()
719 {
720 let path = if prefix.is_empty() {
721 key.clone()
722 } else {
723 format!("{prefix}.{key}")
724 };
725 match (b.get(key), a.get(key)) {
726 (Some(bv), Some(av)) => diff_into(&path, bv, av, out),
727 (None, Some(av)) => diff_into(&path, &empty, av, out),
730 (Some(_), None) => out.push((path, None)),
731 (None, None) => unreachable!("key came from one of the two tables"),
732 }
733 }
734}
735
736fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
737 let Ok(contents) = fs::read_to_string(path) else {
738 return Ok(toml_edit::DocumentMut::new());
739 };
740 contents
741 .parse::<toml_edit::DocumentMut>()
742 .map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
743}
744
745fn write_document(
748 path: &Path,
749 doc: &toml_edit::DocumentMut,
750 secret: bool,
751) -> Result<(), ConfigError> {
752 let contents = doc.to_string();
753 if contents.trim().is_empty() && !path.exists() {
754 return Ok(());
755 }
756 if let Some(parent) = path.parent() {
757 fs::create_dir_all(parent)?;
758 }
759 fs::write(path, contents)?;
760 #[cfg(unix)]
761 if secret {
762 use std::os::unix::fs::PermissionsExt;
763 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
764 }
765 #[cfg(not(unix))]
766 let _ = secret;
767 Ok(())
768}
769
770fn implicit_table() -> toml_edit::Item {
771 let mut table = toml_edit::Table::new();
772 table.set_implicit(true);
775 toml_edit::Item::Table(table)
776}
777
778fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
779 let segments: Vec<&str> = path.split('.').collect();
780 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
781
782 let mut table = doc.as_table_mut();
783 for segment in parents {
784 let item = table.entry(segment).or_insert_with(implicit_table);
785 if !item.is_table() {
788 *item = implicit_table();
789 }
790 table = item.as_table_mut().expect("just ensured it is a table");
791 }
792 match table.get_mut(last) {
795 Some(existing) => *existing = toml_edit::value(to_edit_value(value)),
796 None => {
797 table.insert(last, toml_edit::value(to_edit_value(value)));
798 }
799 }
800}
801
802fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
803 let segments: Vec<&str> = path.split('.').collect();
804 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
805
806 let mut table = doc.as_table_mut();
807 for segment in parents {
808 match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
809 Some(child) => table = child,
810 None => return,
811 }
812 }
813 table.remove(last);
816}
817
818fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
819 match value {
820 toml::Value::String(s) => s.as_str().into(),
821 toml::Value::Integer(i) => (*i).into(),
822 toml::Value::Float(f) => (*f).into(),
823 toml::Value::Boolean(b) => (*b).into(),
824 toml::Value::Datetime(d) => d.to_string().into(),
825 toml::Value::Array(items) => items
826 .iter()
827 .map(to_edit_value)
828 .collect::<toml_edit::Array>()
829 .into(),
830 toml::Value::Table(t) => {
831 let mut inline = toml_edit::InlineTable::new();
832 for (k, v) in t {
833 inline.insert(k, to_edit_value(v));
834 }
835 inline.into()
836 }
837 }
838}
839
840pub fn config_dir() -> PathBuf {
849 if let Some(dir) = CONFIG_DIR.read().clone() {
850 return dir;
851 }
852 if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
853 return PathBuf::from(dir);
854 }
855 platform_config_dir()
856}
857
858#[cfg(not(target_os = "ios"))]
859fn platform_config_dir() -> PathBuf {
860 dirs::home_dir()
861 .unwrap_or_else(|| PathBuf::from("."))
862 .join(".config")
863 .join("koan")
864}
865
866#[cfg(target_os = "ios")]
870fn platform_config_dir() -> PathBuf {
871 ios_library().join("Application Support").join("koan")
872}
873
874#[cfg(target_os = "ios")]
875fn ios_library() -> PathBuf {
876 dirs::home_dir()
877 .unwrap_or_else(|| PathBuf::from("."))
878 .join("Library")
879}
880
881fn default_cache_dir() -> PathBuf {
888 #[cfg(target_os = "ios")]
889 if CONFIG_DIR.read().is_none() && std::env::var_os("KOAN_CONFIG_DIR").is_none() {
890 return ios_library().join("Caches").join("koan");
891 }
892 config_dir().join("cache")
893}
894
895pub fn set_config_dir(dir: impl Into<PathBuf>) {
902 *CONFIG_DIR.write() = Some(dir.into());
903 Config::invalidate_cache();
904}
905
906pub fn isolate_config_for_tests() {
918 let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
919 let _ = fs::create_dir_all(&dir);
920 set_config_dir(dir);
921}
922
923static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
924 LazyLock::new(|| parking_lot::RwLock::new(None));
925
926pub fn config_file_path() -> PathBuf {
928 config_dir().join("config.toml")
929}
930
931pub fn config_local_file_path() -> PathBuf {
933 config_dir().join("config.local.toml")
934}
935
936pub fn db_path() -> PathBuf {
938 config_dir().join("koan.db")
939}
940
941const LOG_LIMIT: u64 = 16 * 1024 * 1024;
944const LOG_CHECK_EVERY: u32 = 4096;
947
948#[derive(Default)]
950pub struct LogFile {
951 file: Option<fs::File>,
952 lines: u32,
953}
954
955impl LogFile {
956 pub fn write(&mut self, line: std::fmt::Arguments) {
957 use std::io::Write as _;
958 if self.file.is_none() {
959 self.file = open_log();
960 }
961 let Some(file) = self.file.as_mut() else {
962 return;
963 };
964 let _ = writeln!(file, "{line}");
965 self.lines = self.lines.wrapping_add(1);
966 if self.lines.is_multiple_of(LOG_CHECK_EVERY)
967 && file.metadata().is_ok_and(|m| m.len() > LOG_LIMIT)
968 {
969 self.file = open_log();
970 }
971 }
972
973 pub fn flush(&mut self) {
974 if let Some(file) = self.file.as_mut() {
975 let _ = std::io::Write::flush(file);
976 }
977 }
978}
979
980fn open_log() -> Option<fs::File> {
986 let dir = config_dir();
987 fs::create_dir_all(&dir).ok()?;
988 let path = dir.join("koan.log");
989 if fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) {
990 let _ = fs::rename(&path, dir.join("koan.log.1"));
991 }
992 fs::OpenOptions::new()
993 .create(true)
994 .append(true)
995 .open(path)
996 .ok()
997}
998
999fn check_secrets_in_git() {
1006 static ONCE: Once = Once::new();
1007 ONCE.call_once(scan_for_tracked_secrets);
1008}
1009
1010fn scan_for_tracked_secrets() {
1011 let sensitive_fields = ["password", "refresh_token"];
1012
1013 for (label, path) in [
1014 ("config.toml", config_file_path()),
1015 ("config.local.toml", config_local_file_path()),
1016 ] {
1017 let Ok(contents) = std::fs::read_to_string(&path) else {
1018 continue;
1019 };
1020
1021 let has_secrets = sensitive_fields.iter().any(|field| {
1023 contents.lines().any(|line| {
1024 let line = line.trim();
1025 if let Some(rest) = line.strip_prefix(field) {
1026 let rest = rest.trim_start();
1027 if let Some(value) = rest.strip_prefix('=') {
1028 let value = value.trim().trim_matches('"').trim_matches('\'');
1029 return !value.is_empty();
1030 }
1031 }
1032 false
1033 })
1034 });
1035
1036 if !has_secrets {
1037 continue;
1038 }
1039
1040 if is_tracked_by_git(&path) {
1042 eprintln!();
1043 eprintln!("╔══════════════════════════════════════════════════════════════╗");
1044 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
1045 eprintln!("╠══════════════════════════════════════════════════════════════╣");
1046 eprintln!("║ ║");
1047 eprintln!("║ File: {:<52} ║", path.display());
1048 eprintln!("║ ║");
1049 eprintln!("║ Your password is in version control. You should: ║");
1050 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
1051 eprintln!("║ 2. Add it to .gitignore ║");
1052 eprintln!("║ 3. Rotate your credentials immediately ║");
1053 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
1054 eprintln!("║ `koan remote login` writes there for you ║");
1055 eprintln!("║ ║");
1056 eprintln!("╚══════════════════════════════════════════════════════════════╝");
1057 eprintln!();
1058 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
1059 }
1060 }
1061}
1062
1063fn is_tracked_by_git(path: &Path) -> bool {
1065 let Some(parent) = path.parent() else {
1066 return false;
1067 };
1068 std::process::Command::new("git")
1070 .args(["ls-files", "--error-unmatch"])
1071 .arg(path)
1072 .current_dir(parent)
1073 .stdout(std::process::Stdio::null())
1074 .stderr(std::process::Stdio::null())
1075 .status()
1076 .is_ok_and(|s| s.success())
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::*;
1082 use std::fs;
1083
1084 fn tmp_dir() -> PathBuf {
1085 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
1086 fs::create_dir_all(&dir).unwrap();
1087 dir
1088 }
1089
1090 #[test]
1091 fn test_defaults() {
1092 let cfg = Config::default();
1093 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1094 assert!(!cfg.remote.enabled);
1095 }
1096
1097 #[test]
1098 fn test_roundtrip_toml() {
1099 let cfg = Config::default();
1100 let serialized = toml::to_string_pretty(&cfg).unwrap();
1101 let deserialized: Config = toml::from_str(&serialized).unwrap();
1102 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
1103 assert_eq!(
1104 deserialized.remote.download_workers,
1105 cfg.remote.download_workers
1106 );
1107 }
1108
1109 #[test]
1110 fn test_load_from_file() {
1111 let dir = tempfile::tempdir().unwrap();
1112 let path = dir.path().join("config.toml");
1113 fs::write(
1114 &path,
1115 r#"
1116[library]
1117folders = ["/tmp/music"]
1118
1119[playback]
1120replaygain = "track"
1121"#,
1122 )
1123 .unwrap();
1124
1125 let cfg = Config::load_from(&path).unwrap();
1126 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
1127 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
1128 assert!(!cfg.remote.enabled);
1129 }
1130
1131 #[test]
1132 fn test_partial_toml_uses_defaults() {
1133 let dir = tempfile::tempdir().unwrap();
1134 let path = dir.path().join("partial.toml");
1135 fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
1136
1137 let cfg = Config::load_from(&path).unwrap();
1138 assert_eq!(cfg.playback.target_fps, 30);
1139 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1140 }
1141
1142 #[test]
1143 fn test_figment_layered_loading() {
1144 let dir = tempfile::tempdir().unwrap();
1145 let base_path = dir.path().join("config.toml");
1146 let local_path = dir.path().join("config.local.toml");
1147
1148 fs::write(
1149 &base_path,
1150 r#"
1151[remote]
1152url = "https://base.example.com"
1153"#,
1154 )
1155 .unwrap();
1156 fs::write(
1157 &local_path,
1158 r#"
1159[remote]
1160enabled = true
1161url = "https://local.example.com"
1162username = "admin"
1163password = "secret"
1164"#,
1165 )
1166 .unwrap();
1167
1168 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1170 .merge(Toml::file(&base_path))
1171 .merge(Toml::file(&local_path))
1172 .extract()
1173 .unwrap();
1174
1175 assert!(cfg.remote.enabled);
1176 assert_eq!(cfg.remote.url, "https://local.example.com");
1177 assert_eq!(cfg.remote.username, "admin");
1178 assert_eq!(cfg.remote.password, "secret");
1179 }
1180
1181 #[test]
1182 fn test_figment_missing_keys_preserved() {
1183 let dir = tempfile::tempdir().unwrap();
1184 let base_path = dir.path().join("config.toml");
1185 let local_path = dir.path().join("config.local.toml");
1186
1187 fs::write(
1188 &base_path,
1189 r#"
1190[remote]
1191url = "https://keep.me"
1192username = "keepuser"
1193"#,
1194 )
1195 .unwrap();
1196 fs::write(
1197 &local_path,
1198 r#"
1199[remote]
1200password = "secret"
1201"#,
1202 )
1203 .unwrap();
1204
1205 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1206 .merge(Toml::file(&base_path))
1207 .merge(Toml::file(&local_path))
1208 .extract()
1209 .unwrap();
1210
1211 assert_eq!(cfg.remote.url, "https://keep.me");
1212 assert_eq!(cfg.remote.username, "keepuser");
1213 assert_eq!(cfg.remote.password, "secret");
1214 }
1215
1216 #[test]
1217 fn test_env_var_override() {
1218 let dir = tempfile::tempdir().unwrap();
1219 let base_path = dir.path().join("config.toml");
1220
1221 fs::write(
1222 &base_path,
1223 r#"
1224[remote]
1225url = "https://file.example.com"
1226"#,
1227 )
1228 .unwrap();
1229
1230 unsafe {
1232 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
1233 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
1234 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
1235 }
1236
1237 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1238 .merge(Toml::file(&base_path))
1239 .merge(Env::prefixed("KOAN_").split("__"))
1240 .extract()
1241 .unwrap();
1242
1243 assert_eq!(cfg.remote.url, "https://env.example.com");
1244 assert_eq!(cfg.remote.password, "env-secret");
1245 assert_eq!(cfg.graphql.port, 9999);
1246
1247 unsafe {
1249 std::env::remove_var("KOAN_REMOTE__URL");
1250 std::env::remove_var("KOAN_REMOTE__PASSWORD");
1251 std::env::remove_var("KOAN_GRAPHQL__PORT");
1252 }
1253 }
1254
1255 #[test]
1256 fn test_cache_dir_default() {
1257 let cfg = Config::default();
1258 assert!(cfg.cache_dir().ends_with("cache"));
1259 }
1260
1261 #[test]
1262 fn test_cache_dir_explicit() {
1263 let mut cfg = Config::default();
1264 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
1265 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
1266 }
1267
1268 #[test]
1269 fn test_organize_config_defaults() {
1270 let cfg = Config::default();
1271 assert!(cfg.organize.default.is_none());
1272 assert!(cfg.organize.patterns.is_empty());
1273 }
1274
1275 #[test]
1276 fn test_organize_config_from_toml() {
1277 let dir = tmp_dir();
1278 let path = dir.join("organize.toml");
1279 fs::write(
1280 &path,
1281 r#"
1282[organize]
1283default = "standard"
1284
1285[organize.patterns]
1286standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
1287va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
1288"#,
1289 )
1290 .unwrap();
1291
1292 let cfg = Config::load_from(&path).unwrap();
1293 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
1294 assert_eq!(cfg.organize.patterns.len(), 2);
1295 assert!(cfg.organize.patterns.contains_key("standard"));
1296 assert!(cfg.organize.patterns.contains_key("va-aware"));
1297
1298 fs::remove_dir_all(&dir).ok();
1299 }
1300
1301 #[test]
1302 fn test_figment_organize_patterns_merge() {
1303 let dir = tempfile::tempdir().unwrap();
1304 let base_path = dir.path().join("config.toml");
1305 let local_path = dir.path().join("config.local.toml");
1306
1307 fs::write(
1308 &base_path,
1309 r#"
1310[organize]
1311default = "standard"
1312
1313[organize.patterns]
1314standard = "base-pattern"
1315"#,
1316 )
1317 .unwrap();
1318 fs::write(
1319 &local_path,
1320 r#"
1321[organize]
1322default = "custom"
1323
1324[organize.patterns]
1325custom = "local-pattern"
1326"#,
1327 )
1328 .unwrap();
1329
1330 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1331 .merge(Toml::file(&base_path))
1332 .merge(Toml::file(&local_path))
1333 .extract()
1334 .unwrap();
1335
1336 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
1338 assert_eq!(cfg.organize.patterns.len(), 2);
1340 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
1341 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
1342 }
1343
1344 #[test]
1345 fn test_output_device_config_roundtrip() {
1346 let mut cfg = Config::default();
1347 cfg.playback.output_device = Some("My DAC".into());
1348
1349 let serialized = toml::to_string_pretty(&cfg).unwrap();
1350 let deserialized: Config = toml::from_str(&serialized).unwrap();
1351 assert_eq!(
1352 deserialized.playback.output_device.as_deref(),
1353 Some("My DAC")
1354 );
1355 }
1356
1357 #[test]
1358 fn test_output_device_config_default_is_none() {
1359 let cfg = Config::default();
1360 assert!(cfg.playback.output_device.is_none());
1361
1362 let serialized = toml::to_string_pretty(&cfg).unwrap();
1364 assert!(!serialized.contains("output_device"));
1365 let deserialized: Config = toml::from_str(&serialized).unwrap();
1366 assert!(deserialized.playback.output_device.is_none());
1367 }
1368
1369 #[test]
1370 fn test_output_device_config_from_toml() {
1371 let dir = tempfile::tempdir().unwrap();
1372 let path = dir.path().join("config.toml");
1373 fs::write(
1374 &path,
1375 r#"
1376[playback]
1377output_device = "External Speakers"
1378"#,
1379 )
1380 .unwrap();
1381
1382 let cfg = Config::load_from(&path).unwrap();
1383 assert_eq!(
1384 cfg.playback.output_device.as_deref(),
1385 Some("External Speakers")
1386 );
1387 }
1388
1389 #[test]
1390 fn test_graphql_bind_defaults_to_localhost() {
1391 let cfg = GraphqlConfig::default();
1392 assert_eq!(
1393 cfg.bind,
1394 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1395 );
1396 }
1397
1398 #[test]
1399 fn test_graphql_bind_from_toml() {
1400 let toml_str = r#"
1401[graphql]
1402bind = "0.0.0.0"
1403port = 5000
1404"#;
1405 let cfg: Config = toml::from_str(toml_str).unwrap();
1406 assert_eq!(
1407 cfg.graphql.bind,
1408 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
1409 );
1410 assert_eq!(cfg.graphql.port, 5000);
1411 }
1412
1413 #[test]
1414 fn test_graphql_bind_omitted_defaults_to_localhost() {
1415 let toml_str = r#"
1416[graphql]
1417port = 4000
1418"#;
1419 let cfg: Config = toml::from_str(toml_str).unwrap();
1420 assert_eq!(
1421 cfg.graphql.bind,
1422 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1423 );
1424 }
1425
1426 #[test]
1427 fn test_organize_config_roundtrip() {
1428 let mut cfg = Config::default();
1429 cfg.organize.default = Some("standard".into());
1430 cfg.organize
1431 .patterns
1432 .insert("standard".into(), "%artist%/%title%".into());
1433
1434 let serialized = toml::to_string_pretty(&cfg).unwrap();
1435 let deserialized: Config = toml::from_str(&serialized).unwrap();
1436 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
1437 assert_eq!(
1438 deserialized.organize.patterns["standard"],
1439 "%artist%/%title%"
1440 );
1441 }
1442
1443 #[test]
1444 fn test_parse_size_bytes() {
1445 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1446 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1447 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1448 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1449 assert_eq!(parse_size_bytes("1024B"), Some(1024));
1450 assert_eq!(parse_size_bytes("1024"), Some(1024));
1451
1452 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1454 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1455
1456 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1458 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1459
1460 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1462 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1463
1464 assert_eq!(
1466 parse_size_bytes("1.5GB"),
1467 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1468 );
1469
1470 assert_eq!(parse_size_bytes(""), None);
1472 assert_eq!(parse_size_bytes("abc"), None);
1473 assert_eq!(parse_size_bytes("50XB"), None);
1474 }
1475
1476 #[test]
1477 fn test_cache_limit_config_from_toml() {
1478 let toml_str = r#"
1479[remote]
1480cache_limit = "50GB"
1481"#;
1482 let cfg: Config = toml::from_str(toml_str).unwrap();
1483 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1484 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1485 }
1486
1487 #[test]
1488 fn test_cache_limit_none_by_default() {
1489 let cfg = Config::default();
1490 assert!(cfg.remote.cache_limit.is_none());
1491 assert!(cfg.cache_limit_bytes().is_none());
1492 }
1493
1494 #[test]
1495 fn test_cache_limit_not_serialized_when_none() {
1496 let cfg = Config::default();
1497 let serialized = toml::to_string_pretty(&cfg).unwrap();
1498 assert!(!serialized.contains("cache_limit"));
1499 }
1500
1501 #[test]
1502 fn player_uses_config_on_init() {
1503 let dir = tempfile::tempdir().unwrap();
1507 let path = dir.path().join("config.toml");
1508 fs::write(
1509 &path,
1510 r#"
1511[playback]
1512replaygain = "track"
1513output_device = "My Fancy DAC"
1514pre_amp_db = -3.5
1515target_fps = 30
1516art_size = 32
1517
1518[visualizer]
1519enabled = false
1520mode = "oscilloscope"
1521fps = 30
1522"#,
1523 )
1524 .unwrap();
1525
1526 let cfg = Config::load_from(&path).unwrap();
1527
1528 assert_eq!(
1530 cfg.playback.replaygain,
1531 ReplayGainMode::Track,
1532 "replaygain should be 'track'"
1533 );
1534 assert_eq!(
1535 cfg.playback.output_device.as_deref(),
1536 Some("My Fancy DAC"),
1537 "output_device should match config"
1538 );
1539 assert!(
1540 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1541 "pre_amp_db should be -3.5"
1542 );
1543 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1544 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1545
1546 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1548 assert_eq!(cfg.visualizer.mode, "oscilloscope");
1549 assert_eq!(cfg.visualizer.fps, 30);
1550 }
1551
1552 #[test]
1553 fn a_missing_config_file_stamps_as_absent() {
1554 let dir = tempfile::tempdir().unwrap();
1555 let base = dir.path().join("config.toml");
1556 let local = dir.path().join("config.local.toml");
1557
1558 assert_eq!(stamp_of(&base, &local), (None, None));
1559
1560 fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
1561 let (base_stamp, local_stamp) = stamp_of(&base, &local);
1562 assert!(base_stamp.is_some(), "creating the file must be a change");
1563 assert!(local_stamp.is_none());
1564 }
1565
1566 #[test]
1567 fn editing_a_config_file_changes_its_stamp() {
1568 let dir = tempfile::tempdir().unwrap();
1569 let base = dir.path().join("config.toml");
1570 let local = dir.path().join("config.local.toml");
1571 fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
1572
1573 let before = stamp_of(&base, &local);
1574 std::thread::sleep(std::time::Duration::from_millis(20));
1576 fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
1577
1578 assert_ne!(
1579 before,
1580 stamp_of(&base, &local),
1581 "a config edited by hand has to be picked up"
1582 );
1583 }
1584
1585 #[test]
1586 fn invalidating_forces_a_reload() {
1587 let first = Config::cached();
1588 Config::invalidate_cache();
1589 assert!(
1590 !Arc::ptr_eq(&first, &Config::cached()),
1591 "koan's own writes invalidate explicitly; the next read must re-parse"
1592 );
1593 }
1594
1595 static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1600
1601 fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
1603 let dir =
1604 std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
1605 let _ = fs::remove_dir_all(&dir);
1606 fs::create_dir_all(&dir).unwrap();
1607 set_config_dir(&dir);
1608 (config_file_path(), config_local_file_path())
1609 }
1610
1611 #[test]
1612 fn persist_keeps_comments_and_untouched_keys() {
1613 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1614 let (base, _local) = persist_sandbox("comments");
1615 fs::write(
1616 &base,
1617 "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
1618 )
1619 .unwrap();
1620
1621 Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
1622
1623 let written = fs::read_to_string(&base).unwrap();
1624 assert!(
1625 written.contains("# koan — shareable defaults"),
1626 "the header comment must survive a write: {written}"
1627 );
1628 assert!(
1629 written.contains("# fps = 60"),
1630 "commented-out defaults are the template's whole point: {written}"
1631 );
1632 assert!(written.contains("palette = \"neon\""));
1633 assert!(
1634 !written.contains("[graphql]"),
1635 "an untouched section must not be invented: {written}"
1636 );
1637 }
1638
1639 #[test]
1640 fn persist_routes_machine_settings_to_the_local_file() {
1641 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1642 let (base, local) = persist_sandbox("routing");
1643
1644 Config::persist(|cfg| {
1645 cfg.playback.replaygain = ReplayGainMode::Album;
1646 cfg.playback.output_device = Some("My DAC".into());
1647 cfg.playback.art_size = 40;
1648 cfg.visualizer.mode = "starfield".into();
1649 })
1650 .unwrap();
1651
1652 let shared = fs::read_to_string(&base).unwrap();
1653 let machine = fs::read_to_string(&local).unwrap();
1654
1655 assert!(shared.contains("replaygain = \"album\""), "{shared}");
1656 for machine_only in ["output_device", "art_size", "starfield"] {
1657 assert!(
1658 !shared.contains(machine_only),
1659 "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
1660 );
1661 assert!(machine.contains(machine_only), "{machine}");
1662 }
1663 }
1664
1665 #[test]
1666 fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
1667 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1668 let (base, _local) = persist_sandbox("folders");
1669
1670 Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
1673
1674 let shared = fs::read_to_string(&base).unwrap_or_default();
1675 assert!(
1676 !shared.contains("folders"),
1677 "a visualiser toggle must not invent library folders: {shared}"
1678 );
1679 }
1680
1681 #[test]
1682 fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
1683 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1684 let (base, local) = persist_sandbox("drain");
1685 fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
1686
1687 Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
1688
1689 let shared = fs::read_to_string(&base).unwrap();
1690 assert!(
1691 !shared.contains("art_size"),
1692 "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
1693 );
1694 assert!(shared.contains("target_fps"), "{shared}");
1695 assert!(
1696 fs::read_to_string(&local)
1697 .unwrap()
1698 .contains("art_size = 48")
1699 );
1700 }
1701
1702 #[test]
1703 fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
1704 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1705 let (_base, local) = persist_sandbox("shadow");
1706 fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
1707
1708 Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
1709
1710 assert_eq!(
1711 Config::from_files().unwrap().playback.target_fps,
1712 120,
1713 "local wins the merge, so a shared write over a local copy would \
1714 otherwise be silently ignored: {}",
1715 fs::read_to_string(&local).unwrap()
1716 );
1717 }
1718
1719 #[test]
1720 fn persist_writes_nothing_when_the_mutation_changes_nothing() {
1721 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1722 let (base, _local) = persist_sandbox("noop");
1723 fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
1724
1725 Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
1726
1727 assert_eq!(
1728 fs::read_to_string(&base).unwrap(),
1729 "# untouched\n[playback]\ntarget_fps = 60\n"
1730 );
1731 }
1732
1733 #[test]
1734 fn persist_keeps_passwords_out_of_the_shared_file() {
1735 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1736 let (base, local) = persist_sandbox("secrets");
1737
1738 Config::persist(|cfg| {
1739 cfg.remote.password = "hunter2".into();
1740 cfg.subsonic.password = "s3cret".into();
1741 cfg.visualizer.palette = "mono".into();
1742 })
1743 .unwrap();
1744
1745 let shared = fs::read_to_string(&base).unwrap();
1746 assert!(!shared.contains("hunter2"), "{shared}");
1747 assert!(!shared.contains("s3cret"), "{shared}");
1748 assert!(shared.contains("mono"));
1749
1750 let machine = fs::read_to_string(&local).unwrap();
1751 assert!(machine.contains("hunter2") && machine.contains("s3cret"));
1752 }
1753
1754 #[test]
1755 fn persist_removes_a_cleared_password_rather_than_blanking_it() {
1756 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1757 let (_base, local) = persist_sandbox("clear-secret");
1758 fs::write(
1759 &local,
1760 "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
1761 )
1762 .unwrap();
1763
1764 Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
1765
1766 let machine = fs::read_to_string(&local).unwrap();
1767 assert!(
1768 !machine.contains("password"),
1769 "an emptied secret should leave no key behind: {machine}"
1770 );
1771 assert!(machine.contains("url"), "{machine}");
1772 }
1773
1774 #[test]
1775 fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
1776 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1777 let (base, _local) = persist_sandbox("patterns");
1778 fs::write(
1779 &base,
1780 "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
1781 )
1782 .unwrap();
1783
1784 Config::persist(|cfg| {
1785 cfg.organize
1786 .patterns
1787 .insert("standard".into(), "%album artist%/%album%".into());
1788 })
1789 .unwrap();
1790
1791 let cfg = Config::from_files().unwrap();
1792 assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
1793 assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
1794 }
1795}