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