Skip to main content

koan_core/
config.rs

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