Skip to main content

koan_core/
config.rs

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