Skip to main content

koan_core/
config.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum ConfigError {
10    #[error("io error: {0}")]
11    Io(#[from] std::io::Error),
12    #[error("parse error: {0}")]
13    Parse(#[from] toml::de::Error),
14    #[error("serialize error: {0}")]
15    Serialize(#[from] toml::ser::Error),
16}
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19#[serde(default)]
20pub struct Config {
21    pub library: LibraryConfig,
22    pub playback: PlaybackConfig,
23    pub remote: RemoteConfig,
24    pub organize: OrganizeConfig,
25    #[serde(alias = "visualiser")]
26    pub visualizer: VisualizerConfig,
27    pub radio: RadioConfig,
28    pub graphql: GraphqlConfig,
29    pub discovery: DiscoveryConfig,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(default)]
34pub struct LibraryConfig {
35    pub folders: Vec<PathBuf>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(default)]
40pub struct PlaybackConfig {
41    pub software_volume: bool,
42    pub replaygain: ReplayGainMode,
43    /// Ticker scroll speed in frames-per-second (default: 8).
44    /// The title scrolls one character per frame. Higher = faster scroll.
45    pub ticker_fps: u8,
46    /// UI render rate in frames-per-second (default: 60).
47    /// Controls how often the TUI redraws. 30, 60, or 120 are typical values.
48    pub target_fps: u8,
49    /// Show an FPS counter overlay in the top-right corner.
50    pub show_fps: bool,
51    /// ReplayGain pre-amplification in dB. Applied on top of track/album gain.
52    /// Positive values boost, negative values attenuate. Default: 0.0.
53    pub pre_amp_db: f64,
54    /// Output audio device name. None = system default.
55    /// Persisted by name (not ID) since IDs can change across reboots.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub output_device: Option<String>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum ReplayGainMode {
63    Off,
64    Track,
65    Album,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(default)]
70pub struct RemoteConfig {
71    pub enabled: bool,
72    pub url: String,
73    pub username: String,
74    /// Password — stored in config.local.toml (gitignored), not Keychain.
75    #[serde(default, skip_serializing_if = "String::is_empty")]
76    pub password: String,
77    /// original | opus-128 | mp3-320
78    pub transcode_quality: String,
79    /// Defaults to config_dir()/cache if empty.
80    pub cache_dir: Option<PathBuf>,
81    /// Parallel download workers for remote tracks (default: 5).
82    pub download_workers: usize,
83}
84
85impl Default for LibraryConfig {
86    fn default() -> Self {
87        let music_dir = dirs::audio_dir().unwrap_or_else(|| {
88            dirs::home_dir()
89                .map(|h| h.join("Music"))
90                .unwrap_or_else(|| PathBuf::from("/Music"))
91        });
92        Self {
93            folders: vec![music_dir],
94        }
95    }
96}
97
98impl Default for PlaybackConfig {
99    fn default() -> Self {
100        Self {
101            software_volume: false,
102            replaygain: ReplayGainMode::Album,
103            ticker_fps: 8,
104            target_fps: 60,
105            show_fps: false,
106            pre_amp_db: 0.0,
107            output_device: None,
108        }
109    }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(default)]
114pub struct VisualizerConfig {
115    pub enabled: bool,
116    pub fps: u8,
117    /// Frequency scale: "bark" (default), "mel", "log", "linear".
118    pub scale: String,
119    /// Amplitude scale: "aweight" (default, A-weighted), "perceptual" (A-weighted + gamma), "sqrt", "linear".
120    pub amplitude_scale: String,
121    /// Bar decay half-life in milliseconds (how fast bars drop).
122    pub bar_decay_ms: u32,
123    /// Peak decay half-life in milliseconds (how long peaks linger).
124    pub peak_decay_ms: u32,
125}
126
127impl Default for VisualizerConfig {
128    fn default() -> Self {
129        Self {
130            enabled: true,
131            fps: 60,
132            scale: "bark".into(),
133            amplitude_scale: "aweight".into(),
134            bar_decay_ms: 50,
135            peak_decay_ms: 180,
136        }
137    }
138}
139
140impl Default for RemoteConfig {
141    fn default() -> Self {
142        Self {
143            enabled: false,
144            url: String::new(),
145            username: String::new(),
146            password: String::new(),
147            transcode_quality: "original".into(),
148            cache_dir: None,
149            download_workers: 5,
150        }
151    }
152}
153
154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
155#[serde(default)]
156pub struct OrganizeConfig {
157    /// Default named pattern to use when --pattern is omitted.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub default: Option<String>,
160    /// Named patterns — keys are names, values are format strings.
161    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
162    pub patterns: HashMap<String, String>,
163}
164
165impl OrganizeConfig {
166    /// Resolve a pattern argument: if it matches a named pattern, return the stored
167    /// format string. Otherwise return it as-is (raw format string).
168    pub fn resolve_pattern<'a>(&'a self, name_or_raw: &'a str) -> &'a str {
169        self.patterns
170            .get(name_or_raw)
171            .map(|s| s.as_str())
172            .unwrap_or(name_or_raw)
173    }
174
175    /// Get the default pattern's format string, if configured.
176    pub fn default_pattern(&self) -> Option<&str> {
177        self.default
178            .as_ref()
179            .and_then(|name| self.patterns.get(name))
180            .map(|s| s.as_str())
181    }
182}
183
184/// GraphQL API server configuration.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[serde(default)]
187pub struct GraphqlConfig {
188    /// Enable the GraphQL API server alongside the TUI (default: true).
189    /// Set to false for TUI-only mode (equivalent to --no-api).
190    pub enabled: bool,
191    /// GraphQL API port (default: 4000).
192    pub port: u16,
193    /// Enable GraphiQL web IDE at GET /graphql.
194    pub playground: bool,
195    /// Enable Subsonic REST API on this port. Omit or null to disable.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub subsonic_port: Option<u16>,
198}
199
200impl Default for GraphqlConfig {
201    fn default() -> Self {
202        Self {
203            enabled: true,
204            port: 4000,
205            playground: false,
206            subsonic_port: None,
207        }
208    }
209}
210
211/// Radio / infinite play mode configuration.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(default)]
214pub struct RadioConfig {
215    /// Number of tracks to keep queued ahead of the cursor.
216    pub lookahead: usize,
217    /// Number of tracks to add each time the queue runs low.
218    pub batch_size: usize,
219    /// Use Subsonic getSimilarSongs2 when a remote server is configured.
220    pub use_subsonic: bool,
221    /// Don't repeat any of the last N tracks (play history exclusion window).
222    pub history_window: usize,
223    /// Number of recently played tracks to use as seed (drifting seed window).
224    pub seed_window: usize,
225    /// Discovery weight: 0.0 = only familiar tracks, 1.0 = maximise discovery.
226    /// Controls the recency bonus — higher values boost never-played/long-forgotten tracks.
227    pub discovery_weight: f64,
228}
229
230impl Default for RadioConfig {
231    fn default() -> Self {
232        Self {
233            lookahead: 5,
234            batch_size: 5,
235            use_subsonic: true,
236            history_window: 200,
237            seed_window: 5,
238            discovery_weight: 0.3,
239        }
240    }
241}
242
243/// Acoustic analysis / discovery configuration.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(default)]
246pub struct DiscoveryConfig {
247    /// Run acoustic analysis automatically after library scan (default: false).
248    pub analysis_on_scan: bool,
249    /// Weight for acoustic similarity signal in radio mode scoring (0.0..1.0).
250    pub acoustic_weight: f64,
251}
252
253impl Default for DiscoveryConfig {
254    fn default() -> Self {
255        Self {
256            analysis_on_scan: false,
257            acoustic_weight: 0.5,
258        }
259    }
260}
261
262impl Config {
263    /// Load config.toml then deep-merge config.local.toml on top.
264    /// Only keys actually present in config.local.toml override — missing keys
265    /// keep their values from config.toml (not serde defaults).
266    pub fn load() -> Result<Self, ConfigError> {
267        let base_path = config_file_path();
268        let local_path = config_local_file_path();
269
270        let mut base_val: toml::Value = if base_path.exists() {
271            let contents = fs::read_to_string(&base_path)?;
272            toml::from_str(&contents)?
273        } else {
274            toml::Value::Table(toml::map::Map::new())
275        };
276
277        if local_path.exists() {
278            let local_contents = fs::read_to_string(&local_path)?;
279            let local_val: toml::Value = toml::from_str(&local_contents)?;
280            deep_merge(&mut base_val, local_val);
281        }
282
283        let config: Config = base_val.try_into()?;
284        Ok(config)
285    }
286
287    /// Load config, logging and falling back to defaults on error.
288    pub fn load_or_default() -> Self {
289        Self::load().unwrap_or_else(|e| {
290            log::warn!("failed to load config, using defaults: {}", e);
291            Self::default()
292        })
293    }
294
295    /// Load from a specific path.
296    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
297        let contents = fs::read_to_string(path)?;
298        let config: Config = toml::from_str(&contents)?;
299        Ok(config)
300    }
301
302    /// Write config to the base config.toml.
303    pub fn save(&self) -> Result<(), ConfigError> {
304        let path = config_file_path();
305        if let Some(parent) = path.parent() {
306            fs::create_dir_all(parent)?;
307        }
308        let contents = toml::to_string_pretty(self)?;
309        fs::write(&path, contents)?;
310        Ok(())
311    }
312
313    /// Write config to config.local.toml (for machine-specific / sensitive values).
314    pub fn save_local(&self) -> Result<(), ConfigError> {
315        let path = config_local_file_path();
316        if let Some(parent) = path.parent() {
317            fs::create_dir_all(parent)?;
318        }
319        let contents = toml::to_string_pretty(self)?;
320        fs::write(&path, contents)?;
321        #[cfg(unix)]
322        {
323            use std::os::unix::fs::PermissionsExt;
324            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
325        }
326        Ok(())
327    }
328
329    /// Resolved cache directory — uses explicit setting or defaults to config_dir/cache.
330    pub fn cache_dir(&self) -> PathBuf {
331        self.remote
332            .cache_dir
333            .clone()
334            .unwrap_or_else(|| config_dir().join("cache"))
335    }
336}
337
338/// Recursively merge `overlay` into `base`. Only keys present in `overlay`
339/// are touched — everything else in `base` is preserved.
340fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
341    match (base, overlay) {
342        (toml::Value::Table(base_map), toml::Value::Table(overlay_map)) => {
343            for (key, overlay_val) in overlay_map {
344                let entry = base_map
345                    .entry(key)
346                    .or_insert(toml::Value::Table(toml::map::Map::new()));
347                deep_merge(entry, overlay_val);
348            }
349        }
350        (base, overlay) => {
351            *base = overlay;
352        }
353    }
354}
355
356/// `~/.config/koan/`
357pub fn config_dir() -> PathBuf {
358    dirs::home_dir()
359        .unwrap_or_else(|| PathBuf::from("."))
360        .join(".config")
361        .join("koan")
362}
363
364/// Path to the base config TOML file (committable to dotfiles).
365pub fn config_file_path() -> PathBuf {
366    config_dir().join("config.toml")
367}
368
369/// Path to the local override config (gitignored, machine-specific).
370pub fn config_local_file_path() -> PathBuf {
371    config_dir().join("config.local.toml")
372}
373
374/// Path to the database file.
375pub fn db_path() -> PathBuf {
376    config_dir().join("koan.db")
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use std::fs;
383
384    fn tmp_dir() -> PathBuf {
385        let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
386        fs::create_dir_all(&dir).unwrap();
387        dir
388    }
389
390    #[test]
391    fn test_defaults() {
392        let cfg = Config::default();
393        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Album);
394        assert!(!cfg.remote.enabled);
395        assert_eq!(cfg.remote.transcode_quality, "original");
396    }
397
398    #[test]
399    fn test_roundtrip_toml() {
400        let cfg = Config::default();
401        let serialized = toml::to_string_pretty(&cfg).unwrap();
402        let deserialized: Config = toml::from_str(&serialized).unwrap();
403        assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
404        assert_eq!(
405            deserialized.remote.transcode_quality,
406            cfg.remote.transcode_quality
407        );
408    }
409
410    #[test]
411    fn test_load_from_file() {
412        let dir = tempfile::tempdir().unwrap();
413        let path = dir.path().join("config.toml");
414        fs::write(
415            &path,
416            r#"
417[library]
418folders = ["/tmp/music"]
419
420[playback]
421replaygain = "track"
422"#,
423        )
424        .unwrap();
425
426        let cfg = Config::load_from(&path).unwrap();
427        assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
428        assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
429        assert!(!cfg.remote.enabled);
430    }
431
432    #[test]
433    fn test_partial_toml_uses_defaults() {
434        let dir = tempfile::tempdir().unwrap();
435        let path = dir.path().join("partial.toml");
436        fs::write(&path, "[playback]\nsoftware_volume = true\n").unwrap();
437
438        let cfg = Config::load_from(&path).unwrap();
439        assert!(cfg.playback.software_volume);
440    }
441
442    #[test]
443    fn test_deep_merge_local_overrides_base() {
444        // Test deep_merge directly on TOML values (no temp files needed).
445        let base_toml = r#"
446[library]
447folders = ["/base/music"]
448
449[remote]
450url = "https://base.example.com"
451"#;
452        let local_toml = r#"
453[library]
454folders = ["/local/music"]
455
456[remote]
457enabled = true
458url = "https://local.example.com"
459username = "admin"
460"#;
461
462        let mut base_val: toml::Value = toml::from_str(base_toml).unwrap();
463        let local_val: toml::Value = toml::from_str(local_toml).unwrap();
464        deep_merge(&mut base_val, local_val);
465
466        let cfg: Config = base_val.try_into().unwrap();
467        assert_eq!(cfg.library.folders, vec![PathBuf::from("/local/music")]);
468        assert!(cfg.remote.enabled);
469        assert_eq!(cfg.remote.url, "https://local.example.com");
470        assert_eq!(cfg.remote.username, "admin");
471    }
472
473    #[test]
474    fn test_deep_merge_missing_keys_preserved() {
475        let base_toml = r#"
476[remote]
477url = "https://keep.me"
478username = "keepuser"
479"#;
480        // Local only sets password — url and username should survive.
481        let local_toml = r#"
482[remote]
483password = "secret"
484"#;
485
486        let mut base_val: toml::Value = toml::from_str(base_toml).unwrap();
487        let local_val: toml::Value = toml::from_str(local_toml).unwrap();
488        deep_merge(&mut base_val, local_val);
489
490        let cfg: Config = base_val.try_into().unwrap();
491        assert_eq!(cfg.remote.url, "https://keep.me");
492        assert_eq!(cfg.remote.username, "keepuser");
493        assert_eq!(cfg.remote.password, "secret");
494    }
495
496    #[test]
497    fn test_cache_dir_default() {
498        let cfg = Config::default();
499        assert!(cfg.cache_dir().ends_with("cache"));
500    }
501
502    #[test]
503    fn test_cache_dir_explicit() {
504        let mut cfg = Config::default();
505        cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
506        assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
507    }
508
509    #[test]
510    fn test_organize_config_defaults() {
511        let cfg = Config::default();
512        assert!(cfg.organize.default.is_none());
513        assert!(cfg.organize.patterns.is_empty());
514    }
515
516    #[test]
517    fn test_organize_config_from_toml() {
518        let dir = tmp_dir();
519        let path = dir.join("organize.toml");
520        fs::write(
521            &path,
522            r#"
523[organize]
524default = "standard"
525
526[organize.patterns]
527standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
528va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
529"#,
530        )
531        .unwrap();
532
533        let cfg = Config::load_from(&path).unwrap();
534        assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
535        assert_eq!(cfg.organize.patterns.len(), 2);
536        assert!(cfg.organize.patterns.contains_key("standard"));
537        assert!(cfg.organize.patterns.contains_key("va-aware"));
538
539        fs::remove_dir_all(&dir).ok();
540    }
541
542    #[test]
543    fn test_organize_resolve_named_pattern() {
544        let mut cfg = OrganizeConfig::default();
545        cfg.patterns
546            .insert("standard".into(), "%artist%/%title%".into());
547
548        assert_eq!(cfg.resolve_pattern("standard"), "%artist%/%title%");
549        // Unknown name falls through as raw pattern
550        assert_eq!(cfg.resolve_pattern("%raw%pattern%"), "%raw%pattern%");
551    }
552
553    #[test]
554    fn test_organize_default_pattern() {
555        let mut cfg = OrganizeConfig {
556            default: Some("standard".into()),
557            ..OrganizeConfig::default()
558        };
559        cfg.patterns
560            .insert("standard".into(), "%artist%/%title%".into());
561
562        assert_eq!(cfg.default_pattern(), Some("%artist%/%title%"));
563    }
564
565    #[test]
566    fn test_organize_default_pattern_missing_name() {
567        let cfg = OrganizeConfig {
568            default: Some("nonexistent".into()),
569            ..OrganizeConfig::default()
570        };
571        // Name doesn't match any pattern → None
572        assert_eq!(cfg.default_pattern(), None);
573    }
574
575    #[test]
576    fn test_deep_merge_organize_patterns() {
577        let base_toml = r#"
578[organize]
579default = "standard"
580
581[organize.patterns]
582standard = "base-pattern"
583"#;
584        let local_toml = r#"
585[organize]
586default = "custom"
587
588[organize.patterns]
589custom = "local-pattern"
590"#;
591
592        let mut base_val: toml::Value = toml::from_str(base_toml).unwrap();
593        let local_val: toml::Value = toml::from_str(local_toml).unwrap();
594        deep_merge(&mut base_val, local_val);
595
596        let cfg: Config = base_val.try_into().unwrap();
597        // Local default wins
598        assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
599        // Both patterns present (deep merge into [organize.patterns] table)
600        assert_eq!(cfg.organize.patterns.len(), 2);
601        assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
602        assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
603    }
604
605    #[test]
606    fn test_output_device_config_roundtrip() {
607        let mut cfg = Config::default();
608        cfg.playback.output_device = Some("My DAC".into());
609
610        let serialized = toml::to_string_pretty(&cfg).unwrap();
611        let deserialized: Config = toml::from_str(&serialized).unwrap();
612        assert_eq!(
613            deserialized.playback.output_device.as_deref(),
614            Some("My DAC")
615        );
616    }
617
618    #[test]
619    fn test_output_device_config_default_is_none() {
620        let cfg = Config::default();
621        assert!(cfg.playback.output_device.is_none());
622
623        // Roundtrip: None should not appear in serialized output.
624        let serialized = toml::to_string_pretty(&cfg).unwrap();
625        assert!(!serialized.contains("output_device"));
626        let deserialized: Config = toml::from_str(&serialized).unwrap();
627        assert!(deserialized.playback.output_device.is_none());
628    }
629
630    #[test]
631    fn test_output_device_config_from_toml() {
632        let dir = tempfile::tempdir().unwrap();
633        let path = dir.path().join("config.toml");
634        fs::write(
635            &path,
636            r#"
637[playback]
638output_device = "External Speakers"
639"#,
640        )
641        .unwrap();
642
643        let cfg = Config::load_from(&path).unwrap();
644        assert_eq!(
645            cfg.playback.output_device.as_deref(),
646            Some("External Speakers")
647        );
648    }
649
650    #[test]
651    fn test_organize_config_roundtrip() {
652        let mut cfg = Config::default();
653        cfg.organize.default = Some("standard".into());
654        cfg.organize
655            .patterns
656            .insert("standard".into(), "%artist%/%title%".into());
657
658        let serialized = toml::to_string_pretty(&cfg).unwrap();
659        let deserialized: Config = toml::from_str(&serialized).unwrap();
660        assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
661        assert_eq!(
662            deserialized.organize.patterns["standard"],
663            "%artist%/%title%"
664        );
665    }
666}