Skip to main content

koan_core/
config.rs

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