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