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