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