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