1use std::collections::HashMap;
7use std::path::{Component, Path, PathBuf};
8
9use serde::Deserialize;
10
11use crate::error::{Error, Result};
12use crate::naming::CharacterSet;
13use crate::vocab::{AudioFormat, SourceMode, StemFormat, VideoCoverRetention};
14
15use super::label_to_env;
16
17#[derive(Debug, Clone, Default, Deserialize)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24pub struct Settings {
25 pub format: Option<AudioFormat>,
26 #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
27 pub concurrency: Option<u32>,
28 #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
29 pub retries: Option<u32>,
30 #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
31 pub min_newest: Option<u32>,
32 pub token_command: Option<String>,
37 pub animated_covers: Option<bool>,
38 pub video_cover_retention: Option<VideoCoverRetention>,
39 #[cfg_attr(feature = "schema", schemars(range(min = 0, max = 100)))]
40 pub animated_cover_quality: Option<u8>,
41 #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
42 pub animated_cover_max_fps: Option<u32>,
43 #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
44 pub animated_cover_max_width: Option<u32>,
45 #[cfg_attr(feature = "schema", schemars(range(min = 0, max = 4)))]
46 pub animated_cover_compression_level: Option<u8>,
47 pub animated_cover_lossless: Option<bool>,
48 pub details_sidecar: Option<bool>,
49 pub lyrics_sidecar: Option<bool>,
50 pub lrc_sidecar: Option<bool>,
51 pub video_mp4: Option<bool>,
52 pub download_stems: Option<bool>,
53 pub stem_format: Option<StemFormat>,
54 pub naming_template: Option<String>,
55 pub character_set: Option<CharacterSet>,
56 pub number_singletons: Option<bool>,
60}
61
62const SETTINGS_KEYS: &[&str] = &[
71 "format",
72 "concurrency",
73 "retries",
74 "min_newest",
75 "token_command",
76 "animated_covers",
77 "video_cover_retention",
78 "animated_cover_quality",
79 "animated_cover_max_fps",
80 "animated_cover_max_width",
81 "animated_cover_compression_level",
82 "animated_cover_lossless",
83 "details_sidecar",
84 "lyrics_sidecar",
85 "lrc_sidecar",
86 "video_mp4",
87 "download_stems",
88 "stem_format",
89 "naming_template",
90 "character_set",
91 "number_singletons",
92];
93
94const ACCOUNT_STRUCTURAL_KEYS: &[&str] = &[
96 "token",
97 "root",
98 "account_id",
99 "sources",
100 "areas",
101 "albums",
102 "lead_tracks",
103];
104
105const TOP_LEVEL_KEYS: &[&str] = &["defaults", "accounts"];
107
108#[derive(Debug, Clone, Default, Deserialize)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111pub struct Defaults {
112 #[serde(flatten)]
113 pub settings: Settings,
114}
115
116#[derive(Debug, Clone, Default, Deserialize)]
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119pub struct SourceConfig {
120 #[serde(flatten)]
121 pub settings: Settings,
122}
123
124#[derive(Debug, Clone, Default, Deserialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub struct AccountConfig {
128 pub token: Option<String>,
129 pub root: Option<String>,
130 pub account_id: Option<String>,
134 #[serde(flatten)]
135 pub settings: Settings,
136 #[serde(default)]
137 pub sources: HashMap<String, SourceConfig>,
138 pub areas: Option<AreasConfig>,
141 #[serde(default)]
146 pub albums: HashMap<String, String>,
147 #[serde(default)]
152 pub lead_tracks: Vec<String>,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum AreaMode {
163 Off,
165 Mode(SourceMode),
167}
168
169impl<'de> Deserialize<'de> for AreaMode {
170 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
171 where
172 D: serde::Deserializer<'de>,
173 {
174 let raw = String::deserialize(deserializer)?;
175 match raw.as_str() {
176 "off" => Ok(AreaMode::Off),
177 "copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
178 "mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
179 other => Err(serde::de::Error::custom(format!(
180 "unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
181 ))),
182 }
183 }
184}
185
186#[cfg(feature = "schema")]
187impl schemars::JsonSchema for AreaMode {
188 fn schema_name() -> std::borrow::Cow<'static, str> {
189 "AreaMode".into()
190 }
191
192 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
193 schemars::json_schema!({
194 "type": "string",
195 "enum": ["off", "copy", "mirror"],
196 "description": "Deletion mode for the library area: 'off' arms deletion of \
197 library-exclusive files, 'copy' is additive, 'mirror' deletes.",
198 })
199 }
200}
201
202#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
210#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
211#[serde(deny_unknown_fields)]
212pub struct AreasConfig {
213 pub library: Option<AreaMode>,
214 pub liked: Option<SourceMode>,
215 pub playlists: Option<SourceMode>,
216 #[serde(default)]
217 pub playlist: HashMap<String, SourceMode>,
218}
219
220#[derive(Debug, Clone, Default, Deserialize)]
222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223pub struct Config {
224 #[serde(default)]
225 pub defaults: Defaults,
226 #[serde(default)]
227 pub accounts: HashMap<String, AccountConfig>,
228}
229
230impl Config {
231 pub fn from_toml(toml_str: &str) -> Result<Self> {
237 let config: Self = toml::from_str(toml_str).map_err(redact_toml_error)?;
238 reject_unknown_keys(toml_str)?;
239 config.validate()?;
240 Ok(config)
241 }
242
243 fn validate(&self) -> Result<()> {
244 let roots: Vec<(&str, &str)> = self
245 .accounts
246 .iter()
247 .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
248 .collect();
249
250 for (i, (label_a, root_a)) in roots.iter().enumerate() {
251 for (label_b, root_b) in roots.iter().skip(i + 1) {
252 let a = normalise_root(root_a);
256 let b = normalise_root(root_b);
257 if a.starts_with(&b) || b.starts_with(&a) {
258 return Err(Error::Config(format!(
259 "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
260 )));
261 }
262 }
263 }
264
265 let templates = std::iter::once(&self.defaults.settings).chain(
271 self.accounts.values().flat_map(|acc| {
272 std::iter::once(&acc.settings).chain(acc.sources.values().map(|s| &s.settings))
273 }),
274 );
275 for settings in templates {
276 if let Some(template) = settings.naming_template.as_deref()
277 && template.trim().is_empty()
278 {
279 return Err(Error::Config(
280 "naming_template must not be empty or whitespace-only".into(),
281 ));
282 }
283 }
284
285 let mut prefix_seen: HashMap<String, &str> = HashMap::new();
286 for label in self.accounts.keys() {
287 let prefix = label_to_env(label);
288 if let Some(other) = prefix_seen.get(&prefix) {
289 return Err(Error::Config(format!(
290 "accounts '{label}' and '{other}' share env prefix '{prefix}'"
291 )));
292 }
293 prefix_seen.insert(prefix, label.as_str());
294 }
295
296 Ok(())
297 }
298}
299
300fn redact_toml_error(e: toml::de::Error) -> Error {
304 let msg = e
305 .to_string()
306 .lines()
307 .filter(|l| !l.contains(" | "))
308 .collect::<Vec<_>>()
309 .join("\n")
310 .trim()
311 .to_owned();
312 Error::Config(if msg.is_empty() {
313 "parse error".into()
314 } else {
315 msg
316 })
317}
318
319fn reject_unknown_keys(toml_str: &str) -> Result<()> {
330 let doc: toml::Table = toml::from_str(toml_str).map_err(redact_toml_error)?;
331
332 check_known_keys(&doc, "the top-level table", |k| TOP_LEVEL_KEYS.contains(&k))?;
333
334 if let Some(defaults) = doc.get("defaults").and_then(toml::Value::as_table) {
335 check_known_keys(defaults, "[defaults]", |k| SETTINGS_KEYS.contains(&k))?;
336 }
337
338 if let Some(accounts) = doc.get("accounts").and_then(toml::Value::as_table) {
339 for (label, account) in accounts {
340 let Some(account) = account.as_table() else {
341 continue;
342 };
343 let section = format!("[accounts.{label}]");
344 check_known_keys(account, §ion, |k| {
345 ACCOUNT_STRUCTURAL_KEYS.contains(&k) || SETTINGS_KEYS.contains(&k)
346 })?;
347
348 if let Some(sources) = account.get("sources").and_then(toml::Value::as_table) {
349 for (name, source) in sources {
350 let Some(source) = source.as_table() else {
351 continue;
352 };
353 let section = format!("[accounts.{label}.sources.{name}]");
354 check_known_keys(source, §ion, |k| SETTINGS_KEYS.contains(&k))?;
355 }
356 }
357 }
358 }
359
360 Ok(())
361}
362
363fn check_known_keys(
366 table: &toml::Table,
367 section: &str,
368 allowed: impl Fn(&str) -> bool,
369) -> Result<()> {
370 for key in table.keys() {
371 if !allowed(key.as_str()) {
372 return Err(Error::Config(format!("unknown key '{key}' in {section}")));
373 }
374 }
375 Ok(())
376}
377
378fn normalise_root(root: &str) -> PathBuf {
386 let mut out = PathBuf::new();
387 for component in Path::new(root).components() {
388 match component {
389 Component::CurDir => {}
390 Component::ParentDir => match out.components().next_back() {
391 Some(Component::Normal(_)) => {
392 out.pop();
393 }
394 Some(Component::RootDir | Component::Prefix(_)) => {}
396 _ => out.push(component.as_os_str()),
398 },
399 other => out.push(other.as_os_str()),
400 }
401 }
402 out
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn parse_empty_toml() {
411 let cfg = Config::from_toml("").unwrap();
412 assert!(cfg.accounts.is_empty());
413 }
414
415 #[test]
416 fn parse_basic_account() {
417 let toml = r#"
418 [accounts.alice]
419 token = "tok"
420 root = "/music"
421 "#;
422 let cfg = Config::from_toml(toml).unwrap();
423 let acc = &cfg.accounts["alice"];
424 assert_eq!(acc.token.as_deref(), Some("tok"));
425 assert_eq!(acc.root.as_deref(), Some("/music"));
426 }
427
428 #[test]
429 fn parse_defaults_section() {
430 let toml = r#"
431 [defaults]
432 format = "mp3"
433 concurrency = 8
434 retries = 5
435 min_newest = 2
436 animated_covers = true
437 video_cover_retention = "both"
438 animated_cover_quality = 85
439 animated_cover_max_fps = 18
440 animated_cover_max_width = 720
441 animated_cover_compression_level = 4
442 "#;
443 let cfg = Config::from_toml(toml).unwrap();
444 assert_eq!(cfg.defaults.settings.format, Some(AudioFormat::Mp3));
445 assert_eq!(cfg.defaults.settings.concurrency, Some(8));
446 assert_eq!(cfg.defaults.settings.retries, Some(5));
447 assert_eq!(cfg.defaults.settings.min_newest, Some(2));
448 assert_eq!(cfg.defaults.settings.animated_covers, Some(true));
449 assert_eq!(
450 cfg.defaults.settings.video_cover_retention,
451 Some(VideoCoverRetention::Both)
452 );
453 assert_eq!(cfg.defaults.settings.animated_cover_quality, Some(85));
454 assert_eq!(cfg.defaults.settings.animated_cover_max_fps, Some(18));
455 assert_eq!(cfg.defaults.settings.animated_cover_max_width, Some(720));
456 assert_eq!(
457 cfg.defaults.settings.animated_cover_compression_level,
458 Some(4)
459 );
460 }
461
462 #[test]
463 fn validation_nested_roots() {
464 let toml = r#"
465 [accounts.alice]
466 root = "/music"
467
468 [accounts.bob]
469 root = "/music/bob"
470 "#;
471 assert!(Config::from_toml(toml).is_err());
472 }
473
474 #[test]
475 fn validation_non_nested_roots_ok() {
476 let toml = r#"
477 [accounts.alice]
478 root = "/music/alice"
479
480 [accounts.bob]
481 root = "/music/bob"
482 "#;
483 assert!(Config::from_toml(toml).is_ok());
484 }
485
486 #[test]
487 fn invalid_toml_errors() {
488 assert!(Config::from_toml("not valid toml ][").is_err());
489 }
490
491 #[test]
492 fn duplicate_account_label_errors() {
493 let toml = "
495 [accounts.alice]
496 token = \"tok1\"
497
498 [accounts.alice]
499 token = \"tok2\"
500 ";
501 assert!(Config::from_toml(toml).is_err());
502 }
503
504 #[test]
505 fn parse_error_does_not_echo_token() {
506 let toml = "[accounts.alice]\ntoken = \"unterminated\n";
508 let err = Config::from_toml(toml).unwrap_err().to_string();
509 assert!(!err.contains("unterminated"), "error leaked token: {err}");
510 }
511
512 #[test]
513 fn validation_env_prefix_collision_errors() {
514 let toml = "
516 [accounts.my-lib]
517 [accounts.my_lib]
518 ";
519 assert!(Config::from_toml(toml).is_err());
520 }
521
522 #[test]
523 fn areas_parse_full_table() {
524 let toml = r#"
525 [accounts.alice]
526 token = "t"
527 [accounts.alice.areas]
528 library = "off"
529 liked = "copy"
530 playlists = "mirror"
531 [accounts.alice.areas.playlist]
532 "pl_abc123" = "mirror"
533 "pl_def456" = "copy"
534 "#;
535 let cfg = Config::from_toml(toml).unwrap();
536 let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
537 assert_eq!(areas.library, Some(AreaMode::Off));
538 assert_eq!(areas.liked, Some(SourceMode::Copy));
539 assert_eq!(areas.playlists, Some(SourceMode::Mirror));
540 assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
541 assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
542 }
543
544 #[test]
545 fn areas_library_accepts_copy_and_mirror() {
546 for (raw, expect) in [
547 ("copy", AreaMode::Mode(SourceMode::Copy)),
548 ("mirror", AreaMode::Mode(SourceMode::Mirror)),
549 ] {
550 let toml =
551 format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
552 let cfg = Config::from_toml(&toml).unwrap();
553 assert_eq!(
554 cfg.accounts["a"].areas.as_ref().unwrap().library,
555 Some(expect)
556 );
557 }
558 }
559
560 #[test]
561 fn areas_bad_mode_errors() {
562 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
563 assert!(Config::from_toml(toml).is_err());
564 }
565
566 #[test]
567 fn areas_bad_playlist_mode_errors() {
568 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
569 assert!(Config::from_toml(toml).is_err());
571 }
572
573 #[test]
574 fn areas_unknown_field_errors() {
575 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
577 assert!(Config::from_toml(toml).is_err());
578 }
579
580 #[test]
581 fn areas_absent_is_none() {
582 let toml = "[accounts.a]\ntoken = \"t\"\n";
583 assert!(
584 Config::from_toml(toml).unwrap().accounts["a"]
585 .areas
586 .is_none()
587 );
588 }
589
590 #[test]
593 fn misspelled_min_newest_key_is_rejected() {
594 let err = Config::from_toml("[defaults]\nmin_newst = 50\n")
598 .unwrap_err()
599 .to_string();
600 assert!(
601 err.contains("min_newst"),
602 "error should name the key: {err}"
603 );
604 assert!(
605 err.contains("[defaults]"),
606 "error should name the tier: {err}"
607 );
608 }
609
610 #[test]
611 fn unknown_defaults_key_is_rejected() {
612 assert!(Config::from_toml("[defaults]\nbogus = true\n").is_err());
613 }
614
615 #[test]
616 fn unknown_account_key_is_rejected() {
617 let err = Config::from_toml("[accounts.alice]\nroout = \"/music\"\n")
618 .unwrap_err()
619 .to_string();
620 assert!(err.contains("roout"), "error should name the key: {err}");
621 assert!(
622 err.contains("[accounts.alice]"),
623 "error names the tier: {err}"
624 );
625 }
626
627 #[test]
628 fn unknown_source_key_is_rejected() {
629 let toml = "[accounts.alice.sources.liked]\nformta = \"mp3\"\n";
630 let err = Config::from_toml(toml).unwrap_err().to_string();
631 assert!(err.contains("formta"), "error should name the key: {err}");
632 assert!(
633 err.contains("[accounts.alice.sources.liked]"),
634 "error should name the source tier: {err}"
635 );
636 }
637
638 #[test]
639 fn unknown_top_level_table_is_rejected() {
640 let err = Config::from_toml("[defalts]\nmin_newest = 50\n")
643 .unwrap_err()
644 .to_string();
645 assert!(err.contains("defalts"), "error should name the key: {err}");
646 }
647
648 #[test]
649 fn every_settings_key_is_accepted() {
650 let toml = r#"
652 [defaults]
653 format = "flac"
654 concurrency = 4
655 retries = 3
656 min_newest = 1
657 token_command = "echo tok"
658 animated_covers = true
659 video_cover_retention = "both"
660 animated_cover_quality = 90
661 animated_cover_max_fps = 24
662 animated_cover_max_width = 640
663 animated_cover_compression_level = 4
664 animated_cover_lossless = false
665 details_sidecar = true
666 lyrics_sidecar = true
667 lrc_sidecar = true
668 video_mp4 = true
669 download_stems = true
670 stem_format = "wav"
671 naming_template = "{title}"
672 character_set = "unicode"
673 number_singletons = false
674 "#;
675 assert!(Config::from_toml(toml).is_ok());
676 }
677
678 #[test]
679 fn known_structural_account_keys_are_accepted() {
680 let toml = r#"
681 [accounts.alice]
682 token = "t"
683 root = "/music/alice"
684 account_id = "user_abc"
685 format = "mp3"
686 lead_tracks = ["abc123"]
687 [accounts.alice.areas]
688 library = "off"
689 [accounts.alice.albums]
690 root_xyz = "Greatest Hits"
691 [accounts.alice.sources.liked]
692 format = "flac"
693 "#;
694 assert!(Config::from_toml(toml).is_ok());
695 }
696
697 #[cfg(feature = "schema")]
698 #[test]
699 fn settings_keys_mirror_the_struct() {
700 let schema = serde_json::to_value(schemars::schema_for!(Settings)).unwrap();
706 let schema_keys: std::collections::BTreeSet<&str> = schema["properties"]
707 .as_object()
708 .expect("Settings schema exposes properties")
709 .keys()
710 .map(String::as_str)
711 .collect();
712 let known: std::collections::BTreeSet<&str> = SETTINGS_KEYS.iter().copied().collect();
713 assert_eq!(
714 schema_keys, known,
715 "SETTINGS_KEYS is out of sync with Settings"
716 );
717 }
718
719 #[test]
722 fn nested_roots_with_dot_prefix_and_bare_are_rejected() {
723 let toml = "[accounts.alice]\nroot = \"./music\"\n\n[accounts.bob]\nroot = \"music\"\n";
726 assert!(Config::from_toml(toml).is_err());
727 }
728
729 #[test]
730 fn nested_roots_with_trailing_slash_are_rejected() {
731 let toml =
732 "[accounts.alice]\nroot = \"/music/\"\n\n[accounts.bob]\nroot = \"/music/alice\"\n";
733 assert!(Config::from_toml(toml).is_err());
734 }
735
736 #[test]
737 fn nested_roots_via_parent_segments_are_rejected() {
738 let toml =
740 "[accounts.alice]\nroot = \"sub/../shared\"\n\n[accounts.bob]\nroot = \"shared\"\n";
741 assert!(Config::from_toml(toml).is_err());
742 }
743
744 #[test]
745 fn disjoint_relative_roots_are_accepted() {
746 let toml = "[accounts.alice]\nroot = \"./alice\"\n\n[accounts.bob]\nroot = \"bob\"\n";
747 assert!(Config::from_toml(toml).is_ok());
748 }
749
750 #[test]
753 fn empty_naming_template_is_rejected() {
754 assert!(Config::from_toml("[defaults]\nnaming_template = \"\"\n").is_err());
755 }
756
757 #[test]
758 fn whitespace_only_naming_template_is_rejected() {
759 assert!(Config::from_toml("[defaults]\nnaming_template = \" \"\n").is_err());
760 }
761
762 #[test]
763 fn empty_naming_template_in_account_is_rejected() {
764 assert!(Config::from_toml("[accounts.alice]\nnaming_template = \"\"\n").is_err());
765 }
766
767 #[test]
768 fn empty_naming_template_in_source_is_rejected() {
769 let toml = "[accounts.alice.sources.liked]\nnaming_template = \"\"\n";
770 assert!(Config::from_toml(toml).is_err());
771 }
772
773 #[test]
774 fn non_empty_naming_template_is_accepted() {
775 let toml = "[defaults]\nnaming_template = \"{creator}/{title}\"\n";
776 assert!(Config::from_toml(toml).is_ok());
777 }
778
779 #[test]
780 fn min_newest_zero_stays_legal() {
781 let cfg = Config::from_toml("[defaults]\nmin_newest = 0\n").unwrap();
784 assert_eq!(cfg.defaults.settings.min_newest, Some(0));
785 }
786}