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