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}
153
154impl Default for VisualizerConfig {
155    fn default() -> Self {
156        Self {
157            enabled: true,
158            fps: 60,
159            mode: "bars".into(),
160            scale: "bark".into(),
161            amplitude_scale: "aweight".into(),
162            bar_decay_ms: 50,
163            peak_decay_ms: 180,
164            palette: "spectrum".into(),
165            reactivity: 1.0,
166            bass_shake: true,
167            matrix_overlay: false,
168        }
169    }
170}
171
172impl Default for RemoteConfig {
173    fn default() -> Self {
174        Self {
175            enabled: false,
176            url: String::new(),
177            username: String::new(),
178            password: String::new(),
179            transcode_quality: "original".into(),
180            cache_dir: None,
181            download_workers: 5,
182            cache_limit: None,
183        }
184    }
185}
186
187/// Parse a human-readable size string like "50GB", "500 MB", "1.5TB" into bytes.
188/// Supports B, KB, MB, GB, TB (case-insensitive). Returns None for invalid input.
189pub fn parse_size_bytes(s: &str) -> Option<u64> {
190    let s = s.trim();
191    if s.is_empty() {
192        return None;
193    }
194
195    // Split into numeric part and suffix.
196    let mut num_end = 0;
197    for (i, c) in s.char_indices() {
198        if c.is_ascii_digit() || c == '.' {
199            num_end = i + c.len_utf8();
200        } else if !c.is_whitespace() {
201            break;
202        }
203    }
204
205    let num_str = s[..num_end].trim();
206    let suffix = s[num_end..].trim().to_ascii_uppercase();
207
208    let value: f64 = num_str.parse().ok()?;
209    let multiplier: u64 = match suffix.as_str() {
210        "" | "B" => 1,
211        "KB" | "K" => 1024,
212        "MB" | "M" => 1024 * 1024,
213        "GB" | "G" => 1024 * 1024 * 1024,
214        "TB" | "T" => 1024 * 1024 * 1024 * 1024,
215        _ => return None,
216    };
217
218    Some((value * multiplier as f64) as u64)
219}
220
221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
222#[serde(default)]
223pub struct OrganizeConfig {
224    /// Default named pattern to use when --pattern is omitted.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub default: Option<String>,
227    /// Named patterns — keys are names, values are format strings.
228    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
229    pub patterns: HashMap<String, String>,
230}
231
232impl OrganizeConfig {
233    /// Resolve a pattern argument: if it matches a named pattern, return the stored
234    /// format string. Otherwise return it as-is (raw format string).
235    pub fn resolve_pattern<'a>(&'a self, name_or_raw: &'a str) -> &'a str {
236        self.patterns
237            .get(name_or_raw)
238            .map(|s| s.as_str())
239            .unwrap_or(name_or_raw)
240    }
241
242    /// Get the default pattern's format string, if configured.
243    pub fn default_pattern(&self) -> Option<&str> {
244        self.default
245            .as_ref()
246            .and_then(|name| self.patterns.get(name))
247            .map(|s| s.as_str())
248    }
249}
250
251/// GraphQL API server configuration.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(default)]
254pub struct GraphqlConfig {
255    /// Enable the GraphQL API server alongside the TUI (default: true).
256    /// Set to false for TUI-only mode (equivalent to --no-api).
257    pub enabled: bool,
258    /// GraphQL API port (default: 4000).
259    pub port: u16,
260    /// Bind address for the API server (default: 127.0.0.1).
261    /// Use "0.0.0.0" to listen on all interfaces (NOT RECOMMENDED without auth).
262    #[serde(default = "default_bind")]
263    pub bind: std::net::IpAddr,
264    /// Enable GraphiQL web IDE at GET /graphql.
265    pub playground: bool,
266    /// Enable Subsonic REST API on this port. Omit or null to disable.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub subsonic_port: Option<u16>,
269}
270
271fn default_bind() -> std::net::IpAddr {
272    std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
273}
274
275impl Default for GraphqlConfig {
276    fn default() -> Self {
277        Self {
278            enabled: true,
279            port: 4000,
280            bind: default_bind(),
281            playground: false,
282            subsonic_port: None,
283        }
284    }
285}
286
287/// Radio / infinite play mode configuration.
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(default)]
290pub struct RadioConfig {
291    /// Number of tracks to keep queued ahead of the cursor.
292    pub lookahead: usize,
293    /// Number of tracks to add each time the queue runs low.
294    pub batch_size: usize,
295    /// Use Subsonic getSimilarSongs2 when a remote server is configured.
296    pub use_subsonic: bool,
297    /// Don't repeat any of the last N tracks (play history exclusion window).
298    pub history_window: usize,
299    /// Number of recently played tracks to use as seed (drifting seed window).
300    pub seed_window: usize,
301    /// Discovery weight: 0.0 = only familiar tracks, 1.0 = maximise discovery.
302    /// Controls the recency bonus — higher values boost never-played/long-forgotten tracks.
303    pub discovery_weight: f64,
304}
305
306impl Default for RadioConfig {
307    fn default() -> Self {
308        Self {
309            lookahead: 5,
310            batch_size: 5,
311            use_subsonic: true,
312            history_window: 200,
313            seed_window: 5,
314            discovery_weight: 0.3,
315        }
316    }
317}
318
319/// Acoustic analysis / discovery configuration.
320#[derive(Debug, Clone, Serialize, Deserialize)]
321#[serde(default)]
322pub struct DiscoveryConfig {
323    /// Run acoustic analysis automatically after library scan (default: false).
324    pub analysis_on_scan: bool,
325    /// Weight for acoustic similarity signal in radio mode scoring (0.0..1.0).
326    pub acoustic_weight: f64,
327}
328
329impl Default for DiscoveryConfig {
330    fn default() -> Self {
331        Self {
332            analysis_on_scan: false,
333            acoustic_weight: 0.5,
334        }
335    }
336}
337
338impl Config {
339    /// Build the figment provider chain:
340    /// defaults → config.toml → config.local.toml → KOAN_* env vars.
341    ///
342    /// Env vars use `KOAN_` prefix with `__` as section separator:
343    ///   KOAN_REMOTE__PASSWORD, KOAN_GRAPHQL__PORT, KOAN_PLAYBACK__TARGET_FPS, etc.
344    fn figment() -> Figment {
345        let base_path = config_file_path();
346        let local_path = config_local_file_path();
347
348        Figment::from(Serialized::defaults(Config::default()))
349            .merge(Toml::file(&base_path))
350            .merge(Toml::file(&local_path))
351            .merge(Env::prefixed("KOAN_").split("__"))
352    }
353
354    /// Load config from all layers: defaults → config.toml → config.local.toml → KOAN_* env vars.
355    pub fn load() -> Result<Self, ConfigError> {
356        Self::figment()
357            .extract()
358            .map_err(|e| ConfigError::Figment(Box::new(e)))
359    }
360
361    /// Load config, logging and falling back to defaults on error.
362    pub fn load_or_default() -> Self {
363        Self::load().unwrap_or_else(|e| {
364            log::warn!("failed to load config, using defaults: {}", e);
365            Self::default()
366        })
367    }
368
369    /// Load from a specific TOML file (no env var overlay).
370    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
371        let contents = fs::read_to_string(path)?;
372        let config: Config = toml::from_str(&contents)?;
373        Ok(config)
374    }
375
376    /// Patch config.toml with a mutation closure. Reads the base file only (not
377    /// config.local.toml or env vars), applies the closure, writes back.
378    /// This prevents secrets from config.local.toml or env vars leaking into config.toml.
379    pub fn update_base<F>(mutate: F) -> Result<(), ConfigError>
380    where
381        F: FnOnce(&mut Config),
382    {
383        let path = config_file_path();
384        let mut cfg = if path.exists() {
385            Config::load_from(&path)?
386        } else {
387            Config::default()
388        };
389        mutate(&mut cfg);
390        cfg.write_to(&path)?;
391        Ok(())
392    }
393
394    /// Write this config to a specific path as TOML.
395    fn write_to(&self, path: &Path) -> Result<(), ConfigError> {
396        if let Some(parent) = path.parent() {
397            fs::create_dir_all(parent)?;
398        }
399        let contents = toml::to_string_pretty(self)?;
400        fs::write(path, contents)?;
401        Ok(())
402    }
403
404    /// Patch a single section in config.local.toml, preserving all other content.
405    /// Creates the file if it doesn't exist. Sets 0o600 permissions on Unix.
406    pub fn patch_local(
407        section: &str,
408        values: &toml::map::Map<String, toml::Value>,
409    ) -> Result<(), ConfigError> {
410        let path = config_local_file_path();
411        let mut doc: toml::Value = if path.exists() {
412            let contents = fs::read_to_string(&path)?;
413            toml::from_str(&contents).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new()))
414        } else {
415            toml::Value::Table(toml::map::Map::new())
416        };
417
418        let table = doc.as_table_mut().expect("root is always a table");
419        let section_table = table
420            .entry(section)
421            .or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
422            .as_table_mut()
423            .ok_or_else(|| {
424                ConfigError::Io(std::io::Error::new(
425                    std::io::ErrorKind::InvalidData,
426                    format!("[{}] is not a table", section),
427                ))
428            })?;
429
430        for (key, value) in values {
431            section_table.insert(key.clone(), value.clone());
432        }
433
434        let contents = toml::to_string_pretty(&doc)?;
435        fs::write(&path, contents)?;
436        #[cfg(unix)]
437        {
438            use std::os::unix::fs::PermissionsExt;
439            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
440        }
441        Ok(())
442    }
443
444    /// Resolved cache directory — uses explicit setting or defaults to config_dir/cache.
445    pub fn cache_dir(&self) -> PathBuf {
446        self.remote
447            .cache_dir
448            .clone()
449            .unwrap_or_else(|| config_dir().join("cache"))
450    }
451
452    /// Parsed cache limit in bytes, or None if unlimited.
453    pub fn cache_limit_bytes(&self) -> Option<u64> {
454        self.remote
455            .cache_limit
456            .as_deref()
457            .and_then(parse_size_bytes)
458    }
459}
460
461/// `~/.config/koan/`
462pub fn config_dir() -> PathBuf {
463    dirs::home_dir()
464        .unwrap_or_else(|| PathBuf::from("."))
465        .join(".config")
466        .join("koan")
467}
468
469/// Path to the base config TOML file (committable to dotfiles).
470pub fn config_file_path() -> PathBuf {
471    config_dir().join("config.toml")
472}
473
474/// Path to the local override config (gitignored, machine-specific).
475pub fn config_local_file_path() -> PathBuf {
476    config_dir().join("config.local.toml")
477}
478
479/// Path to the database file.
480pub fn db_path() -> PathBuf {
481    config_dir().join("koan.db")
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use std::fs;
488
489    fn tmp_dir() -> PathBuf {
490        let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
491        fs::create_dir_all(&dir).unwrap();
492        dir
493    }
494
495    #[test]
496    fn test_defaults() {
497        let cfg = Config::default();
498        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
499        assert!(!cfg.remote.enabled);
500        assert_eq!(cfg.remote.transcode_quality, "original");
501    }
502
503    #[test]
504    fn test_roundtrip_toml() {
505        let cfg = Config::default();
506        let serialized = toml::to_string_pretty(&cfg).unwrap();
507        let deserialized: Config = toml::from_str(&serialized).unwrap();
508        assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
509        assert_eq!(
510            deserialized.remote.transcode_quality,
511            cfg.remote.transcode_quality
512        );
513    }
514
515    #[test]
516    fn test_load_from_file() {
517        let dir = tempfile::tempdir().unwrap();
518        let path = dir.path().join("config.toml");
519        fs::write(
520            &path,
521            r#"
522[library]
523folders = ["/tmp/music"]
524
525[playback]
526replaygain = "track"
527"#,
528        )
529        .unwrap();
530
531        let cfg = Config::load_from(&path).unwrap();
532        assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
533        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
534        assert!(!cfg.remote.enabled);
535    }
536
537    #[test]
538    fn test_partial_toml_uses_defaults() {
539        let dir = tempfile::tempdir().unwrap();
540        let path = dir.path().join("partial.toml");
541        fs::write(&path, "[playback]\nsoftware_volume = true\n").unwrap();
542
543        let cfg = Config::load_from(&path).unwrap();
544        assert!(cfg.playback.software_volume);
545    }
546
547    #[test]
548    fn test_figment_layered_loading() {
549        let dir = tempfile::tempdir().unwrap();
550        let base_path = dir.path().join("config.toml");
551        let local_path = dir.path().join("config.local.toml");
552
553        fs::write(
554            &base_path,
555            r#"
556[remote]
557url = "https://base.example.com"
558"#,
559        )
560        .unwrap();
561        fs::write(
562            &local_path,
563            r#"
564[remote]
565enabled = true
566url = "https://local.example.com"
567username = "admin"
568password = "secret"
569"#,
570        )
571        .unwrap();
572
573        // Build a figment with explicit paths (can't use load() since it reads from ~/.config).
574        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
575            .merge(Toml::file(&base_path))
576            .merge(Toml::file(&local_path))
577            .extract()
578            .unwrap();
579
580        assert!(cfg.remote.enabled);
581        assert_eq!(cfg.remote.url, "https://local.example.com");
582        assert_eq!(cfg.remote.username, "admin");
583        assert_eq!(cfg.remote.password, "secret");
584    }
585
586    #[test]
587    fn test_figment_missing_keys_preserved() {
588        let dir = tempfile::tempdir().unwrap();
589        let base_path = dir.path().join("config.toml");
590        let local_path = dir.path().join("config.local.toml");
591
592        fs::write(
593            &base_path,
594            r#"
595[remote]
596url = "https://keep.me"
597username = "keepuser"
598"#,
599        )
600        .unwrap();
601        fs::write(
602            &local_path,
603            r#"
604[remote]
605password = "secret"
606"#,
607        )
608        .unwrap();
609
610        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
611            .merge(Toml::file(&base_path))
612            .merge(Toml::file(&local_path))
613            .extract()
614            .unwrap();
615
616        assert_eq!(cfg.remote.url, "https://keep.me");
617        assert_eq!(cfg.remote.username, "keepuser");
618        assert_eq!(cfg.remote.password, "secret");
619    }
620
621    #[test]
622    fn test_env_var_override() {
623        let dir = tempfile::tempdir().unwrap();
624        let base_path = dir.path().join("config.toml");
625
626        fs::write(
627            &base_path,
628            r#"
629[remote]
630url = "https://file.example.com"
631"#,
632        )
633        .unwrap();
634
635        // SAFETY: test is single-threaded and vars are cleaned up immediately after.
636        unsafe {
637            std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
638            std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
639            std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
640        }
641
642        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
643            .merge(Toml::file(&base_path))
644            .merge(Env::prefixed("KOAN_").split("__"))
645            .extract()
646            .unwrap();
647
648        assert_eq!(cfg.remote.url, "https://env.example.com");
649        assert_eq!(cfg.remote.password, "env-secret");
650        assert_eq!(cfg.graphql.port, 9999);
651
652        // Clean up env vars.
653        unsafe {
654            std::env::remove_var("KOAN_REMOTE__URL");
655            std::env::remove_var("KOAN_REMOTE__PASSWORD");
656            std::env::remove_var("KOAN_GRAPHQL__PORT");
657        }
658    }
659
660    #[test]
661    fn test_update_base_does_not_leak_secrets() {
662        let dir = tempfile::tempdir().unwrap();
663        let base_path = dir.path().join("config.toml");
664
665        // Write an initial base config.
666        fs::write(
667            &base_path,
668            r#"
669[playback]
670target_fps = 60
671
672[remote]
673url = "https://base.example.com"
674"#,
675        )
676        .unwrap();
677
678        // Simulate: update_base patches base config only.
679        let mut base_cfg = Config::load_from(&base_path).unwrap();
680        base_cfg.visualizer.enabled = false;
681        base_cfg.write_to(&base_path).unwrap();
682
683        // Verify: no password leaked into config.toml.
684        let written = fs::read_to_string(&base_path).unwrap();
685        assert!(!written.contains("secret"));
686        assert!(!written.contains("password"));
687
688        // Verify the field was saved.
689        let reloaded = Config::load_from(&base_path).unwrap();
690        assert!(!reloaded.visualizer.enabled);
691        assert_eq!(reloaded.remote.url, "https://base.example.com");
692    }
693
694    #[test]
695    fn test_cache_dir_default() {
696        let cfg = Config::default();
697        assert!(cfg.cache_dir().ends_with("cache"));
698    }
699
700    #[test]
701    fn test_cache_dir_explicit() {
702        let mut cfg = Config::default();
703        cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
704        assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
705    }
706
707    #[test]
708    fn test_organize_config_defaults() {
709        let cfg = Config::default();
710        assert!(cfg.organize.default.is_none());
711        assert!(cfg.organize.patterns.is_empty());
712    }
713
714    #[test]
715    fn test_organize_config_from_toml() {
716        let dir = tmp_dir();
717        let path = dir.join("organize.toml");
718        fs::write(
719            &path,
720            r#"
721[organize]
722default = "standard"
723
724[organize.patterns]
725standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
726va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
727"#,
728        )
729        .unwrap();
730
731        let cfg = Config::load_from(&path).unwrap();
732        assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
733        assert_eq!(cfg.organize.patterns.len(), 2);
734        assert!(cfg.organize.patterns.contains_key("standard"));
735        assert!(cfg.organize.patterns.contains_key("va-aware"));
736
737        fs::remove_dir_all(&dir).ok();
738    }
739
740    #[test]
741    fn test_organize_resolve_named_pattern() {
742        let mut cfg = OrganizeConfig::default();
743        cfg.patterns
744            .insert("standard".into(), "%artist%/%title%".into());
745
746        assert_eq!(cfg.resolve_pattern("standard"), "%artist%/%title%");
747        // Unknown name falls through as raw pattern
748        assert_eq!(cfg.resolve_pattern("%raw%pattern%"), "%raw%pattern%");
749    }
750
751    #[test]
752    fn test_organize_default_pattern() {
753        let mut cfg = OrganizeConfig {
754            default: Some("standard".into()),
755            ..OrganizeConfig::default()
756        };
757        cfg.patterns
758            .insert("standard".into(), "%artist%/%title%".into());
759
760        assert_eq!(cfg.default_pattern(), Some("%artist%/%title%"));
761    }
762
763    #[test]
764    fn test_organize_default_pattern_missing_name() {
765        let cfg = OrganizeConfig {
766            default: Some("nonexistent".into()),
767            ..OrganizeConfig::default()
768        };
769        // Name doesn't match any pattern → None
770        assert_eq!(cfg.default_pattern(), None);
771    }
772
773    #[test]
774    fn test_figment_organize_patterns_merge() {
775        let dir = tempfile::tempdir().unwrap();
776        let base_path = dir.path().join("config.toml");
777        let local_path = dir.path().join("config.local.toml");
778
779        fs::write(
780            &base_path,
781            r#"
782[organize]
783default = "standard"
784
785[organize.patterns]
786standard = "base-pattern"
787"#,
788        )
789        .unwrap();
790        fs::write(
791            &local_path,
792            r#"
793[organize]
794default = "custom"
795
796[organize.patterns]
797custom = "local-pattern"
798"#,
799        )
800        .unwrap();
801
802        let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
803            .merge(Toml::file(&base_path))
804            .merge(Toml::file(&local_path))
805            .extract()
806            .unwrap();
807
808        // Local default wins.
809        assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
810        // Both patterns present (figment merges maps).
811        assert_eq!(cfg.organize.patterns.len(), 2);
812        assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
813        assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
814    }
815
816    #[test]
817    fn test_output_device_config_roundtrip() {
818        let mut cfg = Config::default();
819        cfg.playback.output_device = Some("My DAC".into());
820
821        let serialized = toml::to_string_pretty(&cfg).unwrap();
822        let deserialized: Config = toml::from_str(&serialized).unwrap();
823        assert_eq!(
824            deserialized.playback.output_device.as_deref(),
825            Some("My DAC")
826        );
827    }
828
829    #[test]
830    fn test_output_device_config_default_is_none() {
831        let cfg = Config::default();
832        assert!(cfg.playback.output_device.is_none());
833
834        // Roundtrip: None should not appear in serialized output.
835        let serialized = toml::to_string_pretty(&cfg).unwrap();
836        assert!(!serialized.contains("output_device"));
837        let deserialized: Config = toml::from_str(&serialized).unwrap();
838        assert!(deserialized.playback.output_device.is_none());
839    }
840
841    #[test]
842    fn test_output_device_config_from_toml() {
843        let dir = tempfile::tempdir().unwrap();
844        let path = dir.path().join("config.toml");
845        fs::write(
846            &path,
847            r#"
848[playback]
849output_device = "External Speakers"
850"#,
851        )
852        .unwrap();
853
854        let cfg = Config::load_from(&path).unwrap();
855        assert_eq!(
856            cfg.playback.output_device.as_deref(),
857            Some("External Speakers")
858        );
859    }
860
861    #[test]
862    fn test_graphql_bind_defaults_to_localhost() {
863        let cfg = GraphqlConfig::default();
864        assert_eq!(
865            cfg.bind,
866            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
867        );
868    }
869
870    #[test]
871    fn test_graphql_bind_from_toml() {
872        let toml_str = r#"
873[graphql]
874bind = "0.0.0.0"
875port = 5000
876"#;
877        let cfg: Config = toml::from_str(toml_str).unwrap();
878        assert_eq!(
879            cfg.graphql.bind,
880            std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
881        );
882        assert_eq!(cfg.graphql.port, 5000);
883    }
884
885    #[test]
886    fn test_graphql_bind_omitted_defaults_to_localhost() {
887        let toml_str = r#"
888[graphql]
889port = 4000
890"#;
891        let cfg: Config = toml::from_str(toml_str).unwrap();
892        assert_eq!(
893            cfg.graphql.bind,
894            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
895        );
896    }
897
898    #[test]
899    fn test_organize_config_roundtrip() {
900        let mut cfg = Config::default();
901        cfg.organize.default = Some("standard".into());
902        cfg.organize
903            .patterns
904            .insert("standard".into(), "%artist%/%title%".into());
905
906        let serialized = toml::to_string_pretty(&cfg).unwrap();
907        let deserialized: Config = toml::from_str(&serialized).unwrap();
908        assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
909        assert_eq!(
910            deserialized.organize.patterns["standard"],
911            "%artist%/%title%"
912        );
913    }
914
915    #[test]
916    fn test_parse_size_bytes() {
917        assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
918        assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
919        assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
920        assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
921        assert_eq!(parse_size_bytes("1024B"), Some(1024));
922        assert_eq!(parse_size_bytes("1024"), Some(1024));
923
924        // Case insensitive.
925        assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
926        assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
927
928        // Short suffixes.
929        assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
930        assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
931
932        // Spaces.
933        assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
934        assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
935
936        // Decimal.
937        assert_eq!(
938            parse_size_bytes("1.5GB"),
939            Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
940        );
941
942        // Invalid.
943        assert_eq!(parse_size_bytes(""), None);
944        assert_eq!(parse_size_bytes("abc"), None);
945        assert_eq!(parse_size_bytes("50XB"), None);
946    }
947
948    #[test]
949    fn test_cache_limit_config_from_toml() {
950        let toml_str = r#"
951[remote]
952cache_limit = "50GB"
953"#;
954        let cfg: Config = toml::from_str(toml_str).unwrap();
955        assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
956        assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
957    }
958
959    #[test]
960    fn test_cache_limit_none_by_default() {
961        let cfg = Config::default();
962        assert!(cfg.remote.cache_limit.is_none());
963        assert!(cfg.cache_limit_bytes().is_none());
964    }
965
966    #[test]
967    fn test_cache_limit_not_serialized_when_none() {
968        let cfg = Config::default();
969        let serialized = toml::to_string_pretty(&cfg).unwrap();
970        assert!(!serialized.contains("cache_limit"));
971    }
972}