Skip to main content

koan_core/
config.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, LazyLock, Once};
5use std::time::SystemTime;
6
7use figment::Figment;
8use figment::providers::{Env, Format, Serialized, Toml};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12#[derive(Debug, Error)]
13pub enum ConfigError {
14    #[error("io error: {0}")]
15    Io(#[from] std::io::Error),
16    #[error("parse error: {0}")]
17    Parse(#[from] toml::de::Error),
18    #[error("serialize error: {0}")]
19    Serialize(#[from] toml::ser::Error),
20    #[error("config error: {0}")]
21    Figment(#[from] Box<figment::Error>),
22}
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25#[serde(default)]
26pub struct Config {
27    pub library: LibraryConfig,
28    pub playback: PlaybackConfig,
29    pub remote: RemoteConfig,
30    pub organize: OrganizeConfig,
31    #[serde(alias = "visualiser")]
32    pub visualizer: VisualizerConfig,
33    pub radio: RadioConfig,
34    pub graphql: GraphqlConfig,
35    pub subsonic: SubsonicConfig,
36    pub discovery: DiscoveryConfig,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(default)]
41pub struct LibraryConfig {
42    pub folders: Vec<PathBuf>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(default)]
47pub struct PlaybackConfig {
48    pub replaygain: ReplayGainMode,
49    /// Ticker scroll speed in frames-per-second (default: 8).
50    /// The title scrolls one character per frame. Higher = faster scroll.
51    pub ticker_fps: u8,
52    /// UI render rate in frames-per-second (default: 60).
53    /// Controls how often the TUI redraws. 30, 60, or 120 are typical values.
54    pub target_fps: u8,
55    /// Show an FPS counter overlay in the top-right corner.
56    pub show_fps: bool,
57    /// ReplayGain pre-amplification in dB. Applied on top of track/album gain.
58    /// Positive values boost, negative values attenuate. Default: 0.0.
59    pub pre_amp_db: f64,
60    /// Output audio device name. None = system default.
61    /// Persisted by name (not ID) since IDs can change across reboots.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub output_device: Option<String>,
64    /// Album art width in terminal columns (default: 24).
65    /// Height is always width/2 (square via halfblock rendering).
66    pub art_size: u16,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "lowercase")]
71pub enum ReplayGainMode {
72    Off,
73    Track,
74    Album,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(default)]
79pub struct RemoteConfig {
80    pub enabled: bool,
81    pub url: String,
82    pub username: String,
83    /// Password — stored in config.local.toml (gitignored), not config.toml.
84    #[serde(default, skip_serializing_if = "String::is_empty")]
85    pub password: String,
86    /// original | opus-128 | mp3-320
87    pub transcode_quality: String,
88    /// Defaults to config_dir()/cache if empty.
89    pub cache_dir: Option<PathBuf>,
90    /// Parallel download workers for remote tracks (default: 5).
91    pub download_workers: usize,
92    /// Maximum cache size on disk. Human-readable: "50GB", "500MB", etc.
93    /// None or empty = unlimited. LRU eviction runs on startup when exceeded.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub cache_limit: Option<String>,
96    /// Sync the library from the server on startup and on a timer.
97    ///
98    /// Incremental — it asks the server what changed rather than walking
99    /// everything, so it is cheap enough to run unattended. A full sync stays a
100    /// deliberate action.
101    pub auto_sync: bool,
102    /// Minutes between automatic syncs. 0 runs one at startup and no more.
103    pub auto_sync_interval_mins: u64,
104}
105
106impl Default for LibraryConfig {
107    fn default() -> Self {
108        let music_dir = dirs::audio_dir().unwrap_or_else(|| {
109            dirs::home_dir()
110                .map(|h| h.join("Music"))
111                .unwrap_or_else(|| PathBuf::from("/Music"))
112        });
113        Self {
114            folders: vec![music_dir],
115        }
116    }
117}
118
119impl Default for PlaybackConfig {
120    fn default() -> Self {
121        Self {
122            replaygain: ReplayGainMode::Off,
123            ticker_fps: 8,
124            target_fps: 60,
125            show_fps: false,
126            pre_amp_db: 0.0,
127            output_device: None,
128            art_size: 24,
129        }
130    }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(default)]
135pub struct VisualizerConfig {
136    pub enabled: bool,
137    pub fps: u8,
138    /// Visualizer mode: "bars" (default), "oscilloscope", "radial", "particles", "lissajous".
139    pub mode: String,
140    /// Frequency scale: "bark" (default), "mel", "log", "linear".
141    pub scale: String,
142    /// Amplitude scale: "aweight" (default, A-weighted), "perceptual" (A-weighted + gamma), "sqrt", "linear".
143    pub amplitude_scale: String,
144    /// Bar decay half-life in milliseconds (how fast bars drop).
145    pub bar_decay_ms: u32,
146    /// Peak decay half-life in milliseconds (how long peaks linger).
147    pub peak_decay_ms: u32,
148    /// Color palette: "spectrum" (default), "mono", "fire", "neon".
149    /// Controls the frequency-mapped color gradient on spectrum bars.
150    pub palette: String,
151    /// Reactivity multiplier (0.0..2.0, default 1.0).
152    /// Scales all beat/spectrum-driven animation coefficients.
153    /// 0.0 = static, 1.0 = normal, 2.0 = hypersensitive.
154    pub reactivity: f32,
155    /// Bass shake: camera jitter + scale pulse on bass hits.
156    /// Applies to braille-rendered modes (oscilloscope, radial, wireframe, starfield, etc.).
157    pub bass_shake: bool,
158    /// Matrix overlay: replace all rendered characters with random matrix glyphs in green.
159    /// Applies to any visualizer mode as a post-processing pass.
160    pub matrix_overlay: bool,
161    /// Beat-reactive background color on braille modes (starfield, wormhole, etc.).
162    pub reactive_bg: bool,
163}
164
165impl Default for VisualizerConfig {
166    fn default() -> Self {
167        Self {
168            enabled: true,
169            fps: 60,
170            mode: "bars".into(),
171            scale: "bark".into(),
172            amplitude_scale: "aweight".into(),
173            bar_decay_ms: 50,
174            peak_decay_ms: 180,
175            palette: "spectrum".into(),
176            reactivity: 1.0,
177            bass_shake: true,
178            matrix_overlay: false,
179            reactive_bg: false,
180        }
181    }
182}
183
184impl Default for RemoteConfig {
185    fn default() -> Self {
186        Self {
187            enabled: false,
188            url: String::new(),
189            username: String::new(),
190            password: String::new(),
191            transcode_quality: "original".into(),
192            cache_dir: None,
193            download_workers: 5,
194            cache_limit: None,
195            auto_sync: true,
196            auto_sync_interval_mins: 60,
197        }
198    }
199}
200
201/// Parse a human-readable size string like "50GB", "500 MB", "1.5TB" into bytes.
202/// Supports B, KB, MB, GB, TB (case-insensitive). Returns None for invalid input.
203pub fn parse_size_bytes(s: &str) -> Option<u64> {
204    let s = s.trim();
205    if s.is_empty() {
206        return None;
207    }
208
209    // Split into numeric part and suffix.
210    let mut num_end = 0;
211    for (i, c) in s.char_indices() {
212        if c.is_ascii_digit() || c == '.' {
213            num_end = i + c.len_utf8();
214        } else if !c.is_whitespace() {
215            break;
216        }
217    }
218
219    let num_str = s[..num_end].trim();
220    let suffix = s[num_end..].trim().to_ascii_uppercase();
221
222    let value: f64 = num_str.parse().ok()?;
223    let multiplier: u64 = match suffix.as_str() {
224        "" | "B" => 1,
225        "KB" | "K" => 1024,
226        "MB" | "M" => 1024 * 1024,
227        "GB" | "G" => 1024 * 1024 * 1024,
228        "TB" | "T" => 1024 * 1024 * 1024 * 1024,
229        _ => return None,
230    };
231
232    Some((value * multiplier as f64) as u64)
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(default)]
237pub struct OrganizeConfig {
238    /// Default named pattern to use when --pattern is omitted.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub default: Option<String>,
241    /// Named patterns — keys are names, values are format strings.
242    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
243    pub patterns: HashMap<String, String>,
244    /// Move cover art, cue sheets and logs alongside the music they belong to.
245    /// On by default: a folder's artwork is part of the release, and leaving it
246    /// behind turns one album into two half-albums.
247    #[serde(default = "default_true")]
248    pub move_ancillary: bool,
249}
250
251impl Default for OrganizeConfig {
252    fn default() -> Self {
253        Self {
254            default: None,
255            patterns: HashMap::new(),
256            move_ancillary: true,
257        }
258    }
259}
260
261impl OrganizeConfig {
262    /// Resolve a pattern argument: if it matches a named pattern, return the stored
263    /// format string. Otherwise return it as-is (raw format string).
264    pub fn resolve_pattern<'a>(&'a self, name_or_raw: &'a str) -> &'a str {
265        self.patterns
266            .get(name_or_raw)
267            .map(|s| s.as_str())
268            .unwrap_or(name_or_raw)
269    }
270
271    /// Get the default pattern's format string, if configured.
272    pub fn default_pattern(&self) -> Option<&str> {
273        self.default
274            .as_ref()
275            .and_then(|name| self.patterns.get(name))
276            .map(|s| s.as_str())
277    }
278}
279
280/// GraphQL API server configuration.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282#[serde(default)]
283pub struct GraphqlConfig {
284    /// Enable the GraphQL API server alongside the TUI (default: true).
285    /// Set to false for TUI-only mode (equivalent to --no-api).
286    pub enabled: bool,
287    /// GraphQL API port (default: 4000).
288    pub port: u16,
289    /// Bind address for the API server (default: 127.0.0.1).
290    /// Use "0.0.0.0" to listen on all interfaces (NOT RECOMMENDED without auth).
291    #[serde(default = "default_bind")]
292    pub bind: std::net::IpAddr,
293    /// Enable GraphiQL web IDE at GET /graphql.
294    pub playground: bool,
295    /// Enable Subsonic REST API on this port. Omit or null to disable.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub subsonic_port: Option<u16>,
298    /// Require authentication for API access (default: true).
299    /// When false, all requests are treated as admin. When true, JWT auth is enforced.
300    pub auth_enabled: bool,
301    /// Access token TTL (default: "15m"). Supports: "15m", "1h", "3600s".
302    pub access_token_ttl: String,
303    /// Refresh token TTL (default: "30d"). Supports: "30d", "7d", "720h".
304    pub refresh_token_ttl: String,
305    /// Allowed CORS origins. Empty = no cross-origin browser access at all.
306    /// Example: ["https://music.example.com"]
307    pub cors_origins: Vec<String>,
308    /// Extra `Host:` values the server will answer to, beyond `localhost` and
309    /// bare IP literals. Requests carrying any other Host are refused, which is
310    /// what stops a DNS-rebinding page from reaching the API as same-origin.
311    pub allowed_hosts: Vec<String>,
312    /// Mark the session cookie `Secure`. Only set this when clients reach koan
313    /// over HTTPS — browsers silently discard `Secure` cookies sent over plain
314    /// `http://` to anything but localhost.
315    pub cookie_secure: bool,
316    /// Expose the `organize*` mutations, which physically move files on disk.
317    pub allow_organize: bool,
318}
319
320fn default_true() -> bool {
321    true
322}
323
324fn default_bind() -> std::net::IpAddr {
325    std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
326}
327
328impl Default for GraphqlConfig {
329    fn default() -> Self {
330        Self {
331            enabled: true,
332            port: 4000,
333            bind: default_bind(),
334            playground: false,
335            subsonic_port: None,
336            auth_enabled: true,
337            access_token_ttl: "15m".into(),
338            refresh_token_ttl: "30d".into(),
339            cors_origins: Vec::new(),
340            allowed_hosts: Vec::new(),
341            cookie_secure: false,
342            allow_organize: false,
343        }
344    }
345}
346
347/// Subsonic-compatible REST API.
348///
349/// Credentials are deliberately separate from `[remote]`: the Subsonic protocol
350/// authenticates with `md5(password + salt)` over whatever transport the client
351/// picked, so the secret has to be recoverable and is exposed to anyone who can
352/// capture a request. Reusing the upstream Navidrome password would hand out
353/// that account too.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355#[serde(default)]
356pub struct SubsonicConfig {
357    /// Serve `/rest/*`. Off unless explicitly enabled.
358    pub enabled: bool,
359    /// Username Subsonic clients authenticate as.
360    pub username: String,
361    /// Shared secret. Prefer the OS keychain (`koan subsonic setup`); this field
362    /// is the fallback for machines without one, and lives in config.local.toml.
363    #[serde(default, skip_serializing_if = "String::is_empty")]
364    pub password: String,
365}
366
367impl Default for SubsonicConfig {
368    fn default() -> Self {
369        Self {
370            enabled: false,
371            username: "koan".into(),
372            password: String::new(),
373        }
374    }
375}
376
377/// Radio / infinite play mode configuration.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379#[serde(default)]
380pub struct RadioConfig {
381    /// Number of tracks to keep queued ahead of the cursor.
382    pub lookahead: usize,
383    /// Number of tracks to add each time the queue runs low.
384    pub batch_size: usize,
385    /// Use Subsonic getSimilarSongs2 when a remote server is configured.
386    pub use_subsonic: bool,
387    /// Don't repeat any of the last N tracks (play history exclusion window).
388    pub history_window: usize,
389    /// Number of recently played tracks to use as seed (drifting seed window).
390    pub seed_window: usize,
391    /// Discovery weight: 0.0 = only familiar tracks, 1.0 = maximise discovery.
392    /// Controls the recency bonus — higher values boost never-played/long-forgotten tracks.
393    pub discovery_weight: f64,
394}
395
396impl Default for RadioConfig {
397    fn default() -> Self {
398        Self {
399            lookahead: 5,
400            batch_size: 5,
401            use_subsonic: true,
402            history_window: 200,
403            seed_window: 5,
404            discovery_weight: 0.3,
405        }
406    }
407}
408
409/// Acoustic analysis / discovery configuration.
410#[derive(Debug, Clone, Serialize, Deserialize)]
411#[serde(default)]
412pub struct DiscoveryConfig {
413    /// Run acoustic analysis automatically after library scan (default: false).
414    pub analysis_on_scan: bool,
415    /// Weight for acoustic similarity signal in radio mode scoring (0.0..1.0).
416    pub acoustic_weight: f64,
417}
418
419impl Default for DiscoveryConfig {
420    fn default() -> Self {
421        Self {
422            analysis_on_scan: false,
423            acoustic_weight: 0.5,
424        }
425    }
426}
427
428/// Mtimes of the two files `figment()` layers. Keyed on these so a config
429/// edited by hand is picked up without koan being told about it; `KOAN_*` env
430/// vars are not tracked, since they are fixed for the life of the process.
431type ConfigStamp = (Option<SystemTime>, Option<SystemTime>);
432
433type CachedConfig = Option<(ConfigStamp, Arc<Config>)>;
434
435static CONFIG_CACHE: LazyLock<parking_lot::RwLock<CachedConfig>> =
436    LazyLock::new(|| parking_lot::RwLock::new(None));
437
438fn config_stamp() -> ConfigStamp {
439    stamp_of(&config_file_path(), &config_local_file_path())
440}
441
442/// A file that does not exist stamps as `None`, so creating one is a change.
443fn stamp_of(base: &Path, local: &Path) -> ConfigStamp {
444    let mtime = |p: &Path| fs::metadata(p).and_then(|m| m.modified()).ok();
445    (mtime(base), mtime(local))
446}
447
448impl Config {
449    /// Build the figment provider chain:
450    /// defaults → config.toml → config.local.toml → KOAN_* env vars.
451    ///
452    /// Env vars use `KOAN_` prefix with `__` as section separator:
453    ///   KOAN_REMOTE__PASSWORD, KOAN_GRAPHQL__PORT, KOAN_PLAYBACK__TARGET_FPS, etc.
454    fn figment() -> Figment {
455        let base_path = config_file_path();
456        let local_path = config_local_file_path();
457
458        Figment::from(Serialized::defaults(Config::default()))
459            .merge(Toml::file(&base_path))
460            .merge(Toml::file(&local_path))
461            .merge(Env::prefixed("KOAN_").split("__"))
462    }
463
464    /// Load config from all layers: defaults → config.toml → config.local.toml → KOAN_* env vars.
465    pub fn load() -> Result<Self, ConfigError> {
466        let cfg: Self = Self::figment()
467            .extract()
468            .map_err(|e| ConfigError::Figment(Box::new(e)))?;
469
470        // Security: refuse to start if config files containing secrets are
471        // tracked by git.
472        check_secrets_in_git();
473
474        Ok(cfg)
475    }
476
477    /// Load config, logging and falling back to defaults on error.
478    ///
479    /// Served from the cache, so what this costs is a clone of the struct
480    /// rather than two file reads and a figment merge.
481    pub fn load_or_default() -> Self {
482        (*Self::cached()).clone()
483    }
484
485    /// The merged config, reloaded only when it changed on disk.
486    ///
487    /// `load()` re-reads both TOML files and re-runs the whole figment merge,
488    /// and koan reaches it from paths that run per frame — `library_folders()`
489    /// is read from a SwiftUI list body. Callers that want to avoid even the
490    /// clone `load_or_default()` does can hold this `Arc`.
491    pub fn cached() -> Arc<Config> {
492        let stamp = config_stamp();
493        if let Some((seen, cfg)) = CONFIG_CACHE.read().as_ref()
494            && *seen == stamp
495        {
496            return cfg.clone();
497        }
498
499        let cfg = Arc::new(Self::load().unwrap_or_else(|e| {
500            log::warn!("failed to load config, using defaults: {}", e);
501            Self::default()
502        }));
503        *CONFIG_CACHE.write() = Some((stamp, cfg.clone()));
504        cfg
505    }
506
507    /// Drop the cached config. koan's own writes invalidate explicitly rather
508    /// than relying on the mtime, which can land in the same filesystem tick as
509    /// the read before it.
510    pub fn invalidate_cache() {
511        *CONFIG_CACHE.write() = None;
512    }
513
514    /// Load from a specific TOML file (no env var overlay).
515    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
516        let contents = fs::read_to_string(path)?;
517        let config: Config = toml::from_str(&contents)?;
518        Ok(config)
519    }
520
521    /// Patch config.toml with a mutation closure. Reads the base file only (not
522    /// config.local.toml or env vars), applies the closure, writes back.
523    /// This prevents secrets from config.local.toml or env vars leaking into config.toml.
524    pub fn update_base<F>(mutate: F) -> Result<(), ConfigError>
525    where
526        F: FnOnce(&mut Config),
527    {
528        let path = config_file_path();
529        let mut cfg = if path.exists() {
530            Config::load_from(&path)?
531        } else {
532            Config::default()
533        };
534        mutate(&mut cfg);
535        cfg.write_to(&path)?;
536        Self::invalidate_cache();
537        Ok(())
538    }
539
540    /// Write this config to a specific path as TOML.
541    fn write_to(&self, path: &Path) -> Result<(), ConfigError> {
542        if let Some(parent) = path.parent() {
543            fs::create_dir_all(parent)?;
544        }
545        let contents = toml::to_string_pretty(self)?;
546        fs::write(path, contents)?;
547        Ok(())
548    }
549
550    /// Patch a single section in config.local.toml, preserving all other content.
551    /// Creates the file if it doesn't exist. Sets 0o600 permissions on Unix.
552    pub fn patch_local(
553        section: &str,
554        values: &toml::map::Map<String, toml::Value>,
555    ) -> Result<(), ConfigError> {
556        let path = config_local_file_path();
557        let mut doc: toml::Value = if path.exists() {
558            let contents = fs::read_to_string(&path)?;
559            toml::from_str(&contents).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new()))
560        } else {
561            toml::Value::Table(toml::map::Map::new())
562        };
563
564        let table = doc.as_table_mut().expect("root is always a table");
565        let section_table = table
566            .entry(section)
567            .or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
568            .as_table_mut()
569            .ok_or_else(|| {
570                ConfigError::Io(std::io::Error::new(
571                    std::io::ErrorKind::InvalidData,
572                    format!("[{}] is not a table", section),
573                ))
574            })?;
575
576        for (key, value) in values {
577            section_table.insert(key.clone(), value.clone());
578        }
579
580        let contents = toml::to_string_pretty(&doc)?;
581        fs::write(&path, contents)?;
582        #[cfg(unix)]
583        {
584            use std::os::unix::fs::PermissionsExt;
585            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
586        }
587        Self::invalidate_cache();
588        Ok(())
589    }
590
591    /// Resolved cache directory — uses explicit setting or defaults to config_dir/cache.
592    pub fn cache_dir(&self) -> PathBuf {
593        self.remote
594            .cache_dir
595            .clone()
596            .unwrap_or_else(|| config_dir().join("cache"))
597    }
598
599    /// Parsed cache limit in bytes, or None if unlimited.
600    pub fn cache_limit_bytes(&self) -> Option<u64> {
601        self.remote
602            .cache_limit
603            .as_deref()
604            .and_then(parse_size_bytes)
605    }
606}
607
608/// Where koan keeps its configuration, library database and cache.
609///
610/// `~/.config/koan/` unless pointed elsewhere. `KOAN_CONFIG_DIR` is the
611/// user-facing way to do that — one machine, more than one library — and
612/// `set_config_dir` is the in-process one, which is what tests need: without
613/// it they read whatever configuration belongs to whoever ran them, right down
614/// to that person's server and their keychain.
615pub fn config_dir() -> PathBuf {
616    if let Some(dir) = CONFIG_DIR.read().clone() {
617        return dir;
618    }
619    if let Some(dir) = std::env::var_os("KOAN_CONFIG_DIR") {
620        return PathBuf::from(dir);
621    }
622    dirs::home_dir()
623        .unwrap_or_else(|| PathBuf::from("."))
624        .join(".config")
625        .join("koan")
626}
627
628/// Point koan's configuration at `dir` for the life of the process.
629///
630/// Takes precedence over `KOAN_CONFIG_DIR`, and drops the cached config, which
631/// was keyed on the mtimes of files in a directory that is no longer the one
632/// being read. Set it before anything spawns: background threads resolve the
633/// directory when they run, not when they are created.
634pub fn set_config_dir(dir: impl Into<PathBuf>) {
635    *CONFIG_DIR.write() = Some(dir.into());
636    Config::invalidate_cache();
637}
638
639/// Point configuration at a directory belonging to this process alone.
640///
641/// Tests call this before anything reads configuration. Without it they read
642/// whatever belongs to whoever ran them — that person's library folders, their
643/// remote server, and a prompt for their keychain — so the same test does
644/// different things on different machines, and passes on CI only because it
645/// finds nothing there at all.
646///
647/// Process-wide rather than per-test on purpose: the threads koan spawns
648/// resolve the directory when they run, which is often after the test that
649/// started them has finished.
650pub fn isolate_config_for_tests() {
651    let dir = std::env::temp_dir().join(format!("koan-test-config-{}", std::process::id()));
652    let _ = fs::create_dir_all(&dir);
653    set_config_dir(dir);
654}
655
656static CONFIG_DIR: LazyLock<parking_lot::RwLock<Option<PathBuf>>> =
657    LazyLock::new(|| parking_lot::RwLock::new(None));
658
659/// Path to the base config TOML file (committable to dotfiles).
660pub fn config_file_path() -> PathBuf {
661    config_dir().join("config.toml")
662}
663
664/// Path to the local override config (gitignored, machine-specific).
665pub fn config_local_file_path() -> PathBuf {
666    config_dir().join("config.local.toml")
667}
668
669/// Path to the database file.
670pub fn db_path() -> PathBuf {
671    config_dir().join("koan.db")
672}
673
674/// Refuse to start when credentials are sitting in version control, which is a
675/// security incident rather than a warning anyone would act on.
676///
677/// Runs once per process. It reads both config files and, when a password is
678/// present, forks `git ls-files` — and `load()` is reached from UI paths that
679/// run per frame. The name says what it is: a gate on starting, not a check
680/// that belongs on every read.
681fn check_secrets_in_git() {
682    static ONCE: Once = Once::new();
683    ONCE.call_once(scan_for_tracked_secrets);
684}
685
686fn scan_for_tracked_secrets() {
687    let sensitive_fields = ["password"];
688
689    for (label, path) in [
690        ("config.toml", config_file_path()),
691        ("config.local.toml", config_local_file_path()),
692    ] {
693        let Ok(contents) = std::fs::read_to_string(&path) else {
694            continue;
695        };
696
697        // Check if this file contains any sensitive fields with non-empty values.
698        let has_secrets = sensitive_fields.iter().any(|field| {
699            contents.lines().any(|line| {
700                let line = line.trim();
701                if let Some(rest) = line.strip_prefix(field) {
702                    let rest = rest.trim_start();
703                    if let Some(value) = rest.strip_prefix('=') {
704                        let value = value.trim().trim_matches('"').trim_matches('\'');
705                        return !value.is_empty();
706                    }
707                }
708                false
709            })
710        });
711
712        if !has_secrets {
713            continue;
714        }
715
716        // Check if this file is tracked by git.
717        if is_tracked_by_git(&path) {
718            eprintln!();
719            eprintln!("╔══════════════════════════════════════════════════════════════╗");
720            eprintln!("║  SECURITY: {label} contains credentials and is tracked by git!  ║");
721            eprintln!("╠══════════════════════════════════════════════════════════════╣");
722            eprintln!("║                                                              ║");
723            eprintln!("║  File: {:<52} ║", path.display());
724            eprintln!("║                                                              ║");
725            eprintln!("║  Your password is in version control. You should:            ║");
726            eprintln!("║  1. Remove the file from git: git rm --cached <file>         ║");
727            eprintln!("║  2. Add it to .gitignore                                     ║");
728            eprintln!("║  3. Rotate your credentials immediately                      ║");
729            eprintln!("║  4. Move secrets to config.local.toml (gitignored)           ║");
730            eprintln!("║     or use `koan remote login` for keyring storage           ║");
731            eprintln!("║                                                              ║");
732            eprintln!("╚══════════════════════════════════════════════════════════════╝");
733            eprintln!();
734            panic!("Refusing to start: credentials tracked by git in {label}. See above.");
735        }
736    }
737}
738
739/// Check if a file is tracked by git (staged or committed, not just in a repo).
740fn is_tracked_by_git(path: &Path) -> bool {
741    let Some(parent) = path.parent() else {
742        return false;
743    };
744    // `git ls-files --error-unmatch <file>` exits 0 if tracked, 1 if not.
745    std::process::Command::new("git")
746        .args(["ls-files", "--error-unmatch"])
747        .arg(path)
748        .current_dir(parent)
749        .stdout(std::process::Stdio::null())
750        .stderr(std::process::Stdio::null())
751        .status()
752        .is_ok_and(|s| s.success())
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758    use std::fs;
759
760    fn tmp_dir() -> PathBuf {
761        let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
762        fs::create_dir_all(&dir).unwrap();
763        dir
764    }
765
766    #[test]
767    fn test_defaults() {
768        let cfg = Config::default();
769        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
770        assert!(!cfg.remote.enabled);
771        assert_eq!(cfg.remote.transcode_quality, "original");
772    }
773
774    #[test]
775    fn test_roundtrip_toml() {
776        let cfg = Config::default();
777        let serialized = toml::to_string_pretty(&cfg).unwrap();
778        let deserialized: Config = toml::from_str(&serialized).unwrap();
779        assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
780        assert_eq!(
781            deserialized.remote.transcode_quality,
782            cfg.remote.transcode_quality
783        );
784    }
785
786    #[test]
787    fn test_load_from_file() {
788        let dir = tempfile::tempdir().unwrap();
789        let path = dir.path().join("config.toml");
790        fs::write(
791            &path,
792            r#"
793[library]
794folders = ["/tmp/music"]
795
796[playback]
797replaygain = "track"
798"#,
799        )
800        .unwrap();
801
802        let cfg = Config::load_from(&path).unwrap();
803        assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
804        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
805        assert!(!cfg.remote.enabled);
806    }
807
808    #[test]
809    fn test_partial_toml_uses_defaults() {
810        let dir = tempfile::tempdir().unwrap();
811        let path = dir.path().join("partial.toml");
812        fs::write(&path, "[playback]\nticker_fps = 12\n").unwrap();
813
814        let cfg = Config::load_from(&path).unwrap();
815        assert_eq!(cfg.playback.ticker_fps, 12);
816        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
817    }
818
819    #[test]
820    fn test_figment_layered_loading() {
821        let dir = tempfile::tempdir().unwrap();
822        let base_path = dir.path().join("config.toml");
823        let local_path = dir.path().join("config.local.toml");
824
825        fs::write(
826            &base_path,
827            r#"
828[remote]
829url = "https://base.example.com"
830"#,
831        )
832        .unwrap();
833        fs::write(
834            &local_path,
835            r#"
836[remote]
837enabled = true
838url = "https://local.example.com"
839username = "admin"
840password = "secret"
841"#,
842        )
843        .unwrap();
844
845        // Build a figment with explicit paths (can't use load() since it reads from ~/.config).
846        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
847            .merge(Toml::file(&base_path))
848            .merge(Toml::file(&local_path))
849            .extract()
850            .unwrap();
851
852        assert!(cfg.remote.enabled);
853        assert_eq!(cfg.remote.url, "https://local.example.com");
854        assert_eq!(cfg.remote.username, "admin");
855        assert_eq!(cfg.remote.password, "secret");
856    }
857
858    #[test]
859    fn test_figment_missing_keys_preserved() {
860        let dir = tempfile::tempdir().unwrap();
861        let base_path = dir.path().join("config.toml");
862        let local_path = dir.path().join("config.local.toml");
863
864        fs::write(
865            &base_path,
866            r#"
867[remote]
868url = "https://keep.me"
869username = "keepuser"
870"#,
871        )
872        .unwrap();
873        fs::write(
874            &local_path,
875            r#"
876[remote]
877password = "secret"
878"#,
879        )
880        .unwrap();
881
882        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
883            .merge(Toml::file(&base_path))
884            .merge(Toml::file(&local_path))
885            .extract()
886            .unwrap();
887
888        assert_eq!(cfg.remote.url, "https://keep.me");
889        assert_eq!(cfg.remote.username, "keepuser");
890        assert_eq!(cfg.remote.password, "secret");
891    }
892
893    #[test]
894    fn test_env_var_override() {
895        let dir = tempfile::tempdir().unwrap();
896        let base_path = dir.path().join("config.toml");
897
898        fs::write(
899            &base_path,
900            r#"
901[remote]
902url = "https://file.example.com"
903"#,
904        )
905        .unwrap();
906
907        // SAFETY: test is single-threaded and vars are cleaned up immediately after.
908        unsafe {
909            std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
910            std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
911            std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
912        }
913
914        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
915            .merge(Toml::file(&base_path))
916            .merge(Env::prefixed("KOAN_").split("__"))
917            .extract()
918            .unwrap();
919
920        assert_eq!(cfg.remote.url, "https://env.example.com");
921        assert_eq!(cfg.remote.password, "env-secret");
922        assert_eq!(cfg.graphql.port, 9999);
923
924        // Clean up env vars.
925        unsafe {
926            std::env::remove_var("KOAN_REMOTE__URL");
927            std::env::remove_var("KOAN_REMOTE__PASSWORD");
928            std::env::remove_var("KOAN_GRAPHQL__PORT");
929        }
930    }
931
932    #[test]
933    fn test_update_base_does_not_leak_secrets() {
934        let dir = tempfile::tempdir().unwrap();
935        let base_path = dir.path().join("config.toml");
936
937        // Write an initial base config.
938        fs::write(
939            &base_path,
940            r#"
941[playback]
942target_fps = 60
943
944[remote]
945url = "https://base.example.com"
946"#,
947        )
948        .unwrap();
949
950        // Simulate: update_base patches base config only.
951        let mut base_cfg = Config::load_from(&base_path).unwrap();
952        base_cfg.visualizer.enabled = false;
953        base_cfg.write_to(&base_path).unwrap();
954
955        // Verify: no password leaked into config.toml.
956        let written = fs::read_to_string(&base_path).unwrap();
957        assert!(!written.contains("secret"));
958        assert!(!written.contains("password"));
959
960        // Verify the field was saved.
961        let reloaded = Config::load_from(&base_path).unwrap();
962        assert!(!reloaded.visualizer.enabled);
963        assert_eq!(reloaded.remote.url, "https://base.example.com");
964    }
965
966    #[test]
967    fn test_subsonic_defaults_off_and_keeps_secret_out_of_base_config() {
968        let cfg = Config::default();
969        assert!(!cfg.subsonic.enabled);
970        assert!(cfg.subsonic.password.is_empty());
971
972        // Serialising a config never invents a `password` key, so a base
973        // config.toml rewritten by any other setting cannot acquire one.
974        let dir = tempfile::tempdir().unwrap();
975        let base_path = dir.path().join("config.toml");
976        cfg.write_to(&base_path).unwrap();
977        let written = fs::read_to_string(&base_path).unwrap();
978        assert!(written.contains("[subsonic]"));
979        assert!(!written.contains("password"));
980    }
981
982    #[test]
983    fn test_cache_dir_default() {
984        let cfg = Config::default();
985        assert!(cfg.cache_dir().ends_with("cache"));
986    }
987
988    #[test]
989    fn test_cache_dir_explicit() {
990        let mut cfg = Config::default();
991        cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
992        assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
993    }
994
995    #[test]
996    fn test_organize_config_defaults() {
997        let cfg = Config::default();
998        assert!(cfg.organize.default.is_none());
999        assert!(cfg.organize.patterns.is_empty());
1000    }
1001
1002    #[test]
1003    fn test_organize_config_from_toml() {
1004        let dir = tmp_dir();
1005        let path = dir.join("organize.toml");
1006        fs::write(
1007            &path,
1008            r#"
1009[organize]
1010default = "standard"
1011
1012[organize.patterns]
1013standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
1014va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
1015"#,
1016        )
1017        .unwrap();
1018
1019        let cfg = Config::load_from(&path).unwrap();
1020        assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
1021        assert_eq!(cfg.organize.patterns.len(), 2);
1022        assert!(cfg.organize.patterns.contains_key("standard"));
1023        assert!(cfg.organize.patterns.contains_key("va-aware"));
1024
1025        fs::remove_dir_all(&dir).ok();
1026    }
1027
1028    #[test]
1029    fn test_organize_resolve_named_pattern() {
1030        let mut cfg = OrganizeConfig::default();
1031        cfg.patterns
1032            .insert("standard".into(), "%artist%/%title%".into());
1033
1034        assert_eq!(cfg.resolve_pattern("standard"), "%artist%/%title%");
1035        // Unknown name falls through as raw pattern
1036        assert_eq!(cfg.resolve_pattern("%raw%pattern%"), "%raw%pattern%");
1037    }
1038
1039    #[test]
1040    fn test_organize_default_pattern() {
1041        let mut cfg = OrganizeConfig {
1042            default: Some("standard".into()),
1043            ..OrganizeConfig::default()
1044        };
1045        cfg.patterns
1046            .insert("standard".into(), "%artist%/%title%".into());
1047
1048        assert_eq!(cfg.default_pattern(), Some("%artist%/%title%"));
1049    }
1050
1051    #[test]
1052    fn test_organize_default_pattern_missing_name() {
1053        let cfg = OrganizeConfig {
1054            default: Some("nonexistent".into()),
1055            ..OrganizeConfig::default()
1056        };
1057        // Name doesn't match any pattern → None
1058        assert_eq!(cfg.default_pattern(), None);
1059    }
1060
1061    #[test]
1062    fn test_figment_organize_patterns_merge() {
1063        let dir = tempfile::tempdir().unwrap();
1064        let base_path = dir.path().join("config.toml");
1065        let local_path = dir.path().join("config.local.toml");
1066
1067        fs::write(
1068            &base_path,
1069            r#"
1070[organize]
1071default = "standard"
1072
1073[organize.patterns]
1074standard = "base-pattern"
1075"#,
1076        )
1077        .unwrap();
1078        fs::write(
1079            &local_path,
1080            r#"
1081[organize]
1082default = "custom"
1083
1084[organize.patterns]
1085custom = "local-pattern"
1086"#,
1087        )
1088        .unwrap();
1089
1090        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
1091            .merge(Toml::file(&base_path))
1092            .merge(Toml::file(&local_path))
1093            .extract()
1094            .unwrap();
1095
1096        // Local default wins.
1097        assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
1098        // Both patterns present (figment merges maps).
1099        assert_eq!(cfg.organize.patterns.len(), 2);
1100        assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
1101        assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
1102    }
1103
1104    #[test]
1105    fn test_output_device_config_roundtrip() {
1106        let mut cfg = Config::default();
1107        cfg.playback.output_device = Some("My DAC".into());
1108
1109        let serialized = toml::to_string_pretty(&cfg).unwrap();
1110        let deserialized: Config = toml::from_str(&serialized).unwrap();
1111        assert_eq!(
1112            deserialized.playback.output_device.as_deref(),
1113            Some("My DAC")
1114        );
1115    }
1116
1117    #[test]
1118    fn test_output_device_config_default_is_none() {
1119        let cfg = Config::default();
1120        assert!(cfg.playback.output_device.is_none());
1121
1122        // Roundtrip: None should not appear in serialized output.
1123        let serialized = toml::to_string_pretty(&cfg).unwrap();
1124        assert!(!serialized.contains("output_device"));
1125        let deserialized: Config = toml::from_str(&serialized).unwrap();
1126        assert!(deserialized.playback.output_device.is_none());
1127    }
1128
1129    #[test]
1130    fn test_output_device_config_from_toml() {
1131        let dir = tempfile::tempdir().unwrap();
1132        let path = dir.path().join("config.toml");
1133        fs::write(
1134            &path,
1135            r#"
1136[playback]
1137output_device = "External Speakers"
1138"#,
1139        )
1140        .unwrap();
1141
1142        let cfg = Config::load_from(&path).unwrap();
1143        assert_eq!(
1144            cfg.playback.output_device.as_deref(),
1145            Some("External Speakers")
1146        );
1147    }
1148
1149    #[test]
1150    fn test_graphql_bind_defaults_to_localhost() {
1151        let cfg = GraphqlConfig::default();
1152        assert_eq!(
1153            cfg.bind,
1154            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1155        );
1156    }
1157
1158    #[test]
1159    fn test_graphql_bind_from_toml() {
1160        let toml_str = r#"
1161[graphql]
1162bind = "0.0.0.0"
1163port = 5000
1164"#;
1165        let cfg: Config = toml::from_str(toml_str).unwrap();
1166        assert_eq!(
1167            cfg.graphql.bind,
1168            std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
1169        );
1170        assert_eq!(cfg.graphql.port, 5000);
1171    }
1172
1173    #[test]
1174    fn test_graphql_bind_omitted_defaults_to_localhost() {
1175        let toml_str = r#"
1176[graphql]
1177port = 4000
1178"#;
1179        let cfg: Config = toml::from_str(toml_str).unwrap();
1180        assert_eq!(
1181            cfg.graphql.bind,
1182            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1183        );
1184    }
1185
1186    #[test]
1187    fn test_organize_config_roundtrip() {
1188        let mut cfg = Config::default();
1189        cfg.organize.default = Some("standard".into());
1190        cfg.organize
1191            .patterns
1192            .insert("standard".into(), "%artist%/%title%".into());
1193
1194        let serialized = toml::to_string_pretty(&cfg).unwrap();
1195        let deserialized: Config = toml::from_str(&serialized).unwrap();
1196        assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
1197        assert_eq!(
1198            deserialized.organize.patterns["standard"],
1199            "%artist%/%title%"
1200        );
1201    }
1202
1203    #[test]
1204    fn test_parse_size_bytes() {
1205        assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1206        assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1207        assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1208        assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1209        assert_eq!(parse_size_bytes("1024B"), Some(1024));
1210        assert_eq!(parse_size_bytes("1024"), Some(1024));
1211
1212        // Case insensitive.
1213        assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1214        assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1215
1216        // Short suffixes.
1217        assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1218        assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1219
1220        // Spaces.
1221        assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1222        assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1223
1224        // Decimal.
1225        assert_eq!(
1226            parse_size_bytes("1.5GB"),
1227            Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1228        );
1229
1230        // Invalid.
1231        assert_eq!(parse_size_bytes(""), None);
1232        assert_eq!(parse_size_bytes("abc"), None);
1233        assert_eq!(parse_size_bytes("50XB"), None);
1234    }
1235
1236    #[test]
1237    fn test_cache_limit_config_from_toml() {
1238        let toml_str = r#"
1239[remote]
1240cache_limit = "50GB"
1241"#;
1242        let cfg: Config = toml::from_str(toml_str).unwrap();
1243        assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1244        assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1245    }
1246
1247    #[test]
1248    fn test_cache_limit_none_by_default() {
1249        let cfg = Config::default();
1250        assert!(cfg.remote.cache_limit.is_none());
1251        assert!(cfg.cache_limit_bytes().is_none());
1252    }
1253
1254    #[test]
1255    fn test_cache_limit_not_serialized_when_none() {
1256        let cfg = Config::default();
1257        let serialized = toml::to_string_pretty(&cfg).unwrap();
1258        assert!(!serialized.contains("cache_limit"));
1259    }
1260
1261    #[test]
1262    fn player_uses_config_on_init() {
1263        // Verify that Config::load_from correctly picks up playback settings
1264        // that Player::new() would consume. This tests the contract between
1265        // config and player initialization without requiring audio hardware.
1266        let dir = tempfile::tempdir().unwrap();
1267        let path = dir.path().join("config.toml");
1268        fs::write(
1269            &path,
1270            r#"
1271[playback]
1272replaygain = "track"
1273output_device = "My Fancy DAC"
1274pre_amp_db = -3.5
1275target_fps = 30
1276art_size = 32
1277
1278[visualizer]
1279enabled = false
1280mode = "oscilloscope"
1281fps = 30
1282"#,
1283        )
1284        .unwrap();
1285
1286        let cfg = Config::load_from(&path).unwrap();
1287
1288        // These are the fields Player::new() reads from config.
1289        assert_eq!(
1290            cfg.playback.replaygain,
1291            ReplayGainMode::Track,
1292            "replaygain should be 'track'"
1293        );
1294        assert_eq!(
1295            cfg.playback.output_device.as_deref(),
1296            Some("My Fancy DAC"),
1297            "output_device should match config"
1298        );
1299        assert!(
1300            (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1301            "pre_amp_db should be -3.5"
1302        );
1303        assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1304        assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1305
1306        // Visualizer config is also consumed at player init.
1307        assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1308        assert_eq!(cfg.visualizer.mode, "oscilloscope");
1309        assert_eq!(cfg.visualizer.fps, 30);
1310    }
1311
1312    #[test]
1313    fn a_missing_config_file_stamps_as_absent() {
1314        let dir = tempfile::tempdir().unwrap();
1315        let base = dir.path().join("config.toml");
1316        let local = dir.path().join("config.local.toml");
1317
1318        assert_eq!(stamp_of(&base, &local), (None, None));
1319
1320        fs::write(&base, "[remote]\nurl = \"https://example.com\"\n").unwrap();
1321        let (base_stamp, local_stamp) = stamp_of(&base, &local);
1322        assert!(base_stamp.is_some(), "creating the file must be a change");
1323        assert!(local_stamp.is_none());
1324    }
1325
1326    #[test]
1327    fn editing_a_config_file_changes_its_stamp() {
1328        let dir = tempfile::tempdir().unwrap();
1329        let base = dir.path().join("config.toml");
1330        let local = dir.path().join("config.local.toml");
1331        fs::write(&base, "[playback]\ntarget_fps = 60\n").unwrap();
1332
1333        let before = stamp_of(&base, &local);
1334        // Coarse-grained filesystems would otherwise stamp both writes alike.
1335        std::thread::sleep(std::time::Duration::from_millis(20));
1336        fs::write(&base, "[playback]\ntarget_fps = 30\n").unwrap();
1337
1338        assert_ne!(
1339            before,
1340            stamp_of(&base, &local),
1341            "a config edited by hand has to be picked up"
1342        );
1343    }
1344
1345    #[test]
1346    fn invalidating_forces_a_reload() {
1347        let first = Config::cached();
1348        Config::invalidate_cache();
1349        assert!(
1350            !Arc::ptr_eq(&first, &Config::cached()),
1351            "koan's own writes invalidate explicitly; the next read must re-parse"
1352        );
1353    }
1354}