1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, LazyLock, Once};
5use std::time::SystemTime;
6
7use figment::Figment;
8use figment::providers::{Env, Format, Serialized, Toml};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12#[derive(Debug, Error)]
13pub enum ConfigError {
14 #[error("io error: {0}")]
15 Io(#[from] std::io::Error),
16 #[error("parse error: {0}")]
17 Parse(#[from] toml::de::Error),
18 #[error("serialize error: {0}")]
19 Serialize(#[from] toml::ser::Error),
20 #[error("config error: {0}")]
21 Figment(#[from] Box<figment::Error>),
22}
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25#[serde(default)]
26pub struct Config {
27 pub library: LibraryConfig,
28 pub playback: PlaybackConfig,
29 pub remote: RemoteConfig,
30 pub organize: OrganizeConfig,
31 #[serde(alias = "visualiser")]
32 pub visualizer: VisualizerConfig,
33 pub graphql: GraphqlConfig,
34 pub subsonic: SubsonicConfig,
35 pub auth: AuthConfig,
36 pub sharing: SharingConfig,
37 pub mcp: McpConfig,
38 pub push: PushConfig,
39 pub devices: DevicesConfig,
40}
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, Default, Serialize, Deserialize)]
55#[serde(default)]
56pub struct McpConfig {
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub redirect_hosts: Vec<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(default)]
70pub struct PushConfig {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub key_path: Option<PathBuf>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub key: Option<String>,
78 #[serde(skip_serializing_if = "String::is_empty")]
80 pub key_id: String,
81 #[serde(skip_serializing_if = "String::is_empty")]
83 pub team_id: String,
84 pub topic: String,
86}
87
88impl Default for PushConfig {
89 fn default() -> Self {
90 Self {
91 key_path: None,
92 key: None,
93 key_id: String::new(),
94 team_id: String::new(),
95 topic: "cc.blit.koan".into(),
96 }
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101#[serde(default)]
102pub struct LibraryConfig {
103 pub folders: Vec<PathBuf>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(default)]
108pub struct PlaybackConfig {
109 pub replaygain: ReplayGainMode,
110 pub target_fps: u8,
113 pub show_fps: bool,
115 pub pre_amp_db: f64,
118 pub fade_on_pause: bool,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub output_device: Option<String>,
124 pub art_size: u16,
127 pub rate_switch_lead_in_ms: u32,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "lowercase")]
135pub enum ReplayGainMode {
136 Off,
137 Track,
138 Album,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(default)]
143pub struct RemoteConfig {
144 pub enabled: bool,
145 pub url: String,
146 pub username: String,
147 #[serde(default, skip_serializing_if = "String::is_empty")]
149 pub password: String,
150 pub cache_dir: Option<PathBuf>,
152 pub download_workers: usize,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub cache_limit: Option<String>,
159 pub auto_sync: bool,
165 pub auto_sync_interval_mins: u64,
167}
168
169impl Default for LibraryConfig {
170 fn default() -> Self {
171 let music_dir = dirs::audio_dir().unwrap_or_else(|| {
172 dirs::home_dir()
173 .map(|h| h.join("Music"))
174 .unwrap_or_else(|| PathBuf::from("/Music"))
175 });
176 Self {
177 folders: vec![music_dir],
178 }
179 }
180}
181
182impl Default for PlaybackConfig {
183 fn default() -> Self {
184 Self {
185 replaygain: ReplayGainMode::Off,
186 target_fps: 60,
187 show_fps: false,
188 pre_amp_db: 0.0,
189 fade_on_pause: true,
190 output_device: None,
191 art_size: 24,
192 rate_switch_lead_in_ms: 1000,
193 }
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(default)]
199pub struct VisualizerConfig {
200 pub enabled: bool,
201 pub fps: u8,
202 pub mode: String,
205 pub scale: String,
207 pub amplitude_scale: String,
209 pub bar_decay_ms: u32,
211 pub peak_decay_ms: u32,
213 pub palette: String,
216 pub reactivity: f32,
220 pub bass_shake: bool,
223 pub matrix_overlay: bool,
226 pub reactive_bg: bool,
228}
229
230impl Default for VisualizerConfig {
231 fn default() -> Self {
232 Self {
233 enabled: true,
234 fps: 60,
235 mode: "bars".into(),
236 scale: "bark".into(),
237 amplitude_scale: "aweight".into(),
238 bar_decay_ms: 50,
239 peak_decay_ms: 180,
240 palette: "spectrum".into(),
241 reactivity: 1.0,
242 bass_shake: true,
243 matrix_overlay: false,
244 reactive_bg: false,
245 }
246 }
247}
248
249impl Default for RemoteConfig {
250 fn default() -> Self {
251 Self {
252 enabled: false,
253 url: String::new(),
254 username: String::new(),
255 password: String::new(),
256 cache_dir: None,
257 download_workers: 5,
258 cache_limit: None,
259 auto_sync: true,
260 auto_sync_interval_mins: 60,
261 }
262 }
263}
264
265pub fn parse_size_bytes(s: &str) -> Option<u64> {
268 let s = s.trim();
269 if s.is_empty() {
270 return None;
271 }
272
273 let mut num_end = 0;
275 for (i, c) in s.char_indices() {
276 if c.is_ascii_digit() || c == '.' {
277 num_end = i + c.len_utf8();
278 } else if !c.is_whitespace() {
279 break;
280 }
281 }
282
283 let num_str = s[..num_end].trim();
284 let suffix = s[num_end..].trim().to_ascii_uppercase();
285
286 let value: f64 = num_str.parse().ok()?;
287 let multiplier: u64 = match suffix.as_str() {
288 "" | "B" => 1,
289 "KB" | "K" => 1024,
290 "MB" | "M" => 1024 * 1024,
291 "GB" | "G" => 1024 * 1024 * 1024,
292 "TB" | "T" => 1024 * 1024 * 1024 * 1024,
293 _ => return None,
294 };
295
296 Some((value * multiplier as f64) as u64)
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
300#[serde(default)]
301pub struct OrganizeConfig {
302 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub default: Option<String>,
305 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
307 pub patterns: HashMap<String, String>,
308 #[serde(default = "default_true")]
312 pub move_ancillary: bool,
313}
314
315impl Default for OrganizeConfig {
316 fn default() -> Self {
317 Self {
318 default: None,
319 patterns: HashMap::new(),
320 move_ancillary: true,
321 }
322 }
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(default)]
328pub struct GraphqlConfig {
329 pub enabled: bool,
332 pub port: u16,
334 #[serde(default = "default_bind")]
337 pub bind: std::net::IpAddr,
338 pub playground: bool,
340 pub auth_enabled: bool,
343 pub access_token_ttl: String,
345 pub refresh_token_ttl: String,
347 pub cors_origins: Vec<String>,
350 pub allowed_hosts: Vec<String>,
354 pub cookie_secure: bool,
358 pub allow_organize: bool,
360}
361
362fn default_true() -> bool {
363 true
364}
365
366fn default_bind() -> std::net::IpAddr {
367 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
368}
369
370impl Default for GraphqlConfig {
371 fn default() -> Self {
372 Self {
373 enabled: true,
374 port: 4000,
375 bind: default_bind(),
376 playground: false,
377 auth_enabled: true,
378 access_token_ttl: "15m".into(),
379 refresh_token_ttl: "30d".into(),
380 cors_origins: Vec::new(),
381 allowed_hosts: Vec::new(),
382 cookie_secure: false,
383 allow_organize: false,
384 }
385 }
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
396#[serde(default)]
397pub struct SubsonicConfig {
398 pub enabled: bool,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub port: Option<u16>,
406 pub username: String,
408 #[serde(default, skip_serializing_if = "String::is_empty")]
411 pub password: String,
412}
413
414impl Default for SubsonicConfig {
415 fn default() -> Self {
416 Self {
417 enabled: false,
418 port: None,
419 username: "koan".into(),
420 password: String::new(),
421 }
422 }
423}
424
425#[derive(Debug, Clone, Default, Serialize, Deserialize)]
430#[serde(default)]
431pub struct AuthConfig {
432 #[serde(skip_serializing_if = "String::is_empty")]
434 pub server: String,
435 #[serde(skip_serializing_if = "String::is_empty")]
438 pub refresh_token: String,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
445#[serde(default)]
446pub struct DevicesConfig {
447 pub discoverable: bool,
450 pub port: u16,
453 pub addresses: Vec<String>,
456}
457
458impl Default for DevicesConfig {
459 fn default() -> Self {
460 Self {
461 discoverable: true,
462 port: DEVICES_PORT,
463 addresses: Vec::new(),
464 }
465 }
466}
467
468pub const DEVICES_PORT: u16 = 5626;
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473pub enum Layer {
474 Shared,
476 Machine,
478}
479
480pub fn layer_of(path: &str) -> Layer {
490 match path {
491 "remote.password"
493 | "subsonic.password"
494 | "auth.refresh_token"
495 | "push.key"
496 | "push.key_path"
497 | "push.key_id"
498 | "push.team_id"
499 | "library.folders"
501 | "remote.enabled"
502 | "remote.url"
503 | "remote.username"
504 | "remote.cache_dir"
505 | "remote.cache_limit"
506 | "playback.output_device"
508 | "playback.rate_switch_lead_in_ms"
509 | "subsonic.enabled"
512 | "subsonic.port"
513 | "subsonic.username"
514 | "devices.discoverable"
516 | "devices.port"
517 | "devices.addresses"
518 | "auth.server"
520 | "playback.art_size"
522 | "visualizer.enabled"
523 | "visualizer.mode"
524 | "visualizer.matrix_overlay"
525 | "visualizer.bass_shake" => Layer::Machine,
526 _ => Layer::Shared,
527 }
528}
529
530type ConfigStamp = (Option<SystemTime>, Option<SystemTime>);
534
535type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
536
537static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
538 LazyLock::new(|| parking_lot::RwLock::new(None));
539
540fn config_stamp() -> ConfigStamp {
541 stamp_of(&config_file_path(), &config_local_file_path())
542}
543
544fn stamp_of(base: &Path, local: &Path) -> ConfigStamp {
546 let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
547 (mtime(base), mtime(local))
548}
549
550impl Config {
551 fn figment() -> Figment {
557 let base_path = config_file_path();
558 let local_path = config_local_file_path();
559
560 Figment::from(Serialized::defaults(Config::default()))
561 .merge(Toml::file(&base_path))
562 .merge(Toml::file(&local_path))
563 .merge(Env::prefixed("KOAN_").split("__"))
564 }
565
566 pub fn load() -> Result<Self, ConfigError> {
568 let cfg: Self = Self::figment()
569 .extract()
570 .map_err(|e| ConfigError::Figment(Box::new(e)))?;
571
572 check_secrets_in_git();
575
576 Ok(cfg)
577 }
578
579 pub fn load_or_default() -> Self {
584 (*Self::cached()).clone()
585 }
586
587 pub fn cached() -> Arc<Config> {
594 let stamp = config_stamp();
595 if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
596 && *seen == stamp
597 {
598 return cfg.clone();
599 }
600
601 let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
602 log::warn!("failed to load config, using defaults: {}", e);
603 Self::default()
604 }));
605 *CONFIG_CACHE.write() = Some((stamp, cfg.clone()));
606 cfg
607 }
608
609 pub fn invalidate_cache() {
613 *CONFIG_CACHE.write() = None;
614 }
615
616 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
618 let contents = fs::read_to_string(path)?;
619 let config: Config = toml::from_str(&contents)?;
620 Ok(config)
621 }
622
623 fn from_files() -> Result<Self, ConfigError> {
629 Figment::from(Serialized::defaults(Config::default()))
630 .merge(Toml::file(config_file_path()))
631 .merge(Toml::file(config_local_file_path()))
632 .extract()
633 .map_err(|e| ConfigError::Figment(Box::new(e)))
634 }
635
636 pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
648 where
649 F: FnOnce(&mut Config),
650 {
651 let before = Self::from_files()?;
652 let mut after = before.clone();
653 mutate(&mut after);
654
655 let mut changes = Vec::new();
656 diff_into(
657 "",
658 &toml::Value::try_from(&before)?,
659 &toml::Value::try_from(&after)?,
660 &mut changes,
661 );
662 if changes.is_empty() {
663 return Ok(());
664 }
665
666 let base_path = config_file_path();
667 let local_path = config_local_file_path();
668 let mut base = read_document(&base_path)?;
669 let mut local = read_document(&local_path)?;
670
671 for (path, value) in &changes {
672 let (target, other) = match layer_of(path) {
673 Layer::Shared => (&mut base, &mut local),
674 Layer::Machine => (&mut local, &mut base),
675 };
676 match value {
677 Some(v) => doc_set(target, path, v),
678 None => doc_remove(target, path),
679 }
680 doc_remove(other, path);
681 }
682
683 write_document(&base_path, &base, false)?;
684 write_document(&local_path, &local, true)?;
685 Self::invalidate_cache();
686 Ok(())
687 }
688
689 pub fn cache_dir(&self) -> PathBuf {
691 self.remote
692 .cache_dir
693 .clone()
694 .unwrap_or_else(default_cache_dir)
695 }
696
697 pub fn cache_limit_bytes(&self) -> Option<u64> {
699 self.remote
700 .cache_limit
701 .as_deref()
702 .and_then(parse_size_bytes)
703 }
704}
705
706fn diff_into(
711 prefix: &str,
712 before: &toml::Value,
713 after: &toml::Value,
714 out: &mut Vec<(String, Option<toml::Value>)>,
715) {
716 let (b, a) = match (before.as_table(), after.as_table()) {
717 (Some(b), Some(a)) => (b, a),
718 _ => {
719 if before != after {
720 out.push((prefix.to_string(), Some(after.clone())));
721 }
722 return;
723 }
724 };
725
726 let empty = toml::Value::Table(toml::map::Map::new());
727 for key in b
728 .keys()
729 .chain(a.keys())
730 .collect::<std::collections::BTreeSet<_>>()
731 {
732 let path = if prefix.is_empty() {
733 key.clone()
734 } else {
735 format!("{prefix}.{key}")
736 };
737 match (b.get(key), a.get(key)) {
738 (Some(bv), Some(av)) => diff_into(&path, bv, av, out),
739 (None, Some(av)) => diff_into(&path, &empty, av, out),
742 (Some(_), None) => out.push((path, None)),
743 (None, None) => unreachable!("key came from one of the two tables"),
744 }
745 }
746}
747
748fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
749 let Ok(contents) = fs::read_to_string(path) else {
750 return Ok(toml_edit::DocumentMut::new());
751 };
752 contents
753 .parse::<toml_edit::DocumentMut>()
754 .map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
755}
756
757fn write_document(
760 path: &Path,
761 doc: &toml_edit::DocumentMut,
762 secret: bool,
763) -> Result<(), ConfigError> {
764 let contents = doc.to_string();
765 if contents.trim().is_empty() && !path.exists() {
766 return Ok(());
767 }
768 if let Some(parent) = path.parent() {
769 fs::create_dir_all(parent)?;
770 }
771 fs::write(path, contents)?;
772 #[cfg(unix)]
773 if secret {
774 use std::os::unix::fs::PermissionsExt;
775 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
776 }
777 #[cfg(not(unix))]
778 let _ = secret;
779 Ok(())
780}
781
782fn implicit_table() -> toml_edit::Item {
783 let mut table = toml_edit::Table::new();
784 table.set_implicit(true);
787 toml_edit::Item::Table(table)
788}
789
790fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
791 let segments: Vec<&str> = path.split('.').collect();
792 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
793
794 let mut table = doc.as_table_mut();
795 for segment in parents {
796 let item = table.entry(segment).or_insert_with(implicit_table);
797 if !item.is_table() {
800 *item = implicit_table();
801 }
802 table = item.as_table_mut().expect("just ensured it is a table");
803 }
804 match table.get_mut(last) {
807 Some(existing) => *existing = toml_edit::value(to_edit_value(value)),
808 None => {
809 table.insert(last, toml_edit::value(to_edit_value(value)));
810 }
811 }
812}
813
814fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
815 let segments: Vec<&str> = path.split('.').collect();
816 let (last, parents) = segments.split_last().expect("a diffed path is never empty");
817
818 let mut table = doc.as_table_mut();
819 for segment in parents {
820 match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
821 Some(child) => table = child,
822 None => return,
823 }
824 }
825 table.remove(last);
828}
829
830fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
831 match value {
832 toml::Value::String(s) => s.as_str().into(),
833 toml::Value::Integer(i) => (*i).into(),
834 toml::Value::Float(f) => (*f).into(),
835 toml::Value::Boolean(b) => (*b).into(),
836 toml::Value::Datetime(d) => d.to_string().into(),
837 toml::Value::Array(items) => items
838 .iter()
839 .map(to_edit_value)
840 .collect::<toml_edit::Array>()
841 .into(),
842 toml::Value::Table(t) => {
843 let mut inline = toml_edit::InlineTable::new();
844 for (k, v) in t {
845 inline.insert(k, to_edit_value(v));
846 }
847 inline.into()
848 }
849 }
850}
851
852pub fn config_dir() -> PathBuf {
861 if let Some(dir) = CONFIG_DIR.read().clone() {
862 return dir;
863 }
864 if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
865 return PathBuf::from(dir);
866 }
867 platform_config_dir()
868}
869
870#[cfg(not(target_os = "ios"))]
871fn platform_config_dir() -> PathBuf {
872 dirs::home_dir()
873 .unwrap_or_else(|| PathBuf::from("."))
874 .join(".config")
875 .join("koan")
876}
877
878#[cfg(target_os = "ios")]
882fn platform_config_dir() -> PathBuf {
883 ios_library().join("Application Support").join("koan")
884}
885
886#[cfg(target_os = "ios")]
887fn ios_library() -> PathBuf {
888 dirs::home_dir()
889 .unwrap_or_else(|| PathBuf::from("."))
890 .join("Library")
891}
892
893fn default_cache_dir() -> PathBuf {
900 #[cfg(target_os = "ios")]
901 if CONFIG_DIR.read().is_none() && std::env::var_os("KOAN_CONFIG_DIR").is_none() {
902 return ios_library().join("Caches").join("koan");
903 }
904 config_dir().join("cache")
905}
906
907pub fn set_config_dir(dir: impl Into<PathBuf>) {
914 *CONFIG_DIR.write() = Some(dir.into());
915 Config::invalidate_cache();
916}
917
918pub fn isolate_config_for_tests() {
930 let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
931 let _ = fs::create_dir_all(&dir);
932 set_config_dir(dir);
933}
934
935static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
936 LazyLock::new(|| parking_lot::RwLock::new(None));
937
938pub fn config_file_path() -> PathBuf {
940 config_dir().join("config.toml")
941}
942
943pub fn config_local_file_path() -> PathBuf {
945 config_dir().join("config.local.toml")
946}
947
948pub fn db_path() -> PathBuf {
950 config_dir().join("koan.db")
951}
952
953const LOG_LIMIT: u64 = 16 * 1024 * 1024;
956const LOG_CHECK_EVERY: u32 = 4096;
959
960#[derive(Default)]
962pub struct LogFile {
963 file: Option<fs::File>,
964 lines: u32,
965}
966
967impl LogFile {
968 pub fn write(&mut self, line: std::fmt::Arguments) {
969 use std::io::Write as _;
970 if self.file.is_none() {
971 self.file = open_log();
972 }
973 let Some(file) = self.file.as_mut() else {
974 return;
975 };
976 let _ = writeln!(file, "{line}");
977 self.lines = self.lines.wrapping_add(1);
978 if self.lines.is_multiple_of(LOG_CHECK_EVERY)
979 && file.metadata().is_ok_and(|m| m.len() > LOG_LIMIT)
980 {
981 self.file = open_log();
982 }
983 }
984
985 pub fn flush(&mut self) {
986 if let Some(file) = self.file.as_mut() {
987 let _ = std::io::Write::flush(file);
988 }
989 }
990}
991
992fn open_log() -> Option<fs::File> {
998 let dir = config_dir();
999 fs::create_dir_all(&dir).ok()?;
1000 let path = dir.join("koan.log");
1001 if fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) {
1002 let _ = fs::rename(&path, dir.join("koan.log.1"));
1003 }
1004 fs::OpenOptions::new()
1005 .create(true)
1006 .append(true)
1007 .open(path)
1008 .ok()
1009}
1010
1011fn check_secrets_in_git() {
1018 static ONCE: Once = Once::new();
1019 ONCE.call_once(scan_for_tracked_secrets);
1020}
1021
1022fn scan_for_tracked_secrets() {
1023 let sensitive_fields = ["password", "refresh_token"];
1024
1025 for (label, path) in [
1026 ("config.toml", config_file_path()),
1027 ("config.local.toml", config_local_file_path()),
1028 ] {
1029 let Ok(contents) = std::fs::read_to_string(&path) else {
1030 continue;
1031 };
1032
1033 let has_secrets = sensitive_fields.iter().any(|field| {
1035 contents.lines().any(|line| {
1036 let line = line.trim();
1037 if let Some(rest) = line.strip_prefix(field) {
1038 let rest = rest.trim_start();
1039 if let Some(value) = rest.strip_prefix('=') {
1040 let value = value.trim().trim_matches('"').trim_matches('\'');
1041 return !value.is_empty();
1042 }
1043 }
1044 false
1045 })
1046 });
1047
1048 if !has_secrets {
1049 continue;
1050 }
1051
1052 if is_tracked_by_git(&path) {
1054 eprintln!();
1055 eprintln!("╔══════════════════════════════════════════════════════════════╗");
1056 eprintln!("║ SECURITY: {label} contains credentials and is tracked by git! ║");
1057 eprintln!("╠══════════════════════════════════════════════════════════════╣");
1058 eprintln!("║ ║");
1059 eprintln!("║ File: {:<52} ║", path.display());
1060 eprintln!("║ ║");
1061 eprintln!("║ Your password is in version control. You should: ║");
1062 eprintln!("║ 1. Remove the file from git: git rm --cached <file> ║");
1063 eprintln!("║ 2. Add it to .gitignore ║");
1064 eprintln!("║ 3. Rotate your credentials immediately ║");
1065 eprintln!("║ 4. Move secrets to config.local.toml (gitignored) ║");
1066 eprintln!("║ `koan remote login` writes there for you ║");
1067 eprintln!("║ ║");
1068 eprintln!("╚══════════════════════════════════════════════════════════════╝");
1069 eprintln!();
1070 panic!("Refusing to start: credentials tracked by git in {label}. See above.");
1071 }
1072 }
1073}
1074
1075fn is_tracked_by_git(path: &Path) -> bool {
1077 let Some(parent) = path.parent() else {
1078 return false;
1079 };
1080 std::process::Command::new("git")
1082 .args(["ls-files", "--error-unmatch"])
1083 .arg(path)
1084 .current_dir(parent)
1085 .stdout(std::process::Stdio::null())
1086 .stderr(std::process::Stdio::null())
1087 .status()
1088 .is_ok_and(|s| s.success())
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use super::*;
1094 use std::fs;
1095
1096 fn tmp_dir() -> PathBuf {
1097 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
1098 fs::create_dir_all(&dir).unwrap();
1099 dir
1100 }
1101
1102 #[test]
1103 fn test_defaults() {
1104 let cfg = Config::default();
1105 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1106 assert!(!cfg.remote.enabled);
1107 }
1108
1109 #[test]
1110 fn test_roundtrip_toml() {
1111 let cfg = Config::default();
1112 let serialized = toml::to_string_pretty(&cfg).unwrap();
1113 let deserialized: Config = toml::from_str(&serialized).unwrap();
1114 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
1115 assert_eq!(
1116 deserialized.remote.download_workers,
1117 cfg.remote.download_workers
1118 );
1119 }
1120
1121 #[test]
1122 fn test_load_from_file() {
1123 let dir = tempfile::tempdir().unwrap();
1124 let path = dir.path().join("config.toml");
1125 fs::write(
1126 &path,
1127 r#"
1128[library]
1129folders = ["/tmp/music"]
1130
1131[playback]
1132replaygain = "track"
1133"#,
1134 )
1135 .unwrap();
1136
1137 let cfg = Config::load_from(&path).unwrap();
1138 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
1139 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
1140 assert!(!cfg.remote.enabled);
1141 }
1142
1143 #[test]
1144 fn test_partial_toml_uses_defaults() {
1145 let dir = tempfile::tempdir().unwrap();
1146 let path = dir.path().join("partial.toml");
1147 fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
1148
1149 let cfg = Config::load_from(&path).unwrap();
1150 assert_eq!(cfg.playback.target_fps, 30);
1151 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
1152 }
1153
1154 #[test]
1155 fn test_figment_layered_loading() {
1156 let dir = tempfile::tempdir().unwrap();
1157 let base_path = dir.path().join("config.toml");
1158 let local_path = dir.path().join("config.local.toml");
1159
1160 fs::write(
1161 &base_path,
1162 r#"
1163[remote]
1164url = "https://base.example.com"
1165"#,
1166 )
1167 .unwrap();
1168 fs::write(
1169 &local_path,
1170 r#"
1171[remote]
1172enabled = true
1173url = "https://local.example.com"
1174username = "admin"
1175password = "secret"
1176"#,
1177 )
1178 .unwrap();
1179
1180 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1182 .merge(Toml::file(&base_path))
1183 .merge(Toml::file(&local_path))
1184 .extract()
1185 .unwrap();
1186
1187 assert!(cfg.remote.enabled);
1188 assert_eq!(cfg.remote.url, "https://local.example.com");
1189 assert_eq!(cfg.remote.username, "admin");
1190 assert_eq!(cfg.remote.password, "secret");
1191 }
1192
1193 #[test]
1194 fn test_figment_missing_keys_preserved() {
1195 let dir = tempfile::tempdir().unwrap();
1196 let base_path = dir.path().join("config.toml");
1197 let local_path = dir.path().join("config.local.toml");
1198
1199 fs::write(
1200 &base_path,
1201 r#"
1202[remote]
1203url = "https://keep.me"
1204username = "keepuser"
1205"#,
1206 )
1207 .unwrap();
1208 fs::write(
1209 &local_path,
1210 r#"
1211[remote]
1212password = "secret"
1213"#,
1214 )
1215 .unwrap();
1216
1217 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1218 .merge(Toml::file(&base_path))
1219 .merge(Toml::file(&local_path))
1220 .extract()
1221 .unwrap();
1222
1223 assert_eq!(cfg.remote.url, "https://keep.me");
1224 assert_eq!(cfg.remote.username, "keepuser");
1225 assert_eq!(cfg.remote.password, "secret");
1226 }
1227
1228 #[test]
1229 fn test_env_var_override() {
1230 let dir = tempfile::tempdir().unwrap();
1231 let base_path = dir.path().join("config.toml");
1232
1233 fs::write(
1234 &base_path,
1235 r#"
1236[remote]
1237url = "https://file.example.com"
1238"#,
1239 )
1240 .unwrap();
1241
1242 unsafe {
1244 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
1245 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
1246 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
1247 }
1248
1249 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1250 .merge(Toml::file(&base_path))
1251 .merge(Env::prefixed("KOAN_").split("__"))
1252 .extract()
1253 .unwrap();
1254
1255 assert_eq!(cfg.remote.url, "https://env.example.com");
1256 assert_eq!(cfg.remote.password, "env-secret");
1257 assert_eq!(cfg.graphql.port, 9999);
1258
1259 unsafe {
1261 std::env::remove_var("KOAN_REMOTE__URL");
1262 std::env::remove_var("KOAN_REMOTE__PASSWORD");
1263 std::env::remove_var("KOAN_GRAPHQL__PORT");
1264 }
1265 }
1266
1267 #[test]
1268 fn test_cache_dir_default() {
1269 let cfg = Config::default();
1270 assert!(cfg.cache_dir().ends_with("cache"));
1271 }
1272
1273 #[test]
1274 fn test_cache_dir_explicit() {
1275 let mut cfg = Config::default();
1276 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
1277 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
1278 }
1279
1280 #[test]
1281 fn test_organize_config_defaults() {
1282 let cfg = Config::default();
1283 assert!(cfg.organize.default.is_none());
1284 assert!(cfg.organize.patterns.is_empty());
1285 }
1286
1287 #[test]
1288 fn test_organize_config_from_toml() {
1289 let dir = tmp_dir();
1290 let path = dir.join("organize.toml");
1291 fs::write(
1292 &path,
1293 r#"
1294[organize]
1295default = "standard"
1296
1297[organize.patterns]
1298standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
1299va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
1300"#,
1301 )
1302 .unwrap();
1303
1304 let cfg = Config::load_from(&path).unwrap();
1305 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
1306 assert_eq!(cfg.organize.patterns.len(), 2);
1307 assert!(cfg.organize.patterns.contains_key("standard"));
1308 assert!(cfg.organize.patterns.contains_key("va-aware"));
1309
1310 fs::remove_dir_all(&dir).ok();
1311 }
1312
1313 #[test]
1314 fn test_figment_organize_patterns_merge() {
1315 let dir = tempfile::tempdir().unwrap();
1316 let base_path = dir.path().join("config.toml");
1317 let local_path = dir.path().join("config.local.toml");
1318
1319 fs::write(
1320 &base_path,
1321 r#"
1322[organize]
1323default = "standard"
1324
1325[organize.patterns]
1326standard = "base-pattern"
1327"#,
1328 )
1329 .unwrap();
1330 fs::write(
1331 &local_path,
1332 r#"
1333[organize]
1334default = "custom"
1335
1336[organize.patterns]
1337custom = "local-pattern"
1338"#,
1339 )
1340 .unwrap();
1341
1342 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1343 .merge(Toml::file(&base_path))
1344 .merge(Toml::file(&local_path))
1345 .extract()
1346 .unwrap();
1347
1348 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
1350 assert_eq!(cfg.organize.patterns.len(), 2);
1352 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
1353 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
1354 }
1355
1356 #[test]
1357 fn test_output_device_config_roundtrip() {
1358 let mut cfg = Config::default();
1359 cfg.playback.output_device = Some("My DAC".into());
1360
1361 let serialized = toml::to_string_pretty(&cfg).unwrap();
1362 let deserialized: Config = toml::from_str(&serialized).unwrap();
1363 assert_eq!(
1364 deserialized.playback.output_device.as_deref(),
1365 Some("My DAC")
1366 );
1367 }
1368
1369 #[test]
1370 fn test_output_device_config_default_is_none() {
1371 let cfg = Config::default();
1372 assert!(cfg.playback.output_device.is_none());
1373
1374 let serialized = toml::to_string_pretty(&cfg).unwrap();
1376 assert!(!serialized.contains("output_device"));
1377 let deserialized: Config = toml::from_str(&serialized).unwrap();
1378 assert!(deserialized.playback.output_device.is_none());
1379 }
1380
1381 #[test]
1382 fn test_output_device_config_from_toml() {
1383 let dir = tempfile::tempdir().unwrap();
1384 let path = dir.path().join("config.toml");
1385 fs::write(
1386 &path,
1387 r#"
1388[playback]
1389output_device = "External Speakers"
1390"#,
1391 )
1392 .unwrap();
1393
1394 let cfg = Config::load_from(&path).unwrap();
1395 assert_eq!(
1396 cfg.playback.output_device.as_deref(),
1397 Some("External Speakers")
1398 );
1399 }
1400
1401 #[test]
1402 fn test_graphql_bind_defaults_to_localhost() {
1403 let cfg = GraphqlConfig::default();
1404 assert_eq!(
1405 cfg.bind,
1406 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1407 );
1408 }
1409
1410 #[test]
1411 fn test_graphql_bind_from_toml() {
1412 let toml_str = r#"
1413[graphql]
1414bind = "0.0.0.0"
1415port = 5000
1416"#;
1417 let cfg: Config = toml::from_str(toml_str).unwrap();
1418 assert_eq!(
1419 cfg.graphql.bind,
1420 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
1421 );
1422 assert_eq!(cfg.graphql.port, 5000);
1423 }
1424
1425 #[test]
1426 fn test_graphql_bind_omitted_defaults_to_localhost() {
1427 let toml_str = r#"
1428[graphql]
1429port = 4000
1430"#;
1431 let cfg: Config = toml::from_str(toml_str).unwrap();
1432 assert_eq!(
1433 cfg.graphql.bind,
1434 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1435 );
1436 }
1437
1438 #[test]
1439 fn test_organize_config_roundtrip() {
1440 let mut cfg = Config::default();
1441 cfg.organize.default = Some("standard".into());
1442 cfg.organize
1443 .patterns
1444 .insert("standard".into(), "%artist%/%title%".into());
1445
1446 let serialized = toml::to_string_pretty(&cfg).unwrap();
1447 let deserialized: Config = toml::from_str(&serialized).unwrap();
1448 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
1449 assert_eq!(
1450 deserialized.organize.patterns["standard"],
1451 "%artist%/%title%"
1452 );
1453 }
1454
1455 #[test]
1456 fn test_parse_size_bytes() {
1457 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1458 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1459 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1460 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1461 assert_eq!(parse_size_bytes("1024B"), Some(1024));
1462 assert_eq!(parse_size_bytes("1024"), Some(1024));
1463
1464 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1466 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1467
1468 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1470 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1471
1472 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1474 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1475
1476 assert_eq!(
1478 parse_size_bytes("1.5GB"),
1479 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1480 );
1481
1482 assert_eq!(parse_size_bytes(""), None);
1484 assert_eq!(parse_size_bytes("abc"), None);
1485 assert_eq!(parse_size_bytes("50XB"), None);
1486 }
1487
1488 #[test]
1489 fn test_cache_limit_config_from_toml() {
1490 let toml_str = r#"
1491[remote]
1492cache_limit = "50GB"
1493"#;
1494 let cfg: Config = toml::from_str(toml_str).unwrap();
1495 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1496 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1497 }
1498
1499 #[test]
1500 fn test_cache_limit_none_by_default() {
1501 let cfg = Config::default();
1502 assert!(cfg.remote.cache_limit.is_none());
1503 assert!(cfg.cache_limit_bytes().is_none());
1504 }
1505
1506 #[test]
1507 fn test_cache_limit_not_serialized_when_none() {
1508 let cfg = Config::default();
1509 let serialized = toml::to_string_pretty(&cfg).unwrap();
1510 assert!(!serialized.contains("cache_limit"));
1511 }
1512
1513 #[test]
1514 fn player_uses_config_on_init() {
1515 let dir = tempfile::tempdir().unwrap();
1519 let path = dir.path().join("config.toml");
1520 fs::write(
1521 &path,
1522 r#"
1523[playback]
1524replaygain = "track"
1525output_device = "My Fancy DAC"
1526pre_amp_db = -3.5
1527target_fps = 30
1528art_size = 32
1529
1530[visualizer]
1531enabled = false
1532mode = "oscilloscope"
1533fps = 30
1534"#,
1535 )
1536 .unwrap();
1537
1538 let cfg = Config::load_from(&path).unwrap();
1539
1540 assert_eq!(
1542 cfg.playback.replaygain,
1543 ReplayGainMode::Track,
1544 "replaygain should be 'track'"
1545 );
1546 assert_eq!(
1547 cfg.playback.output_device.as_deref(),
1548 Some("My Fancy DAC"),
1549 "output_device should match config"
1550 );
1551 assert!(
1552 (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1553 "pre_amp_db should be -3.5"
1554 );
1555 assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1556 assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1557
1558 assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1560 assert_eq!(cfg.visualizer.mode, "oscilloscope");
1561 assert_eq!(cfg.visualizer.fps, 30);
1562 }
1563
1564 #[test]
1565 fn a_missing_config_file_stamps_as_absent() {
1566 let dir = tempfile::tempdir().unwrap();
1567 let base = dir.path().join("config.toml");
1568 let local = dir.path().join("config.local.toml");
1569
1570 assert_eq!(stamp_of(&base, &local), (None, None));
1571
1572 fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
1573 let (base_stamp, local_stamp) = stamp_of(&base, &local);
1574 assert!(base_stamp.is_some(), "creating the file must be a change");
1575 assert!(local_stamp.is_none());
1576 }
1577
1578 #[test]
1579 fn editing_a_config_file_changes_its_stamp() {
1580 let dir = tempfile::tempdir().unwrap();
1581 let base = dir.path().join("config.toml");
1582 let local = dir.path().join("config.local.toml");
1583 fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
1584
1585 let before = stamp_of(&base, &local);
1586 std::thread::sleep(std::time::Duration::from_millis(20));
1588 fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
1589
1590 assert_ne!(
1591 before,
1592 stamp_of(&base, &local),
1593 "a config edited by hand has to be picked up"
1594 );
1595 }
1596
1597 #[test]
1598 fn invalidating_forces_a_reload() {
1599 let first = Config::cached();
1600 Config::invalidate_cache();
1601 assert!(
1602 !Arc::ptr_eq(&first, &Config::cached()),
1603 "koan's own writes invalidate explicitly; the next read must re-parse"
1604 );
1605 }
1606
1607 static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1612
1613 fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
1615 let dir =
1616 std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
1617 let _ = fs::remove_dir_all(&dir);
1618 fs::create_dir_all(&dir).unwrap();
1619 set_config_dir(&dir);
1620 (config_file_path(), config_local_file_path())
1621 }
1622
1623 #[test]
1624 fn persist_keeps_comments_and_untouched_keys() {
1625 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1626 let (base, _local) = persist_sandbox("comments");
1627 fs::write(
1628 &base,
1629 "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
1630 )
1631 .unwrap();
1632
1633 Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
1634
1635 let written = fs::read_to_string(&base).unwrap();
1636 assert!(
1637 written.contains("# koan — shareable defaults"),
1638 "the header comment must survive a write: {written}"
1639 );
1640 assert!(
1641 written.contains("# fps = 60"),
1642 "commented-out defaults are the template's whole point: {written}"
1643 );
1644 assert!(written.contains("palette = \"neon\""));
1645 assert!(
1646 !written.contains("[graphql]"),
1647 "an untouched section must not be invented: {written}"
1648 );
1649 }
1650
1651 #[test]
1652 fn persist_routes_machine_settings_to_the_local_file() {
1653 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1654 let (base, local) = persist_sandbox("routing");
1655
1656 Config::persist(|cfg| {
1657 cfg.playback.replaygain = ReplayGainMode::Album;
1658 cfg.playback.output_device = Some("My DAC".into());
1659 cfg.playback.art_size = 40;
1660 cfg.visualizer.mode = "starfield".into();
1661 })
1662 .unwrap();
1663
1664 let shared = fs::read_to_string(&base).unwrap();
1665 let machine = fs::read_to_string(&local).unwrap();
1666
1667 assert!(shared.contains("replaygain = \"album\""), "{shared}");
1668 for machine_only in ["output_device", "art_size", "starfield"] {
1669 assert!(
1670 !shared.contains(machine_only),
1671 "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
1672 );
1673 assert!(machine.contains(machine_only), "{machine}");
1674 }
1675 }
1676
1677 #[test]
1678 fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
1679 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1680 let (base, _local) = persist_sandbox("folders");
1681
1682 Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
1685
1686 let shared = fs::read_to_string(&base).unwrap_or_default();
1687 assert!(
1688 !shared.contains("folders"),
1689 "a visualiser toggle must not invent library folders: {shared}"
1690 );
1691 }
1692
1693 #[test]
1694 fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
1695 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1696 let (base, local) = persist_sandbox("drain");
1697 fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
1698
1699 Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
1700
1701 let shared = fs::read_to_string(&base).unwrap();
1702 assert!(
1703 !shared.contains("art_size"),
1704 "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
1705 );
1706 assert!(shared.contains("target_fps"), "{shared}");
1707 assert!(
1708 fs::read_to_string(&local)
1709 .unwrap()
1710 .contains("art_size = 48")
1711 );
1712 }
1713
1714 #[test]
1715 fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
1716 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1717 let (_base, local) = persist_sandbox("shadow");
1718 fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
1719
1720 Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
1721
1722 assert_eq!(
1723 Config::from_files().unwrap().playback.target_fps,
1724 120,
1725 "local wins the merge, so a shared write over a local copy would \
1726 otherwise be silently ignored: {}",
1727 fs::read_to_string(&local).unwrap()
1728 );
1729 }
1730
1731 #[test]
1732 fn persist_writes_nothing_when_the_mutation_changes_nothing() {
1733 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1734 let (base, _local) = persist_sandbox("noop");
1735 fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
1736
1737 Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
1738
1739 assert_eq!(
1740 fs::read_to_string(&base).unwrap(),
1741 "# untouched\n[playback]\ntarget_fps = 60\n"
1742 );
1743 }
1744
1745 #[test]
1746 fn persist_keeps_passwords_out_of_the_shared_file() {
1747 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1748 let (base, local) = persist_sandbox("secrets");
1749
1750 Config::persist(|cfg| {
1751 cfg.remote.password = "hunter2".into();
1752 cfg.subsonic.password = "s3cret".into();
1753 cfg.visualizer.palette = "mono".into();
1754 })
1755 .unwrap();
1756
1757 let shared = fs::read_to_string(&base).unwrap();
1758 assert!(!shared.contains("hunter2"), "{shared}");
1759 assert!(!shared.contains("s3cret"), "{shared}");
1760 assert!(shared.contains("mono"));
1761
1762 let machine = fs::read_to_string(&local).unwrap();
1763 assert!(machine.contains("hunter2") && machine.contains("s3cret"));
1764 }
1765
1766 #[test]
1767 fn persist_removes_a_cleared_password_rather_than_blanking_it() {
1768 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1769 let (_base, local) = persist_sandbox("clear-secret");
1770 fs::write(
1771 &local,
1772 "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
1773 )
1774 .unwrap();
1775
1776 Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
1777
1778 let machine = fs::read_to_string(&local).unwrap();
1779 assert!(
1780 !machine.contains("password"),
1781 "an emptied secret should leave no key behind: {machine}"
1782 );
1783 assert!(machine.contains("url"), "{machine}");
1784 }
1785
1786 #[test]
1787 fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
1788 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1789 let (base, _local) = persist_sandbox("patterns");
1790 fs::write(
1791 &base,
1792 "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
1793 )
1794 .unwrap();
1795
1796 Config::persist(|cfg| {
1797 cfg.organize
1798 .patterns
1799 .insert("standard".into(), "%album artist%/%album%".into());
1800 })
1801 .unwrap();
1802
1803 let cfg = Config::from_files().unwrap();
1804 assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
1805 assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
1806 }
1807}