Skip to main content

koan_core/
config.rs

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