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