Skip to main content

koan_core/
config.rs

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}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(default)]
40pub struct LibraryConfig {
41    pub folders: Vec<PathBuf>,
42    /// Run acoustic analysis as part of every scan rather than only on
43    /// `koan scan --analyze`. It roughly doubles a scan, so it is off.
44    pub analyze_on_scan: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(default)]
49pub struct PlaybackConfig {
50    pub replaygain: ReplayGainMode,
51    /// UI render rate in frames-per-second (default: 60).
52    /// Controls how often the TUI redraws. 30, 60, or 120 are typical values.
53    pub target_fps: u8,
54    /// Show an FPS counter overlay in the top-right corner.
55    pub show_fps: bool,
56    /// ReplayGain pre-amplification in dB. Applied on top of track/album gain.
57    /// Positive values boost, negative values attenuate. Default: 0.0.
58    pub pre_amp_db: f64,
59    /// Output audio device name. None = system default.
60    /// Persisted by name (not ID) since IDs can change across reboots.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub output_device: Option<String>,
63    /// Album art width in terminal columns (default: 24).
64    /// Height is always width/2 (square via halfblock rendering).
65    pub art_size: u16,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum ReplayGainMode {
71    Off,
72    Track,
73    Album,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(default)]
78pub struct RemoteConfig {
79    pub enabled: bool,
80    pub url: String,
81    pub username: String,
82    /// Password — stored in config.local.toml (gitignored), not config.toml.
83    #[serde(default, skip_serializing_if = "String::is_empty")]
84    pub password: String,
85    /// Defaults to config_dir()/cache if empty.
86    pub cache_dir: Option<PathBuf>,
87    /// Parallel download workers for remote tracks (default: 5).
88    pub download_workers: usize,
89    /// Maximum cache size on disk. Human-readable: "50GB", "500MB", etc.
90    /// None or empty = unlimited. LRU eviction runs on startup when exceeded.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub cache_limit: Option<String>,
93    /// Sync the library from the server on startup and on a timer.
94    ///
95    /// Incremental — it asks the server what changed rather than walking
96    /// everything, so it is cheap enough to run unattended. A full sync stays a
97    /// deliberate action.
98    pub auto_sync: bool,
99    /// Minutes between automatic syncs. 0 runs one at startup and no more.
100    pub auto_sync_interval_mins: u64,
101}
102
103impl Default for LibraryConfig {
104    fn default() -> Self {
105        let music_dir = dirs::audio_dir().unwrap_or_else(|| {
106            dirs::home_dir()
107                .map(|h| h.join("Music"))
108                .unwrap_or_else(|| PathBuf::from("/Music"))
109        });
110        Self {
111            folders: vec![music_dir],
112            analyze_on_scan: false,
113        }
114    }
115}
116
117impl Default for PlaybackConfig {
118    fn default() -> Self {
119        Self {
120            replaygain: ReplayGainMode::Off,
121            target_fps: 60,
122            show_fps: false,
123            pre_amp_db: 0.0,
124            output_device: None,
125            art_size: 24,
126        }
127    }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(default)]
132pub struct VisualizerConfig {
133    pub enabled: bool,
134    pub fps: u8,
135    /// Visualizer mode: "bars" (default), "oscilloscope", "radial", "particles", "lissajous".
136    pub mode: String,
137    /// Frequency scale: "bark" (default), "mel", "log", "linear".
138    pub scale: String,
139    /// Amplitude scale: "aweight" (default, A-weighted), "perceptual" (A-weighted + gamma), "sqrt", "linear".
140    pub amplitude_scale: String,
141    /// Bar decay half-life in milliseconds (how fast bars drop).
142    pub bar_decay_ms: u32,
143    /// Peak decay half-life in milliseconds (how long peaks linger).
144    pub peak_decay_ms: u32,
145    /// Color palette: "spectrum" (default), "mono", "fire", "neon".
146    /// Controls the frequency-mapped color gradient on spectrum bars.
147    pub palette: String,
148    /// Reactivity multiplier (0.0..2.0, default 1.0).
149    /// Scales all beat/spectrum-driven animation coefficients.
150    /// 0.0 = static, 1.0 = normal, 2.0 = hypersensitive.
151    pub reactivity: f32,
152    /// Bass shake: camera jitter + scale pulse on bass hits.
153    /// Applies to braille-rendered modes (oscilloscope, radial, wireframe, starfield, etc.).
154    pub bass_shake: bool,
155    /// Matrix overlay: replace all rendered characters with random matrix glyphs in green.
156    /// Applies to any visualizer mode as a post-processing pass.
157    pub matrix_overlay: bool,
158    /// Beat-reactive background color on braille modes (starfield, wormhole, etc.).
159    pub reactive_bg: bool,
160}
161
162impl Default for VisualizerConfig {
163    fn default() -> Self {
164        Self {
165            enabled: true,
166            fps: 60,
167            mode: "bars".into(),
168            scale: "bark".into(),
169            amplitude_scale: "aweight".into(),
170            bar_decay_ms: 50,
171            peak_decay_ms: 180,
172            palette: "spectrum".into(),
173            reactivity: 1.0,
174            bass_shake: true,
175            matrix_overlay: false,
176            reactive_bg: false,
177        }
178    }
179}
180
181impl Default for RemoteConfig {
182    fn default() -> Self {
183        Self {
184            enabled: false,
185            url: String::new(),
186            username: String::new(),
187            password: String::new(),
188            cache_dir: None,
189            download_workers: 5,
190            cache_limit: None,
191            auto_sync: true,
192            auto_sync_interval_mins: 60,
193        }
194    }
195}
196
197/// Parse a human-readable size string like "50GB", "500 MB", "1.5TB" into bytes.
198/// Supports B, KB, MB, GB, TB (case-insensitive). Returns None for invalid input.
199pub fn parse_size_bytes(s: &str) -> Option<u64> {
200    let s = s.trim();
201    if s.is_empty() {
202        return None;
203    }
204
205    // Split into numeric part and suffix.
206    let mut num_end = 0;
207    for (i, c) in s.char_indices() {
208        if c.is_ascii_digit() || c == '.' {
209            num_end = i + c.len_utf8();
210        } else if !c.is_whitespace() {
211            break;
212        }
213    }
214
215    let num_str = s[..num_end].trim();
216    let suffix = s[num_end..].trim().to_ascii_uppercase();
217
218    let value: f64 = num_str.parse().ok()?;
219    let multiplier: u64 = match suffix.as_str() {
220        "" | "B" => 1,
221        "KB" | "K" => 1024,
222        "MB" | "M" => 1024 * 1024,
223        "GB" | "G" => 1024 * 1024 * 1024,
224        "TB" | "T" => 1024 * 1024 * 1024 * 1024,
225        _ => return None,
226    };
227
228    Some((value * multiplier as f64) as u64)
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(default)]
233pub struct OrganizeConfig {
234    /// Named pattern preselected when an organize sheet or modal opens.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub default: Option<String>,
237    /// Named patterns — keys are names, values are format strings.
238    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
239    pub patterns: HashMap<String, String>,
240    /// Move cover art, cue sheets and logs alongside the music they belong to.
241    /// On by default: a folder's artwork is part of the release, and leaving it
242    /// behind turns one album into two half-albums.
243    #[serde(default = "default_true")]
244    pub move_ancillary: bool,
245}
246
247impl Default for OrganizeConfig {
248    fn default() -> Self {
249        Self {
250            default: None,
251            patterns: HashMap::new(),
252            move_ancillary: true,
253        }
254    }
255}
256
257/// GraphQL API server configuration.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(default)]
260pub struct GraphqlConfig {
261    /// Enable the GraphQL API server alongside the TUI (default: true).
262    /// Set to false for TUI-only mode (equivalent to --no-api).
263    pub enabled: bool,
264    /// GraphQL API port (default: 4000).
265    pub port: u16,
266    /// Bind address for the API server (default: 127.0.0.1).
267    /// Use "0.0.0.0" to listen on all interfaces (NOT RECOMMENDED without auth).
268    #[serde(default = "default_bind")]
269    pub bind: std::net::IpAddr,
270    /// Enable GraphiQL web IDE at GET /graphql.
271    pub playground: bool,
272    /// Require authentication for API access (default: true).
273    /// When false, all requests are treated as admin. When true, JWT auth is enforced.
274    pub auth_enabled: bool,
275    /// Access token TTL (default: "15m"). Supports: "15m", "1h", "3600s".
276    pub access_token_ttl: String,
277    /// Refresh token TTL (default: "30d"). Supports: "30d", "7d", "720h".
278    pub refresh_token_ttl: String,
279    /// Allowed CORS origins. Empty = no cross-origin browser access at all.
280    /// Example: ["https://music.example.com"]
281    pub cors_origins: Vec<String>,
282    /// Extra `Host:` values the server will answer to, beyond `localhost` and
283    /// bare IP literals. Requests carrying any other Host are refused, which is
284    /// what stops a DNS-rebinding page from reaching the API as same-origin.
285    pub allowed_hosts: Vec<String>,
286    /// Mark the session cookie `Secure`. Only set this when clients reach koan
287    /// over HTTPS — browsers silently discard `Secure` cookies sent over plain
288    /// `http://` to anything but localhost.
289    pub cookie_secure: bool,
290    /// Expose the `organize*` mutations, which physically move files on disk.
291    pub allow_organize: bool,
292}
293
294fn default_true() -> bool {
295    true
296}
297
298fn default_bind() -> std::net::IpAddr {
299    std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
300}
301
302impl Default for GraphqlConfig {
303    fn default() -> Self {
304        Self {
305            enabled: true,
306            port: 4000,
307            bind: default_bind(),
308            playground: false,
309            auth_enabled: true,
310            access_token_ttl: "15m".into(),
311            refresh_token_ttl: "30d".into(),
312            cors_origins: Vec::new(),
313            allowed_hosts: Vec::new(),
314            cookie_secure: false,
315            allow_organize: false,
316        }
317    }
318}
319
320/// Subsonic-compatible REST API.
321///
322/// Credentials are deliberately separate from `[remote]`: the Subsonic protocol
323/// authenticates with `md5(password + salt)` over whatever transport the client
324/// picked, so the secret has to be recoverable and is exposed to anyone who can
325/// capture a request. Reusing the upstream Navidrome password would hand out
326/// that account too.
327#[derive(Debug, Clone, Serialize, Deserialize)]
328#[serde(default)]
329pub struct SubsonicConfig {
330    /// Serve `/rest/*`. Off unless explicitly enabled.
331    ///
332    /// The routes are mounted on the GraphQL port. `port` adds a second
333    /// listener for clients that expect Subsonic on one of its own.
334    pub enabled: bool,
335    /// Serve `/rest/*` on a dedicated port as well as the GraphQL one.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub port: Option<u16>,
338    /// Username Subsonic clients authenticate as.
339    pub username: String,
340    /// Shared secret. Prefer the OS keychain (`koan subsonic setup`); this field
341    /// is the fallback for machines without one, and lives in config.local.toml.
342    #[serde(default, skip_serializing_if = "String::is_empty")]
343    pub password: String,
344}
345
346impl Default for SubsonicConfig {
347    fn default() -> Self {
348        Self {
349            enabled: false,
350            port: None,
351            username: "koan".into(),
352            password: String::new(),
353        }
354    }
355}
356
357/// Radio / infinite play mode configuration.
358#[derive(Debug, Clone, Serialize, Deserialize)]
359#[serde(default)]
360pub struct RadioConfig {
361    /// Number of tracks to keep queued ahead of the cursor.
362    pub lookahead: usize,
363    /// Number of tracks to add each time the queue runs low.
364    pub batch_size: usize,
365    /// Don't repeat any of the last N tracks (play history exclusion window).
366    pub history_window: usize,
367    /// Number of recently played tracks to use as seed (drifting seed window).
368    pub seed_window: usize,
369    /// Discovery weight: 0.0 = only familiar tracks, 1.0 = maximise discovery.
370    /// Controls the recency bonus — higher values boost never-played/long-forgotten tracks.
371    pub discovery_weight: f64,
372}
373
374impl Default for RadioConfig {
375    fn default() -> Self {
376        Self {
377            lookahead: 5,
378            batch_size: 5,
379            history_window: 200,
380            seed_window: 5,
381            discovery_weight: 0.3,
382        }
383    }
384}
385
386/// Which of the two files a setting is written to when koan changes it itself.
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum Layer {
389    /// `config.toml` — taste, meaningful on any machine, safe in a dotfiles repo.
390    Shared,
391    /// `config.local.toml` — belongs to this machine and nowhere else.
392    Machine,
393}
394
395/// Where the setting at a dotted path belongs.
396///
397/// `config.toml` is meant to be committed to a dotfiles repo, so three kinds of
398/// setting have no business in it: secrets, anything naming this machine's
399/// hardware, paths or account, and UI state that a keypress flips — the last
400/// kind would rewrite the shared file every session and land on the next
401/// machine as someone else's window size.
402///
403/// Anything not named here is taste, and taste travels.
404pub fn layer_of(path: &str) -> Layer {
405    match path {
406        // Secrets.
407        "remote.password"
408        | "subsonic.password"
409        // This machine's paths, disk and account.
410        | "library.folders"
411        | "remote.enabled"
412        | "remote.url"
413        | "remote.username"
414        | "remote.cache_dir"
415        | "remote.cache_limit"
416        // This machine's hardware.
417        | "playback.output_device"
418        // Which machine serves Subsonic, and as whom. Enabling a REST API is a
419        // decision about one host, and the secret guarding it is per-machine.
420        | "subsonic.enabled"
421        | "subsonic.port"
422        | "subsonic.username"
423        // Volatile: UI state behind a keybind or a mouse drag.
424        | "playback.art_size"
425        | "visualizer.enabled"
426        | "visualizer.mode"
427        | "visualizer.matrix_overlay"
428        | "visualizer.bass_shake" => Layer::Machine,
429        _ => Layer::Shared,
430    }
431}
432
433/// Mtimes of the two files `figment()` layers. Keyed on these so a config
434/// edited by hand is picked up without koan being told about it; `KOAN_*` env
435/// vars are not tracked, since they are fixed for the life of the process.
436type ConfigStamp = (Option<SystemTime>, Option<SystemTime>);
437
438type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
439
440static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
441    LazyLock::new(|| parking_lot::RwLock::new(None));
442
443fn config_stamp() -> ConfigStamp {
444    stamp_of(&config_file_path(), &config_local_file_path())
445}
446
447/// A file that does not exist stamps as `None`, so creating one is a change.
448fn stamp_of(base: &Path, local: &Path) -> ConfigStamp {
449    let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
450    (mtime(base), mtime(local))
451}
452
453impl Config {
454    /// Build the figment provider chain:
455    /// defaults → config.toml → config.local.toml → KOAN_* env vars.
456    ///
457    /// Env vars use `KOAN_` prefix with `__` as section separator:
458    ///   KOAN_REMOTE__PASSWORD, KOAN_GRAPHQL__PORT, KOAN_PLAYBACK__TARGET_FPS, etc.
459    fn figment() -> Figment {
460        let base_path = config_file_path();
461        let local_path = config_local_file_path();
462
463        Figment::from(Serialized::defaults(Config::default()))
464            .merge(Toml::file(&base_path))
465            .merge(Toml::file(&local_path))
466            .merge(Env::prefixed("KOAN_").split("__"))
467    }
468
469    /// Load config from all layers: defaults → config.toml → config.local.toml → KOAN_* env vars.
470    pub fn load() -> Result<Self, ConfigError> {
471        let cfg: Self = Self::figment()
472            .extract()
473            .map_err(|e| ConfigError::Figment(Box::new(e)))?;
474
475        // Security: refuse to start if config files containing secrets are
476        // tracked by git.
477        check_secrets_in_git();
478
479        Ok(cfg)
480    }
481
482    /// Load config, logging and falling back to defaults on error.
483    ///
484    /// Served from the cache, so what this costs is a clone of the struct
485    /// rather than two file reads and a figment merge.
486    pub fn load_or_default() -> Self {
487        (*Self::cached()).clone()
488    }
489
490    /// The merged config, reloaded only when it changed on disk.
491    ///
492    /// `load()` re-reads both TOML files and re-runs the whole figment merge,
493    /// and koan reaches it from paths that run per frame — `library_folders()`
494    /// is read from a SwiftUI list body. Callers that want to avoid even the
495    /// clone `load_or_default()` does can hold this `Arc`.
496    pub fn cached() -> Arc<Config> {
497        let stamp = config_stamp();
498        if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
499            && *seen == stamp
500        {
501            return cfg.clone();
502        }
503
504        let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
505            log::warn!("failed to load config, using defaults: {}", e);
506            Self::default()
507        }));
508        *CONFIG_CACHE.write() = Some((stamp, cfg.clone()));
509        cfg
510    }
511
512    /// Drop the cached config. koan's own writes invalidate explicitly rather
513    /// than relying on the mtime, which can land in the same filesystem tick as
514    /// the read before it.
515    pub fn invalidate_cache() {
516        *CONFIG_CACHE.write() = None;
517    }
518
519    /// Load from a specific TOML file (no env var overlay).
520    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
521        let contents = fs::read_to_string(path)?;
522        let config: Config = toml::from_str(&contents)?;
523        Ok(config)
524    }
525
526    /// The two files merged, without the env layer.
527    ///
528    /// `persist` diffs against this rather than against `config.toml` alone, so
529    /// a mutation setting a value the user already has writes nothing at all.
530    /// `KOAN_*` is left out because there is no file to write it back to.
531    fn from_files() -> Result<Self, ConfigError> {
532        Figment::from(Serialized::defaults(Config::default()))
533            .merge(Toml::file(config_file_path()))
534            .merge(Toml::file(config_local_file_path()))
535            .extract()
536            .map_err(|e| ConfigError::Figment(Box::new(e)))
537    }
538
539    /// Apply a mutation and write each changed setting to the file that owns it.
540    ///
541    /// Only what the closure actually changed is written, so comments, layout
542    /// and every untouched key survive — including the commented-out defaults
543    /// `koan config init` leaves as a reference. `layer_of` decides the file.
544    ///
545    /// A shared write also clears any copy of that key from
546    /// `config.local.toml`: the local layer wins, so leaving one there would
547    /// make the write silently do nothing. A machine write clears the key from
548    /// `config.toml` for the same reason in reverse, which drains settings that
549    /// older versions of koan wrongly wrote to the shared file.
550    pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
551    where
552        F: FnOnce(&mut Config),
553    {
554        let before = Self::from_files()?;
555        let mut after = before.clone();
556        mutate(&mut after);
557
558        let mut changes = Vec::new();
559        diff_into(
560            "",
561            &toml::Value::try_from(&before)?,
562            &toml::Value::try_from(&after)?,
563            &mut changes,
564        );
565        if changes.is_empty() {
566            return Ok(());
567        }
568
569        let base_path = config_file_path();
570        let local_path = config_local_file_path();
571        let mut base = read_document(&base_path)?;
572        let mut local = read_document(&local_path)?;
573
574        for (path, value) in &changes {
575            let (target, other) = match layer_of(path) {
576                Layer::Shared => (&mut base, &mut local),
577                Layer::Machine => (&mut local, &mut base),
578            };
579            match value {
580                Some(v) => doc_set(target, path, v),
581                None => doc_remove(target, path),
582            }
583            doc_remove(other, path);
584        }
585
586        write_document(&base_path, &base, false)?;
587        write_document(&local_path, &local, true)?;
588        Self::invalidate_cache();
589        Ok(())
590    }
591
592    /// Resolved cache directory — uses explicit setting or defaults to config_dir/cache.
593    pub fn cache_dir(&self) -> PathBuf {
594        self.remote
595            .cache_dir
596            .clone()
597            .unwrap_or_else(|| config_dir().join("cache"))
598    }
599
600    /// Parsed cache limit in bytes, or None if unlimited.
601    pub fn cache_limit_bytes(&self) -> Option<u64> {
602        self.remote
603            .cache_limit
604            .as_deref()
605            .and_then(parse_size_bytes)
606    }
607}
608
609/// Collect the leaf settings that differ between two serialized configs, as
610/// `(dotted path, new value)`. `None` means the key is gone and should be
611/// removed rather than written — which is how an emptied password or a cleared
612/// output device reaches the file as an absent key rather than a blank one.
613fn diff_into(
614    prefix: &str,
615    before: &toml::Value,
616    after: &toml::Value,
617    out: &mut Vec<(String, Option<toml::Value>)>,
618) {
619    let (b, a) = match (before.as_table(), after.as_table()) {
620        (Some(b), Some(a)) => (b, a),
621        _ => {
622            if before != after {
623                out.push((prefix.to_string(), Some(after.clone())));
624            }
625            return;
626        }
627    };
628
629    let empty = toml::Value::Table(toml::map::Map::new());
630    for key in b
631        .keys()
632        .chain(a.keys())
633        .collect::<std::collections::BTreeSet<_>>()
634    {
635        let path = if prefix.is_empty() {
636            key.clone()
637        } else {
638            format!("{prefix}.{key}")
639        };
640        match (b.get(key), a.get(key)) {
641            (Some(bv), Some(av)) => diff_into(&path, bv, av, out),
642            // Newly present: recurse into tables so a new pattern writes one
643            // key rather than replacing the whole table.
644            (None, Some(av)) => diff_into(&path, &empty, av, out),
645            (Some(_), None) => out.push((path, None)),
646            (None, None) => unreachable!("key came from one of the two tables"),
647        }
648    }
649}
650
651fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
652    let Ok(contents) = fs::read_to_string(path) else {
653        return Ok(toml_edit::DocumentMut::new());
654    };
655    contents
656        .parse::<toml_edit::DocumentMut>()
657        .map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
658}
659
660/// Write a document, skipping files that would be created empty. `secret` marks
661/// the file 0o600 — it is the one that holds passwords.
662fn write_document(
663    path: &Path,
664    doc: &toml_edit::DocumentMut,
665    secret: bool,
666) -> Result<(), ConfigError> {
667    let contents = doc.to_string();
668    if contents.trim().is_empty() && !path.exists() {
669        return Ok(());
670    }
671    if let Some(parent) = path.parent() {
672        fs::create_dir_all(parent)?;
673    }
674    fs::write(path, contents)?;
675    #[cfg(unix)]
676    if secret {
677        use std::os::unix::fs::PermissionsExt;
678        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
679    }
680    #[cfg(not(unix))]
681    let _ = secret;
682    Ok(())
683}
684
685fn implicit_table() -> toml_edit::Item {
686    let mut table = toml_edit::Table::new();
687    // Implicit: the header prints only if the table ends up holding something,
688    // so writing a nested key never leaves a bare `[organize]` behind.
689    table.set_implicit(true);
690    toml_edit::Item::Table(table)
691}
692
693fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
694    let segments: Vec<&str> = path.split('.').collect();
695    let (last, parents) = segments.split_last().expect("a diffed path is never empty");
696
697    let mut table = doc.as_table_mut();
698    for segment in parents {
699        let item = table.entry(segment).or_insert_with(implicit_table);
700        // A scalar sitting where a section belongs is malformed either way;
701        // the setting koan is writing wins.
702        if !item.is_table() {
703            *item = implicit_table();
704        }
705        table = item.as_table_mut().expect("just ensured it is a table");
706    }
707    // Comments attach to the key, and `insert` replaces the key. Overwrite the
708    // value in place where one already exists so the line keeps its notes.
709    match table.get_mut(last) {
710        Some(existing) => *existing = toml_edit::value(to_edit_value(value)),
711        None => {
712            table.insert(last, toml_edit::value(to_edit_value(value)));
713        }
714    }
715}
716
717fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
718    let segments: Vec<&str> = path.split('.').collect();
719    let (last, parents) = segments.split_last().expect("a diffed path is never empty");
720
721    let mut table = doc.as_table_mut();
722    for segment in parents {
723        match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
724            Some(child) => table = child,
725            None => return,
726        }
727    }
728    // An emptied table keeps its header: it still carries the commented-out
729    // defaults `koan config init` wrote, and those are the reference.
730    table.remove(last);
731}
732
733fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
734    match value {
735        toml::Value::String(s) => s.as_str().into(),
736        toml::Value::Integer(i) => (*i).into(),
737        toml::Value::Float(f) => (*f).into(),
738        toml::Value::Boolean(b) => (*b).into(),
739        toml::Value::Datetime(d) => d.to_string().into(),
740        toml::Value::Array(items) => items
741            .iter()
742            .map(to_edit_value)
743            .collect::<toml_edit::Array>()
744            .into(),
745        toml::Value::Table(t) => {
746            let mut inline = toml_edit::InlineTable::new();
747            for (k, v) in t {
748                inline.insert(k, to_edit_value(v));
749            }
750            inline.into()
751        }
752    }
753}
754
755/// Where koan keeps its configuration, library database and cache.
756///
757/// `~/.config/koan/` unless pointed elsewhere. `KOAN_CONFIG_DIR` is the
758/// user-facing way to do that — one machine, more than one library — and
759/// `set_config_dir` is the in-process one, which is what tests need: without
760/// it they read whatever configuration belongs to whoever ran them, right down
761/// to that person's server and their keychain.
762pub fn config_dir() -> PathBuf {
763    if let Some(dir) = CONFIG_DIR.read().clone() {
764        return dir;
765    }
766    if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
767        return PathBuf::from(dir);
768    }
769    dirs::home_dir()
770        .unwrap_or_else(|| PathBuf::from("."))
771        .join(".config")
772        .join("koan")
773}
774
775/// Point koan's configuration at `dir` for the life of the process.
776///
777/// Takes precedence over `KOAN_CONFIG_DIR`, and drops the cached config, which
778/// was keyed on the mtimes of files in a directory that is no longer the one
779/// being read. Set it before anything spawns: background threads resolve the
780/// directory when they run, not when they are created.
781pub fn set_config_dir(dir: impl Into<PathBuf>) {
782    *CONFIG_DIR.write() = Some(dir.into());
783    Config::invalidate_cache();
784}
785
786/// Point configuration at a directory belonging to this process alone.
787///
788/// Tests call this before anything reads configuration. Without it they read
789/// whatever belongs to whoever ran them — that person's library folders, their
790/// remote server, and a prompt for their keychain — so the same test does
791/// different things on different machines, and passes on CI only because it
792/// finds nothing there at all.
793///
794/// Process-wide rather than per-test on purpose: the threads koan spawns
795/// resolve the directory when they run, which is often after the test that
796/// started them has finished.
797pub fn isolate_config_for_tests() {
798    let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
799    let _ = fs::create_dir_all(&dir);
800    set_config_dir(dir);
801}
802
803static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
804    LazyLock::new(|| parking_lot::RwLock::new(None));
805
806/// Path to the base config TOML file (committable to dotfiles).
807pub fn config_file_path() -> PathBuf {
808    config_dir().join("config.toml")
809}
810
811/// Path to the local override config (gitignored, machine-specific).
812pub fn config_local_file_path() -> PathBuf {
813    config_dir().join("config.local.toml")
814}
815
816/// Path to the database file.
817pub fn db_path() -> PathBuf {
818    config_dir().join("koan.db")
819}
820
821/// Refuse to start when credentials are sitting in version control, which is a
822/// security incident rather than a warning anyone would act on.
823///
824/// Runs once per process. It reads both config files and, when a password is
825/// present, forks `git ls-files` — and `load()` is reached from UI paths that
826/// run per frame. The name says what it is: a gate on starting, not a check
827/// that belongs on every read.
828fn check_secrets_in_git() {
829    static ONCE: Once = Once::new();
830    ONCE.call_once(scan_for_tracked_secrets);
831}
832
833fn scan_for_tracked_secrets() {
834    let sensitive_fields = ["password"];
835
836    for (label, path) in [
837        ("config.toml", config_file_path()),
838        ("config.local.toml", config_local_file_path()),
839    ] {
840        let Ok(contents) = std::fs::read_to_string(&path) else {
841            continue;
842        };
843
844        // Check if this file contains any sensitive fields with non-empty values.
845        let has_secrets = sensitive_fields.iter().any(|field| {
846            contents.lines().any(|line| {
847                let line = line.trim();
848                if let Some(rest) = line.strip_prefix(field) {
849                    let rest = rest.trim_start();
850                    if let Some(value) = rest.strip_prefix('=') {
851                        let value = value.trim().trim_matches('"').trim_matches('\'');
852                        return !value.is_empty();
853                    }
854                }
855                false
856            })
857        });
858
859        if !has_secrets {
860            continue;
861        }
862
863        // Check if this file is tracked by git.
864        if is_tracked_by_git(&path) {
865            eprintln!();
866            eprintln!("╔══════════════════════════════════════════════════════════════╗");
867            eprintln!("║  SECURITY: {label} contains credentials and is tracked by git!  ║");
868            eprintln!("╠══════════════════════════════════════════════════════════════╣");
869            eprintln!("║                                                              ║");
870            eprintln!("║  File: {:<52} ║", path.display());
871            eprintln!("║                                                              ║");
872            eprintln!("║  Your password is in version control. You should:            ║");
873            eprintln!("║  1. Remove the file from git: git rm --cached <file>         ║");
874            eprintln!("║  2. Add it to .gitignore                                     ║");
875            eprintln!("║  3. Rotate your credentials immediately                      ║");
876            eprintln!("║  4. Move secrets to config.local.toml (gitignored)           ║");
877            eprintln!("║     or use `koan remote login` for keyring storage           ║");
878            eprintln!("║                                                              ║");
879            eprintln!("╚══════════════════════════════════════════════════════════════╝");
880            eprintln!();
881            panic!("Refusing to start: credentials tracked by git in {label}. See above.");
882        }
883    }
884}
885
886/// Check if a file is tracked by git (staged or committed, not just in a repo).
887fn is_tracked_by_git(path: &Path) -> bool {
888    let Some(parent) = path.parent() else {
889        return false;
890    };
891    // `git ls-files --error-unmatch <file>` exits 0 if tracked, 1 if not.
892    std::process::Command::new("git")
893        .args(["ls-files", "--error-unmatch"])
894        .arg(path)
895        .current_dir(parent)
896        .stdout(std::process::Stdio::null())
897        .stderr(std::process::Stdio::null())
898        .status()
899        .is_ok_and(|s| s.success())
900}
901
902#[cfg(test)]
903mod tests {
904    use super::*;
905    use std::fs;
906
907    fn tmp_dir() -> PathBuf {
908        let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
909        fs::create_dir_all(&dir).unwrap();
910        dir
911    }
912
913    #[test]
914    fn test_defaults() {
915        let cfg = Config::default();
916        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
917        assert!(!cfg.remote.enabled);
918    }
919
920    #[test]
921    fn test_roundtrip_toml() {
922        let cfg = Config::default();
923        let serialized = toml::to_string_pretty(&cfg).unwrap();
924        let deserialized: Config = toml::from_str(&serialized).unwrap();
925        assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
926        assert_eq!(
927            deserialized.remote.download_workers,
928            cfg.remote.download_workers
929        );
930    }
931
932    #[test]
933    fn test_load_from_file() {
934        let dir = tempfile::tempdir().unwrap();
935        let path = dir.path().join("config.toml");
936        fs::write(
937            &path,
938            r#"
939[library]
940folders = ["/tmp/music"]
941
942[playback]
943replaygain = "track"
944"#,
945        )
946        .unwrap();
947
948        let cfg = Config::load_from(&path).unwrap();
949        assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
950        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
951        assert!(!cfg.remote.enabled);
952    }
953
954    #[test]
955    fn test_partial_toml_uses_defaults() {
956        let dir = tempfile::tempdir().unwrap();
957        let path = dir.path().join("partial.toml");
958        fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
959
960        let cfg = Config::load_from(&path).unwrap();
961        assert_eq!(cfg.playback.target_fps, 30);
962        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
963    }
964
965    #[test]
966    fn test_figment_layered_loading() {
967        let dir = tempfile::tempdir().unwrap();
968        let base_path = dir.path().join("config.toml");
969        let local_path = dir.path().join("config.local.toml");
970
971        fs::write(
972            &base_path,
973            r#"
974[remote]
975url = "https://base.example.com"
976"#,
977        )
978        .unwrap();
979        fs::write(
980            &local_path,
981            r#"
982[remote]
983enabled = true
984url = "https://local.example.com"
985username = "admin"
986password = "secret"
987"#,
988        )
989        .unwrap();
990
991        // Build a figment with explicit paths (can't use load() since it reads from ~/.config).
992        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
993            .merge(Toml::file(&base_path))
994            .merge(Toml::file(&local_path))
995            .extract()
996            .unwrap();
997
998        assert!(cfg.remote.enabled);
999        assert_eq!(cfg.remote.url, "https://local.example.com");
1000        assert_eq!(cfg.remote.username, "admin");
1001        assert_eq!(cfg.remote.password, "secret");
1002    }
1003
1004    #[test]
1005    fn test_figment_missing_keys_preserved() {
1006        let dir = tempfile::tempdir().unwrap();
1007        let base_path = dir.path().join("config.toml");
1008        let local_path = dir.path().join("config.local.toml");
1009
1010        fs::write(
1011            &base_path,
1012            r#"
1013[remote]
1014url = "https://keep.me"
1015username = "keepuser"
1016"#,
1017        )
1018        .unwrap();
1019        fs::write(
1020            &local_path,
1021            r#"
1022[remote]
1023password = "secret"
1024"#,
1025        )
1026        .unwrap();
1027
1028        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1029            .merge(Toml::file(&base_path))
1030            .merge(Toml::file(&local_path))
1031            .extract()
1032            .unwrap();
1033
1034        assert_eq!(cfg.remote.url, "https://keep.me");
1035        assert_eq!(cfg.remote.username, "keepuser");
1036        assert_eq!(cfg.remote.password, "secret");
1037    }
1038
1039    #[test]
1040    fn test_env_var_override() {
1041        let dir = tempfile::tempdir().unwrap();
1042        let base_path = dir.path().join("config.toml");
1043
1044        fs::write(
1045            &base_path,
1046            r#"
1047[remote]
1048url = "https://file.example.com"
1049"#,
1050        )
1051        .unwrap();
1052
1053        // SAFETY: test is single-threaded and vars are cleaned up immediately after.
1054        unsafe {
1055            std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
1056            std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
1057            std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
1058        }
1059
1060        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1061            .merge(Toml::file(&base_path))
1062            .merge(Env::prefixed("KOAN_").split("__"))
1063            .extract()
1064            .unwrap();
1065
1066        assert_eq!(cfg.remote.url, "https://env.example.com");
1067        assert_eq!(cfg.remote.password, "env-secret");
1068        assert_eq!(cfg.graphql.port, 9999);
1069
1070        // Clean up env vars.
1071        unsafe {
1072            std::env::remove_var("KOAN_REMOTE__URL");
1073            std::env::remove_var("KOAN_REMOTE__PASSWORD");
1074            std::env::remove_var("KOAN_GRAPHQL__PORT");
1075        }
1076    }
1077
1078    #[test]
1079    fn test_cache_dir_default() {
1080        let cfg = Config::default();
1081        assert!(cfg.cache_dir().ends_with("cache"));
1082    }
1083
1084    #[test]
1085    fn test_cache_dir_explicit() {
1086        let mut cfg = Config::default();
1087        cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
1088        assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
1089    }
1090
1091    #[test]
1092    fn test_organize_config_defaults() {
1093        let cfg = Config::default();
1094        assert!(cfg.organize.default.is_none());
1095        assert!(cfg.organize.patterns.is_empty());
1096    }
1097
1098    #[test]
1099    fn test_organize_config_from_toml() {
1100        let dir = tmp_dir();
1101        let path = dir.join("organize.toml");
1102        fs::write(
1103            &path,
1104            r#"
1105[organize]
1106default = "standard"
1107
1108[organize.patterns]
1109standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
1110va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
1111"#,
1112        )
1113        .unwrap();
1114
1115        let cfg = Config::load_from(&path).unwrap();
1116        assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
1117        assert_eq!(cfg.organize.patterns.len(), 2);
1118        assert!(cfg.organize.patterns.contains_key("standard"));
1119        assert!(cfg.organize.patterns.contains_key("va-aware"));
1120
1121        fs::remove_dir_all(&dir).ok();
1122    }
1123
1124    #[test]
1125    fn test_figment_organize_patterns_merge() {
1126        let dir = tempfile::tempdir().unwrap();
1127        let base_path = dir.path().join("config.toml");
1128        let local_path = dir.path().join("config.local.toml");
1129
1130        fs::write(
1131            &base_path,
1132            r#"
1133[organize]
1134default = "standard"
1135
1136[organize.patterns]
1137standard = "base-pattern"
1138"#,
1139        )
1140        .unwrap();
1141        fs::write(
1142            &local_path,
1143            r#"
1144[organize]
1145default = "custom"
1146
1147[organize.patterns]
1148custom = "local-pattern"
1149"#,
1150        )
1151        .unwrap();
1152
1153        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1154            .merge(Toml::file(&base_path))
1155            .merge(Toml::file(&local_path))
1156            .extract()
1157            .unwrap();
1158
1159        // Local default wins.
1160        assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
1161        // Both patterns present (figment merges maps).
1162        assert_eq!(cfg.organize.patterns.len(), 2);
1163        assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
1164        assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
1165    }
1166
1167    #[test]
1168    fn test_output_device_config_roundtrip() {
1169        let mut cfg = Config::default();
1170        cfg.playback.output_device = Some("My DAC".into());
1171
1172        let serialized = toml::to_string_pretty(&cfg).unwrap();
1173        let deserialized: Config = toml::from_str(&serialized).unwrap();
1174        assert_eq!(
1175            deserialized.playback.output_device.as_deref(),
1176            Some("My DAC")
1177        );
1178    }
1179
1180    #[test]
1181    fn test_output_device_config_default_is_none() {
1182        let cfg = Config::default();
1183        assert!(cfg.playback.output_device.is_none());
1184
1185        // Roundtrip: None should not appear in serialized output.
1186        let serialized = toml::to_string_pretty(&cfg).unwrap();
1187        assert!(!serialized.contains("output_device"));
1188        let deserialized: Config = toml::from_str(&serialized).unwrap();
1189        assert!(deserialized.playback.output_device.is_none());
1190    }
1191
1192    #[test]
1193    fn test_output_device_config_from_toml() {
1194        let dir = tempfile::tempdir().unwrap();
1195        let path = dir.path().join("config.toml");
1196        fs::write(
1197            &path,
1198            r#"
1199[playback]
1200output_device = "External Speakers"
1201"#,
1202        )
1203        .unwrap();
1204
1205        let cfg = Config::load_from(&path).unwrap();
1206        assert_eq!(
1207            cfg.playback.output_device.as_deref(),
1208            Some("External Speakers")
1209        );
1210    }
1211
1212    #[test]
1213    fn test_graphql_bind_defaults_to_localhost() {
1214        let cfg = GraphqlConfig::default();
1215        assert_eq!(
1216            cfg.bind,
1217            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1218        );
1219    }
1220
1221    #[test]
1222    fn test_graphql_bind_from_toml() {
1223        let toml_str = r#"
1224[graphql]
1225bind = "0.0.0.0"
1226port = 5000
1227"#;
1228        let cfg: Config = toml::from_str(toml_str).unwrap();
1229        assert_eq!(
1230            cfg.graphql.bind,
1231            std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
1232        );
1233        assert_eq!(cfg.graphql.port, 5000);
1234    }
1235
1236    #[test]
1237    fn test_graphql_bind_omitted_defaults_to_localhost() {
1238        let toml_str = r#"
1239[graphql]
1240port = 4000
1241"#;
1242        let cfg: Config = toml::from_str(toml_str).unwrap();
1243        assert_eq!(
1244            cfg.graphql.bind,
1245            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1246        );
1247    }
1248
1249    #[test]
1250    fn test_organize_config_roundtrip() {
1251        let mut cfg = Config::default();
1252        cfg.organize.default = Some("standard".into());
1253        cfg.organize
1254            .patterns
1255            .insert("standard".into(), "%artist%/%title%".into());
1256
1257        let serialized = toml::to_string_pretty(&cfg).unwrap();
1258        let deserialized: Config = toml::from_str(&serialized).unwrap();
1259        assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
1260        assert_eq!(
1261            deserialized.organize.patterns["standard"],
1262            "%artist%/%title%"
1263        );
1264    }
1265
1266    #[test]
1267    fn test_parse_size_bytes() {
1268        assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1269        assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1270        assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1271        assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1272        assert_eq!(parse_size_bytes("1024B"), Some(1024));
1273        assert_eq!(parse_size_bytes("1024"), Some(1024));
1274
1275        // Case insensitive.
1276        assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1277        assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1278
1279        // Short suffixes.
1280        assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1281        assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1282
1283        // Spaces.
1284        assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1285        assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1286
1287        // Decimal.
1288        assert_eq!(
1289            parse_size_bytes("1.5GB"),
1290            Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1291        );
1292
1293        // Invalid.
1294        assert_eq!(parse_size_bytes(""), None);
1295        assert_eq!(parse_size_bytes("abc"), None);
1296        assert_eq!(parse_size_bytes("50XB"), None);
1297    }
1298
1299    #[test]
1300    fn test_cache_limit_config_from_toml() {
1301        let toml_str = r#"
1302[remote]
1303cache_limit = "50GB"
1304"#;
1305        let cfg: Config = toml::from_str(toml_str).unwrap();
1306        assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1307        assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1308    }
1309
1310    #[test]
1311    fn test_cache_limit_none_by_default() {
1312        let cfg = Config::default();
1313        assert!(cfg.remote.cache_limit.is_none());
1314        assert!(cfg.cache_limit_bytes().is_none());
1315    }
1316
1317    #[test]
1318    fn test_cache_limit_not_serialized_when_none() {
1319        let cfg = Config::default();
1320        let serialized = toml::to_string_pretty(&cfg).unwrap();
1321        assert!(!serialized.contains("cache_limit"));
1322    }
1323
1324    #[test]
1325    fn player_uses_config_on_init() {
1326        // Verify that Config::load_from correctly picks up playback settings
1327        // that Player::new() would consume. This tests the contract between
1328        // config and player initialization without requiring audio hardware.
1329        let dir = tempfile::tempdir().unwrap();
1330        let path = dir.path().join("config.toml");
1331        fs::write(
1332            &path,
1333            r#"
1334[playback]
1335replaygain = "track"
1336output_device = "My Fancy DAC"
1337pre_amp_db = -3.5
1338target_fps = 30
1339art_size = 32
1340
1341[visualizer]
1342enabled = false
1343mode = "oscilloscope"
1344fps = 30
1345"#,
1346        )
1347        .unwrap();
1348
1349        let cfg = Config::load_from(&path).unwrap();
1350
1351        // These are the fields Player::new() reads from config.
1352        assert_eq!(
1353            cfg.playback.replaygain,
1354            ReplayGainMode::Track,
1355            "replaygain should be 'track'"
1356        );
1357        assert_eq!(
1358            cfg.playback.output_device.as_deref(),
1359            Some("My Fancy DAC"),
1360            "output_device should match config"
1361        );
1362        assert!(
1363            (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1364            "pre_amp_db should be -3.5"
1365        );
1366        assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1367        assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1368
1369        // Visualizer config is also consumed at player init.
1370        assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1371        assert_eq!(cfg.visualizer.mode, "oscilloscope");
1372        assert_eq!(cfg.visualizer.fps, 30);
1373    }
1374
1375    #[test]
1376    fn a_missing_config_file_stamps_as_absent() {
1377        let dir = tempfile::tempdir().unwrap();
1378        let base = dir.path().join("config.toml");
1379        let local = dir.path().join("config.local.toml");
1380
1381        assert_eq!(stamp_of(&base, &local), (None, None));
1382
1383        fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
1384        let (base_stamp, local_stamp) = stamp_of(&base, &local);
1385        assert!(base_stamp.is_some(), "creating the file must be a change");
1386        assert!(local_stamp.is_none());
1387    }
1388
1389    #[test]
1390    fn editing_a_config_file_changes_its_stamp() {
1391        let dir = tempfile::tempdir().unwrap();
1392        let base = dir.path().join("config.toml");
1393        let local = dir.path().join("config.local.toml");
1394        fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
1395
1396        let before = stamp_of(&base, &local);
1397        // Coarse-grained filesystems would otherwise stamp both writes alike.
1398        std::thread::sleep(std::time::Duration::from_millis(20));
1399        fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
1400
1401        assert_ne!(
1402            before,
1403            stamp_of(&base, &local),
1404            "a config edited by hand has to be picked up"
1405        );
1406    }
1407
1408    #[test]
1409    fn invalidating_forces_a_reload() {
1410        let first = Config::cached();
1411        Config::invalidate_cache();
1412        assert!(
1413            !Arc::ptr_eq(&first, &Config::cached()),
1414            "koan's own writes invalidate explicitly; the next read must re-parse"
1415        );
1416    }
1417
1418    // ---- persist: which file a setting lands in -------------------------
1419
1420    /// `persist` reads and writes process-global paths, so these run one at a
1421    /// time rather than racing each other through `set_config_dir`.
1422    static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1423
1424    /// Point config at a fresh directory and hand back (base, local) paths.
1425    fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
1426        let dir =
1427            std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
1428        let _ = fs::remove_dir_all(&dir);
1429        fs::create_dir_all(&dir).unwrap();
1430        set_config_dir(&dir);
1431        (config_file_path(), config_local_file_path())
1432    }
1433
1434    #[test]
1435    fn persist_keeps_comments_and_untouched_keys() {
1436        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1437        let (base, _local) = persist_sandbox("comments");
1438        fs::write(
1439            &base,
1440            "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
1441        )
1442        .unwrap();
1443
1444        Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
1445
1446        let written = fs::read_to_string(&base).unwrap();
1447        assert!(
1448            written.contains("# koan — shareable defaults"),
1449            "the header comment must survive a write: {written}"
1450        );
1451        assert!(
1452            written.contains("# fps = 60"),
1453            "commented-out defaults are the template's whole point: {written}"
1454        );
1455        assert!(written.contains("palette = \"neon\""));
1456        assert!(
1457            !written.contains("[graphql]"),
1458            "an untouched section must not be invented: {written}"
1459        );
1460    }
1461
1462    #[test]
1463    fn persist_routes_machine_settings_to_the_local_file() {
1464        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1465        let (base, local) = persist_sandbox("routing");
1466
1467        Config::persist(|cfg| {
1468            cfg.playback.replaygain = ReplayGainMode::Album;
1469            cfg.playback.output_device = Some("My DAC".into());
1470            cfg.playback.art_size = 40;
1471            cfg.visualizer.mode = "starfield".into();
1472        })
1473        .unwrap();
1474
1475        let shared = fs::read_to_string(&base).unwrap();
1476        let machine = fs::read_to_string(&local).unwrap();
1477
1478        assert!(shared.contains("replaygain = \"album\""), "{shared}");
1479        for machine_only in ["output_device", "art_size", "starfield"] {
1480            assert!(
1481                !shared.contains(machine_only),
1482                "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
1483            );
1484            assert!(machine.contains(machine_only), "{machine}");
1485        }
1486    }
1487
1488    #[test]
1489    fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
1490        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1491        let (base, _local) = persist_sandbox("folders");
1492
1493        // Toggling the visualiser used to serialise the whole struct, baking
1494        // the *default* music directory into the file people commit.
1495        Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
1496
1497        let shared = fs::read_to_string(&base).unwrap_or_default();
1498        assert!(
1499            !shared.contains("folders"),
1500            "a visualiser toggle must not invent library folders: {shared}"
1501        );
1502    }
1503
1504    #[test]
1505    fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
1506        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1507        let (base, local) = persist_sandbox("drain");
1508        fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
1509
1510        Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
1511
1512        let shared = fs::read_to_string(&base).unwrap();
1513        assert!(
1514            !shared.contains("art_size"),
1515            "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
1516        );
1517        assert!(shared.contains("target_fps"), "{shared}");
1518        assert!(
1519            fs::read_to_string(&local)
1520                .unwrap()
1521                .contains("art_size = 48")
1522        );
1523    }
1524
1525    #[test]
1526    fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
1527        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1528        let (_base, local) = persist_sandbox("shadow");
1529        fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
1530
1531        Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
1532
1533        assert_eq!(
1534            Config::from_files().unwrap().playback.target_fps,
1535            120,
1536            "local wins the merge, so a shared write over a local copy would \
1537             otherwise be silently ignored: {}",
1538            fs::read_to_string(&local).unwrap()
1539        );
1540    }
1541
1542    #[test]
1543    fn persist_writes_nothing_when_the_mutation_changes_nothing() {
1544        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1545        let (base, _local) = persist_sandbox("noop");
1546        fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
1547
1548        Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
1549
1550        assert_eq!(
1551            fs::read_to_string(&base).unwrap(),
1552            "# untouched\n[playback]\ntarget_fps = 60\n"
1553        );
1554    }
1555
1556    #[test]
1557    fn persist_keeps_passwords_out_of_the_shared_file() {
1558        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1559        let (base, local) = persist_sandbox("secrets");
1560
1561        Config::persist(|cfg| {
1562            cfg.remote.password = "hunter2".into();
1563            cfg.subsonic.password = "s3cret".into();
1564            cfg.visualizer.palette = "mono".into();
1565        })
1566        .unwrap();
1567
1568        let shared = fs::read_to_string(&base).unwrap();
1569        assert!(!shared.contains("hunter2"), "{shared}");
1570        assert!(!shared.contains("s3cret"), "{shared}");
1571        assert!(shared.contains("mono"));
1572
1573        let machine = fs::read_to_string(&local).unwrap();
1574        assert!(machine.contains("hunter2") && machine.contains("s3cret"));
1575    }
1576
1577    #[test]
1578    fn persist_removes_a_cleared_password_rather_than_blanking_it() {
1579        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1580        let (_base, local) = persist_sandbox("clear-secret");
1581        fs::write(
1582            &local,
1583            "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
1584        )
1585        .unwrap();
1586
1587        Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
1588
1589        let machine = fs::read_to_string(&local).unwrap();
1590        assert!(
1591            !machine.contains("password"),
1592            "an emptied secret should leave no key behind: {machine}"
1593        );
1594        assert!(machine.contains("url"), "{machine}");
1595    }
1596
1597    #[test]
1598    fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
1599        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1600        let (base, _local) = persist_sandbox("patterns");
1601        fs::write(
1602            &base,
1603            "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
1604        )
1605        .unwrap();
1606
1607        Config::persist(|cfg| {
1608            cfg.organize
1609                .patterns
1610                .insert("standard".into(), "%album artist%/%album%".into());
1611        })
1612        .unwrap();
1613
1614        let cfg = Config::from_files().unwrap();
1615        assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
1616        assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
1617    }
1618}