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