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 lastfm: LastfmConfig,
40    pub devices: DevicesConfig,
41    pub dsp: DspConfig,
42    pub appearance: AppearanceConfig,
43}
44
45/// How the apps are drawn: Settings → Appearance.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(default)]
48pub struct AppearanceConfig {
49    /// `"koan"`, the site's look throughout (see `docs/design/koan-theme.md`),
50    /// or `"system"`, the platform's own look in koan's colours. Read at launch.
51    pub theme: String,
52    /// In the kōan theme, draw the app's icons beside navigation, tabs and
53    /// controls; off, labels alone.
54    pub theme_icons: bool,
55    /// Take colour from the record playing: the wash behind the window and
56    /// the accent. Off, there is no wash and the accent is koan's mint, in
57    /// either theme.
58    pub record_colours: bool,
59    /// On the Mac in the kōan theme, the wash runs under the whole window:
60    /// sidebar, toolbar, transport and lyrics drawn clear over it. Off, those
61    /// keep grounds of their own. Provisional: a way to live with both while
62    /// the look is decided, and expected to go once it is.
63    pub wash_window: bool,
64    /// An alternative palette for the accent, wash and indicators (see the
65    /// apps' `Rainbow.swift`). Per device, and not offered in Settings.
66    pub rainbow: bool,
67    /// Text size on the Mac, in percent of the theme's own (80–130, in steps
68    /// of 5). Spacing follows it at half the rate. Per device.
69    pub text_size: u16,
70    /// In the kōan theme, how far the wash is held to the theme's contrast
71    /// limit, in percent: 100 keeps every sample dark enough (or, in light
72    /// mode, light enough) for `muted` text over it to read; 0 leaves the
73    /// sleeve's own colours. Values between blend the two; above 100 reads
74    /// as 100.
75    pub wash_tint: u16,
76}
77
78impl Default for AppearanceConfig {
79    fn default() -> Self {
80        Self {
81            theme: "koan".into(),
82            theme_icons: true,
83            record_colours: true,
84            wash_window: true,
85            rainbow: false,
86            text_size: 100,
87            wash_tint: 100,
88        }
89    }
90}
91
92/// Share links this koan serves itself.
93#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94#[serde(default)]
95pub struct SharingConfig {
96    /// Where this server is reached from outside, e.g. `https://koan.example.com`.
97    /// Share links are built on it; without it a server makes no links, since
98    /// it cannot know which of its addresses a stranger can reach.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub public_url: Option<String>,
101}
102
103/// The Last.fm API account this server scrobbles through. Last.fm hands out
104/// one key and shared secret per application, so the server's admin registers
105/// them (<https://www.last.fm/api/account/create>) and every account on the
106/// server connects its own Last.fm through them. Without both, Last.fm is not
107/// offered. Secrets: `config.local.toml`, or `KOAN_LASTFM__API_KEY` and
108/// `KOAN_LASTFM__SECRET`.
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110#[serde(default)]
111pub struct LastfmConfig {
112    #[serde(skip_serializing_if = "String::is_empty")]
113    pub api_key: String,
114    #[serde(skip_serializing_if = "String::is_empty")]
115    pub secret: String,
116}
117
118impl LastfmConfig {
119    /// Both are set, so connecting and sending can work.
120    pub fn is_set(&self) -> bool {
121        !self.api_key.trim().is_empty() && !self.secret.trim().is_empty()
122    }
123}
124
125/// MCP clients signing in through this server's OAuth.
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127#[serde(default)]
128pub struct McpConfig {
129    /// The only hosts an MCP client may register a redirect to, besides this
130    /// machine. Empty allows any HTTPS host: each approval then rests on the
131    /// user recognising the host the consent page names.
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub redirect_hosts: Vec<String>,
134}
135
136/// Push notifications to koan's iOS app, which reach a phone iOS has
137/// suspended. Apple accepts them only signed with the key of the team that
138/// ships the app, so only a server holding that key can send them; without one
139/// a server reaches phones only while they are linked.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct PushConfig {
143    /// The APNs auth key (`AuthKey_XXXXXXXXXX.p8`).
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub key_path: Option<PathBuf>,
146    /// The key itself, PEM, for a deployment that hands secrets over as
147    /// environment (`KOAN_PUSH__KEY`) rather than files.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub key: Option<String>,
150    /// The key's ten-character id.
151    #[serde(skip_serializing_if = "String::is_empty")]
152    pub key_id: String,
153    /// The Apple developer team the key belongs to.
154    #[serde(skip_serializing_if = "String::is_empty")]
155    pub team_id: String,
156    /// The app's bundle id, which every push names.
157    pub topic: String,
158}
159
160impl Default for PushConfig {
161    fn default() -> Self {
162        Self {
163            key_path: None,
164            key: None,
165            key_id: String::new(),
166            team_id: String::new(),
167            topic: "cc.blit.koan".into(),
168        }
169    }
170}
171
172/// The folders koan indexes. None until someone chooses one: looking in
173/// `~/Music` unasked reads the Apple Music library inside it, and macOS asks
174/// the person whether koan may.
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176#[serde(default)]
177pub struct LibraryConfig {
178    pub folders: Vec<PathBuf>,
179}
180
181/// The folder koan used to index when none was chosen, `~/Music`. Kept so a
182/// library built from it can be written into the config before the default
183/// it came from goes (`helpers::keep_former_default_folder`).
184pub fn former_default_folder() -> PathBuf {
185    dirs::audio_dir().unwrap_or_else(|| {
186        dirs::home_dir()
187            .map(|h| h.join("Music"))
188            .unwrap_or_else(|| PathBuf::from("/Music"))
189    })
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(default)]
194pub struct PlaybackConfig {
195    pub replaygain: ReplayGainMode,
196    /// UI render rate in frames-per-second (default: 60).
197    /// Controls how often the TUI redraws. 30, 60, or 120 are typical values.
198    pub target_fps: u8,
199    /// Show an FPS counter overlay in the top-right corner.
200    pub show_fps: bool,
201    /// ReplayGain pre-amplification in dB. Applied on top of track/album gain.
202    /// Positive values boost, negative values attenuate. Default: 0.0.
203    pub pre_amp_db: f64,
204    /// Fade out on pause and back in on resume, rather than cutting.
205    pub fade_on_pause: bool,
206    /// Output audio device name. None = system default.
207    /// Persisted by name (not ID) since IDs can change across reboots.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub output_device: Option<String>,
210    /// The UPnP renderer last picked as the output, by UDN, and its name for
211    /// showing. Gone back to at launch if it is on the network; otherwise the
212    /// music plays on `output_device`.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub renderer: Option<String>,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub renderer_name: Option<String>,
217    /// Look for UPnP renderers on the network. Off, none is found, so none
218    /// can be played to.
219    pub renderers: bool,
220    /// Play silence: the output runs as usual, with every sample zeroed. For
221    /// automated runs on a machine someone is using.
222    pub muted: bool,
223    /// Album art width in terminal columns (default: 24).
224    /// Height is always width/2 (square via halfblock rendering).
225    pub art_size: u16,
226    /// Draw cover art in the terminal with the kitty, iTerm2 or sixel graphics
227    /// protocol where it supports one. Off, art is drawn in half blocks, which
228    /// every terminal can show.
229    pub art_graphics: bool,
230    /// The TUI's palette: `koan` for the kōan theme's colours (the cover's
231    /// accent included), `ansi` for the terminal's own sixteen.
232    pub tui_theme: String,
233    /// The TUI's queue in album runs, rather than a row per track.
234    pub queue_grouped: bool,
235    /// Silence played after the output device changes sample rate, so the
236    /// start of the track is not lost while the device relocks its clock.
237    /// How long that takes is a property of the device.
238    pub rate_switch_lead_in_ms: u32,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "lowercase")]
243pub enum ReplayGainMode {
244    Off,
245    Track,
246    Album,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(default)]
251pub struct RemoteConfig {
252    pub enabled: bool,
253    pub url: String,
254    pub username: String,
255    /// Password — stored in config.local.toml (gitignored), not config.toml.
256    #[serde(default, skip_serializing_if = "String::is_empty")]
257    pub password: String,
258    /// An OpenSubsonic API key, signing in instead of the password. What
259    /// joining with a koan invite stores. config.local.toml, like the password.
260    #[serde(default, skip_serializing_if = "String::is_empty")]
261    pub api_key: String,
262    /// The keypair this device proves itself with to the account's other
263    /// devices on the local network: base64 of its Ed25519 PKCS#8. Made at
264    /// each sign-in with an API key and dropped at sign-out, so the server's
265    /// copy of the public key goes with the API key it is kept on. A secret,
266    /// kept with the credentials. See `remote::proof`.
267    #[serde(default, skip_serializing_if = "String::is_empty")]
268    pub device_key: String,
269    /// Defaults to config_dir()/cache if empty.
270    pub cache_dir: Option<PathBuf>,
271    /// Parallel download workers for remote tracks (default: 5).
272    pub download_workers: usize,
273    /// Maximum cache size on disk. Human-readable: "50GB", "500MB", etc.
274    /// None or empty = unlimited. Past it, whole albums are evicted, least
275    /// recently used first, at startup and as downloads land.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub cache_limit: Option<String>,
278    /// Sync the library from the server on startup, and on a timer for a server
279    /// that cannot say when it changes; a koan server tells its apps itself.
280    ///
281    /// Each run walks the library only if the server says it changed since the
282    /// last walk, so it is cheap enough to run unattended.
283    pub auto_sync: bool,
284    /// Minutes between automatic syncs. 0 runs one at startup and no more.
285    pub auto_sync_interval_mins: u64,
286    /// Keep this device's queue in the account's play queue on the server,
287    /// where Subsonic clients save theirs, so a queue can be picked up in
288    /// another client or on another kōan device after a restart. Off by
289    /// default: between kōan devices the queue already moves live over the
290    /// link. Per device.
291    pub play_queue: bool,
292}
293
294impl Default for PlaybackConfig {
295    fn default() -> Self {
296        Self {
297            replaygain: ReplayGainMode::Off,
298            target_fps: 60,
299            show_fps: false,
300            pre_amp_db: 0.0,
301            fade_on_pause: true,
302            output_device: None,
303            renderer: None,
304            renderer_name: None,
305            renderers: true,
306            muted: false,
307            art_size: 24,
308            art_graphics: true,
309            tui_theme: "koan".into(),
310            queue_grouped: true,
311            rate_switch_lead_in_ms: 1000,
312        }
313    }
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
317#[serde(default)]
318pub struct VisualizerConfig {
319    pub enabled: bool,
320    pub fps: u8,
321    /// Visualizer mode by name: "bars" (default), or any name
322    /// `VisualizerMode::parse` in koan-tui accepts.
323    pub mode: String,
324    /// Frequency scale: "bark" (default), "mel", "log", "linear".
325    pub scale: String,
326    /// Amplitude scale: "aweight" (default, A-weighted), "perceptual" (A-weighted + gamma), "sqrt", "linear".
327    pub amplitude_scale: String,
328    /// Bar decay half-life in milliseconds (how fast bars drop).
329    pub bar_decay_ms: u32,
330    /// Peak decay half-life in milliseconds (how long peaks linger).
331    pub peak_decay_ms: u32,
332    /// Color palette: "theme" (default; the UI's own accent, taken from the
333    /// playing cover), "rainbow", "mono", "fire", "neon". "spectrum" and
334    /// "record" are older names for "theme".
335    /// Controls the frequency-mapped color gradient on spectrum bars.
336    pub palette: String,
337    /// Reactivity multiplier (0.0..2.0, default 1.0).
338    /// Scales all beat/spectrum-driven animation coefficients.
339    /// 0.0 = static, 1.0 = normal, 2.0 = hypersensitive.
340    pub reactivity: f32,
341    /// Bass shake: camera jitter + scale pulse on bass hits.
342    /// Applies to braille-rendered modes (oscilloscope, radial, wireframe, starfield, etc.).
343    pub bass_shake: bool,
344    /// Matrix overlay: replace all rendered characters with random matrix glyphs in green.
345    /// Applies to any visualizer mode as a post-processing pass.
346    pub matrix_overlay: bool,
347    /// Beat-reactive background color on braille modes (starfield, wormhole, etc.).
348    pub reactive_bg: bool,
349}
350
351impl Default for VisualizerConfig {
352    fn default() -> Self {
353        Self {
354            enabled: true,
355            fps: 60,
356            mode: "bars".into(),
357            scale: "bark".into(),
358            amplitude_scale: "aweight".into(),
359            bar_decay_ms: 50,
360            peak_decay_ms: 180,
361            palette: "theme".into(),
362            reactivity: 1.0,
363            bass_shake: true,
364            matrix_overlay: false,
365            reactive_bg: false,
366        }
367    }
368}
369
370impl Default for RemoteConfig {
371    fn default() -> Self {
372        Self {
373            enabled: false,
374            url: String::new(),
375            username: String::new(),
376            password: String::new(),
377            api_key: String::new(),
378            device_key: String::new(),
379            cache_dir: None,
380            download_workers: 5,
381            cache_limit: None,
382            auto_sync: true,
383            auto_sync_interval_mins: 60,
384            play_queue: false,
385        }
386    }
387}
388
389/// Parse a human-readable size string like "50GB", "500 MB", "1.5TB" into bytes.
390/// Supports B, KB, MB, GB, TB (case-insensitive). Returns None for invalid input.
391pub fn parse_size_bytes(s: &str) -> Option<u64> {
392    let s = s.trim();
393    if s.is_empty() {
394        return None;
395    }
396
397    // Split into numeric part and suffix.
398    let mut num_end = 0;
399    for (i, c) in s.char_indices() {
400        if c.is_ascii_digit() || c == '.' {
401            num_end = i + c.len_utf8();
402        } else if !c.is_whitespace() {
403            break;
404        }
405    }
406
407    let num_str = s[..num_end].trim();
408    let suffix = s[num_end..].trim().to_ascii_uppercase();
409
410    let value: f64 = num_str.parse().ok()?;
411    let multiplier: u64 = match suffix.as_str() {
412        "" | "B" => 1,
413        "KB" | "K" => 1024,
414        "MB" | "M" => 1024 * 1024,
415        "GB" | "G" => 1024 * 1024 * 1024,
416        "TB" | "T" => 1024 * 1024 * 1024 * 1024,
417        _ => return None,
418    };
419
420    Some((value * multiplier as f64) as u64)
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
424#[serde(default)]
425pub struct OrganizeConfig {
426    /// Named pattern preselected when an organize sheet or modal opens.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub default: Option<String>,
429    /// Named patterns — keys are names, values are format strings.
430    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
431    pub patterns: HashMap<String, String>,
432    /// Move cover art, cue sheets and logs alongside the music they belong to.
433    /// On by default: a folder's artwork is part of the release, and leaving it
434    /// behind turns one album into two half-albums.
435    #[serde(default = "default_true")]
436    pub move_ancillary: bool,
437}
438
439impl Default for OrganizeConfig {
440    fn default() -> Self {
441        Self {
442            default: None,
443            patterns: HashMap::new(),
444            move_ancillary: true,
445        }
446    }
447}
448
449/// GraphQL API server configuration.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451#[serde(default)]
452pub struct GraphqlConfig {
453    /// Enable the GraphQL API server alongside the TUI (default: true).
454    /// Set to false for TUI-only mode (equivalent to --no-api).
455    pub enabled: bool,
456    /// GraphQL API port (default: 4000).
457    pub port: u16,
458    /// Bind address for the API server (default: 127.0.0.1).
459    /// Use "0.0.0.0" to listen on all interfaces (NOT RECOMMENDED without auth).
460    #[serde(default = "default_bind")]
461    pub bind: std::net::IpAddr,
462    /// Enable GraphiQL web IDE at GET /graphql.
463    pub playground: bool,
464    /// Require authentication for API access (default: true).
465    /// When false, all requests are treated as admin. When true, JWT auth is enforced.
466    pub auth_enabled: bool,
467    /// Access token TTL (default: "15m"). Supports: "15m", "1h", "3600s".
468    pub access_token_ttl: String,
469    /// Refresh token TTL (default: "30d"). Supports: "30d", "7d", "720h".
470    pub refresh_token_ttl: String,
471    /// Allowed CORS origins. Empty = no cross-origin browser access at all.
472    /// Example: ["https://music.example.com"]
473    pub cors_origins: Vec<String>,
474    /// Extra `Host:` values the server will answer to, beyond `localhost` and
475    /// bare IP literals. Requests carrying any other Host are refused, which is
476    /// what stops a DNS-rebinding page from reaching the API as same-origin.
477    pub allowed_hosts: Vec<String>,
478    /// Mark the session cookie `Secure`. Only set this when clients reach koan
479    /// over HTTPS — browsers silently discard `Secure` cookies sent over plain
480    /// `http://` to anything but localhost.
481    pub cookie_secure: bool,
482    /// A request header an authenticating reverse proxy (Authelia, Authentik,
483    /// oauth2-proxy) sets to the signed-in username, e.g. `Remote-User`. The
484    /// web UI signs that account in without asking for its password. Honoured
485    /// only on connections from `proxy_auth_from`; empty turns it off.
486    #[serde(default, skip_serializing_if = "String::is_empty")]
487    pub proxy_auth_header: String,
488    /// The addresses or CIDR ranges the authenticating proxy connects from.
489    /// The header is ignored on a connection from anywhere else.
490    #[serde(default, skip_serializing_if = "Vec::is_empty")]
491    pub proxy_auth_from: Vec<String>,
492    /// Expose the `organize*` mutations, which physically move files on disk.
493    pub allow_organize: bool,
494    /// While auth is on and no account exists, the web UI's `/setup` page
495    /// makes the first admin, so a server in a container needs no shell. The
496    /// first person to reach it becomes that admin: turn it off to keep
497    /// `koan auth setup` the only way.
498    #[serde(default = "default_true")]
499    pub setup_wizard: bool,
500}
501
502fn default_true() -> bool {
503    true
504}
505
506fn default_bind() -> std::net::IpAddr {
507    std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
508}
509
510impl Default for GraphqlConfig {
511    fn default() -> Self {
512        Self {
513            enabled: true,
514            port: 4000,
515            bind: default_bind(),
516            playground: false,
517            auth_enabled: true,
518            access_token_ttl: "15m".into(),
519            refresh_token_ttl: "30d".into(),
520            cors_origins: Vec::new(),
521            allowed_hosts: Vec::new(),
522            cookie_secure: false,
523            proxy_auth_header: String::new(),
524            proxy_auth_from: Vec::new(),
525            allow_organize: false,
526            setup_wizard: true,
527        }
528    }
529}
530
531/// Subsonic-compatible REST API.
532///
533/// Credentials are deliberately separate from `[remote]`: the Subsonic protocol
534/// authenticates with `md5(password + salt)` over whatever transport the client
535/// picked, so the secret has to be recoverable and is exposed to anyone who can
536/// capture a request. Reusing the upstream Navidrome password would hand out
537/// that account too.
538#[derive(Debug, Clone, Serialize, Deserialize)]
539#[serde(default)]
540pub struct SubsonicConfig {
541    /// Serve `/rest/*`. Off unless explicitly enabled.
542    ///
543    /// The routes are mounted on the GraphQL port. `port` adds a second
544    /// listener for clients that expect Subsonic on one of its own.
545    pub enabled: bool,
546    /// Serve `/rest/*` on a dedicated port as well as the GraphQL one.
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub port: Option<u16>,
549    /// Username Subsonic clients authenticate as.
550    pub username: String,
551    /// Shared secret, written by `koan subsonic setup`. Lives in
552    /// config.local.toml, which is gitignored and `0600`.
553    #[serde(default, skip_serializing_if = "String::is_empty")]
554    pub password: String,
555    /// Transcode `stream` for clients that ask for a lower bitrate or another
556    /// format. Off, every client gets the original file.
557    pub transcode: bool,
558    /// The `ffmpeg` transcoding runs, by name on `PATH` or by path. Without
559    /// one, originals are served.
560    pub ffmpeg: String,
561}
562
563impl Default for SubsonicConfig {
564    fn default() -> Self {
565        Self {
566            enabled: false,
567            port: None,
568            username: "koan".into(),
569            password: String::new(),
570            transcode: true,
571            ffmpeg: "ffmpeg".into(),
572        }
573    }
574}
575
576/// Credentials for a remote koan server this machine signs in to.
577///
578/// The other direction from `GraphqlConfig`, which configures the server koan
579/// *is*. Written by `koan auth login` and cleared by `koan auth logout`.
580#[derive(Debug, Clone, Default, Serialize, Deserialize)]
581#[serde(default)]
582pub struct AuthConfig {
583    /// Server the token below belongs to. One at a time.
584    #[serde(skip_serializing_if = "String::is_empty")]
585    pub server: String,
586    /// Refresh token, exchanged for short-lived access tokens. Revocable at the
587    /// server, which is what separates it from a password.
588    #[serde(skip_serializing_if = "String::is_empty")]
589    pub refresh_token: String,
590}
591
592/// Controlling this device from others on the local network, and finding
593/// them. Devices on the same account reach each other through the server
594/// whatever this says.
595#[derive(Debug, Clone, Serialize, Deserialize)]
596#[serde(default)]
597pub struct DevicesConfig {
598    /// Take part in the local network at all: listen, announce, look for other
599    /// devices and dial them. Off, this device reaches others only through the
600    /// server. For a shared network, and for test runs that relaunch an app
601    /// over and over, which would otherwise announce it to every device in
602    /// the house each time.
603    pub nearby: bool,
604    /// Listen on the local network and announce this device there, so any
605    /// koan app on it can see what is playing and control it.
606    pub discoverable: bool,
607    /// The port listened on. Fixed so that an address typed into another
608    /// device (over Tailscale, where nothing is announced) keeps working.
609    pub port: u16,
610    /// Devices to connect to by address, `host:port`: for networks that do
611    /// not carry Bonjour, such as a tailnet.
612    pub addresses: Vec<String>,
613    /// What a kōan on the local network may have this device do, whoever is
614    /// signed in there.
615    pub nearby_control: NearbyControl,
616    /// Addresses whose connections to this device are hung up at once: a
617    /// stranger on the network, refused from Settings.
618    pub refused: Vec<String>,
619    /// Keep the Mac app running in the menu bar once its window is closed, so
620    /// other devices can still see and control this one. Quitting it then
621    /// means this Mac is out of reach until it is opened again.
622    pub keep_running: bool,
623}
624
625/// What devices on the local network may do with this one.
626#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
627#[serde(rename_all = "snake_case")]
628pub enum NearbyControl {
629    /// Play, the queue, the output, the preset and the volume, and moving the
630    /// music here or away: what a household network wants.
631    #[default]
632    Full,
633    /// Play and the queue only, and a hand-off that stays on the network: for
634    /// a network shared with strangers.
635    Playback,
636}
637
638impl Default for DevicesConfig {
639    fn default() -> Self {
640        Self {
641            nearby: true,
642            discoverable: true,
643            port: DEVICES_PORT,
644            addresses: Vec::new(),
645            nearby_control: NearbyControl::Full,
646            refused: Vec::new(),
647            keep_running: false,
648        }
649    }
650}
651
652/// "koan" on a phone keypad.
653pub const DEVICES_PORT: u16 = 5626;
654
655/// Equalisation and convolution for the listening setup: headphones, speakers,
656/// a room. Each profile names the output devices it is for, so plugging in the
657/// headphones selects their correction. A device no profile names plays
658/// bit-perfect, untouched.
659#[derive(Debug, Clone, Serialize, Deserialize)]
660#[serde(default)]
661pub struct DspConfig {
662    /// Off bypasses every profile without forgetting any of them.
663    pub enabled: bool,
664    pub profiles: Vec<DspProfile>,
665    /// Output devices whose AutoEQ suggestion was turned down. See
666    /// `audio::dsp::autoeq::suggest`.
667    #[serde(skip_serializing_if = "Vec::is_empty")]
668    pub autoeq_dismissed: Vec<String>,
669    /// The tuning each output plays on top of its correction, the profile
670    /// that lists it: its EQs, in the order listed, each switched on or off.
671    /// This device's, as which profile an output plays is.
672    #[serde(skip_serializing_if = "Vec::is_empty")]
673    pub tunings: Vec<DspOutputTuning>,
674    /// The preset each output's correction and tuning were last set from,
675    /// which they are compared with to say whether it was changed since.
676    #[serde(skip_serializing_if = "Vec::is_empty")]
677    pub presets: Vec<DspOutputPreset>,
678    /// Outputs that play filters alone: no headphone correction, and
679    /// nothing matched to a target. Speakers, mostly.
680    #[serde(skip_serializing_if = "Vec::is_empty")]
681    pub filters_only: Vec<String>,
682    /// The uids of profiles deleted here and not yet deleted on the account's
683    /// server. Sync deletes a profile everywhere only when it is listed here
684    /// or kept on this device by choice; one that is merely missing, after a
685    /// config that failed to load or an edit by hand, is not deleted.
686    #[serde(skip_serializing_if = "Vec::is_empty")]
687    pub removed: Vec<String>,
688}
689
690/// One EQ of an output's tuning.
691#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
692pub struct DspOutputTuning {
693    pub device: String,
694    pub tuning: String,
695    /// Switched off, it stays in the tuning and plays nothing.
696    #[serde(default = "on", skip_serializing_if = "is_on")]
697    pub on: bool,
698}
699
700fn on() -> bool {
701    true
702}
703
704fn is_on(on: &bool) -> bool {
705    *on
706}
707
708/// The preset an output was set from.
709#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
710pub struct DspOutputPreset {
711    pub device: String,
712    pub preset: String,
713}
714
715impl Default for DspConfig {
716    fn default() -> Self {
717        Self {
718            enabled: true,
719            profiles: Vec::new(),
720            autoeq_dismissed: Vec::new(),
721            tunings: Vec::new(),
722            presets: Vec::new(),
723            filters_only: Vec::new(),
724            removed: Vec::new(),
725        }
726    }
727}
728
729impl DspProfile {
730    /// The measurement and target a correction is built from: fitted, or
731    /// saved before kōan fitted bands.
732    pub fn measured(&self) -> Option<&DspMeasurement> {
733        self.fitted.as_ref().or(self.measurement.as_ref())
734    }
735}
736
737impl DspConfig {
738    /// The profile for the output device called `device`, if DSP is on and
739    /// one names it. None that corrects a headphone for an output playing
740    /// filters alone, however it came to name it.
741    pub fn profile_for(&self, device: &str) -> Option<&DspProfile> {
742        if !self.enabled {
743            return None;
744        }
745        let only = self.filters_only.iter().any(|d| d == device);
746        self.profiles
747            .iter()
748            .find(|p| p.devices.iter().any(|d| d == device))
749            .filter(|p| {
750                !only || !crate::audio::dsp::profiles::shown_role(p, &self.profiles).corrects()
751            })
752    }
753}
754
755#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
756#[serde(default)]
757pub struct DspProfile {
758    pub name: String,
759    /// Output devices, by name, that play through this profile.
760    pub devices: Vec<String>,
761    /// Gain before the filters. Unset, it is derived from the filters' peak
762    /// gain, so that no boost can push a sample past full scale.
763    #[serde(skip_serializing_if = "Option::is_none")]
764    pub preamp_db: Option<f64>,
765    /// Run in order, at the output rate, ahead of the impulse responses.
766    pub filters: Vec<DspFilter>,
767    /// Impulse responses as WAV files, one per sample rate the correction was
768    /// exported at; a relative path is read from beside the config. Each
769    /// file's own rate is the rate it applies to.
770    pub impulses: Vec<PathBuf>,
771    /// The files it was imported from, by name, for showing where it came
772    /// from. Nothing reads them again.
773    #[serde(default, skip_serializing_if = "Vec::is_empty")]
774    pub source: Vec<String>,
775    /// For a correction installed from AutoEQ: the target it was made for,
776    /// and another to move it to. See `audio::dsp::targets`.
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub target: Option<DspTarget>,
779    /// Other profiles played first, in order: a headphone's correction and
780    /// then taste on top of it, each switched on or off. A profile with
781    /// layers is a stack. See `audio::dsp::Setup::load`.
782    #[serde(default, skip_serializing_if = "Vec::is_empty")]
783    pub layers: Vec<DspLayer>,
784    /// A group rather than a stack of layers: exactly one of its layers
785    /// plays, the one switched on, chosen as one picks a radio button.
786    /// Whole presets imported together are a group: alternatives, not taste
787    /// to play together.
788    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
789    pub group: bool,
790    /// A preset: a correction and its tuning saved together, its layers the
791    /// correction first and then the tuning's EQs, each on or off. What an
792    /// output is set from, and what the quick EQ menu lists.
793    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
794    pub preset: bool,
795    /// For a preset: the output plays its filters alone, with no correction
796    /// (`DspConfig::filters_only`).
797    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
798    pub filters_only: bool,
799    /// Whether it is the account's, kept on every device signed in to its
800    /// kōan server, or this device's alone. Unset, it follows from what the
801    /// profile is: see `audio::dsp::profiles::scope`.
802    #[serde(default, skip_serializing_if = "Option::is_none")]
803    pub scope: Option<DspScope>,
804    /// What every device calls it, made when it is first shared. Its name may
805    /// change; this does not.
806    #[serde(default, skip_serializing_if = "Option::is_none")]
807    pub uid: Option<String>,
808    /// The device that first shared it, by name: what tells two different
809    /// profiles of one name apart when they meet.
810    #[serde(default, skip_serializing_if = "Option::is_none")]
811    pub origin: Option<String>,
812    /// What it is for: correcting a headphone, or tuning on top of one.
813    /// Unset, it follows from what the profile is: see
814    /// `audio::dsp::profiles::role`.
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub role: Option<DspRole>,
817    /// A correction built from a headphone's measurement, kept as
818    /// `measurement.csv` in the profile's folder, to `target`, as kōan
819    /// saved one before it fitted bands: played as the bands squig.link's
820    /// auto-EQ fits to it, which are not written back. See `fitted`.
821    #[serde(default, skip_serializing_if = "Option::is_none")]
822    pub measurement: Option<DspMeasurement>,
823    /// A correction fitted to a headphone's measurement, kept as
824    /// `measurement.csv` in the profile's folder, and `target`: its filters
825    /// and preamp are the bands squig.link's auto-EQ fits, refitted when the
826    /// target changes. A key of its own rather than `measurement`, so a
827    /// build that knows only `measurement` plays the bands alone and not a
828    /// correction of its own after them.
829    #[serde(default, skip_serializing_if = "Option::is_none")]
830    pub fitted: Option<DspMeasurement>,
831    /// For a tuning: the target it was made against, by id. On a correction
832    /// to another target, the difference between the two plays first, so
833    /// the tuning sounds as it was made to.
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub tuned_for: Option<String>,
836    /// What an import made it, kept so an edit can be undone.
837    #[serde(default, skip_serializing_if = "Option::is_none")]
838    pub original: Option<DspOriginal>,
839}
840
841/// A profile as imported: its filters and preamp.
842#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
843pub struct DspOriginal {
844    pub filters: Vec<DspFilter>,
845    #[serde(default, skip_serializing_if = "Option::is_none")]
846    pub preamp_db: Option<f64>,
847}
848
849/// What a profile is for. A chain, a profile with its layers, corrects a
850/// headphone once: two corrections would each undo the same headphone.
851#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
852#[serde(rename_all = "snake_case")]
853pub enum DspRole {
854    /// Brings a headphone to a target: an AutoEQ install, a correction built
855    /// from a measurement, or a finished EQ made for the headphone.
856    Correction,
857    /// Taste on top: a bass shelf, a tilt, bands made by hand.
858    Tuning,
859    /// A correction with a tuning already in it, as most finished presets
860    /// are: Qudelix's "Lush", or a squig.link export to a target with a
861    /// preference. Counts as the chain's correction; a tuning on top would
862    /// add taste twice.
863    Baked,
864}
865
866impl DspRole {
867    /// Whether it brings a headphone to a target, so a chain has one.
868    pub fn corrects(self) -> bool {
869        matches!(self, Self::Correction | Self::Baked)
870    }
871}
872
873/// A headphone's measurement and the target it is corrected to.
874#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
875pub struct DspMeasurement {
876    pub ear: DspEar,
877    /// A target id: one that ships, or `added:<name>`.
878    pub target: String,
879}
880
881#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
882#[serde(rename_all = "snake_case")]
883pub enum DspEar {
884    In,
885    Over,
886    Speaker,
887}
888
889/// The bounds a profile must keep to be played: what any real correction
890/// asks, and nothing that would deafen a listener, starve the decode thread
891/// or exhaust memory. `DspProfile::sanitize` brings every profile within
892/// them, wherever it came from. The one place they are set.
893pub mod dsp_bounds {
894    use std::ops::RangeInclusive;
895    pub const FREQ_HZ: RangeInclusive<f64> = 1.0..=48_000.0;
896    pub const GAIN_DB: RangeInclusive<f64> = -30.0..=30.0;
897    pub const Q: RangeInclusive<f64> = 0.01..=100.0;
898    pub const DELAY_MS: RangeInclusive<f64> = 0.0..=2_000.0;
899    /// Two seconds at 384 kHz.
900    pub const DELAY_SAMPLES: RangeInclusive<f64> = 0.0..=768_000.0;
901    /// A mix's linear gain: ±30 dB.
902    pub const MIX_GAIN: f64 = 31.63;
903    pub const CHANNELS: u16 = 64;
904    /// Parametric bands applying to any one channel.
905    pub const BANDS_PER_CHANNEL: usize = 64;
906    pub const FILTERS: usize = 256;
907    pub const GRAPHIC_POINTS: usize = 2_048;
908    pub const LAYERS: usize = 32;
909    pub const NAME: usize = 128;
910
911    // What a whole chain, layers and target step included, may add up to:
912    // each value within bounds can still sum past what a device can hold
913    // or keep up with.
914
915    /// Delay on any one channel, every delay summed, in ms. A delay in
916    /// samples counts at 44.1 kHz, the lowest rate it could mean the most
917    /// time at.
918    pub const CHAIN_DELAY_MS: f64 = 2_000.0;
919    /// Graphic curves on any one channel: each is a minimum-phase FIR of up
920    /// to a second of taps at the output rate. Two is a target step and a
921    /// curve of the user's own.
922    pub const CHAIN_GRAPHICS: usize = 2;
923    /// Mixes in a chain.
924    pub const CHAIN_MIXES: usize = 8;
925    /// Taps of an impulse response, per route: the longest a shipped room
926    /// correction is known to need, Harman's 780 pack at 192 kHz, which is
927    /// over five seconds at 48 kHz, longer than any room's decay.
928    pub const IMPULSE_TAPS: usize = 262_145;
929}
930
931/// Bring a whole chain within the `dsp_bounds` budgets, saying what was
932/// changed: delays past the total on a channel shortened or dropped,
933/// graphic curves and mixes past their counts dropped.
934pub fn budget(filters: &mut Vec<DspFilter>) -> Vec<String> {
935    use dsp_bounds as b;
936    let mut notes = Vec::new();
937    let mut note = |n: String| {
938        if !notes.contains(&n) {
939            notes.push(n);
940        }
941    };
942    let channels = b::CHANNELS as usize;
943    let mut delay = vec![0.0f64; channels];
944    let mut graphics = vec![0usize; channels];
945    let mut mixes = 0;
946    let on = |cs: &[u16], c: usize| cs.is_empty() || cs.contains(&(c as u16));
947    filters.retain_mut(|f| match f {
948        DspFilter::Delay(d) => {
949            let ms = d.ms + d.samples / 44.1;
950            let room = (0..channels)
951                .filter(|c| on(&d.channels, *c))
952                .map(|c| b::CHAIN_DELAY_MS - delay[c])
953                .fold(b::CHAIN_DELAY_MS, f64::min)
954                .max(0.0);
955            if ms > room {
956                note(format!(
957                    "delays shortened to {} ms in all",
958                    b::CHAIN_DELAY_MS
959                ));
960                if room <= 0.0 {
961                    return false;
962                }
963                // Shortened in proportion, keeping how it was given.
964                let scale = room / ms;
965                d.ms *= scale;
966                d.samples = (d.samples * scale).floor();
967            }
968            let ms = d.ms + d.samples / 44.1;
969            for (c, total) in delay.iter_mut().enumerate() {
970                if on(&d.channels, c) {
971                    *total += ms;
972                }
973            }
974            true
975        }
976        DspFilter::Graphic(g) => {
977            let full =
978                (0..channels).any(|c| on(&g.channels, c) && graphics[c] == b::CHAIN_GRAPHICS);
979            if full {
980                note(format!(
981                    "graphic EQs past {} a channel dropped",
982                    b::CHAIN_GRAPHICS
983                ));
984                return false;
985            }
986            for (c, n) in graphics.iter_mut().enumerate() {
987                if on(&g.channels, c) {
988                    *n += 1;
989                }
990            }
991            true
992        }
993        DspFilter::Mix(_) => {
994            mixes += 1;
995            if mixes > b::CHAIN_MIXES {
996                note(format!("mixes past {} dropped", b::CHAIN_MIXES));
997                return false;
998            }
999            true
1000        }
1001        DspFilter::Band(_) => true,
1002    });
1003    notes
1004}
1005
1006impl DspProfile {
1007    /// Bring the profile within `dsp_bounds`, saying what was changed: values
1008    /// past a bound are clamped to it, ones that are not finite are dropped
1009    /// with what holds them, and filters, bands, layers and points past a
1010    /// cap are cut off at it. Every profile is played as this leaves it,
1011    /// wherever it came from, so one that would deafen a listener, starve
1012    /// the decode thread or exhaust memory still plays, adjusted, and its
1013    /// page says how.
1014    pub fn sanitize(&mut self) -> Vec<String> {
1015        use dsp_bounds as b;
1016        let mut notes: Vec<String> = Vec::new();
1017        let mut note = |n: String| {
1018            if !notes.contains(&n) {
1019                notes.push(n);
1020            }
1021        };
1022        let mut clamp = |what: &str, v: &mut f64, r: &std::ops::RangeInclusive<f64>, unit: &str| {
1023            let c = v.clamp(*r.start(), *r.end());
1024            if c != *v {
1025                note(format!(
1026                    "{what} clamped to {}{unit}",
1027                    if *v > *r.end() { r.end() } else { r.start() }
1028                ));
1029                *v = c;
1030            }
1031        };
1032        let finite = |v: f64| v.is_finite();
1033        let mut dropped = Vec::new();
1034
1035        let cleaned: String = self
1036            .name
1037            .chars()
1038            .filter(|c| !c.is_control())
1039            .take(b::NAME)
1040            .collect();
1041        let cleaned = if cleaned.trim().is_empty() {
1042            "Profile".to_owned()
1043        } else {
1044            cleaned
1045        };
1046        if cleaned != self.name {
1047            dropped.push("name shortened".to_owned());
1048            self.name = cleaned;
1049        }
1050        if let Some(db) = self.preamp_db.as_mut() {
1051            if finite(*db) {
1052                clamp("preamp", db, &b::GAIN_DB, " dB");
1053            } else {
1054                self.preamp_db = None;
1055                dropped.push("preamp dropped".to_owned());
1056            }
1057        }
1058        let bad_channel = |cs: &[u16]| cs.iter().any(|c| *c >= b::CHANNELS);
1059        let mut per_channel = [0usize; b::CHANNELS as usize];
1060        let before = self.filters.len();
1061        let mut kept = Vec::with_capacity(before.min(b::FILTERS));
1062        for mut filter in std::mem::take(&mut self.filters) {
1063            if kept.len() == b::FILTERS {
1064                dropped.push(format!("filters past {} dropped", b::FILTERS));
1065                break;
1066            }
1067            let keep = match &mut filter {
1068                DspFilter::Band(f) => {
1069                    if ![f.freq, f.gain_db, f.q].into_iter().all(finite) || bad_channel(&f.channels)
1070                    {
1071                        false
1072                    } else {
1073                        let on =
1074                            |c: usize| f.channels.is_empty() || f.channels.contains(&(c as u16));
1075                        if (0..b::CHANNELS as usize)
1076                            .any(|c| on(c) && per_channel[c] == b::BANDS_PER_CHANNEL)
1077                        {
1078                            dropped.push(format!(
1079                                "bands past {} a channel dropped",
1080                                b::BANDS_PER_CHANNEL
1081                            ));
1082                            continue;
1083                        } else {
1084                            for (c, n) in per_channel.iter_mut().enumerate() {
1085                                if on(c) {
1086                                    *n += 1;
1087                                }
1088                            }
1089                            clamp("a band's frequency", &mut f.freq, &b::FREQ_HZ, " Hz");
1090                            clamp("a band's gain", &mut f.gain_db, &b::GAIN_DB, " dB");
1091                            clamp("a band's Q", &mut f.q, &b::Q, "");
1092                            true
1093                        }
1094                    }
1095                }
1096                DspFilter::Delay(d) => {
1097                    if !finite(d.ms) || !finite(d.samples) || bad_channel(&d.channels) {
1098                        false
1099                    } else {
1100                        clamp("delay", &mut d.ms, &b::DELAY_MS, " ms");
1101                        clamp("delay", &mut d.samples, &b::DELAY_SAMPLES, " samples");
1102                        true
1103                    }
1104                }
1105                DspFilter::Mix(m) => {
1106                    m.outputs.truncate(b::CHANNELS as usize);
1107                    for o in &mut m.outputs {
1108                        o.retain(|(c, g)| *c < b::CHANNELS && finite(*g));
1109                        o.truncate(b::CHANNELS as usize);
1110                        for (_, g) in o.iter_mut() {
1111                            clamp("a mix's gain", g, &(-b::MIX_GAIN..=b::MIX_GAIN), "");
1112                        }
1113                    }
1114                    true
1115                }
1116                DspFilter::Graphic(g) => {
1117                    if bad_channel(&g.channels) {
1118                        false
1119                    } else {
1120                        g.points.retain(|(hz, db)| finite(*hz) && finite(*db));
1121                        if g.points.len() > b::GRAPHIC_POINTS {
1122                            g.points.truncate(b::GRAPHIC_POINTS);
1123                            dropped.push(format!(
1124                                "graphic EQ points past {} dropped",
1125                                b::GRAPHIC_POINTS
1126                            ));
1127                        }
1128                        for (hz, db) in &mut g.points {
1129                            clamp(
1130                                "a graphic EQ's frequency",
1131                                hz,
1132                                &(0.0..=*b::FREQ_HZ.end()),
1133                                " Hz",
1134                            );
1135                            clamp("a graphic EQ's gain", db, &b::GAIN_DB, " dB");
1136                        }
1137                        true
1138                    }
1139                }
1140            };
1141            if keep {
1142                kept.push(filter);
1143            } else {
1144                dropped.push("a filter that could not play dropped".to_owned());
1145            }
1146        }
1147        self.filters = kept;
1148        for n in budget(&mut self.filters) {
1149            dropped.push(n);
1150        }
1151        if self.layers.len() > b::LAYERS {
1152            self.layers.truncate(b::LAYERS);
1153            dropped.push(format!("layers past {} dropped", b::LAYERS));
1154        }
1155        self.layers.retain(|l| l.profile.chars().count() <= b::NAME);
1156        if self
1157            .origin
1158            .as_ref()
1159            .is_some_and(|o| o.chars().count() > b::NAME)
1160        {
1161            self.origin = None;
1162        }
1163        if self
1164            .tuned_for
1165            .as_ref()
1166            .is_some_and(|t| t.chars().count() > b::NAME)
1167        {
1168            self.tuned_for = None;
1169            dropped.push("tuned-for target dropped".to_owned());
1170        }
1171        if self
1172            .measurement
1173            .as_ref()
1174            .is_some_and(|m| m.target.chars().count() > b::NAME)
1175        {
1176            self.measurement = None;
1177            dropped.push("measurement's target dropped".to_owned());
1178        }
1179        if self
1180            .fitted
1181            .as_ref()
1182            .is_some_and(|m| m.target.chars().count() > b::NAME)
1183        {
1184            self.fitted = None;
1185            dropped.push("measurement's target dropped".to_owned());
1186        }
1187        if let Some(t) = &mut self.target {
1188            let long = |s: &str| s.chars().count() > b::NAME;
1189            if long(&t.made_for) {
1190                self.target = None;
1191                dropped.push("target dropped".to_owned());
1192            } else if t.chosen.as_deref().is_some_and(long) {
1193                t.chosen = None;
1194                dropped.push("chosen target dropped".to_owned());
1195            }
1196        }
1197        // The original plays when reverted to: held to the same bounds.
1198        if let Some(o) = self.original.take() {
1199            let mut kept = DspProfile {
1200                filters: o.filters,
1201                preamp_db: o.preamp_db,
1202                ..Default::default()
1203            };
1204            kept.sanitize();
1205            self.original = Some(DspOriginal {
1206                filters: kept.filters,
1207                preamp_db: kept.preamp_db,
1208            });
1209        }
1210        for d in dropped {
1211            note(d);
1212        }
1213        notes
1214    }
1215
1216    /// As played: `sanitize`d, leaving this one as it is.
1217    pub fn sanitized(&self) -> Self {
1218        let mut p = self.clone();
1219        p.sanitize();
1220        p
1221    }
1222}
1223
1224/// Where a profile is kept.
1225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1226#[serde(rename_all = "snake_case")]
1227pub enum DspScope {
1228    /// The account's: synced through its kōan server to its other devices.
1229    Everywhere,
1230    /// This device's: never uploaded.
1231    Device,
1232}
1233
1234/// One profile played as part of another.
1235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1236pub struct DspLayer {
1237    pub profile: String,
1238    #[serde(default = "layer_on")]
1239    pub on: bool,
1240}
1241
1242fn layer_on() -> bool {
1243    true
1244}
1245
1246/// The target a correction was made for, and the one chosen in its place.
1247#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1248pub struct DspTarget {
1249    /// One of the targets koan ships, by id.
1250    pub made_for: String,
1251    /// A target koan ships, or one added (`added:<name>`). Unset, or the same
1252    /// as `made_for`, the correction plays as it was made.
1253    #[serde(default, skip_serializing_if = "Option::is_none")]
1254    pub chosen: Option<String>,
1255}
1256
1257/// One step of a profile's processing. Bands on different channels commute;
1258/// a mix does not, which is why the steps are an ordered list.
1259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1260#[serde(tag = "type", rename_all = "snake_case")]
1261pub enum DspFilter {
1262    Delay(Delay),
1263    Mix(Mix),
1264    Graphic(GraphicEq),
1265    #[serde(untagged)]
1266    Band(EqFilter),
1267}
1268
1269impl DspFilter {
1270    /// The channels it applies to, from 0. Empty is every channel; a mix
1271    /// names its own.
1272    pub fn channels(&self) -> &[u16] {
1273        match self {
1274            Self::Band(f) => &f.channels,
1275            Self::Delay(d) => &d.channels,
1276            Self::Graphic(g) => &g.channels,
1277            Self::Mix(_) => &[],
1278        }
1279    }
1280
1281    pub fn channels_mut(&mut self) -> Option<&mut Vec<u16>> {
1282        match self {
1283            Self::Band(f) => Some(&mut f.channels),
1284            Self::Delay(d) => Some(&mut d.channels),
1285            Self::Graphic(g) => Some(&mut g.channels),
1286            Self::Mix(_) => None,
1287        }
1288    }
1289}
1290
1291impl From<EqFilter> for DspFilter {
1292    fn from(f: EqFilter) -> Self {
1293        Self::Band(f)
1294    }
1295}
1296
1297/// A fixed delay: `ms` and `samples` added together, at the output rate.
1298#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1299#[serde(default)]
1300pub struct Delay {
1301    #[serde(skip_serializing_if = "is_zero")]
1302    pub ms: f64,
1303    #[serde(skip_serializing_if = "is_zero")]
1304    pub samples: f64,
1305    /// Keep the fraction of a sample, through an allpass, rather than round
1306    /// to the nearest whole one.
1307    #[serde(skip_serializing_if = "std::ops::Not::not")]
1308    pub subsample: bool,
1309    #[serde(skip_serializing_if = "Vec::is_empty")]
1310    pub channels: Vec<u16>,
1311}
1312
1313fn is_zero(v: &f64) -> bool {
1314    *v == 0.0
1315}
1316
1317/// Channels made from others. `outputs[o]` is what channel `o` becomes, as
1318/// `(input channel, linear gain)` pairs read before any is written; an empty
1319/// list is silence. Channels past the end of `outputs` pass through.
1320#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1321pub struct Mix {
1322    pub outputs: Vec<Vec<(u16, f64)>>,
1323}
1324
1325/// A curve of `(Hz, dB)` points, as Equalizer APO's `GraphicEQ:` gives one,
1326/// run as a minimum-phase FIR designed at the output rate. Between points the
1327/// gain is interpolated against log frequency; past the ends it holds.
1328#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1329pub struct GraphicEq {
1330    pub points: Vec<(f64, f64)>,
1331    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1332    pub channels: Vec<u16>,
1333}
1334
1335/// One parametric band, as AutoEQ and Equalizer APO describe it.
1336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1337pub struct EqFilter {
1338    #[serde(rename = "type")]
1339    pub kind: EqFilterKind,
1340    /// Centre or corner frequency in Hz.
1341    pub freq: f64,
1342    #[serde(default)]
1343    pub gain_db: f64,
1344    #[serde(default = "default_q")]
1345    pub q: f64,
1346    /// The channels it applies to, from 0. Empty is every channel.
1347    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1348    pub channels: Vec<u16>,
1349}
1350
1351fn default_q() -> f64 {
1352    std::f64::consts::FRAC_1_SQRT_2
1353}
1354
1355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1356#[serde(rename_all = "snake_case")]
1357pub enum EqFilterKind {
1358    Peaking,
1359    LowShelf,
1360    HighShelf,
1361    LowPass,
1362    HighPass,
1363    Notch,
1364    BandPass,
1365    AllPass,
1366    /// First-order sections, 6 dB per octave. The shelves are at half their
1367    /// gain at `freq`; `q` is not used.
1368    LowShelfFirstOrder,
1369    HighShelfFirstOrder,
1370    LowPassFirstOrder,
1371    HighPassFirstOrder,
1372    AllPassFirstOrder,
1373    /// Flat gain, `gain_db`: a channel's own preamp.
1374    Gain,
1375}
1376
1377impl EqFilterKind {
1378    /// As written in the config.
1379    pub fn name(self) -> &'static str {
1380        match self {
1381            Self::Peaking => "peaking",
1382            Self::LowShelf => "low_shelf",
1383            Self::HighShelf => "high_shelf",
1384            Self::LowPass => "low_pass",
1385            Self::HighPass => "high_pass",
1386            Self::Notch => "notch",
1387            Self::BandPass => "band_pass",
1388            Self::AllPass => "all_pass",
1389            Self::LowShelfFirstOrder => "low_shelf_first_order",
1390            Self::HighShelfFirstOrder => "high_shelf_first_order",
1391            Self::LowPassFirstOrder => "low_pass_first_order",
1392            Self::HighPassFirstOrder => "high_pass_first_order",
1393            Self::AllPassFirstOrder => "all_pass_first_order",
1394            Self::Gain => "gain",
1395        }
1396    }
1397}
1398
1399/// Which of the two files a setting is written to when koan changes it itself.
1400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1401pub enum Layer {
1402    /// `config.toml` — taste, meaningful on any machine, safe in a dotfiles repo.
1403    Shared,
1404    /// `config.local.toml` — belongs to this machine and nowhere else.
1405    Machine,
1406}
1407
1408/// Where the setting at a dotted path belongs.
1409///
1410/// `config.toml` is meant to be committed to a dotfiles repo, so three kinds of
1411/// setting have no business in it: secrets, anything naming this machine's
1412/// hardware, paths or account, and UI state that a keypress flips — the last
1413/// kind would rewrite the shared file every session and land on the next
1414/// machine as someone else's window size.
1415///
1416/// Anything not named here is taste, and taste travels.
1417pub fn layer_of(path: &str) -> Layer {
1418    match path {
1419        // Secrets.
1420        "remote.password"
1421        | "remote.api_key"
1422        | "remote.device_key"
1423        | "subsonic.password"
1424        | "auth.refresh_token"
1425        | "push.key"
1426        | "push.key_path"
1427        | "push.key_id"
1428        | "push.team_id"
1429        | "lastfm.api_key"
1430        | "lastfm.secret"
1431        // This machine's paths, disk and account.
1432        | "library.folders"
1433        | "remote.enabled"
1434        | "remote.url"
1435        | "remote.username"
1436        | "remote.cache_dir"
1437        | "remote.cache_limit"
1438        // Whether this device follows the account's saved play queue.
1439        | "remote.play_queue"
1440        // This machine's hardware.
1441        | "playback.output_device"
1442        | "playback.renderer"
1443        | "playback.renderer_name"
1444        | "playback.renderers"
1445        | "playback.muted"
1446        | "playback.rate_switch_lead_in_ms"
1447        // Which machine serves Subsonic, and as whom. Enabling a REST API is a
1448        // decision about one host, and the secret guarding it is per-machine.
1449        | "subsonic.enabled"
1450        | "subsonic.port"
1451        | "subsonic.username"
1452        | "subsonic.transcode"
1453        | "subsonic.ffmpeg"
1454        // Whether this machine is open to its network, and where others are.
1455        | "devices.nearby"
1456        | "devices.discoverable"
1457        | "devices.port"
1458        | "devices.addresses"
1459        | "devices.nearby_control"
1460        | "devices.refused"
1461        | "devices.keep_running"
1462        // Which koan server this machine signs in to.
1463        | "auth.server"
1464        // Volatile: UI state behind a keybind or a mouse drag.
1465        | "playback.art_size"
1466        | "playback.art_graphics"
1467        | "playback.tui_theme"
1468        | "playback.queue_grouped"
1469        | "visualizer.enabled"
1470        | "visualizer.mode"
1471        | "visualizer.matrix_overlay"
1472        | "visualizer.bass_shake"
1473        | "appearance.rainbow"
1474        // How large this screen draws text, which is a matter of the screen.
1475        | "appearance.text_size" => Layer::Machine,
1476        // The listening setup: these headphones, this room.
1477        p if p == "dsp" || p.starts_with("dsp.") => Layer::Machine,
1478        _ => Layer::Shared,
1479    }
1480}
1481
1482/// Mtimes of the two files `figment()` layers. Keyed on these so a config
1483/// edited by hand is picked up without koan being told about it; `KOAN_*` env
1484/// vars are not tracked, since they are fixed for the life of the process.
1485/// Which directory, and its two files' mtimes. The directory is part of it:
1486/// two directories' files can share an mtime, and a read in one must never
1487/// be served what was read in the other.
1488type ConfigStamp = (PathBuf, Option<SystemTime>, Option<SystemTime>);
1489
1490type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
1491
1492static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
1493    LazyLock::new(|| parking_lot::RwLock::new(None));
1494
1495/// Moved by every invalidation. A read keeps what it loaded only if this has
1496/// not moved since it began: the stamp alone cannot tell a directory switched
1497/// away and back again mid-read, which leaves the stamp as it was and the
1498/// config loaded from the other directory.
1499static CONFIG_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1500
1501fn config_stamp() -> ConfigStamp {
1502    let (base, local) = stamp_of(&config_file_path(), &config_local_file_path());
1503    (config_dir(), base, local)
1504}
1505
1506/// A file that does not exist stamps as `None`, so creating one is a change.
1507fn stamp_of(base: &Path, local: &Path) -> (Option<SystemTime>, Option<SystemTime>) {
1508    let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
1509    (mtime(base), mtime(local))
1510}
1511
1512impl Config {
1513    /// Build the figment provider chain:
1514    /// defaults → config.toml → config.local.toml → KOAN_* env vars.
1515    ///
1516    /// Env vars use `KOAN_` prefix with `__` as section separator:
1517    ///   KOAN_REMOTE__PASSWORD, KOAN_GRAPHQL__PORT, KOAN_PLAYBACK__TARGET_FPS, etc.
1518    fn figment() -> Figment {
1519        let base_path = config_file_path();
1520        let local_path = config_local_file_path();
1521
1522        Figment::from(Serialized::defaults(Config::default()))
1523            .merge(Toml::file(&base_path))
1524            .merge(Toml::file(&local_path))
1525            .merge(Env::prefixed("KOAN_").split("__"))
1526    }
1527
1528    /// Load config from all layers: defaults → config.toml → config.local.toml → KOAN_* env vars.
1529    pub fn load() -> Result<Self, ConfigError> {
1530        let cfg: Self = Self::figment()
1531            .extract()
1532            .map_err(|e| ConfigError::Figment(Box::new(e)))?;
1533
1534        // Security: refuse to start if config files containing secrets are
1535        // tracked by git.
1536        check_secrets_in_git();
1537
1538        Ok(cfg)
1539    }
1540
1541    /// Load config, logging and falling back to defaults on error.
1542    ///
1543    /// Served from the cache, so what this costs is a clone of the struct
1544    /// rather than two file reads and a figment merge.
1545    pub fn load_or_default() -> Self {
1546        (*Self::cached()).clone()
1547    }
1548
1549    /// The merged config, reloaded only when it changed on disk.
1550    ///
1551    /// `load()` re-reads both TOML files and re-runs the whole figment merge,
1552    /// and koan reaches it from paths that run per frame — `library_folders()`
1553    /// is read from a SwiftUI list body. Callers that want to avoid even the
1554    /// clone `load_or_default()` does can hold this `Arc`.
1555    pub fn cached() -> Arc<Config> {
1556        let generation = CONFIG_GENERATION.load(std::sync::atomic::Ordering::SeqCst);
1557        let stamp = config_stamp();
1558        if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
1559            && *seen == stamp
1560        {
1561            return cfg.clone();
1562        }
1563
1564        let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
1565            log::warn!("failed to load config, using defaults: {}", e);
1566            Self::default()
1567        }));
1568        // Kept only if nothing moved while it was read: a switch of
1569        // directory or a write between the stamp and the load would keep one
1570        // directory's config under the other's stamp. Checked under the lock
1571        // a switch takes to drop the cache, so none slips in between.
1572        let mut cache = CONFIG_CACHE.write();
1573        if CONFIG_GENERATION.load(std::sync::atomic::Ordering::SeqCst) == generation
1574            && config_stamp() == stamp
1575        {
1576            *cache = Some((stamp, cfg.clone()));
1577        }
1578        cfg
1579    }
1580
1581    /// Drop the cached config. koan's own writes invalidate explicitly rather
1582    /// than relying on the mtime, which can land in the same filesystem tick as
1583    /// the read before it.
1584    pub fn invalidate_cache() {
1585        let mut cache = CONFIG_CACHE.write();
1586        CONFIG_GENERATION.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1587        *cache = None;
1588    }
1589
1590    /// Load from a specific TOML file (no env var overlay).
1591    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
1592        let contents = fs::read_to_string(path)?;
1593        let config: Config = toml::from_str(&contents)?;
1594        Ok(config)
1595    }
1596
1597    /// The two files merged, without the env layer.
1598    ///
1599    /// `persist` diffs against this rather than against `config.toml` alone, so
1600    /// a mutation setting a value the user already has writes nothing at all.
1601    /// `KOAN_*` is left out because there is no file to write it back to.
1602    fn from_files() -> Result<Self, ConfigError> {
1603        Figment::from(Serialized::defaults(Config::default()))
1604            .merge(Toml::file(config_file_path()))
1605            .merge(Toml::file(config_local_file_path()))
1606            .extract()
1607            .map_err(|e| ConfigError::Figment(Box::new(e)))
1608    }
1609
1610    /// Whether either config file sets the setting at a dotted path, as
1611    /// opposed to it taking its default.
1612    pub fn set_in_files(path: &str) -> bool {
1613        [config_file_path(), config_local_file_path()]
1614            .iter()
1615            .any(|file| {
1616                let Ok(doc) = read_document(file) else {
1617                    // Unreadable is not unset: assume it says something.
1618                    return true;
1619                };
1620                let mut item = doc.as_item();
1621                for key in path.split('.') {
1622                    match item.get(key) {
1623                        Some(next) => item = next,
1624                        None => return false,
1625                    }
1626                }
1627                true
1628            })
1629    }
1630
1631    /// Apply a mutation and write each changed setting to the file that owns it.
1632    ///
1633    /// Only what the closure actually changed is written, so comments, layout
1634    /// and every untouched key survive — including the commented-out defaults
1635    /// `koan config init` leaves as a reference. `layer_of` decides the file.
1636    ///
1637    /// A shared write also clears any copy of that key from
1638    /// `config.local.toml`: the local layer wins, so leaving one there would
1639    /// make the write silently do nothing. A machine write clears the key from
1640    /// `config.toml` for the same reason in reverse, which drains settings that
1641    /// older versions of koan wrongly wrote to the shared file.
1642    pub fn persist<F>(mutate: F) -> Result<(), ConfigError>
1643    where
1644        F: FnOnce(&mut Config),
1645    {
1646        // Each write reads both files and writes them back whole, so two at
1647        // once would lose whichever finished first: the player's volume and a
1648        // setting changed in the app, say.
1649        static ONE_AT_A_TIME: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
1650        let _one = ONE_AT_A_TIME.lock();
1651        let before = Self::from_files()?;
1652        let mut after = before.clone();
1653        mutate(&mut after);
1654
1655        let mut changes = Vec::new();
1656        diff_into(
1657            "",
1658            &toml::Value::try_from(&before)?,
1659            &toml::Value::try_from(&after)?,
1660            &mut changes,
1661        );
1662        if changes.is_empty() {
1663            return Ok(());
1664        }
1665
1666        let base_path = config_file_path();
1667        let local_path = config_local_file_path();
1668        let mut base = read_document(&base_path)?;
1669        let mut local = read_document(&local_path)?;
1670
1671        for (path, value) in &changes {
1672            let (target, other) = match layer_of(path) {
1673                Layer::Shared => (&mut base, &mut local),
1674                Layer::Machine => (&mut local, &mut base),
1675            };
1676            match value {
1677                Some(v) => doc_set(target, path, v),
1678                None => doc_remove(target, path),
1679            }
1680            doc_remove(other, path);
1681        }
1682
1683        write_document(&base_path, &base, false)?;
1684        write_document(&local_path, &local, true)?;
1685        Self::invalidate_cache();
1686        Ok(())
1687    }
1688
1689    /// Resolved cache directory — the explicit setting, or `default_cache_dir`.
1690    pub fn cache_dir(&self) -> PathBuf {
1691        self.remote
1692            .cache_dir
1693            .clone()
1694            .unwrap_or_else(default_cache_dir)
1695    }
1696
1697    /// Parsed cache limit in bytes, or None if unlimited.
1698    pub fn cache_limit_bytes(&self) -> Option<u64> {
1699        self.remote
1700            .cache_limit
1701            .as_deref()
1702            .and_then(parse_size_bytes)
1703    }
1704}
1705
1706/// Collect the leaf settings that differ between two serialized configs, as
1707/// `(dotted path, new value)`. `None` means the key is gone and should be
1708/// removed rather than written — which is how an emptied password or a cleared
1709/// output device reaches the file as an absent key rather than a blank one.
1710fn diff_into(
1711    prefix: &str,
1712    before: &toml::Value,
1713    after: &toml::Value,
1714    out: &mut Vec<(String, Option<toml::Value>)>,
1715) {
1716    let (b, a) = match (before.as_table(), after.as_table()) {
1717        (Some(b), Some(a)) => (b, a),
1718        _ => {
1719            if before != after {
1720                out.push((prefix.to_string(), Some(after.clone())));
1721            }
1722            return;
1723        }
1724    };
1725
1726    let empty = toml::Value::Table(toml::map::Map::new());
1727    for key in b
1728        .keys()
1729        .chain(a.keys())
1730        .collect::<std::collections::BTreeSet<_>>()
1731    {
1732        let path = if prefix.is_empty() {
1733            key.clone()
1734        } else {
1735            format!("{prefix}.{key}")
1736        };
1737        match (b.get(key), a.get(key)) {
1738            (Some(bv), Some(av)) => diff_into(&path, bv, av, out),
1739            // Newly present: recurse into tables so a new pattern writes one
1740            // key rather than replacing the whole table.
1741            (None, Some(av)) => diff_into(&path, &empty, av, out),
1742            (Some(_), None) => out.push((path, None)),
1743            (None, None) => unreachable!("key came from one of the two tables"),
1744        }
1745    }
1746}
1747
1748fn read_document(path: &Path) -> Result<toml_edit::DocumentMut, ConfigError> {
1749    let Ok(contents) = fs::read_to_string(path) else {
1750        return Ok(toml_edit::DocumentMut::new());
1751    };
1752    contents
1753        .parse::<toml_edit::DocumentMut>()
1754        .map_err(|e| ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
1755}
1756
1757/// Write a document, skipping files that would be created empty. `secret` marks
1758/// the file 0o600 — it is the one that holds passwords.
1759fn write_document(
1760    path: &Path,
1761    doc: &toml_edit::DocumentMut,
1762    secret: bool,
1763) -> Result<(), ConfigError> {
1764    let contents = doc.to_string();
1765    if contents.trim().is_empty() && !path.exists() {
1766        return Ok(());
1767    }
1768    if let Some(parent) = path.parent() {
1769        fs::create_dir_all(parent)?;
1770    }
1771    // Written beside the file and renamed over it, so a read never sees it
1772    // half written.
1773    let target = link_target(path);
1774    let temp = unique_beside(&target);
1775    let written = write_new(&temp, &target, contents.as_bytes(), secret)
1776        .and_then(|()| fs::rename(&temp, &target));
1777    if let Err(e) = written {
1778        let _ = fs::remove_file(&temp);
1779        return Err(e.into());
1780    }
1781    #[cfg(target_os = "tvos")]
1782    kept::written(path, contents.as_bytes());
1783    Ok(())
1784}
1785
1786/// Where a write to `path` lands: a symlink, as dotfiles keep, is followed
1787/// rather than replaced, even when what it points at does not exist yet.
1788fn link_target(path: &Path) -> PathBuf {
1789    let mut target = path.to_path_buf();
1790    for _ in 0..40 {
1791        let Ok(next) = fs::read_link(&target) else {
1792            break;
1793        };
1794        target = match target.parent() {
1795            Some(parent) if next.is_relative() => parent.join(next),
1796            _ => next,
1797        };
1798    }
1799    target
1800}
1801
1802/// A name beside `target` no other writer, in this process or another, is
1803/// using.
1804fn unique_beside(target: &Path) -> PathBuf {
1805    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1806    let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1807    let name = target
1808        .file_name()
1809        .map(|n| n.to_string_lossy().into_owned())
1810        .unwrap_or_default();
1811    target.with_file_name(format!(".{name}.{}.{n}.tmp", std::process::id()))
1812}
1813
1814/// Create `temp` holding `contents`, never readable by more than `target`
1815/// already is: a secret file is 0600 from the moment it exists, any other
1816/// keeps the mode the file it replaces had.
1817fn write_new(temp: &Path, target: &Path, contents: &[u8], secret: bool) -> std::io::Result<()> {
1818    use std::io::Write as _;
1819    let mut options = fs::OpenOptions::new();
1820    options.write(true).create_new(true);
1821    #[cfg(unix)]
1822    let kept_mode = {
1823        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1824        let kept_mode = (!secret)
1825            .then(|| fs::metadata(target).ok())
1826            .flatten()
1827            .map(|m| m.permissions().mode() & 0o7777);
1828        options.mode(if secret {
1829            0o600
1830        } else {
1831            kept_mode.unwrap_or(0o666)
1832        });
1833        kept_mode
1834    };
1835    #[cfg(not(unix))]
1836    let _ = (target, secret);
1837    let mut file = options.open(temp)?;
1838    file.write_all(contents)?;
1839    // The umask can narrow the mode asked for at creation; the file being
1840    // replaced had what it had.
1841    #[cfg(unix)]
1842    if let Some(mode) = kept_mode {
1843        use std::os::unix::fs::PermissionsExt;
1844        file.set_permissions(fs::Permissions::from_mode(mode))?;
1845    }
1846    Ok(())
1847}
1848
1849fn implicit_table() -> toml_edit::Item {
1850    let mut table = toml_edit::Table::new();
1851    // Implicit: the header prints only if the table ends up holding something,
1852    // so writing a nested key never leaves a bare `[organize]` behind.
1853    table.set_implicit(true);
1854    toml_edit::Item::Table(table)
1855}
1856
1857fn doc_set(doc: &mut toml_edit::DocumentMut, path: &str, value: &toml::Value) {
1858    let segments: Vec<&str> = path.split('.').collect();
1859    let (last, parents) = segments.split_last().expect("a diffed path is never empty");
1860
1861    let mut table = doc.as_table_mut();
1862    for segment in parents {
1863        let item = table.entry(segment).or_insert_with(implicit_table);
1864        // A scalar sitting where a section belongs is malformed either way;
1865        // the setting koan is writing wins.
1866        if !item.is_table() {
1867            *item = implicit_table();
1868        }
1869        table = item.as_table_mut().expect("just ensured it is a table");
1870    }
1871    // Comments attach to the key, and `insert` replaces the key. Overwrite the
1872    // value in place where one already exists so the line keeps its notes.
1873    match table.get_mut(last) {
1874        Some(existing) => *existing = to_edit_item(value),
1875        None => {
1876            table.insert(last, to_edit_item(value));
1877        }
1878    }
1879}
1880
1881/// A list of tables — DSP profiles — is written as `[[sections]]`, and any list
1882/// of tables inside one a line per entry, so the file stays editable by hand.
1883fn to_edit_item(value: &toml::Value) -> toml_edit::Item {
1884    let toml::Value::Array(items) = value else {
1885        return toml_edit::value(to_edit_value(value));
1886    };
1887    if items.is_empty() || !items.iter().all(toml::Value::is_table) {
1888        return toml_edit::value(to_edit_value(value));
1889    }
1890    let mut sections = toml_edit::ArrayOfTables::new();
1891    for item in items {
1892        let mut section = toml_edit::Table::new();
1893        for (k, v) in item.as_table().expect("checked above") {
1894            let mut v = to_edit_value(v);
1895            if let toml_edit::Value::Array(list) = &mut v
1896                && list.iter().any(toml_edit::Value::is_inline_table)
1897            {
1898                for entry in list.iter_mut() {
1899                    entry.decor_mut().set_prefix("\n    ");
1900                    if let Some(t) = entry.as_inline_table_mut() {
1901                        t.sort_values_by(|a, _, b, _| key_rank(a).cmp(&key_rank(b)));
1902                    }
1903                }
1904                list.set_trailing("\n");
1905                list.set_trailing_comma(true);
1906            }
1907            section.insert(k, toml_edit::value(v));
1908        }
1909        section.sort_values_by(|a, _, b, _| key_rank(a).cmp(&key_rank(b)));
1910        sections.push(section);
1911    }
1912    toml_edit::Item::ArrayOfTables(sections)
1913}
1914
1915/// What an entry is reads first: a profile's name, a band's type.
1916fn key_rank(key: &toml_edit::Key) -> (u8, &str) {
1917    let k = key.get();
1918    (if k == "name" || k == "type" { 0 } else { 1 }, k)
1919}
1920
1921fn doc_remove(doc: &mut toml_edit::DocumentMut, path: &str) {
1922    let segments: Vec<&str> = path.split('.').collect();
1923    let (last, parents) = segments.split_last().expect("a diffed path is never empty");
1924
1925    let mut table = doc.as_table_mut();
1926    for segment in parents {
1927        match table.get_mut(segment).and_then(|i| i.as_table_mut()) {
1928            Some(child) => table = child,
1929            None => return,
1930        }
1931    }
1932    // An emptied table keeps its header: it still carries the commented-out
1933    // defaults `koan config init` wrote, and those are the reference.
1934    table.remove(last);
1935}
1936
1937fn to_edit_value(value: &toml::Value) -> toml_edit::Value {
1938    match value {
1939        toml::Value::String(s) => s.as_str().into(),
1940        toml::Value::Integer(i) => (*i).into(),
1941        toml::Value::Float(f) => (*f).into(),
1942        toml::Value::Boolean(b) => (*b).into(),
1943        toml::Value::Datetime(d) => d.to_string().into(),
1944        toml::Value::Array(items) => items
1945            .iter()
1946            .map(to_edit_value)
1947            .collect::<toml_edit::Array>()
1948            .into(),
1949        toml::Value::Table(t) => {
1950            let mut inline = toml_edit::InlineTable::new();
1951            for (k, v) in t {
1952                inline.insert(k, to_edit_value(v));
1953            }
1954            inline.into()
1955        }
1956    }
1957}
1958
1959/// Creates a cache directory inside the config directory, with a
1960/// `.gitignore` that ignores everything in it, so a config directory kept in
1961/// version control does not pick up downloads it can fetch again.
1962pub fn create_cache_dir(dir: &std::path::Path) -> std::io::Result<()> {
1963    std::fs::create_dir_all(dir)?;
1964    let gitignore = dir.join(".gitignore");
1965    if !gitignore.exists() {
1966        std::fs::write(gitignore, "*\n")?;
1967    }
1968    Ok(())
1969}
1970
1971/// Where koan keeps its configuration, library database and cache.
1972///
1973/// `~/.config/koan/` (on iOS, `Library/Application Support/koan`) unless
1974/// pointed elsewhere. `KOAN_CONFIG_DIR` is the
1975/// user-facing way to do that — one machine, more than one library — and
1976/// `set_config_dir` is the in-process one, which is what tests need: without
1977/// it they read whatever configuration belongs to whoever ran them, right down
1978/// to that person's server and their password.
1979pub fn config_dir() -> PathBuf {
1980    if let Some(dir) = CONFIG_DIR.read().clone() {
1981        return dir;
1982    }
1983    if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
1984        return PathBuf::from(dir);
1985    }
1986    platform_config_dir()
1987}
1988
1989#[cfg(not(any(target_os = "ios", target_os = "tvos")))]
1990fn platform_config_dir() -> PathBuf {
1991    dirs::home_dir()
1992        .unwrap_or_else(|| PathBuf::from("."))
1993        .join(".config")
1994        .join("koan")
1995}
1996
1997/// An iOS app may write inside its container's `Documents`, `Library` and
1998/// `tmp`, and nowhere else: `~/.config` is refused on a device, though the
1999/// simulator allows it. Application Support is where an app's own state goes.
2000#[cfg(target_os = "ios")]
2001fn platform_config_dir() -> PathBuf {
2002    ios_library().join("Application Support").join("koan")
2003}
2004
2005/// A tvOS app has no persistent storage of its own: `Library/Caches` is the
2006/// one directory it can write, and the system empties it when space runs short.
2007/// The files live there, beside the download cache rather than inside it, which
2008/// is trimmed; `kept` holds a copy that survives a purge.
2009#[cfg(target_os = "tvos")]
2010fn platform_config_dir() -> PathBuf {
2011    let dir = ios_library().join("Caches").join("koan-config");
2012    kept::restore(&dir);
2013    dir
2014}
2015
2016/// The configuration files of a tvOS app, mirrored into its preferences.
2017///
2018/// tvOS keeps an app's preferences (up to 500 KB) when it purges
2019/// `Library/Caches`, and a purge would otherwise sign the television out and
2020/// forget its settings. Every write to the platform directory is copied here,
2021/// and the first look at the directory puts back a file that has gone. The
2022/// Keychain would also survive, and is deliberately not used for credentials.
2023/// The library database is not kept: the next sync rebuilds it.
2024#[cfg(target_os = "tvos")]
2025mod kept {
2026    use std::fs;
2027    use std::os::unix::fs::PermissionsExt;
2028    use std::path::Path;
2029    use std::sync::Once;
2030
2031    use core_foundation::base::TCFType;
2032    use core_foundation::data::{CFData, CFDataRef};
2033    use core_foundation::string::CFString;
2034    use core_foundation_sys::base::{CFGetTypeID, CFRelease};
2035    use core_foundation_sys::data::CFDataGetTypeID;
2036    use core_foundation_sys::preferences::{
2037        CFPreferencesAppSynchronize, CFPreferencesCopyAppValue, CFPreferencesSetAppValue,
2038        kCFPreferencesCurrentApplication,
2039    };
2040
2041    const FILES: [&str; 2] = ["config.toml", "config.local.toml"];
2042
2043    /// Once per process: a file that is missing comes back from the
2044    /// preferences, and one that is present is copied into them, which is how
2045    /// a configuration written before this existed starts being kept.
2046    pub(super) fn restore(dir: &Path) {
2047        static ONCE: Once = Once::new();
2048        ONCE.call_once(|| {
2049            for name in FILES {
2050                let path = dir.join(name);
2051                match fs::read(&path) {
2052                    Ok(contents) => save(name, &contents),
2053                    Err(_) => {
2054                        let Some(contents) = load(name) else { continue };
2055                        let _ = fs::create_dir_all(dir);
2056                        if fs::write(&path, &contents).is_ok() {
2057                            let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
2058                            log::info!("config: restored {name} after the system cleared it");
2059                        }
2060                    }
2061                }
2062            }
2063        });
2064    }
2065
2066    /// Mirror a write, if it went to the platform directory rather than one a
2067    /// test or `KOAN_CONFIG_DIR` chose.
2068    pub(super) fn written(path: &Path, contents: &[u8]) {
2069        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
2070            return;
2071        };
2072        if FILES.contains(&name) && path.parent() == Some(super::platform_config_dir().as_path()) {
2073            save(name, contents);
2074        }
2075    }
2076
2077    fn save(name: &str, contents: &[u8]) {
2078        let key = CFString::new(name);
2079        let data = CFData::from_buffer(contents);
2080        // SAFETY: both are live CF objects for the length of the call, and the
2081        // preferences retain what they keep.
2082        unsafe {
2083            CFPreferencesSetAppValue(
2084                key.as_concrete_TypeRef(),
2085                data.as_CFTypeRef(),
2086                kCFPreferencesCurrentApplication,
2087            );
2088            CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication);
2089        }
2090    }
2091
2092    fn load(name: &str) -> Option<Vec<u8>> {
2093        let key = CFString::new(name);
2094        // SAFETY: a Copy function returns an owned reference or null. Anything
2095        // but data is released here; data is owned by the wrapper.
2096        unsafe {
2097            let value = CFPreferencesCopyAppValue(
2098                key.as_concrete_TypeRef(),
2099                kCFPreferencesCurrentApplication,
2100            );
2101            if value.is_null() {
2102                return None;
2103            }
2104            if CFGetTypeID(value) != CFDataGetTypeID() {
2105                CFRelease(value);
2106                return None;
2107            }
2108            Some(
2109                CFData::wrap_under_create_rule(value as CFDataRef)
2110                    .bytes()
2111                    .to_vec(),
2112            )
2113        }
2114    }
2115}
2116
2117#[cfg(any(target_os = "ios", target_os = "tvos"))]
2118fn ios_library() -> PathBuf {
2119    dirs::home_dir()
2120        .unwrap_or_else(|| PathBuf::from("."))
2121        .join("Library")
2122}
2123
2124/// Where downloads are kept when the config names nowhere.
2125///
2126/// Beside the config everywhere but iOS. There it is `Library/Caches`, which
2127/// is not backed up to iCloud — a cache of lossless files would otherwise count
2128/// against someone's iCloud storage — and which iOS may clear when the device
2129/// is short of space, which is what a cache is for.
2130fn default_cache_dir() -> PathBuf {
2131    #[cfg(any(target_os = "ios", target_os = "tvos"))]
2132    if CONFIG_DIR.read().is_none() && std::env::var_os("KOAN_CONFIG_DIR").is_none() {
2133        return ios_library().join("Caches").join("koan");
2134    }
2135    config_dir().join("cache")
2136}
2137
2138/// Point koan's configuration at `dir` for the life of the process.
2139///
2140/// Takes precedence over `KOAN_CONFIG_DIR`, and drops the cached config, which
2141/// was keyed on the mtimes of files in a directory that is no longer the one
2142/// being read. Set it before anything spawns: background threads resolve the
2143/// directory when they run, not when they are created.
2144pub fn set_config_dir(dir: impl Into<PathBuf>) {
2145    *CONFIG_DIR.write() = Some(dir.into());
2146    Config::invalidate_cache();
2147}
2148
2149/// Point configuration at a directory belonging to this process alone.
2150///
2151/// Tests call this before anything reads configuration. Without it they read
2152/// whatever belongs to whoever ran them — that person's library folders, their
2153/// remote server, and their password — so the same test does
2154/// different things on different machines, and passes on CI only because it
2155/// finds nothing there at all.
2156///
2157/// Process-wide rather than per-test on purpose: the threads koan spawns
2158/// resolve the directory when they run, which is often after the test that
2159/// started them has finished.
2160///
2161/// It waits for a test that holds `SWITCHING` (koan-core's `PERSIST_LOCK`)
2162/// to finish before switching, so that test never has the directory moved
2163/// out from under it mid-way.
2164pub fn isolate_config_for_tests() {
2165    let _one = SWITCHING.lock().unwrap_or_else(|e| e.into_inner());
2166    let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
2167    let _ = fs::create_dir_all(&dir);
2168    set_config_dir(dir);
2169}
2170
2171/// Held by a test that points config at a directory of its own for its
2172/// whole run, and by `isolate_config_for_tests` while it switches, so no
2173/// test's directory changes under it.
2174#[doc(hidden)]
2175pub static SWITCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
2176
2177static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
2178    LazyLock::new(|| parking_lot::RwLock::new(None));
2179
2180/// Path to the base config TOML file (committable to dotfiles).
2181pub fn config_file_path() -> PathBuf {
2182    config_dir().join("config.toml")
2183}
2184
2185/// Path to the local override config (gitignored, machine-specific).
2186pub fn config_local_file_path() -> PathBuf {
2187    config_dir().join("config.local.toml")
2188}
2189
2190/// Path to the database file.
2191pub fn db_path() -> PathBuf {
2192    config_dir().join("koan.db")
2193}
2194
2195/// Above this, `koan.log` is moved aside to `koan.log.1`, replacing the one
2196/// before, so the log never holds more than twice it.
2197const LOG_LIMIT: u64 = 16 * 1024 * 1024;
2198/// How many lines go by between looks at the file's size: the app stays open
2199/// for days, so checking only when the file is opened is not enough.
2200const LOG_CHECK_EVERY: u32 = 4096;
2201
2202/// `koan.log`, kept to a size. Every logger writes through one of these.
2203#[derive(Default)]
2204pub struct LogFile {
2205    file: Option<fs::File>,
2206    lines: u32,
2207}
2208
2209impl LogFile {
2210    pub fn write(&mut self, line: std::fmt::Arguments) {
2211        use std::io::Write as _;
2212        if self.file.is_none() {
2213            self.file = open_log();
2214        }
2215        let Some(file) = self.file.as_mut() else {
2216            return;
2217        };
2218        let _ = writeln!(file, "{line}");
2219        self.lines = self.lines.wrapping_add(1);
2220        if self.lines.is_multiple_of(LOG_CHECK_EVERY)
2221            && file.metadata().is_ok_and(|m| m.len() > LOG_LIMIT)
2222        {
2223            self.file = open_log();
2224        }
2225    }
2226
2227    pub fn flush(&mut self) {
2228        if let Some(file) = self.file.as_mut() {
2229            let _ = std::io::Write::flush(file);
2230        }
2231    }
2232}
2233
2234/// Open `koan.log` for appending, creating the configuration directory first
2235/// and moving an oversized log aside.
2236///
2237/// A logger starts before anything else has had reason to create the
2238/// directory, so on a first launch it would otherwise find nowhere to write.
2239fn open_log() -> Option<fs::File> {
2240    let dir = config_dir();
2241    fs::create_dir_all(&dir).ok()?;
2242    let path = dir.join("koan.log");
2243    if fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) {
2244        let _ = fs::rename(&path, dir.join("koan.log.1"));
2245    }
2246    fs::OpenOptions::new()
2247        .create(true)
2248        .append(true)
2249        .open(path)
2250        .ok()
2251}
2252
2253/// Refuse to start when credentials are sitting in version control, which is a
2254/// security incident rather than a warning anyone would act on.
2255///
2256/// Runs once per process. It reads both config files and, when a password is
2257/// present, forks `git ls-files` — and `load()` is reached from UI paths that
2258/// run per frame.
2259fn check_secrets_in_git() {
2260    static ONCE: Once = Once::new();
2261    ONCE.call_once(scan_for_tracked_secrets);
2262}
2263
2264fn scan_for_tracked_secrets() {
2265    let sensitive_fields = ["password", "api_key", "refresh_token", "secret"];
2266
2267    for (label, path) in [
2268        ("config.toml", config_file_path()),
2269        ("config.local.toml", config_local_file_path()),
2270    ] {
2271        let Ok(contents) = std::fs::read_to_string(&path) else {
2272            continue;
2273        };
2274
2275        // Check if this file contains any sensitive fields with non-empty values.
2276        let has_secrets = sensitive_fields.iter().any(|field| {
2277            contents.lines().any(|line| {
2278                let line = line.trim();
2279                if let Some(rest) = line.strip_prefix(field) {
2280                    let rest = rest.trim_start();
2281                    if let Some(value) = rest.strip_prefix('=') {
2282                        let value = value.trim().trim_matches('"').trim_matches('\'');
2283                        return !value.is_empty();
2284                    }
2285                }
2286                false
2287            })
2288        });
2289
2290        if !has_secrets {
2291            continue;
2292        }
2293
2294        // Check if this file is tracked by git.
2295        if is_tracked_by_git(&path) {
2296            eprintln!();
2297            eprintln!("╔══════════════════════════════════════════════════════════════╗");
2298            eprintln!("║  SECURITY: {label} contains credentials and is tracked by git!  ║");
2299            eprintln!("╠══════════════════════════════════════════════════════════════╣");
2300            eprintln!("║                                                              ║");
2301            eprintln!("║  File: {:<52} ║", path.display());
2302            eprintln!("║                                                              ║");
2303            eprintln!("║  Your password is in version control. You should:            ║");
2304            eprintln!("║  1. Remove the file from git: git rm --cached <file>         ║");
2305            eprintln!("║  2. Add it to .gitignore                                     ║");
2306            eprintln!("║  3. Rotate your credentials immediately                      ║");
2307            eprintln!("║  4. Move secrets to config.local.toml (gitignored)           ║");
2308            eprintln!("║     `koan remote login` writes there for you                 ║");
2309            eprintln!("║                                                              ║");
2310            eprintln!("╚══════════════════════════════════════════════════════════════╝");
2311            eprintln!();
2312            panic!("Refusing to start: credentials tracked by git in {label}. See above.");
2313        }
2314    }
2315}
2316
2317/// Check if a file is tracked by git (staged or committed, not just in a repo).
2318fn is_tracked_by_git(path: &Path) -> bool {
2319    let Some(parent) = path.parent() else {
2320        return false;
2321    };
2322    // `git ls-files --error-unmatch <file>` exits 0 if tracked, 1 if not.
2323    std::process::Command::new("git")
2324        .args(["ls-files", "--error-unmatch"])
2325        .arg(path)
2326        .current_dir(parent)
2327        .stdout(std::process::Stdio::null())
2328        .stderr(std::process::Stdio::null())
2329        .status()
2330        .is_ok_and(|s| s.success())
2331}
2332
2333#[cfg(test)]
2334pub(crate) mod tests {
2335    use super::*;
2336    use std::fs;
2337
2338    fn tmp_dir() -> PathBuf {
2339        let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
2340        fs::create_dir_all(&dir).unwrap();
2341        dir
2342    }
2343
2344    #[test]
2345    fn test_defaults() {
2346        let cfg = Config::default();
2347        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
2348        assert!(!cfg.remote.enabled);
2349    }
2350
2351    #[test]
2352    fn test_roundtrip_toml() {
2353        let cfg = Config::default();
2354        let serialized = toml::to_string_pretty(&cfg).unwrap();
2355        let deserialized: Config = toml::from_str(&serialized).unwrap();
2356        assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
2357        assert_eq!(
2358            deserialized.remote.download_workers,
2359            cfg.remote.download_workers
2360        );
2361    }
2362
2363    #[test]
2364    fn test_load_from_file() {
2365        let dir = tempfile::tempdir().unwrap();
2366        let path = dir.path().join("config.toml");
2367        fs::write(
2368            &path,
2369            r#"
2370[library]
2371folders = ["/tmp/music"]
2372
2373[playback]
2374replaygain = "track"
2375"#,
2376        )
2377        .unwrap();
2378
2379        let cfg = Config::load_from(&path).unwrap();
2380        assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
2381        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
2382        assert!(!cfg.remote.enabled);
2383    }
2384
2385    #[test]
2386    fn test_partial_toml_uses_defaults() {
2387        let dir = tempfile::tempdir().unwrap();
2388        let path = dir.path().join("partial.toml");
2389        fs::write(&path, "[playback]\ntarget_fps = 30\n").unwrap();
2390
2391        let cfg = Config::load_from(&path).unwrap();
2392        assert_eq!(cfg.playback.target_fps, 30);
2393        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
2394    }
2395
2396    #[test]
2397    fn test_figment_layered_loading() {
2398        let dir = tempfile::tempdir().unwrap();
2399        let base_path = dir.path().join("config.toml");
2400        let local_path = dir.path().join("config.local.toml");
2401
2402        fs::write(
2403            &base_path,
2404            r#"
2405[remote]
2406url = "https://base.example.com"
2407"#,
2408        )
2409        .unwrap();
2410        fs::write(
2411            &local_path,
2412            r#"
2413[remote]
2414enabled = true
2415url = "https://local.example.com"
2416username = "admin"
2417password = "secret"
2418"#,
2419        )
2420        .unwrap();
2421
2422        // Build a figment with explicit paths (can't use load() since it reads from ~/.config).
2423        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2424            .merge(Toml::file(&base_path))
2425            .merge(Toml::file(&local_path))
2426            .extract()
2427            .unwrap();
2428
2429        assert!(cfg.remote.enabled);
2430        assert_eq!(cfg.remote.url, "https://local.example.com");
2431        assert_eq!(cfg.remote.username, "admin");
2432        assert_eq!(cfg.remote.password, "secret");
2433    }
2434
2435    #[test]
2436    fn test_figment_missing_keys_preserved() {
2437        let dir = tempfile::tempdir().unwrap();
2438        let base_path = dir.path().join("config.toml");
2439        let local_path = dir.path().join("config.local.toml");
2440
2441        fs::write(
2442            &base_path,
2443            r#"
2444[remote]
2445url = "https://keep.me"
2446username = "keepuser"
2447"#,
2448        )
2449        .unwrap();
2450        fs::write(
2451            &local_path,
2452            r#"
2453[remote]
2454password = "secret"
2455"#,
2456        )
2457        .unwrap();
2458
2459        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2460            .merge(Toml::file(&base_path))
2461            .merge(Toml::file(&local_path))
2462            .extract()
2463            .unwrap();
2464
2465        assert_eq!(cfg.remote.url, "https://keep.me");
2466        assert_eq!(cfg.remote.username, "keepuser");
2467        assert_eq!(cfg.remote.password, "secret");
2468    }
2469
2470    #[test]
2471    fn test_env_var_override() {
2472        let dir = tempfile::tempdir().unwrap();
2473        let base_path = dir.path().join("config.toml");
2474
2475        fs::write(
2476            &base_path,
2477            r#"
2478[remote]
2479url = "https://file.example.com"
2480"#,
2481        )
2482        .unwrap();
2483
2484        // SAFETY: test is single-threaded and vars are cleaned up immediately after.
2485        unsafe {
2486            std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
2487            std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
2488            std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
2489        }
2490
2491        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2492            .merge(Toml::file(&base_path))
2493            .merge(Env::prefixed("KOAN_").split("__"))
2494            .extract()
2495            .unwrap();
2496
2497        assert_eq!(cfg.remote.url, "https://env.example.com");
2498        assert_eq!(cfg.remote.password, "env-secret");
2499        assert_eq!(cfg.graphql.port, 9999);
2500
2501        // Clean up env vars.
2502        unsafe {
2503            std::env::remove_var("KOAN_REMOTE__URL");
2504            std::env::remove_var("KOAN_REMOTE__PASSWORD");
2505            std::env::remove_var("KOAN_GRAPHQL__PORT");
2506        }
2507    }
2508
2509    #[test]
2510    fn test_cache_dir_default() {
2511        let cfg = Config::default();
2512        assert!(cfg.cache_dir().ends_with("cache"));
2513    }
2514
2515    #[test]
2516    fn test_cache_dir_explicit() {
2517        let mut cfg = Config::default();
2518        cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
2519        assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
2520    }
2521
2522    #[test]
2523    fn test_organize_config_defaults() {
2524        let cfg = Config::default();
2525        assert!(cfg.organize.default.is_none());
2526        assert!(cfg.organize.patterns.is_empty());
2527    }
2528
2529    #[test]
2530    fn test_organize_config_from_toml() {
2531        let dir = tmp_dir();
2532        let path = dir.join("organize.toml");
2533        fs::write(
2534            &path,
2535            r#"
2536[organize]
2537default = "standard"
2538
2539[organize.patterns]
2540standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
2541va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
2542"#,
2543        )
2544        .unwrap();
2545
2546        let cfg = Config::load_from(&path).unwrap();
2547        assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
2548        assert_eq!(cfg.organize.patterns.len(), 2);
2549        assert!(cfg.organize.patterns.contains_key("standard"));
2550        assert!(cfg.organize.patterns.contains_key("va-aware"));
2551
2552        fs::remove_dir_all(&dir).ok();
2553    }
2554
2555    #[test]
2556    fn test_figment_organize_patterns_merge() {
2557        let dir = tempfile::tempdir().unwrap();
2558        let base_path = dir.path().join("config.toml");
2559        let local_path = dir.path().join("config.local.toml");
2560
2561        fs::write(
2562            &base_path,
2563            r#"
2564[organize]
2565default = "standard"
2566
2567[organize.patterns]
2568standard = "base-pattern"
2569"#,
2570        )
2571        .unwrap();
2572        fs::write(
2573            &local_path,
2574            r#"
2575[organize]
2576default = "custom"
2577
2578[organize.patterns]
2579custom = "local-pattern"
2580"#,
2581        )
2582        .unwrap();
2583
2584        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
2585            .merge(Toml::file(&base_path))
2586            .merge(Toml::file(&local_path))
2587            .extract()
2588            .unwrap();
2589
2590        // Local default wins.
2591        assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
2592        // Both patterns present (figment merges maps).
2593        assert_eq!(cfg.organize.patterns.len(), 2);
2594        assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
2595        assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
2596    }
2597
2598    #[test]
2599    fn test_output_device_config_roundtrip() {
2600        let mut cfg = Config::default();
2601        cfg.playback.output_device = Some("My DAC".into());
2602
2603        let serialized = toml::to_string_pretty(&cfg).unwrap();
2604        let deserialized: Config = toml::from_str(&serialized).unwrap();
2605        assert_eq!(
2606            deserialized.playback.output_device.as_deref(),
2607            Some("My DAC")
2608        );
2609    }
2610
2611    #[test]
2612    fn test_output_device_config_default_is_none() {
2613        let cfg = Config::default();
2614        assert!(cfg.playback.output_device.is_none());
2615
2616        // Roundtrip: None should not appear in serialized output.
2617        let serialized = toml::to_string_pretty(&cfg).unwrap();
2618        assert!(!serialized.contains("output_device"));
2619        let deserialized: Config = toml::from_str(&serialized).unwrap();
2620        assert!(deserialized.playback.output_device.is_none());
2621    }
2622
2623    #[test]
2624    fn test_output_device_config_from_toml() {
2625        let dir = tempfile::tempdir().unwrap();
2626        let path = dir.path().join("config.toml");
2627        fs::write(
2628            &path,
2629            r#"
2630[playback]
2631output_device = "External Speakers"
2632"#,
2633        )
2634        .unwrap();
2635
2636        let cfg = Config::load_from(&path).unwrap();
2637        assert_eq!(
2638            cfg.playback.output_device.as_deref(),
2639            Some("External Speakers")
2640        );
2641    }
2642
2643    #[test]
2644    fn test_graphql_bind_defaults_to_localhost() {
2645        let cfg = GraphqlConfig::default();
2646        assert_eq!(
2647            cfg.bind,
2648            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
2649        );
2650    }
2651
2652    #[test]
2653    fn test_graphql_bind_from_toml() {
2654        let toml_str = r#"
2655[graphql]
2656bind = "0.0.0.0"
2657port = 5000
2658"#;
2659        let cfg: Config = toml::from_str(toml_str).unwrap();
2660        assert_eq!(
2661            cfg.graphql.bind,
2662            std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
2663        );
2664        assert_eq!(cfg.graphql.port, 5000);
2665    }
2666
2667    #[test]
2668    fn test_graphql_bind_omitted_defaults_to_localhost() {
2669        let toml_str = r#"
2670[graphql]
2671port = 4000
2672"#;
2673        let cfg: Config = toml::from_str(toml_str).unwrap();
2674        assert_eq!(
2675            cfg.graphql.bind,
2676            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
2677        );
2678    }
2679
2680    #[test]
2681    fn test_organize_config_roundtrip() {
2682        let mut cfg = Config::default();
2683        cfg.organize.default = Some("standard".into());
2684        cfg.organize
2685            .patterns
2686            .insert("standard".into(), "%artist%/%title%".into());
2687
2688        let serialized = toml::to_string_pretty(&cfg).unwrap();
2689        let deserialized: Config = toml::from_str(&serialized).unwrap();
2690        assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
2691        assert_eq!(
2692            deserialized.organize.patterns["standard"],
2693            "%artist%/%title%"
2694        );
2695    }
2696
2697    #[test]
2698    fn test_parse_size_bytes() {
2699        assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
2700        assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
2701        assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
2702        assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
2703        assert_eq!(parse_size_bytes("1024B"), Some(1024));
2704        assert_eq!(parse_size_bytes("1024"), Some(1024));
2705
2706        // Case insensitive.
2707        assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
2708        assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
2709
2710        // Short suffixes.
2711        assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
2712        assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
2713
2714        // Spaces.
2715        assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
2716        assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
2717
2718        // Decimal.
2719        assert_eq!(
2720            parse_size_bytes("1.5GB"),
2721            Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
2722        );
2723
2724        // Invalid.
2725        assert_eq!(parse_size_bytes(""), None);
2726        assert_eq!(parse_size_bytes("abc"), None);
2727        assert_eq!(parse_size_bytes("50XB"), None);
2728    }
2729
2730    #[test]
2731    fn test_cache_limit_config_from_toml() {
2732        let toml_str = r#"
2733[remote]
2734cache_limit = "50GB"
2735"#;
2736        let cfg: Config = toml::from_str(toml_str).unwrap();
2737        assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
2738        assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
2739    }
2740
2741    #[test]
2742    fn test_cache_limit_none_by_default() {
2743        let cfg = Config::default();
2744        assert!(cfg.remote.cache_limit.is_none());
2745        assert!(cfg.cache_limit_bytes().is_none());
2746    }
2747
2748    #[test]
2749    fn test_cache_limit_not_serialized_when_none() {
2750        let cfg = Config::default();
2751        let serialized = toml::to_string_pretty(&cfg).unwrap();
2752        assert!(!serialized.contains("cache_limit"));
2753    }
2754
2755    #[test]
2756    fn player_uses_config_on_init() {
2757        // Verify that Config::load_from correctly picks up playback settings
2758        // that Player::new() would consume. This tests the contract between
2759        // config and player initialization without requiring audio hardware.
2760        let dir = tempfile::tempdir().unwrap();
2761        let path = dir.path().join("config.toml");
2762        fs::write(
2763            &path,
2764            r#"
2765[playback]
2766replaygain = "track"
2767output_device = "My Fancy DAC"
2768pre_amp_db = -3.5
2769target_fps = 30
2770art_size = 32
2771
2772[visualizer]
2773enabled = false
2774mode = "oscilloscope"
2775fps = 30
2776"#,
2777        )
2778        .unwrap();
2779
2780        let cfg = Config::load_from(&path).unwrap();
2781
2782        // These are the fields Player::new() reads from config.
2783        assert_eq!(
2784            cfg.playback.replaygain,
2785            ReplayGainMode::Track,
2786            "replaygain should be 'track'"
2787        );
2788        assert_eq!(
2789            cfg.playback.output_device.as_deref(),
2790            Some("My Fancy DAC"),
2791            "output_device should match config"
2792        );
2793        assert!(
2794            (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
2795            "pre_amp_db should be -3.5"
2796        );
2797        assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
2798        assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
2799
2800        // Visualizer config is also consumed at player init.
2801        assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
2802        assert_eq!(cfg.visualizer.mode, "oscilloscope");
2803        assert_eq!(cfg.visualizer.fps, 30);
2804    }
2805
2806    #[test]
2807    fn a_missing_config_file_stamps_as_absent() {
2808        let dir = tempfile::tempdir().unwrap();
2809        let base = dir.path().join("config.toml");
2810        let local = dir.path().join("config.local.toml");
2811
2812        assert_eq!(stamp_of(&base, &local), (None, None));
2813
2814        fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
2815        let (base_stamp, local_stamp) = stamp_of(&base, &local);
2816        assert!(base_stamp.is_some(), "creating the file must be a change");
2817        assert!(local_stamp.is_none());
2818    }
2819
2820    #[test]
2821    fn editing_a_config_file_changes_its_stamp() {
2822        let dir = tempfile::tempdir().unwrap();
2823        let base = dir.path().join("config.toml");
2824        let local = dir.path().join("config.local.toml");
2825        fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
2826
2827        let before = stamp_of(&base, &local);
2828        // Coarse-grained filesystems would otherwise stamp both writes alike.
2829        std::thread::sleep(std::time::Duration::from_millis(20));
2830        fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
2831
2832        assert_ne!(
2833            before,
2834            stamp_of(&base, &local),
2835            "a config edited by hand has to be picked up"
2836        );
2837    }
2838
2839    #[test]
2840    fn invalidating_forces_a_reload() {
2841        let first = Config::cached();
2842        Config::invalidate_cache();
2843        assert!(
2844            !Arc::ptr_eq(&first, &Config::cached()),
2845            "koan's own writes invalidate explicitly; the next read must re-parse"
2846        );
2847    }
2848
2849    // ---- persist: which file a setting lands in -------------------------
2850
2851    /// `persist` reads and writes process-global paths, so these run one at a
2852    /// time rather than racing each other through `set_config_dir`.
2853    pub(crate) static PERSIST_LOCK: &std::sync::Mutex<()> = &super::SWITCHING;
2854
2855    /// Point config at a fresh directory and hand back (base, local) paths.
2856    fn persist_sandbox(name: &str) -> (PathBuf, PathBuf) {
2857        let dir =
2858            std::env::temp_dir().join(format!("koan-persist-{}-{}", name, std::process::id()));
2859        let _ = fs::remove_dir_all(&dir);
2860        fs::create_dir_all(&dir).unwrap();
2861        set_config_dir(&dir);
2862        (config_file_path(), config_local_file_path())
2863    }
2864
2865    /// A write lands where a symlink points, even one pointing at nothing
2866    /// yet; the secret file is 0600, the other keeps its mode; nothing is left
2867    /// beside them.
2868    #[cfg(unix)]
2869    #[test]
2870    fn a_write_keeps_links_modes_and_no_leftovers() {
2871        use std::os::unix::fs::PermissionsExt;
2872        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2873        let dir = tempfile::tempdir().unwrap();
2874        let dotfiles = tempfile::tempdir().unwrap();
2875        std::os::unix::fs::symlink(
2876            dotfiles.path().join("config.toml"),
2877            dir.path().join("config.toml"),
2878        )
2879        .unwrap();
2880        set_config_dir(dir.path());
2881
2882        Config::persist(|c| c.graphql.port = 4100).unwrap();
2883        assert!(
2884            fs::symlink_metadata(config_file_path())
2885                .unwrap()
2886                .is_symlink()
2887        );
2888        assert!(
2889            fs::read_to_string(dotfiles.path().join("config.toml"))
2890                .unwrap()
2891                .contains("4100")
2892        );
2893
2894        fs::set_permissions(
2895            dotfiles.path().join("config.toml"),
2896            fs::Permissions::from_mode(0o640),
2897        )
2898        .unwrap();
2899        Config::persist(|c| {
2900            c.graphql.port = 4101;
2901            c.remote.password = "hunter2".into();
2902        })
2903        .unwrap();
2904        let mode = |p: PathBuf| fs::metadata(p).unwrap().permissions().mode() & 0o777;
2905        assert_eq!(mode(dotfiles.path().join("config.toml")), 0o640);
2906        assert_eq!(mode(config_local_file_path()), 0o600);
2907        assert!(
2908            fs::symlink_metadata(config_file_path())
2909                .unwrap()
2910                .is_symlink()
2911        );
2912
2913        let names = |d: &Path| -> Vec<String> {
2914            fs::read_dir(d)
2915                .unwrap()
2916                .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2917                .collect()
2918        };
2919        assert!(!names(dir.path()).iter().any(|n| n.ends_with(".tmp")));
2920        assert_eq!(names(dotfiles.path()), vec!["config.toml".to_string()]);
2921    }
2922
2923    /// A read racing directory switches never caches one directory's config
2924    /// under the other's, even when the directory switches away and back
2925    /// while the read is loading.
2926    #[test]
2927    fn a_read_during_switches_is_never_kept_for_the_wrong_directory() {
2928        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2929        let dirs: Vec<tempfile::TempDir> = (0..2).map(|_| tempfile::tempdir().unwrap()).collect();
2930        for (dir, port) in dirs.iter().zip([4001, 4002]) {
2931            fs::write(
2932                dir.path().join("config.toml"),
2933                format!("[graphql]\nport = {port}\n"),
2934            )
2935            .unwrap();
2936        }
2937        let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
2938        let reader = std::thread::spawn({
2939            let done = done.clone();
2940            move || {
2941                while !done.load(std::sync::atomic::Ordering::Relaxed) {
2942                    Config::cached();
2943                }
2944            }
2945        });
2946        for i in 0..2000 {
2947            let (dir, port) = if i % 2 == 0 {
2948                (&dirs[0], 4001)
2949            } else {
2950                (&dirs[1], 4002)
2951            };
2952            set_config_dir(dir.path());
2953            for _ in 0..3 {
2954                assert_eq!(Config::cached().graphql.port, port, "switch {i}");
2955            }
2956        }
2957        done.store(true, std::sync::atomic::Ordering::Relaxed);
2958        reader.join().unwrap();
2959    }
2960
2961    #[test]
2962    fn persist_keeps_comments_and_untouched_keys() {
2963        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2964        let (base, _local) = persist_sandbox("comments");
2965        fs::write(
2966            &base,
2967            "# koan — shareable defaults\n\n[visualizer]\n# fps = 60\npalette = \"fire\"\n",
2968        )
2969        .unwrap();
2970
2971        Config::persist(|cfg| cfg.visualizer.palette = "neon".into()).unwrap();
2972
2973        let written = fs::read_to_string(&base).unwrap();
2974        assert!(
2975            written.contains("# koan — shareable defaults"),
2976            "the header comment must survive a write: {written}"
2977        );
2978        assert!(
2979            written.contains("# fps = 60"),
2980            "commented-out defaults are the template's whole point: {written}"
2981        );
2982        assert!(written.contains("palette = \"neon\""));
2983        assert!(
2984            !written.contains("[graphql]"),
2985            "an untouched section must not be invented: {written}"
2986        );
2987    }
2988
2989    #[test]
2990    fn persist_routes_machine_settings_to_the_local_file() {
2991        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2992        let (base, local) = persist_sandbox("routing");
2993
2994        Config::persist(|cfg| {
2995            cfg.playback.replaygain = ReplayGainMode::Album;
2996            cfg.playback.output_device = Some("My DAC".into());
2997            cfg.playback.art_size = 40;
2998            cfg.visualizer.mode = "starfield".into();
2999        })
3000        .unwrap();
3001
3002        let shared = fs::read_to_string(&base).unwrap();
3003        let machine = fs::read_to_string(&local).unwrap();
3004
3005        assert!(shared.contains("replaygain = \"album\""), "{shared}");
3006        for machine_only in ["output_device", "art_size", "starfield"] {
3007            assert!(
3008                !shared.contains(machine_only),
3009                "{machine_only} is this machine's, not the dotfiles repo's: {shared}"
3010            );
3011            assert!(machine.contains(machine_only), "{machine}");
3012        }
3013    }
3014
3015    #[test]
3016    fn persist_writes_dsp_profiles_to_the_local_file_as_sections() {
3017        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3018        let (base, local) = persist_sandbox("dsp");
3019
3020        let profile = DspProfile {
3021            name: "HD 600".into(),
3022            devices: vec!["Topping E30".into()],
3023            preamp_db: None,
3024            filters: vec![
3025                DspFilter::Band(EqFilter {
3026                    kind: EqFilterKind::Peaking,
3027                    freq: 20.0,
3028                    gain_db: -1.3,
3029                    q: 2.0,
3030                    channels: vec![],
3031                }),
3032                DspFilter::Band(EqFilter {
3033                    kind: EqFilterKind::HighShelfFirstOrder,
3034                    freq: 10000.0,
3035                    gain_db: 2.5,
3036                    q: 0.7,
3037                    channels: vec![],
3038                }),
3039                DspFilter::Delay(Delay {
3040                    ms: 1.5,
3041                    channels: vec![1],
3042                    ..Default::default()
3043                }),
3044                DspFilter::Mix(Mix {
3045                    outputs: vec![vec![(0, 0.5), (1, 0.5)], vec![]],
3046                }),
3047                DspFilter::Graphic(GraphicEq {
3048                    points: vec![(20.0, -1.0), (1000.0, 0.0)],
3049                    channels: vec![],
3050                }),
3051            ],
3052            impulses: vec![],
3053            source: vec![],
3054            target: None,
3055            layers: vec![],
3056            group: false,
3057            preset: false,
3058            filters_only: false,
3059            scope: None,
3060            uid: None,
3061            origin: None,
3062            role: None,
3063            measurement: None,
3064            fitted: None,
3065            tuned_for: None,
3066            original: None,
3067        };
3068        Config::persist(|cfg| cfg.dsp.profiles.push(profile.clone())).unwrap();
3069
3070        assert!(!base.exists() || !fs::read_to_string(&base).unwrap().contains("dsp"));
3071        let machine = fs::read_to_string(&local).unwrap();
3072        assert!(machine.contains("[[dsp.profiles]]"), "{machine}");
3073        assert!(
3074            machine.contains("\n    { type = \"peaking\""),
3075            "one band per line: {machine}"
3076        );
3077        assert_eq!(Config::from_files().unwrap().dsp.profiles, vec![profile]);
3078        assert_eq!(
3079            Config::from_files()
3080                .unwrap()
3081                .dsp
3082                .profile_for("Topping E30")
3083                .map(|p| p.name.as_str()),
3084            Some("HD 600")
3085        );
3086    }
3087
3088    /// Every bound holds, whatever a profile says: values clamped, ones
3089    /// that are not numbers dropped, counts cut off, each change noted.
3090    #[test]
3091    fn a_profile_is_played_within_bounds() {
3092        let band = |freq: f64, gain_db: f64, q: f64| {
3093            DspFilter::Band(EqFilter {
3094                kind: EqFilterKind::Peaking,
3095                freq,
3096                gain_db,
3097                q,
3098                channels: vec![],
3099            })
3100        };
3101        let mut p = DspProfile {
3102            name: "Loud\u{7}".into(),
3103            preamp_db: Some(60.0),
3104            filters: vec![
3105                DspFilter::Delay(Delay {
3106                    ms: 1e12,
3107                    ..Default::default()
3108                }),
3109                band(0.5, 200.0, 0.0),
3110                band(f64::NAN, 0.0, 1.0),
3111                DspFilter::Band(EqFilter {
3112                    kind: EqFilterKind::Peaking,
3113                    freq: 1000.0,
3114                    gain_db: 0.0,
3115                    q: 1.0,
3116                    channels: vec![99],
3117                }),
3118                DspFilter::Mix(Mix {
3119                    outputs: vec![vec![(0, 1e9), (1, f64::INFINITY)]],
3120                }),
3121                DspFilter::Graphic(GraphicEq {
3122                    points: (0..3000).map(|i| (i as f64, 1.0)).collect(),
3123                    channels: vec![],
3124                }),
3125            ],
3126            ..Default::default()
3127        };
3128        p.filters.extend((0..300).map(|_| band(1000.0, 1.0, 1.0)));
3129        let notes = p.sanitize();
3130        let has = |n: &str| notes.iter().any(|x| x == n);
3131        assert!(has("delay clamped to 2000 ms"), "{notes:?}");
3132        assert!(has("preamp clamped to 30 dB"), "{notes:?}");
3133        assert!(has("a band's gain clamped to 30 dB"), "{notes:?}");
3134        assert!(has("a band's frequency clamped to 1 Hz"), "{notes:?}");
3135        assert!(has("a filter that could not play dropped"), "{notes:?}");
3136        assert!(has("bands past 64 a channel dropped"), "{notes:?}");
3137        assert!(has("graphic EQ points past 2048 dropped"), "{notes:?}");
3138        assert_eq!(p.name, "Loud");
3139        assert_eq!(p.preamp_db, Some(30.0));
3140        assert_eq!(
3141            p.filters[0],
3142            DspFilter::Delay(Delay {
3143                ms: 2000.0,
3144                ..Default::default()
3145            })
3146        );
3147        assert_eq!(p.filters[1], band(1.0, 30.0, 0.01));
3148        let DspFilter::Mix(m) = &p.filters[2] else {
3149            panic!("{:?}", p.filters[2])
3150        };
3151        assert_eq!(m.outputs, vec![vec![(0, dsp_bounds::MIX_GAIN)]]);
3152        let bands = p
3153            .filters
3154            .iter()
3155            .filter(|f| matches!(f, DspFilter::Band(_)))
3156            .count();
3157        assert_eq!(bands, dsp_bounds::BANDS_PER_CHANNEL);
3158        assert!(p.clone().sanitize().is_empty(), "within bounds now");
3159    }
3160
3161    #[test]
3162    fn persist_never_writes_the_default_library_folder_into_the_shared_file() {
3163        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3164        let (base, _local) = persist_sandbox("folders");
3165
3166        // Toggling the visualiser used to serialise the whole struct, baking
3167        // the *default* music directory into the file people commit.
3168        Config::persist(|cfg| cfg.visualizer.enabled = false).unwrap();
3169
3170        let shared = fs::read_to_string(&base).unwrap_or_default();
3171        assert!(
3172            !shared.contains("folders"),
3173            "a visualiser toggle must not invent library folders: {shared}"
3174        );
3175    }
3176
3177    #[test]
3178    fn persist_drains_machine_settings_an_older_koan_left_in_the_shared_file() {
3179        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3180        let (base, local) = persist_sandbox("drain");
3181        fs::write(&base, "[playback]\nart_size = 24\ntarget_fps = 60\n").unwrap();
3182
3183        Config::persist(|cfg| cfg.playback.art_size = 48).unwrap();
3184
3185        let shared = fs::read_to_string(&base).unwrap();
3186        assert!(
3187            !shared.contains("art_size"),
3188            "the stale shared copy has to go, or dotfiles keep carrying it: {shared}"
3189        );
3190        assert!(shared.contains("target_fps"), "{shared}");
3191        assert!(
3192            fs::read_to_string(&local)
3193                .unwrap()
3194                .contains("art_size = 48")
3195        );
3196    }
3197
3198    #[test]
3199    fn persist_clears_the_local_copy_so_a_shared_write_takes_effect() {
3200        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3201        let (_base, local) = persist_sandbox("shadow");
3202        fs::write(&local, "[playback]\ntarget_fps = 30\n").unwrap();
3203
3204        Config::persist(|cfg| cfg.playback.target_fps = 120).unwrap();
3205
3206        assert_eq!(
3207            Config::from_files().unwrap().playback.target_fps,
3208            120,
3209            "local wins the merge, so a shared write over a local copy would \
3210             otherwise be silently ignored: {}",
3211            fs::read_to_string(&local).unwrap()
3212        );
3213    }
3214
3215    #[test]
3216    fn lastfm_credentials_are_secrets_and_need_both_halves() {
3217        assert!(matches!(layer_of("lastfm.api_key"), Layer::Machine));
3218        assert!(matches!(layer_of("lastfm.secret"), Layer::Machine));
3219        let mut cfg = LastfmConfig::default();
3220        assert!(!cfg.is_set());
3221        cfg.api_key = "key".into();
3222        assert!(!cfg.is_set());
3223        cfg.secret = " ".into();
3224        assert!(!cfg.is_set());
3225        cfg.secret = "secret".into();
3226        assert!(cfg.is_set());
3227    }
3228
3229    #[test]
3230    fn persist_writes_nothing_when_the_mutation_changes_nothing() {
3231        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3232        let (base, _local) = persist_sandbox("noop");
3233        fs::write(&base, "# untouched\n[playback]\ntarget_fps = 60\n").unwrap();
3234
3235        Config::persist(|cfg| cfg.playback.target_fps = 60).unwrap();
3236
3237        assert_eq!(
3238            fs::read_to_string(&base).unwrap(),
3239            "# untouched\n[playback]\ntarget_fps = 60\n"
3240        );
3241    }
3242
3243    #[test]
3244    fn persist_keeps_passwords_out_of_the_shared_file() {
3245        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3246        let (base, local) = persist_sandbox("secrets");
3247
3248        Config::persist(|cfg| {
3249            cfg.remote.password = "hunter2".into();
3250            cfg.subsonic.password = "s3cret".into();
3251            cfg.visualizer.palette = "mono".into();
3252        })
3253        .unwrap();
3254
3255        let shared = fs::read_to_string(&base).unwrap();
3256        assert!(!shared.contains("hunter2"), "{shared}");
3257        assert!(!shared.contains("s3cret"), "{shared}");
3258        assert!(shared.contains("mono"));
3259
3260        let machine = fs::read_to_string(&local).unwrap();
3261        assert!(machine.contains("hunter2") && machine.contains("s3cret"));
3262    }
3263
3264    #[test]
3265    fn persist_removes_a_cleared_password_rather_than_blanking_it() {
3266        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3267        let (_base, local) = persist_sandbox("clear-secret");
3268        fs::write(
3269            &local,
3270            "[remote]\nurl = \"https://a.example\"\npassword = \"old\"\n",
3271        )
3272        .unwrap();
3273
3274        Config::persist(|cfg| cfg.remote.password = String::new()).unwrap();
3275
3276        let machine = fs::read_to_string(&local).unwrap();
3277        assert!(
3278            !machine.contains("password"),
3279            "an emptied secret should leave no key behind: {machine}"
3280        );
3281        assert!(machine.contains("url"), "{machine}");
3282    }
3283
3284    #[test]
3285    fn persist_adds_one_organize_pattern_without_disturbing_the_others() {
3286        let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3287        let (base, _local) = persist_sandbox("patterns");
3288        fs::write(
3289            &base,
3290            "[organize.patterns]\nflat = \"%artist% - %title%\"\n",
3291        )
3292        .unwrap();
3293
3294        Config::persist(|cfg| {
3295            cfg.organize
3296                .patterns
3297                .insert("standard".into(), "%album artist%/%album%".into());
3298        })
3299        .unwrap();
3300
3301        let cfg = Config::from_files().unwrap();
3302        assert_eq!(cfg.organize.patterns["flat"], "%artist% - %title%");
3303        assert_eq!(cfg.organize.patterns["standard"], "%album artist%/%album%");
3304    }
3305}