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