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