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