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 pub ticker_fps: u8,
50 pub target_fps: u8,
53 pub show_fps: bool,
55 pub pre_amp_db: f64,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub output_device: Option<String>,
62 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 #[serde(default, skip_serializing_if = "String::is_empty")]
83 pub password: String,
84 pub transcode_quality: String,
86 pub cache_dir: Option<PathBuf>,
88 pub download_workers: usize,
90 #[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 pub mode: String,
131 pub scale: String,
133 pub amplitude_scale: String,
135 pub bar_decay_ms: u32,
137 pub peak_decay_ms: u32,
139 pub palette: String,
142 pub reactivity: f32,
146 pub bass_shake: bool,
149 pub matrix_overlay: bool,
152}
153
154impl Default for VisualizerConfig {
155 fn default() -> Self {
156 Self {
157 enabled: true,
158 fps: 60,
159 mode: "bars".into(),
160 scale: "bark".into(),
161 amplitude_scale: "aweight".into(),
162 bar_decay_ms: 50,
163 peak_decay_ms: 180,
164 palette: "spectrum".into(),
165 reactivity: 1.0,
166 bass_shake: true,
167 matrix_overlay: false,
168 }
169 }
170}
171
172impl Default for RemoteConfig {
173 fn default() -> Self {
174 Self {
175 enabled: false,
176 url: String::new(),
177 username: String::new(),
178 password: String::new(),
179 transcode_quality: "original".into(),
180 cache_dir: None,
181 download_workers: 5,
182 cache_limit: None,
183 }
184 }
185}
186
187pub fn parse_size_bytes(s: &str) -> Option<u64> {
190 let s = s.trim();
191 if s.is_empty() {
192 return None;
193 }
194
195 let mut num_end = 0;
197 for (i, c) in s.char_indices() {
198 if c.is_ascii_digit() || c == '.' {
199 num_end = i + c.len_utf8();
200 } else if !c.is_whitespace() {
201 break;
202 }
203 }
204
205 let num_str = s[..num_end].trim();
206 let suffix = s[num_end..].trim().to_ascii_uppercase();
207
208 let value: f64 = num_str.parse().ok()?;
209 let multiplier: u64 = match suffix.as_str() {
210 "" | "B" => 1,
211 "KB" | "K" => 1024,
212 "MB" | "M" => 1024 * 1024,
213 "GB" | "G" => 1024 * 1024 * 1024,
214 "TB" | "T" => 1024 * 1024 * 1024 * 1024,
215 _ => return None,
216 };
217
218 Some((value * multiplier as f64) as u64)
219}
220
221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
222#[serde(default)]
223pub struct OrganizeConfig {
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub default: Option<String>,
227 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
229 pub patterns: HashMap<String, String>,
230}
231
232impl OrganizeConfig {
233 pub fn resolve_pattern<'a>(&'a self, name_or_raw: &'a str) -> &'a str {
236 self.patterns
237 .get(name_or_raw)
238 .map(|s| s.as_str())
239 .unwrap_or(name_or_raw)
240 }
241
242 pub fn default_pattern(&self) -> Option<&str> {
244 self.default
245 .as_ref()
246 .and_then(|name| self.patterns.get(name))
247 .map(|s| s.as_str())
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(default)]
254pub struct GraphqlConfig {
255 pub enabled: bool,
258 pub port: u16,
260 #[serde(default = "default_bind")]
263 pub bind: std::net::IpAddr,
264 pub playground: bool,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub subsonic_port: Option<u16>,
269}
270
271fn default_bind() -> std::net::IpAddr {
272 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
273}
274
275impl Default for GraphqlConfig {
276 fn default() -> Self {
277 Self {
278 enabled: true,
279 port: 4000,
280 bind: default_bind(),
281 playground: false,
282 subsonic_port: None,
283 }
284 }
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(default)]
290pub struct RadioConfig {
291 pub lookahead: usize,
293 pub batch_size: usize,
295 pub use_subsonic: bool,
297 pub history_window: usize,
299 pub seed_window: usize,
301 pub discovery_weight: f64,
304}
305
306impl Default for RadioConfig {
307 fn default() -> Self {
308 Self {
309 lookahead: 5,
310 batch_size: 5,
311 use_subsonic: true,
312 history_window: 200,
313 seed_window: 5,
314 discovery_weight: 0.3,
315 }
316 }
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
321#[serde(default)]
322pub struct DiscoveryConfig {
323 pub analysis_on_scan: bool,
325 pub acoustic_weight: f64,
327}
328
329impl Default for DiscoveryConfig {
330 fn default() -> Self {
331 Self {
332 analysis_on_scan: false,
333 acoustic_weight: 0.5,
334 }
335 }
336}
337
338impl Config {
339 fn figment() -> Figment {
345 let base_path = config_file_path();
346 let local_path = config_local_file_path();
347
348 Figment::from(Serialized::defaults(Config::default()))
349 .merge(Toml::file(&base_path))
350 .merge(Toml::file(&local_path))
351 .merge(Env::prefixed("KOAN_").split("__"))
352 }
353
354 pub fn load() -> Result<Self, ConfigError> {
356 Self::figment()
357 .extract()
358 .map_err(|e| ConfigError::Figment(Box::new(e)))
359 }
360
361 pub fn load_or_default() -> Self {
363 Self::load().unwrap_or_else(|e| {
364 log::warn!("failed to load config, using defaults: {}", e);
365 Self::default()
366 })
367 }
368
369 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
371 let contents = fs::read_to_string(path)?;
372 let config: Config = toml::from_str(&contents)?;
373 Ok(config)
374 }
375
376 pub fn update_base<F>(mutate: F) -> Result<(), ConfigError>
380 where
381 F: FnOnce(&mut Config),
382 {
383 let path = config_file_path();
384 let mut cfg = if path.exists() {
385 Config::load_from(&path)?
386 } else {
387 Config::default()
388 };
389 mutate(&mut cfg);
390 cfg.write_to(&path)?;
391 Ok(())
392 }
393
394 fn write_to(&self, path: &Path) -> Result<(), ConfigError> {
396 if let Some(parent) = path.parent() {
397 fs::create_dir_all(parent)?;
398 }
399 let contents = toml::to_string_pretty(self)?;
400 fs::write(path, contents)?;
401 Ok(())
402 }
403
404 pub fn patch_local(
407 section: &str,
408 values: &toml::map::Map<String, toml::Value>,
409 ) -> Result<(), ConfigError> {
410 let path = config_local_file_path();
411 let mut doc: toml::Value = if path.exists() {
412 let contents = fs::read_to_string(&path)?;
413 toml::from_str(&contents).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new()))
414 } else {
415 toml::Value::Table(toml::map::Map::new())
416 };
417
418 let table = doc.as_table_mut().expect("root is always a table");
419 let section_table = table
420 .entry(section)
421 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
422 .as_table_mut()
423 .ok_or_else(|| {
424 ConfigError::Io(std::io::Error::new(
425 std::io::ErrorKind::InvalidData,
426 format!("[{}] is not a table", section),
427 ))
428 })?;
429
430 for (key, value) in values {
431 section_table.insert(key.clone(), value.clone());
432 }
433
434 let contents = toml::to_string_pretty(&doc)?;
435 fs::write(&path, contents)?;
436 #[cfg(unix)]
437 {
438 use std::os::unix::fs::PermissionsExt;
439 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
440 }
441 Ok(())
442 }
443
444 pub fn cache_dir(&self) -> PathBuf {
446 self.remote
447 .cache_dir
448 .clone()
449 .unwrap_or_else(|| config_dir().join("cache"))
450 }
451
452 pub fn cache_limit_bytes(&self) -> Option<u64> {
454 self.remote
455 .cache_limit
456 .as_deref()
457 .and_then(parse_size_bytes)
458 }
459}
460
461pub fn config_dir() -> PathBuf {
463 dirs::home_dir()
464 .unwrap_or_else(|| PathBuf::from("."))
465 .join(".config")
466 .join("koan")
467}
468
469pub fn config_file_path() -> PathBuf {
471 config_dir().join("config.toml")
472}
473
474pub fn config_local_file_path() -> PathBuf {
476 config_dir().join("config.local.toml")
477}
478
479pub fn db_path() -> PathBuf {
481 config_dir().join("koan.db")
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use std::fs;
488
489 fn tmp_dir() -> PathBuf {
490 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
491 fs::create_dir_all(&dir).unwrap();
492 dir
493 }
494
495 #[test]
496 fn test_defaults() {
497 let cfg = Config::default();
498 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Off);
499 assert!(!cfg.remote.enabled);
500 assert_eq!(cfg.remote.transcode_quality, "original");
501 }
502
503 #[test]
504 fn test_roundtrip_toml() {
505 let cfg = Config::default();
506 let serialized = toml::to_string_pretty(&cfg).unwrap();
507 let deserialized: Config = toml::from_str(&serialized).unwrap();
508 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
509 assert_eq!(
510 deserialized.remote.transcode_quality,
511 cfg.remote.transcode_quality
512 );
513 }
514
515 #[test]
516 fn test_load_from_file() {
517 let dir = tempfile::tempdir().unwrap();
518 let path = dir.path().join("config.toml");
519 fs::write(
520 &path,
521 r#"
522[library]
523folders = ["/tmp/music"]
524
525[playback]
526replaygain = "track"
527"#,
528 )
529 .unwrap();
530
531 let cfg = Config::load_from(&path).unwrap();
532 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
533 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
534 assert!(!cfg.remote.enabled);
535 }
536
537 #[test]
538 fn test_partial_toml_uses_defaults() {
539 let dir = tempfile::tempdir().unwrap();
540 let path = dir.path().join("partial.toml");
541 fs::write(&path, "[playback]\nsoftware_volume = true\n").unwrap();
542
543 let cfg = Config::load_from(&path).unwrap();
544 assert!(cfg.playback.software_volume);
545 }
546
547 #[test]
548 fn test_figment_layered_loading() {
549 let dir = tempfile::tempdir().unwrap();
550 let base_path = dir.path().join("config.toml");
551 let local_path = dir.path().join("config.local.toml");
552
553 fs::write(
554 &base_path,
555 r#"
556[remote]
557url = "https://base.example.com"
558"#,
559 )
560 .unwrap();
561 fs::write(
562 &local_path,
563 r#"
564[remote]
565enabled = true
566url = "https://local.example.com"
567username = "admin"
568password = "secret"
569"#,
570 )
571 .unwrap();
572
573 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
575 .merge(Toml::file(&base_path))
576 .merge(Toml::file(&local_path))
577 .extract()
578 .unwrap();
579
580 assert!(cfg.remote.enabled);
581 assert_eq!(cfg.remote.url, "https://local.example.com");
582 assert_eq!(cfg.remote.username, "admin");
583 assert_eq!(cfg.remote.password, "secret");
584 }
585
586 #[test]
587 fn test_figment_missing_keys_preserved() {
588 let dir = tempfile::tempdir().unwrap();
589 let base_path = dir.path().join("config.toml");
590 let local_path = dir.path().join("config.local.toml");
591
592 fs::write(
593 &base_path,
594 r#"
595[remote]
596url = "https://keep.me"
597username = "keepuser"
598"#,
599 )
600 .unwrap();
601 fs::write(
602 &local_path,
603 r#"
604[remote]
605password = "secret"
606"#,
607 )
608 .unwrap();
609
610 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
611 .merge(Toml::file(&base_path))
612 .merge(Toml::file(&local_path))
613 .extract()
614 .unwrap();
615
616 assert_eq!(cfg.remote.url, "https://keep.me");
617 assert_eq!(cfg.remote.username, "keepuser");
618 assert_eq!(cfg.remote.password, "secret");
619 }
620
621 #[test]
622 fn test_env_var_override() {
623 let dir = tempfile::tempdir().unwrap();
624 let base_path = dir.path().join("config.toml");
625
626 fs::write(
627 &base_path,
628 r#"
629[remote]
630url = "https://file.example.com"
631"#,
632 )
633 .unwrap();
634
635 unsafe {
637 std::env::set_var("KOAN_REMOTE__URL", "https://env.example.com");
638 std::env::set_var("KOAN_REMOTE__PASSWORD", "env-secret");
639 std::env::set_var("KOAN_GRAPHQL__PORT", "9999");
640 }
641
642 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
643 .merge(Toml::file(&base_path))
644 .merge(Env::prefixed("KOAN_").split("__"))
645 .extract()
646 .unwrap();
647
648 assert_eq!(cfg.remote.url, "https://env.example.com");
649 assert_eq!(cfg.remote.password, "env-secret");
650 assert_eq!(cfg.graphql.port, 9999);
651
652 unsafe {
654 std::env::remove_var("KOAN_REMOTE__URL");
655 std::env::remove_var("KOAN_REMOTE__PASSWORD");
656 std::env::remove_var("KOAN_GRAPHQL__PORT");
657 }
658 }
659
660 #[test]
661 fn test_update_base_does_not_leak_secrets() {
662 let dir = tempfile::tempdir().unwrap();
663 let base_path = dir.path().join("config.toml");
664
665 fs::write(
667 &base_path,
668 r#"
669[playback]
670target_fps = 60
671
672[remote]
673url = "https://base.example.com"
674"#,
675 )
676 .unwrap();
677
678 let mut base_cfg = Config::load_from(&base_path).unwrap();
680 base_cfg.visualizer.enabled = false;
681 base_cfg.write_to(&base_path).unwrap();
682
683 let written = fs::read_to_string(&base_path).unwrap();
685 assert!(!written.contains("secret"));
686 assert!(!written.contains("password"));
687
688 let reloaded = Config::load_from(&base_path).unwrap();
690 assert!(!reloaded.visualizer.enabled);
691 assert_eq!(reloaded.remote.url, "https://base.example.com");
692 }
693
694 #[test]
695 fn test_cache_dir_default() {
696 let cfg = Config::default();
697 assert!(cfg.cache_dir().ends_with("cache"));
698 }
699
700 #[test]
701 fn test_cache_dir_explicit() {
702 let mut cfg = Config::default();
703 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
704 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
705 }
706
707 #[test]
708 fn test_organize_config_defaults() {
709 let cfg = Config::default();
710 assert!(cfg.organize.default.is_none());
711 assert!(cfg.organize.patterns.is_empty());
712 }
713
714 #[test]
715 fn test_organize_config_from_toml() {
716 let dir = tmp_dir();
717 let path = dir.join("organize.toml");
718 fs::write(
719 &path,
720 r#"
721[organize]
722default = "standard"
723
724[organize.patterns]
725standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
726va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
727"#,
728 )
729 .unwrap();
730
731 let cfg = Config::load_from(&path).unwrap();
732 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
733 assert_eq!(cfg.organize.patterns.len(), 2);
734 assert!(cfg.organize.patterns.contains_key("standard"));
735 assert!(cfg.organize.patterns.contains_key("va-aware"));
736
737 fs::remove_dir_all(&dir).ok();
738 }
739
740 #[test]
741 fn test_organize_resolve_named_pattern() {
742 let mut cfg = OrganizeConfig::default();
743 cfg.patterns
744 .insert("standard".into(), "%artist%/%title%".into());
745
746 assert_eq!(cfg.resolve_pattern("standard"), "%artist%/%title%");
747 assert_eq!(cfg.resolve_pattern("%raw%pattern%"), "%raw%pattern%");
749 }
750
751 #[test]
752 fn test_organize_default_pattern() {
753 let mut cfg = OrganizeConfig {
754 default: Some("standard".into()),
755 ..OrganizeConfig::default()
756 };
757 cfg.patterns
758 .insert("standard".into(), "%artist%/%title%".into());
759
760 assert_eq!(cfg.default_pattern(), Some("%artist%/%title%"));
761 }
762
763 #[test]
764 fn test_organize_default_pattern_missing_name() {
765 let cfg = OrganizeConfig {
766 default: Some("nonexistent".into()),
767 ..OrganizeConfig::default()
768 };
769 assert_eq!(cfg.default_pattern(), None);
771 }
772
773 #[test]
774 fn test_figment_organize_patterns_merge() {
775 let dir = tempfile::tempdir().unwrap();
776 let base_path = dir.path().join("config.toml");
777 let local_path = dir.path().join("config.local.toml");
778
779 fs::write(
780 &base_path,
781 r#"
782[organize]
783default = "standard"
784
785[organize.patterns]
786standard = "base-pattern"
787"#,
788 )
789 .unwrap();
790 fs::write(
791 &local_path,
792 r#"
793[organize]
794default = "custom"
795
796[organize.patterns]
797custom = "local-pattern"
798"#,
799 )
800 .unwrap();
801
802 let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
803 .merge(Toml::file(&base_path))
804 .merge(Toml::file(&local_path))
805 .extract()
806 .unwrap();
807
808 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
810 assert_eq!(cfg.organize.patterns.len(), 2);
812 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
813 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
814 }
815
816 #[test]
817 fn test_output_device_config_roundtrip() {
818 let mut cfg = Config::default();
819 cfg.playback.output_device = Some("My DAC".into());
820
821 let serialized = toml::to_string_pretty(&cfg).unwrap();
822 let deserialized: Config = toml::from_str(&serialized).unwrap();
823 assert_eq!(
824 deserialized.playback.output_device.as_deref(),
825 Some("My DAC")
826 );
827 }
828
829 #[test]
830 fn test_output_device_config_default_is_none() {
831 let cfg = Config::default();
832 assert!(cfg.playback.output_device.is_none());
833
834 let serialized = toml::to_string_pretty(&cfg).unwrap();
836 assert!(!serialized.contains("output_device"));
837 let deserialized: Config = toml::from_str(&serialized).unwrap();
838 assert!(deserialized.playback.output_device.is_none());
839 }
840
841 #[test]
842 fn test_output_device_config_from_toml() {
843 let dir = tempfile::tempdir().unwrap();
844 let path = dir.path().join("config.toml");
845 fs::write(
846 &path,
847 r#"
848[playback]
849output_device = "External Speakers"
850"#,
851 )
852 .unwrap();
853
854 let cfg = Config::load_from(&path).unwrap();
855 assert_eq!(
856 cfg.playback.output_device.as_deref(),
857 Some("External Speakers")
858 );
859 }
860
861 #[test]
862 fn test_graphql_bind_defaults_to_localhost() {
863 let cfg = GraphqlConfig::default();
864 assert_eq!(
865 cfg.bind,
866 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
867 );
868 }
869
870 #[test]
871 fn test_graphql_bind_from_toml() {
872 let toml_str = r#"
873[graphql]
874bind = "0.0.0.0"
875port = 5000
876"#;
877 let cfg: Config = toml::from_str(toml_str).unwrap();
878 assert_eq!(
879 cfg.graphql.bind,
880 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
881 );
882 assert_eq!(cfg.graphql.port, 5000);
883 }
884
885 #[test]
886 fn test_graphql_bind_omitted_defaults_to_localhost() {
887 let toml_str = r#"
888[graphql]
889port = 4000
890"#;
891 let cfg: Config = toml::from_str(toml_str).unwrap();
892 assert_eq!(
893 cfg.graphql.bind,
894 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
895 );
896 }
897
898 #[test]
899 fn test_organize_config_roundtrip() {
900 let mut cfg = Config::default();
901 cfg.organize.default = Some("standard".into());
902 cfg.organize
903 .patterns
904 .insert("standard".into(), "%artist%/%title%".into());
905
906 let serialized = toml::to_string_pretty(&cfg).unwrap();
907 let deserialized: Config = toml::from_str(&serialized).unwrap();
908 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
909 assert_eq!(
910 deserialized.organize.patterns["standard"],
911 "%artist%/%title%"
912 );
913 }
914
915 #[test]
916 fn test_parse_size_bytes() {
917 assert_eq!(parse_size_bytes("50GB"), Some(50 * 1024 * 1024 * 1024));
918 assert_eq!(parse_size_bytes("500MB"), Some(500 * 1024 * 1024));
919 assert_eq!(parse_size_bytes("1TB"), Some(1024 * 1024 * 1024 * 1024));
920 assert_eq!(parse_size_bytes("100KB"), Some(100 * 1024));
921 assert_eq!(parse_size_bytes("1024B"), Some(1024));
922 assert_eq!(parse_size_bytes("1024"), Some(1024));
923
924 assert_eq!(parse_size_bytes("50gb"), Some(50 * 1024 * 1024 * 1024));
926 assert_eq!(parse_size_bytes("50Gb"), Some(50 * 1024 * 1024 * 1024));
927
928 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
930 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
931
932 assert_eq!(parse_size_bytes("50 GB"), Some(50 * 1024 * 1024 * 1024));
934 assert_eq!(parse_size_bytes(" 50GB "), Some(50 * 1024 * 1024 * 1024));
935
936 assert_eq!(
938 parse_size_bytes("1.5GB"),
939 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
940 );
941
942 assert_eq!(parse_size_bytes(""), None);
944 assert_eq!(parse_size_bytes("abc"), None);
945 assert_eq!(parse_size_bytes("50XB"), None);
946 }
947
948 #[test]
949 fn test_cache_limit_config_from_toml() {
950 let toml_str = r#"
951[remote]
952cache_limit = "50GB"
953"#;
954 let cfg: Config = toml::from_str(toml_str).unwrap();
955 assert_eq!(cfg.remote.cache_limit.as_deref(), Some("50GB"));
956 assert_eq!(cfg.cache_limit_bytes(), Some(50 * 1024 * 1024 * 1024));
957 }
958
959 #[test]
960 fn test_cache_limit_none_by_default() {
961 let cfg = Config::default();
962 assert!(cfg.remote.cache_limit.is_none());
963 assert!(cfg.cache_limit_bytes().is_none());
964 }
965
966 #[test]
967 fn test_cache_limit_not_serialized_when_none() {
968 let cfg = Config::default();
969 let serialized = toml::to_string_pretty(&cfg).unwrap();
970 assert!(!serialized.contains("cache_limit"));
971 }
972}