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