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