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