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