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    /// Require authentication for API access (default: false for backward compat).
273    /// When false, all requests are treated as admin. When true, JWT auth is enforced.
274    pub auth_enabled: bool,
275    /// Access token TTL (default: "15m"). Supports: "15m", "1h", "3600s".
276    pub access_token_ttl: String,
277    /// Refresh token TTL (default: "30d"). Supports: "30d", "7d", "720h".
278    pub refresh_token_ttl: String,
279}
280
281fn default_bind() -> std::net::IpAddr {
282    std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
283}
284
285impl Default for GraphqlConfig {
286    fn default() -> Self {
287        Self {
288            enabled: true,
289            port: 4000,
290            bind: default_bind(),
291            playground: false,
292            subsonic_port: None,
293            auth_enabled: true,
294            access_token_ttl: "15m".into(),
295            refresh_token_ttl: "30d".into(),
296        }
297    }
298}
299
300/// Radio / infinite play mode configuration.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(default)]
303pub struct RadioConfig {
304    /// Number of tracks to keep queued ahead of the cursor.
305    pub lookahead: usize,
306    /// Number of tracks to add each time the queue runs low.
307    pub batch_size: usize,
308    /// Use Subsonic getSimilarSongs2 when a remote server is configured.
309    pub use_subsonic: bool,
310    /// Don't repeat any of the last N tracks (play history exclusion window).
311    pub history_window: usize,
312    /// Number of recently played tracks to use as seed (drifting seed window).
313    pub seed_window: usize,
314    /// Discovery weight: 0.0 = only familiar tracks, 1.0 = maximise discovery.
315    /// Controls the recency bonus — higher values boost never-played/long-forgotten tracks.
316    pub discovery_weight: f64,
317}
318
319impl Default for RadioConfig {
320    fn default() -> Self {
321        Self {
322            lookahead: 5,
323            batch_size: 5,
324            use_subsonic: true,
325            history_window: 200,
326            seed_window: 5,
327            discovery_weight: 0.3,
328        }
329    }
330}
331
332/// Acoustic analysis / discovery configuration.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334#[serde(default)]
335pub struct DiscoveryConfig {
336    /// Run acoustic analysis automatically after library scan (default: false).
337    pub analysis_on_scan: bool,
338    /// Weight for acoustic similarity signal in radio mode scoring (0.0..1.0).
339    pub acoustic_weight: f64,
340}
341
342impl Default for DiscoveryConfig {
343    fn default() -> Self {
344        Self {
345            analysis_on_scan: false,
346            acoustic_weight: 0.5,
347        }
348    }
349}
350
351impl Config {
352    /// Build the figment provider chain:
353    /// defaults → config.toml → config.local.toml → KOAN_* env vars.
354    ///
355    /// Env vars use `KOAN_` prefix with `__` as section separator:
356    ///   KOAN_REMOTE__PASSWORD, KOAN_GRAPHQL__PORT, KOAN_PLAYBACK__TARGET_FPS, etc.
357    fn figment() -> Figment {
358        let base_path = config_file_path();
359        let local_path = config_local_file_path();
360
361        Figment::from(Serialized::defaults(Config::default()))
362            .merge(Toml::file(&base_path))
363            .merge(Toml::file(&local_path))
364            .merge(Env::prefixed("KOAN_").split("__"))
365    }
366
367    /// Load config from all layers: defaults → config.toml → config.local.toml → KOAN_* env vars.
368    pub fn load() -> Result<Self, ConfigError> {
369        let cfg: Self = Self::figment()
370            .extract()
371            .map_err(|e| ConfigError::Figment(Box::new(e)))?;
372
373        // Security: warn if config files containing secrets are tracked by git.
374        check_secrets_in_git();
375
376        Ok(cfg)
377    }
378
379    /// Load config, logging and falling back to defaults on error.
380    pub fn load_or_default() -> Self {
381        Self::load().unwrap_or_else(|e| {
382            log::warn!("failed to load config, using defaults: {}", e);
383            Self::default()
384        })
385    }
386
387    /// Load from a specific TOML file (no env var overlay).
388    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
389        let contents = fs::read_to_string(path)?;
390        let config: Config = toml::from_str(&contents)?;
391        Ok(config)
392    }
393
394    /// Patch config.toml with a mutation closure. Reads the base file only (not
395    /// config.local.toml or env vars), applies the closure, writes back.
396    /// This prevents secrets from config.local.toml or env vars leaking into config.toml.
397    pub fn update_base<F>(mutate: F) -> Result<(), ConfigError>
398    where
399        F: FnOnce(&mut Config),
400    {
401        let path = config_file_path();
402        let mut cfg = if path.exists() {
403            Config::load_from(&path)?
404        } else {
405            Config::default()
406        };
407        mutate(&mut cfg);
408        cfg.write_to(&path)?;
409        Ok(())
410    }
411
412    /// Write this config to a specific path as TOML.
413    fn write_to(&self, path: &Path) -> Result<(), ConfigError> {
414        if let Some(parent) = path.parent() {
415            fs::create_dir_all(parent)?;
416        }
417        let contents = toml::to_string_pretty(self)?;
418        fs::write(path, contents)?;
419        Ok(())
420    }
421
422    /// Patch a single section in config.local.toml, preserving all other content.
423    /// Creates the file if it doesn't exist. Sets 0o600 permissions on Unix.
424    pub fn patch_local(
425        section: &str,
426        values: &toml::map::Map<String, toml::Value>,
427    ) -> Result<(), ConfigError> {
428        let path = config_local_file_path();
429        let mut doc: toml::Value = if path.exists() {
430            let contents = fs::read_to_string(&path)?;
431            toml::from_str(&contents).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new()))
432        } else {
433            toml::Value::Table(toml::map::Map::new())
434        };
435
436        let table = doc.as_table_mut().expect("root is always a table");
437        let section_table = table
438            .entry(section)
439            .or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
440            .as_table_mut()
441            .ok_or_else(|| {
442                ConfigError::Io(std::io::Error::new(
443                    std::io::ErrorKind::InvalidData,
444                    format!("[{}] is not a table", section),
445                ))
446            })?;
447
448        for (key, value) in values {
449            section_table.insert(key.clone(), value.clone());
450        }
451
452        let contents = toml::to_string_pretty(&doc)?;
453        fs::write(&path, contents)?;
454        #[cfg(unix)]
455        {
456            use std::os::unix::fs::PermissionsExt;
457            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
458        }
459        Ok(())
460    }
461
462    /// Resolved cache directory — uses explicit setting or defaults to config_dir/cache.
463    pub fn cache_dir(&self) -> PathBuf {
464        self.remote
465            .cache_dir
466            .clone()
467            .unwrap_or_else(|| config_dir().join("cache"))
468    }
469
470    /// Parsed cache limit in bytes, or None if unlimited.
471    pub fn cache_limit_bytes(&self) -> Option<u64> {
472        self.remote
473            .cache_limit
474            .as_deref()
475            .and_then(parse_size_bytes)
476    }
477}
478
479/// `~/.config/koan/`
480pub fn config_dir() -> PathBuf {
481    dirs::home_dir()
482        .unwrap_or_else(|| PathBuf::from("."))
483        .join(".config")
484        .join("koan")
485}
486
487/// Path to the base config TOML file (committable to dotfiles).
488pub fn config_file_path() -> PathBuf {
489    config_dir().join("config.toml")
490}
491
492/// Path to the local override config (gitignored, machine-specific).
493pub fn config_local_file_path() -> PathBuf {
494    config_dir().join("config.local.toml")
495}
496
497/// Path to the database file.
498pub fn db_path() -> PathBuf {
499    config_dir().join("koan.db")
500}
501
502/// Check if any config file containing secrets (password fields) is tracked by git.
503/// Prints a loud warning to stderr and panics if so — credentials in version control
504/// is a security incident, not a warning you can ignore.
505fn check_secrets_in_git() {
506    let sensitive_fields = ["password"];
507
508    for (label, path) in [
509        ("config.toml", config_file_path()),
510        ("config.local.toml", config_local_file_path()),
511    ] {
512        let Ok(contents) = std::fs::read_to_string(&path) else {
513            continue;
514        };
515
516        // Check if this file contains any sensitive fields with non-empty values.
517        let has_secrets = sensitive_fields.iter().any(|field| {
518            contents.lines().any(|line| {
519                let line = line.trim();
520                if let Some(rest) = line.strip_prefix(field) {
521                    let rest = rest.trim_start();
522                    if let Some(value) = rest.strip_prefix('=') {
523                        let value = value.trim().trim_matches('"').trim_matches('\'');
524                        return !value.is_empty();
525                    }
526                }
527                false
528            })
529        });
530
531        if !has_secrets {
532            continue;
533        }
534
535        // Check if this file is tracked by git.
536        if is_tracked_by_git(&path) {
537            eprintln!();
538            eprintln!("╔══════════════════════════════════════════════════════════════╗");
539            eprintln!("║  SECURITY: {label} contains credentials and is tracked by git!  ║");
540            eprintln!("╠══════════════════════════════════════════════════════════════╣");
541            eprintln!("║                                                              ║");
542            eprintln!("║  File: {:<52} ║", path.display());
543            eprintln!("║                                                              ║");
544            eprintln!("║  Your password is in version control. You should:            ║");
545            eprintln!("║  1. Remove the file from git: git rm --cached <file>         ║");
546            eprintln!("║  2. Add it to .gitignore                                     ║");
547            eprintln!("║  3. Rotate your credentials immediately                      ║");
548            eprintln!("║  4. Move secrets to config.local.toml (gitignored)           ║");
549            eprintln!("║     or use `koan remote login` for keyring storage           ║");
550            eprintln!("║                                                              ║");
551            eprintln!("╚══════════════════════════════════════════════════════════════╝");
552            eprintln!();
553            panic!("Refusing to start: credentials tracked by git in {label}. See above.");
554        }
555    }
556}
557
558/// Check if a file is tracked by git (staged or committed, not just in a repo).
559fn is_tracked_by_git(path: &Path) -> bool {
560    let Some(parent) = path.parent() else {
561        return false;
562    };
563    // `git ls-files --error-unmatch <file>` exits 0 if tracked, 1 if not.
564    std::process::Command::new("git")
565        .args(["ls-files", "--error-unmatch"])
566        .arg(path)
567        .current_dir(parent)
568        .stdout(std::process::Stdio::null())
569        .stderr(std::process::Stdio::null())
570        .status()
571        .is_ok_and(|s| s.success())
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use std::fs;
578
579    fn tmp_dir() -> PathBuf {
580        let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
581        fs::create_dir_all(&dir).unwrap();
582        dir
583    }
584
585    #[test]
586    fn test_defaults() {
587        let cfg = Config::default();
588        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
589        assert!(!cfg.remote.enabled);
590        assert_eq!(cfg.remote.transcode_quality, "original");
591    }
592
593    #[test]
594    fn test_roundtrip_toml() {
595        let cfg = Config::default();
596        let serialized = toml::to_string_pretty(&cfg).unwrap();
597        let deserialized: Config = toml::from_str(&serialized).unwrap();
598        assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
599        assert_eq!(
600            deserialized.remote.transcode_quality,
601            cfg.remote.transcode_quality
602        );
603    }
604
605    #[test]
606    fn test_load_from_file() {
607        let dir = tempfile::tempdir().unwrap();
608        let path = dir.path().join("config.toml");
609        fs::write(
610            &path,
611            r#"
612[library]
613folders = ["/tmp/music"]
614
615[playback]
616replaygain = "track"
617"#,
618        )
619        .unwrap();
620
621        let cfg = Config::load_from(&path).unwrap();
622        assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
623        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
624        assert!(!cfg.remote.enabled);
625    }
626
627    #[test]
628    fn test_partial_toml_uses_defaults() {
629        let dir = tempfile::tempdir().unwrap();
630        let path = dir.path().join("partial.toml");
631        fs::write(&path, "[playback]\nsoftware_volume = true\n").unwrap();
632
633        let cfg = Config::load_from(&path).unwrap();
634        assert!(cfg.playback.software_volume);
635    }
636
637    #[test]
638    fn test_figment_layered_loading() {
639        let dir = tempfile::tempdir().unwrap();
640        let base_path = dir.path().join("config.toml");
641        let local_path = dir.path().join("config.local.toml");
642
643        fs::write(
644            &base_path,
645            r#"
646[remote]
647url = "https://base.example.com"
648"#,
649        )
650        .unwrap();
651        fs::write(
652            &local_path,
653            r#"
654[remote]
655enabled = true
656url = "https://local.example.com"
657username = "admin"
658password = "secret"
659"#,
660        )
661        .unwrap();
662
663        // Build a figment with explicit paths (can't use load() since it reads from ~/.config).
664        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
665            .merge(Toml::file(&base_path))
666            .merge(Toml::file(&local_path))
667            .extract()
668            .unwrap();
669
670        assert!(cfg.remote.enabled);
671        assert_eq!(cfg.remote.url, "https://local.example.com");
672        assert_eq!(cfg.remote.username, "admin");
673        assert_eq!(cfg.remote.password, "secret");
674    }
675
676    #[test]
677    fn test_figment_missing_keys_preserved() {
678        let dir = tempfile::tempdir().unwrap();
679        let base_path = dir.path().join("config.toml");
680        let local_path = dir.path().join("config.local.toml");
681
682        fs::write(
683            &base_path,
684            r#"
685[remote]
686url = "https://keep.me"
687username = "keepuser"
688"#,
689        )
690        .unwrap();
691        fs::write(
692            &local_path,
693            r#"
694[remote]
695password = "secret"
696"#,
697        )
698        .unwrap();
699
700        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
701            .merge(Toml::file(&base_path))
702            .merge(Toml::file(&local_path))
703            .extract()
704            .unwrap();
705
706        assert_eq!(cfg.remote.url, "https://keep.me");
707        assert_eq!(cfg.remote.username, "keepuser");
708        assert_eq!(cfg.remote.password, "secret");
709    }
710
711    #[test]
712    fn test_env_var_override() {
713        let dir = tempfile::tempdir().unwrap();
714        let base_path = dir.path().join("config.toml");
715
716        fs::write(
717            &base_path,
718            r#"
719[remote]
720url = "https://file.example.com"
721"#,
722        )
723        .unwrap();
724
725        // SAFETY: test is single-threaded and vars are cleaned up immediately after.
726        unsafe {
727            std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
728            std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
729            std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
730        }
731
732        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
733            .merge(Toml::file(&base_path))
734            .merge(Env::prefixed("KOAN_").split("__"))
735            .extract()
736            .unwrap();
737
738        assert_eq!(cfg.remote.url, "https://env.example.com");
739        assert_eq!(cfg.remote.password, "env-secret");
740        assert_eq!(cfg.graphql.port, 9999);
741
742        // Clean up env vars.
743        unsafe {
744            std::env::remove_var("KOAN_REMOTE__URL");
745            std::env::remove_var("KOAN_REMOTE__PASSWORD");
746            std::env::remove_var("KOAN_GRAPHQL__PORT");
747        }
748    }
749
750    #[test]
751    fn test_update_base_does_not_leak_secrets() {
752        let dir = tempfile::tempdir().unwrap();
753        let base_path = dir.path().join("config.toml");
754
755        // Write an initial base config.
756        fs::write(
757            &base_path,
758            r#"
759[playback]
760target_fps = 60
761
762[remote]
763url = "https://base.example.com"
764"#,
765        )
766        .unwrap();
767
768        // Simulate: update_base patches base config only.
769        let mut base_cfg = Config::load_from(&base_path).unwrap();
770        base_cfg.visualizer.enabled = false;
771        base_cfg.write_to(&base_path).unwrap();
772
773        // Verify: no password leaked into config.toml.
774        let written = fs::read_to_string(&base_path).unwrap();
775        assert!(!written.contains("secret"));
776        assert!(!written.contains("password"));
777
778        // Verify the field was saved.
779        let reloaded = Config::load_from(&base_path).unwrap();
780        assert!(!reloaded.visualizer.enabled);
781        assert_eq!(reloaded.remote.url, "https://base.example.com");
782    }
783
784    #[test]
785    fn test_cache_dir_default() {
786        let cfg = Config::default();
787        assert!(cfg.cache_dir().ends_with("cache"));
788    }
789
790    #[test]
791    fn test_cache_dir_explicit() {
792        let mut cfg = Config::default();
793        cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
794        assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
795    }
796
797    #[test]
798    fn test_organize_config_defaults() {
799        let cfg = Config::default();
800        assert!(cfg.organize.default.is_none());
801        assert!(cfg.organize.patterns.is_empty());
802    }
803
804    #[test]
805    fn test_organize_config_from_toml() {
806        let dir = tmp_dir();
807        let path = dir.join("organize.toml");
808        fs::write(
809            &path,
810            r#"
811[organize]
812default = "standard"
813
814[organize.patterns]
815standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
816va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
817"#,
818        )
819        .unwrap();
820
821        let cfg = Config::load_from(&path).unwrap();
822        assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
823        assert_eq!(cfg.organize.patterns.len(), 2);
824        assert!(cfg.organize.patterns.contains_key("standard"));
825        assert!(cfg.organize.patterns.contains_key("va-aware"));
826
827        fs::remove_dir_all(&dir).ok();
828    }
829
830    #[test]
831    fn test_organize_resolve_named_pattern() {
832        let mut cfg = OrganizeConfig::default();
833        cfg.patterns
834            .insert("standard".into(), "%artist%/%title%".into());
835
836        assert_eq!(cfg.resolve_pattern("standard"), "%artist%/%title%");
837        // Unknown name falls through as raw pattern
838        assert_eq!(cfg.resolve_pattern("%raw%pattern%"), "%raw%pattern%");
839    }
840
841    #[test]
842    fn test_organize_default_pattern() {
843        let mut cfg = OrganizeConfig {
844            default: Some("standard".into()),
845            ..OrganizeConfig::default()
846        };
847        cfg.patterns
848            .insert("standard".into(), "%artist%/%title%".into());
849
850        assert_eq!(cfg.default_pattern(), Some("%artist%/%title%"));
851    }
852
853    #[test]
854    fn test_organize_default_pattern_missing_name() {
855        let cfg = OrganizeConfig {
856            default: Some("nonexistent".into()),
857            ..OrganizeConfig::default()
858        };
859        // Name doesn't match any pattern → None
860        assert_eq!(cfg.default_pattern(), None);
861    }
862
863    #[test]
864    fn test_figment_organize_patterns_merge() {
865        let dir = tempfile::tempdir().unwrap();
866        let base_path = dir.path().join("config.toml");
867        let local_path = dir.path().join("config.local.toml");
868
869        fs::write(
870            &base_path,
871            r#"
872[organize]
873default = "standard"
874
875[organize.patterns]
876standard = "base-pattern"
877"#,
878        )
879        .unwrap();
880        fs::write(
881            &local_path,
882            r#"
883[organize]
884default = "custom"
885
886[organize.patterns]
887custom = "local-pattern"
888"#,
889        )
890        .unwrap();
891
892        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
893            .merge(Toml::file(&base_path))
894            .merge(Toml::file(&local_path))
895            .extract()
896            .unwrap();
897
898        // Local default wins.
899        assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
900        // Both patterns present (figment merges maps).
901        assert_eq!(cfg.organize.patterns.len(), 2);
902        assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
903        assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
904    }
905
906    #[test]
907    fn test_output_device_config_roundtrip() {
908        let mut cfg = Config::default();
909        cfg.playback.output_device = Some("My DAC".into());
910
911        let serialized = toml::to_string_pretty(&cfg).unwrap();
912        let deserialized: Config = toml::from_str(&serialized).unwrap();
913        assert_eq!(
914            deserialized.playback.output_device.as_deref(),
915            Some("My DAC")
916        );
917    }
918
919    #[test]
920    fn test_output_device_config_default_is_none() {
921        let cfg = Config::default();
922        assert!(cfg.playback.output_device.is_none());
923
924        // Roundtrip: None should not appear in serialized output.
925        let serialized = toml::to_string_pretty(&cfg).unwrap();
926        assert!(!serialized.contains("output_device"));
927        let deserialized: Config = toml::from_str(&serialized).unwrap();
928        assert!(deserialized.playback.output_device.is_none());
929    }
930
931    #[test]
932    fn test_output_device_config_from_toml() {
933        let dir = tempfile::tempdir().unwrap();
934        let path = dir.path().join("config.toml");
935        fs::write(
936            &path,
937            r#"
938[playback]
939output_device = "External Speakers"
940"#,
941        )
942        .unwrap();
943
944        let cfg = Config::load_from(&path).unwrap();
945        assert_eq!(
946            cfg.playback.output_device.as_deref(),
947            Some("External Speakers")
948        );
949    }
950
951    #[test]
952    fn test_graphql_bind_defaults_to_localhost() {
953        let cfg = GraphqlConfig::default();
954        assert_eq!(
955            cfg.bind,
956            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
957        );
958    }
959
960    #[test]
961    fn test_graphql_bind_from_toml() {
962        let toml_str = r#"
963[graphql]
964bind = "0.0.0.0"
965port = 5000
966"#;
967        let cfg: Config = toml::from_str(toml_str).unwrap();
968        assert_eq!(
969            cfg.graphql.bind,
970            std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
971        );
972        assert_eq!(cfg.graphql.port, 5000);
973    }
974
975    #[test]
976    fn test_graphql_bind_omitted_defaults_to_localhost() {
977        let toml_str = r#"
978[graphql]
979port = 4000
980"#;
981        let cfg: Config = toml::from_str(toml_str).unwrap();
982        assert_eq!(
983            cfg.graphql.bind,
984            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
985        );
986    }
987
988    #[test]
989    fn test_organize_config_roundtrip() {
990        let mut cfg = Config::default();
991        cfg.organize.default = Some("standard".into());
992        cfg.organize
993            .patterns
994            .insert("standard".into(), "%artist%/%title%".into());
995
996        let serialized = toml::to_string_pretty(&cfg).unwrap();
997        let deserialized: Config = toml::from_str(&serialized).unwrap();
998        assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
999        assert_eq!(
1000            deserialized.organize.patterns["standard"],
1001            "%artist%/%title%"
1002        );
1003    }
1004
1005    #[test]
1006    fn test_parse_size_bytes() {
1007        assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
1008        assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
1009        assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
1010        assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
1011        assert_eq!(parse_size_bytes("1024B"), Some(1024));
1012        assert_eq!(parse_size_bytes("1024"), Some(1024));
1013
1014        // Case insensitive.
1015        assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
1016        assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
1017
1018        // Short suffixes.
1019        assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
1020        assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
1021
1022        // Spaces.
1023        assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
1024        assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
1025
1026        // Decimal.
1027        assert_eq!(
1028            parse_size_bytes("1.5GB"),
1029            Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
1030        );
1031
1032        // Invalid.
1033        assert_eq!(parse_size_bytes(""), None);
1034        assert_eq!(parse_size_bytes("abc"), None);
1035        assert_eq!(parse_size_bytes("50XB"), None);
1036    }
1037
1038    #[test]
1039    fn test_cache_limit_config_from_toml() {
1040        let toml_str = r#"
1041[remote]
1042cache_limit = "50GB"
1043"#;
1044        let cfg: Config = toml::from_str(toml_str).unwrap();
1045        assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
1046        assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
1047    }
1048
1049    #[test]
1050    fn test_cache_limit_none_by_default() {
1051        let cfg = Config::default();
1052        assert!(cfg.remote.cache_limit.is_none());
1053        assert!(cfg.cache_limit_bytes().is_none());
1054    }
1055
1056    #[test]
1057    fn test_cache_limit_not_serialized_when_none() {
1058        let cfg = Config::default();
1059        let serialized = toml::to_string_pretty(&cfg).unwrap();
1060        assert!(!serialized.contains("cache_limit"));
1061    }
1062
1063    #[test]
1064    fn player_uses_config_on_init() {
1065        // Verify that Config::load_from correctly picks up playback settings
1066        // that Player::new() would consume. This tests the contract between
1067        // config and player initialization without requiring audio hardware.
1068        let dir = tempfile::tempdir().unwrap();
1069        let path = dir.path().join("config.toml");
1070        fs::write(
1071            &path,
1072            r#"
1073[playback]
1074replaygain = "track"
1075output_device = "My Fancy DAC"
1076pre_amp_db = -3.5
1077software_volume = true
1078target_fps = 30
1079art_size = 32
1080
1081[visualizer]
1082enabled = false
1083mode = "oscilloscope"
1084fps = 30
1085"#,
1086        )
1087        .unwrap();
1088
1089        let cfg = Config::load_from(&path).unwrap();
1090
1091        // These are the fields Player::new() reads from config.
1092        assert_eq!(
1093            cfg.playback.replaygain,
1094            ReplayGainMode::Track,
1095            "replaygain should be 'track'"
1096        );
1097        assert_eq!(
1098            cfg.playback.output_device.as_deref(),
1099            Some("My Fancy DAC"),
1100            "output_device should match config"
1101        );
1102        assert!(
1103            (cfg.playback.pre_amp_db - (-3.5)).abs() < f64::EPSILON,
1104            "pre_amp_db should be -3.5"
1105        );
1106        assert!(
1107            cfg.playback.software_volume,
1108            "software_volume should be true"
1109        );
1110        assert_eq!(cfg.playback.target_fps, 30, "target_fps should be 30");
1111        assert_eq!(cfg.playback.art_size, 32, "art_size should be 32");
1112
1113        // Visualizer config is also consumed at player init.
1114        assert!(!cfg.visualizer.enabled, "visualizer should be disabled");
1115        assert_eq!(cfg.visualizer.mode, "oscilloscope");
1116        assert_eq!(cfg.visualizer.fps, 30);
1117    }
1118}