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}
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
174pub fn parse_size_bytes(s: &str) -> Option<u64> {
177 let s = s.trim();
178 if s.is_empty() {
179 return None;
180 }
181
182 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 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub default: Option<String>,
214 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
216 pub patterns: HashMap<String, String>,
217}
218
219impl OrganizeConfig {
220 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(default)]
241pub struct GraphqlConfig {
242 pub enabled: bool,
245 pub port: u16,
247 #[serde(default = "default_bind")]
250 pub bind: std::net::IpAddr,
251 pub playground: bool,
253 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(default)]
277pub struct RadioConfig {
278 pub lookahead: usize,
280 pub batch_size: usize,
282 pub use_subsonic: bool,
284 pub history_window: usize,
286 pub seed_window: usize,
288 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#[derive(Debug, Clone, Serialize, Deserialize)]
308#[serde(default)]
309pub struct DiscoveryConfig {
310 pub analysis_on_scan: bool,
312 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 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 pub fn load() -> Result<Self, ConfigError> {
343 Self::figment()
344 .extract()
345 .map_err(|e| ConfigError::Figment(Box::new(e)))
346 }
347
348 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 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 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 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 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 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 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
448pub fn config_dir() -> PathBuf {
450 dirs::home_dir()
451 .unwrap_or_else(|| PathBuf::from("."))
452 .join(".config")
453 .join("koan")
454}
455
456pub fn config_file_path() -> PathBuf {
458 config_dir().join("config.toml")
459}
460
461pub fn config_local_file_path() -> PathBuf {
463 config_dir().join("config.local.toml")
464}
465
466pub 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 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 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 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 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 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 let written = fs::read_to_string(&base_path).unwrap();
672 assert!(!written.contains("secret"));
673 assert!(!written.contains("password"));
674
675 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 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 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 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
797 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 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 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 assert_eq!(parse_size_bytes("50G"), Some(50 * 1024 * 1024 * 1024));
917 assert_eq!(parse_size_bytes("500M"), Some(500 * 1024 * 1024));
918
919 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 assert_eq!(
925 parse_size_bytes("1.5GB"),
926 Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
927 );
928
929 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}