1use std::collections::HashMap;
7use std::fmt;
8use std::path::Path;
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14use crate::naming::CharacterSet;
15use crate::reconcile::SourceMode;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "lowercase")]
20pub enum AudioFormat {
21 Mp3,
22 #[default]
23 Flac,
24 Wav,
25}
26
27impl FromStr for AudioFormat {
28 type Err = Error;
29
30 fn from_str(s: &str) -> Result<Self> {
31 match s.to_ascii_lowercase().as_str() {
32 "mp3" => Ok(Self::Mp3),
33 "flac" => Ok(Self::Flac),
34 "wav" => Ok(Self::Wav),
35 other => Err(Error::Config(format!("unknown format '{other}'"))),
36 }
37 }
38}
39
40impl fmt::Display for AudioFormat {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 match self {
43 Self::Mp3 => f.write_str("mp3"),
44 Self::Flac => f.write_str("flac"),
45 Self::Wav => f.write_str("wav"),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Default, Deserialize)]
52pub struct Defaults {
53 pub format: Option<AudioFormat>,
54 pub concurrency: Option<u32>,
55 pub retries: Option<u32>,
56 pub min_newest: Option<u32>,
57 pub animated_covers: Option<bool>,
58 pub details_sidecar: Option<bool>,
59 pub lyrics_sidecar: Option<bool>,
60 pub lrc_sidecar: Option<bool>,
61 pub naming_template: Option<String>,
62 pub character_set: Option<CharacterSet>,
63}
64
65#[derive(Debug, Clone, Default, Deserialize)]
67pub struct SourceConfig {
68 pub format: Option<AudioFormat>,
69 pub concurrency: Option<u32>,
70 pub retries: Option<u32>,
71 pub min_newest: Option<u32>,
72 pub animated_covers: Option<bool>,
73 pub details_sidecar: Option<bool>,
74 pub lyrics_sidecar: Option<bool>,
75 pub lrc_sidecar: Option<bool>,
76 pub naming_template: Option<String>,
77 pub character_set: Option<CharacterSet>,
78}
79
80#[derive(Debug, Clone, Default, Deserialize)]
82pub struct AccountConfig {
83 pub token: Option<String>,
84 pub root: Option<String>,
85 pub account_id: Option<String>,
89 pub format: Option<AudioFormat>,
90 pub concurrency: Option<u32>,
91 pub retries: Option<u32>,
92 pub min_newest: Option<u32>,
93 pub animated_covers: Option<bool>,
94 pub details_sidecar: Option<bool>,
95 pub lyrics_sidecar: Option<bool>,
96 pub lrc_sidecar: Option<bool>,
97 pub naming_template: Option<String>,
98 pub character_set: Option<CharacterSet>,
99 #[serde(default)]
100 pub sources: HashMap<String, SourceConfig>,
101 pub areas: Option<AreasConfig>,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum AreaMode {
114 Off,
116 Mode(SourceMode),
118}
119
120impl<'de> Deserialize<'de> for AreaMode {
121 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
122 where
123 D: serde::Deserializer<'de>,
124 {
125 let raw = String::deserialize(deserializer)?;
126 match raw.as_str() {
127 "off" => Ok(AreaMode::Off),
128 "copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
129 "mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
130 other => Err(serde::de::Error::custom(format!(
131 "unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
132 ))),
133 }
134 }
135}
136
137#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
146#[serde(deny_unknown_fields)]
147pub struct AreasConfig {
148 pub library: Option<AreaMode>,
149 pub liked: Option<SourceMode>,
150 pub playlists: Option<SourceMode>,
151 #[serde(default)]
152 pub playlist: HashMap<String, SourceMode>,
153}
154
155#[derive(Debug, Clone, Default, Deserialize)]
157pub struct Config {
158 #[serde(default)]
159 pub defaults: Defaults,
160 #[serde(default)]
161 pub accounts: HashMap<String, AccountConfig>,
162}
163
164impl Config {
165 pub fn from_toml(toml_str: &str) -> Result<Self> {
171 let config: Self = toml::from_str(toml_str).map_err(|e| {
172 let raw = e.to_string();
175 let msg = raw
176 .lines()
177 .filter(|l| !l.contains(" | "))
178 .collect::<Vec<_>>()
179 .join("\n")
180 .trim()
181 .to_owned();
182 Error::Config(if msg.is_empty() {
183 "parse error".into()
184 } else {
185 msg
186 })
187 })?;
188 config.validate()?;
189 Ok(config)
190 }
191
192 fn validate(&self) -> Result<()> {
193 let roots: Vec<(&str, &str)> = self
194 .accounts
195 .iter()
196 .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
197 .collect();
198
199 for (i, (label_a, root_a)) in roots.iter().enumerate() {
200 for (label_b, root_b) in roots.iter().skip(i + 1) {
201 let a = Path::new(root_a);
202 let b = Path::new(root_b);
203 if a.starts_with(b) || b.starts_with(a) {
204 return Err(Error::Config(format!(
205 "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
206 )));
207 }
208 }
209 }
210
211 let mut prefix_seen: HashMap<String, &str> = HashMap::new();
212 for label in self.accounts.keys() {
213 let prefix = label_to_env(label);
214 if let Some(other) = prefix_seen.get(&prefix) {
215 return Err(Error::Config(format!(
216 "accounts '{label}' and '{other}' share env prefix '{prefix}'"
217 )));
218 }
219 prefix_seen.insert(prefix, label.as_str());
220 }
221
222 Ok(())
223 }
224
225 pub fn resolve(
231 &self,
232 account: &str,
233 source: Option<&str>,
234 env: &HashMap<String, String>,
235 flags: &FlagOverrides,
236 ) -> Result<EffectiveSettings> {
237 let acc = self
238 .accounts
239 .get(account)
240 .ok_or_else(|| Error::Config(format!("account '{account}' not found")))?;
241
242 let src = source.and_then(|s| acc.sources.get(s));
243 let label_env = label_to_env(account);
244
245 let env_val = |suffix: &str| -> Option<&str> {
247 env.get(&format!("SUNO_{label_env}_{suffix}"))
248 .or_else(|| env.get(&format!("SUNO_{suffix}")))
249 .map(String::as_str)
250 };
251
252 let format_from_env = env_val("FORMAT")
253 .map(str::parse::<AudioFormat>)
254 .transpose()?;
255
256 let format = flags
257 .format
258 .or(format_from_env)
259 .or_else(|| src.and_then(|s| s.format))
260 .or(acc.format)
261 .or(self.defaults.format)
262 .unwrap_or(AudioFormat::Flac);
263
264 let concurrency = resolve_u32(
265 flags.concurrency,
266 env_val("CONCURRENCY"),
267 src.and_then(|s| s.concurrency),
268 acc.concurrency,
269 self.defaults.concurrency,
270 4,
271 "CONCURRENCY",
272 )?;
273
274 let retries = resolve_u32(
275 flags.retries,
276 env_val("RETRIES"),
277 src.and_then(|s| s.retries),
278 acc.retries,
279 self.defaults.retries,
280 3,
281 "RETRIES",
282 )?;
283
284 let min_newest = resolve_u32(
285 flags.min_newest,
286 env_val("MIN_NEWEST"),
287 src.and_then(|s| s.min_newest),
288 acc.min_newest,
289 self.defaults.min_newest,
290 1,
291 "MIN_NEWEST",
292 )?;
293
294 let animated_covers = resolve_bool(
295 flags.animated_covers,
296 env_val("ANIMATED_COVERS"),
297 src.and_then(|s| s.animated_covers),
298 acc.animated_covers,
299 self.defaults.animated_covers,
300 false,
301 "ANIMATED_COVERS",
302 )?;
303
304 let details_sidecar = resolve_bool(
305 flags.details_sidecar,
306 env_val("DETAILS_SIDECAR"),
307 src.and_then(|s| s.details_sidecar),
308 acc.details_sidecar,
309 self.defaults.details_sidecar,
310 false,
311 "DETAILS_SIDECAR",
312 )?;
313
314 let lyrics_sidecar = resolve_bool(
315 flags.lyrics_sidecar,
316 env_val("LYRICS_SIDECAR"),
317 src.and_then(|s| s.lyrics_sidecar),
318 acc.lyrics_sidecar,
319 self.defaults.lyrics_sidecar,
320 false,
321 "LYRICS_SIDECAR",
322 )?;
323
324 let lrc_sidecar = resolve_bool(
325 flags.lrc_sidecar,
326 env_val("LRC_SIDECAR"),
327 src.and_then(|s| s.lrc_sidecar),
328 acc.lrc_sidecar,
329 self.defaults.lrc_sidecar,
330 false,
331 "LRC_SIDECAR",
332 )?;
333
334 let naming_template_from_env = env_val("NAMING_TEMPLATE").map(str::to_owned);
335 let naming_template = flags
336 .naming_template
337 .clone()
338 .or(naming_template_from_env)
339 .or_else(|| src.and_then(|s| s.naming_template.clone()))
340 .or_else(|| acc.naming_template.clone())
341 .or_else(|| self.defaults.naming_template.clone())
342 .unwrap_or_else(|| crate::naming::DEFAULT_TEMPLATE.to_owned());
343
344 let character_set_from_env = env_val("CHARACTER_SET")
345 .map(str::parse::<CharacterSet>)
346 .transpose()?;
347 let character_set = flags
348 .character_set
349 .or(character_set_from_env)
350 .or_else(|| src.and_then(|s| s.character_set))
351 .or(acc.character_set)
352 .or(self.defaults.character_set)
353 .unwrap_or(CharacterSet::Unicode);
354
355 let token = flags
356 .token
357 .clone()
358 .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
359 .or_else(|| env.get("SUNO_TOKEN").cloned())
360 .or_else(|| acc.token.clone());
361
362 Ok(EffectiveSettings {
363 token,
364 account_id: acc.account_id.clone(),
365 format,
366 concurrency,
367 retries,
368 min_newest,
369 animated_covers,
370 details_sidecar,
371 lyrics_sidecar,
372 lrc_sidecar,
373 naming_template,
374 character_set,
375 areas: acc.areas.clone(),
376 })
377 }
378}
379
380fn resolve_u32(
381 flag: Option<u32>,
382 env_str: Option<&str>,
383 src: Option<u32>,
384 acc: Option<u32>,
385 defaults: Option<u32>,
386 compiled: u32,
387 name: &str,
388) -> Result<u32> {
389 if let Some(v) = flag {
390 return Ok(v);
391 }
392 if let Some(s) = env_str {
393 return s
394 .parse()
395 .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
396 }
397 Ok(src.or(acc).or(defaults).unwrap_or(compiled))
398}
399
400fn resolve_bool(
401 flag: Option<bool>,
402 env_str: Option<&str>,
403 src: Option<bool>,
404 acc: Option<bool>,
405 defaults: Option<bool>,
406 compiled: bool,
407 name: &str,
408) -> Result<bool> {
409 if let Some(v) = flag {
410 return Ok(v);
411 }
412 if let Some(s) = env_str {
413 return s
414 .parse()
415 .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
416 }
417 Ok(src.or(acc).or(defaults).unwrap_or(compiled))
418}
419
420fn label_to_env(label: &str) -> String {
424 label.to_ascii_uppercase().replace('-', "_")
425}
426
427#[derive(Debug, Default)]
430pub struct FlagOverrides {
431 pub token: Option<String>,
432 pub format: Option<AudioFormat>,
433 pub concurrency: Option<u32>,
434 pub retries: Option<u32>,
435 pub min_newest: Option<u32>,
436 pub animated_covers: Option<bool>,
437 pub details_sidecar: Option<bool>,
438 pub lyrics_sidecar: Option<bool>,
439 pub lrc_sidecar: Option<bool>,
440 pub naming_template: Option<String>,
441 pub character_set: Option<CharacterSet>,
442}
443
444#[derive(Debug, Clone, PartialEq)]
446pub struct EffectiveSettings {
447 pub token: Option<String>,
448 pub account_id: Option<String>,
450 pub format: AudioFormat,
451 pub concurrency: u32,
452 pub retries: u32,
453 pub min_newest: u32,
454 pub animated_covers: bool,
455 pub details_sidecar: bool,
456 pub lyrics_sidecar: bool,
457 pub lrc_sidecar: bool,
458 pub naming_template: String,
459 pub character_set: CharacterSet,
460 pub areas: Option<AreasConfig>,
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 fn no_env() -> HashMap<String, String> {
469 HashMap::new()
470 }
471
472 fn no_flags() -> FlagOverrides {
473 FlagOverrides::default()
474 }
475
476 #[test]
477 fn parse_empty_toml() {
478 let cfg = Config::from_toml("").unwrap();
479 assert!(cfg.accounts.is_empty());
480 }
481
482 #[test]
483 fn parse_basic_account() {
484 let toml = r#"
485 [accounts.alice]
486 token = "tok"
487 root = "/music"
488 "#;
489 let cfg = Config::from_toml(toml).unwrap();
490 let acc = &cfg.accounts["alice"];
491 assert_eq!(acc.token.as_deref(), Some("tok"));
492 assert_eq!(acc.root.as_deref(), Some("/music"));
493 }
494
495 #[test]
496 fn account_id_parses_and_resolves() {
497 let toml = r#"
498 [accounts.alice]
499 token = "tok"
500 root = "/music"
501 account_id = "user_abc123"
502 "#;
503 let cfg = Config::from_toml(toml).unwrap();
504 assert_eq!(
505 cfg.accounts["alice"].account_id.as_deref(),
506 Some("user_abc123")
507 );
508 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
509 assert_eq!(eff.account_id.as_deref(), Some("user_abc123"));
510 }
511
512 #[test]
513 fn parse_defaults_section() {
514 let toml = r#"
515 [defaults]
516 format = "mp3"
517 concurrency = 8
518 retries = 5
519 min_newest = 2
520 animated_covers = true
521 "#;
522 let cfg = Config::from_toml(toml).unwrap();
523 assert_eq!(cfg.defaults.format, Some(AudioFormat::Mp3));
524 assert_eq!(cfg.defaults.concurrency, Some(8));
525 assert_eq!(cfg.defaults.retries, Some(5));
526 assert_eq!(cfg.defaults.min_newest, Some(2));
527 assert_eq!(cfg.defaults.animated_covers, Some(true));
528 }
529
530 #[test]
531 fn compiled_defaults_when_nothing_set() {
532 let toml = "[accounts.alice]\n";
533 let cfg = Config::from_toml(toml).unwrap();
534 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
535 assert_eq!(
536 eff,
537 EffectiveSettings {
538 token: None,
539 account_id: None,
540 format: AudioFormat::Flac,
541 concurrency: 4,
542 retries: 3,
543 min_newest: 1,
544 animated_covers: false,
545 details_sidecar: false,
546 lyrics_sidecar: false,
547 lrc_sidecar: false,
548 naming_template: crate::naming::DEFAULT_TEMPLATE.to_owned(),
549 character_set: CharacterSet::Unicode,
550 areas: None,
551 }
552 );
553 }
554
555 #[test]
556 fn file_defaults_override_compiled() {
557 let toml = r#"
558 [defaults]
559 format = "mp3"
560 concurrency = 8
561
562 [accounts.alice]
563 "#;
564 let cfg = Config::from_toml(toml).unwrap();
565 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
566 assert_eq!(eff.format, AudioFormat::Mp3);
567 assert_eq!(eff.concurrency, 8);
568 assert_eq!(eff.retries, 3); }
570
571 #[test]
572 fn account_settings_override_defaults() {
573 let toml = r#"
574 [defaults]
575 format = "mp3"
576
577 [accounts.alice]
578 format = "wav"
579 "#;
580 let cfg = Config::from_toml(toml).unwrap();
581 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
582 assert_eq!(eff.format, AudioFormat::Wav);
583 }
584
585 #[test]
586 fn per_source_overrides_account() {
587 let toml = r#"
588 [accounts.alice]
589 format = "flac"
590
591 [accounts.alice.sources.liked]
592 format = "mp3"
593 "#;
594 let cfg = Config::from_toml(toml).unwrap();
595 let eff = cfg
596 .resolve("alice", Some("liked"), &no_env(), &no_flags())
597 .unwrap();
598 assert_eq!(eff.format, AudioFormat::Mp3);
599 }
600
601 #[test]
602 fn unknown_source_falls_back_to_account() {
603 let toml = r#"
604 [accounts.alice]
605 format = "wav"
606 "#;
607 let cfg = Config::from_toml(toml).unwrap();
608 let eff = cfg
609 .resolve("alice", Some("nonexistent"), &no_env(), &no_flags())
610 .unwrap();
611 assert_eq!(eff.format, AudioFormat::Wav);
612 }
613
614 #[test]
615 fn global_env_overrides_file() {
616 let toml = r#"
617 [accounts.alice]
618 format = "flac"
619 "#;
620 let cfg = Config::from_toml(toml).unwrap();
621 let env: HashMap<String, String> =
622 [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
623 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
624 assert_eq!(eff.format, AudioFormat::Mp3);
625 }
626
627 #[test]
628 fn per_account_env_overrides_global_env() {
629 let toml = "[accounts.alice]\n";
630 let cfg = Config::from_toml(toml).unwrap();
631 let env: HashMap<String, String> = [
632 ("SUNO_FORMAT".into(), "mp3".into()),
633 ("SUNO_ALICE_FORMAT".into(), "wav".into()),
634 ]
635 .into_iter()
636 .collect();
637 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
638 assert_eq!(eff.format, AudioFormat::Wav);
639 }
640
641 #[test]
642 fn per_account_env_label_uppersnakedcase() {
643 let toml = "[accounts.my-lib]\n";
644 let cfg = Config::from_toml(toml).unwrap();
645 let env: HashMap<String, String> = [("SUNO_MY_LIB_FORMAT".into(), "wav".into())]
646 .into_iter()
647 .collect();
648 let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
649 assert_eq!(eff.format, AudioFormat::Wav);
650 }
651
652 #[test]
653 fn flag_overrides_env_and_file() {
654 let toml = r#"
655 [accounts.alice]
656 format = "flac"
657 "#;
658 let cfg = Config::from_toml(toml).unwrap();
659 let env: HashMap<String, String> =
660 [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
661 let flags = FlagOverrides {
662 format: Some(AudioFormat::Wav),
663 ..Default::default()
664 };
665 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
666 assert_eq!(eff.format, AudioFormat::Wav);
667 }
668
669 #[test]
670 fn token_precedence() {
671 let toml = r#"
672 [accounts.alice]
673 token = "file_tok"
674 "#;
675 let cfg = Config::from_toml(toml).unwrap();
676
677 let env: HashMap<String, String> = [("SUNO_TOKEN".into(), "env_tok".into())]
679 .into_iter()
680 .collect();
681 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
682 assert_eq!(eff.token.as_deref(), Some("env_tok"));
683
684 let flags = FlagOverrides {
686 token: Some("flag_tok".into()),
687 ..Default::default()
688 };
689 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
690 assert_eq!(eff.token.as_deref(), Some("flag_tok"));
691 }
692
693 #[test]
694 fn per_account_token_env_overrides_global() {
695 let toml = "[accounts.alice]\n";
696 let cfg = Config::from_toml(toml).unwrap();
697 let env: HashMap<String, String> = [
698 ("SUNO_TOKEN".into(), "global".into()),
699 ("SUNO_ALICE_TOKEN".into(), "per_account".into()),
700 ]
701 .into_iter()
702 .collect();
703 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
704 assert_eq!(eff.token.as_deref(), Some("per_account"));
705 }
706
707 #[test]
708 fn invalid_env_u32_errors() {
709 let toml = "[accounts.alice]\n";
710 let cfg = Config::from_toml(toml).unwrap();
711 let env: HashMap<String, String> = [("SUNO_CONCURRENCY".into(), "many".into())]
712 .into_iter()
713 .collect();
714 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
715 }
716
717 #[test]
718 fn animated_covers_defaults_off_and_follows_precedence() {
719 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
721 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
722 assert!(!eff.animated_covers);
723
724 let toml = r#"
726 [defaults]
727 animated_covers = true
728
729 [accounts.alice.sources.liked]
730 animated_covers = false
731 "#;
732 let cfg = Config::from_toml(toml).unwrap();
733
734 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
736 assert!(eff.animated_covers);
737
738 let eff = cfg
740 .resolve("alice", Some("liked"), &no_env(), &no_flags())
741 .unwrap();
742 assert!(!eff.animated_covers);
743
744 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "true".into())]
746 .into_iter()
747 .collect();
748 let eff = cfg
749 .resolve("alice", Some("liked"), &env, &no_flags())
750 .unwrap();
751 assert!(eff.animated_covers);
752
753 let flags = FlagOverrides {
755 animated_covers: Some(false),
756 ..Default::default()
757 };
758 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
759 assert!(!eff.animated_covers);
760 }
761
762 #[test]
763 fn text_sidecars_default_off_and_follow_precedence() {
764 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
766 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
767 assert!(!eff.details_sidecar);
768 assert!(!eff.lyrics_sidecar);
769
770 let toml = r#"
771 [defaults]
772 details_sidecar = true
773
774 [accounts.alice.sources.liked]
775 details_sidecar = false
776 "#;
777 let cfg = Config::from_toml(toml).unwrap();
778
779 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
781 assert!(eff.details_sidecar);
782 assert!(!eff.lyrics_sidecar);
783
784 let eff = cfg
786 .resolve("alice", Some("liked"), &no_env(), &no_flags())
787 .unwrap();
788 assert!(!eff.details_sidecar);
789
790 let env: HashMap<String, String> = [
792 ("SUNO_DETAILS_SIDECAR".into(), "true".into()),
793 ("SUNO_LYRICS_SIDECAR".into(), "true".into()),
794 ]
795 .into_iter()
796 .collect();
797 let eff = cfg
798 .resolve("alice", Some("liked"), &env, &no_flags())
799 .unwrap();
800 assert!(eff.details_sidecar);
801 assert!(eff.lyrics_sidecar);
802
803 let flags = FlagOverrides {
804 lyrics_sidecar: Some(false),
805 ..Default::default()
806 };
807 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
808 assert!(eff.details_sidecar);
809 assert!(!eff.lyrics_sidecar);
810 }
811
812 #[test]
813 fn invalid_env_bool_errors() {
814 let toml = "[accounts.alice]\n";
815 let cfg = Config::from_toml(toml).unwrap();
816 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "yes".into())]
817 .into_iter()
818 .collect();
819 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
820 }
821
822 #[test]
823 fn unknown_account_errors() {
824 let cfg = Config::from_toml("").unwrap();
825 assert!(cfg.resolve("nobody", None, &no_env(), &no_flags()).is_err());
826 }
827
828 #[test]
829 fn validation_nested_roots() {
830 let toml = r#"
831 [accounts.alice]
832 root = "/music"
833
834 [accounts.bob]
835 root = "/music/bob"
836 "#;
837 assert!(Config::from_toml(toml).is_err());
838 }
839
840 #[test]
841 fn validation_non_nested_roots_ok() {
842 let toml = r#"
843 [accounts.alice]
844 root = "/music/alice"
845
846 [accounts.bob]
847 root = "/music/bob"
848 "#;
849 assert!(Config::from_toml(toml).is_ok());
850 }
851
852 #[test]
853 fn invalid_toml_errors() {
854 assert!(Config::from_toml("not valid toml ][").is_err());
855 }
856
857 #[test]
858 fn duplicate_account_label_errors() {
859 let toml = "
861 [accounts.alice]
862 token = \"tok1\"
863
864 [accounts.alice]
865 token = \"tok2\"
866 ";
867 assert!(Config::from_toml(toml).is_err());
868 }
869
870 #[test]
871 fn parse_error_does_not_echo_token() {
872 let toml = "[accounts.alice]\ntoken = \"unterminated\n";
874 let err = Config::from_toml(toml).unwrap_err().to_string();
875 assert!(!err.contains("unterminated"), "error leaked token: {err}");
876 }
877
878 #[test]
879 fn validation_env_prefix_collision_errors() {
880 let toml = "
882 [accounts.my-lib]
883 [accounts.my_lib]
884 ";
885 assert!(Config::from_toml(toml).is_err());
886 }
887
888 #[test]
889 fn audio_format_display_roundtrip() {
890 for fmt in [AudioFormat::Mp3, AudioFormat::Flac, AudioFormat::Wav] {
891 let s = fmt.to_string();
892 assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
893 }
894 }
895
896 #[test]
897 fn naming_template_follows_precedence() {
898 let toml = r#"
899 [defaults]
900 naming_template = "{title}"
901
902 [accounts.alice]
903 naming_template = "{creator}/{title}"
904
905 [accounts.alice.sources.liked]
906 naming_template = "{handle}/{title} [{id8}]"
907 "#;
908 let cfg = Config::from_toml(toml).unwrap();
909
910 let eff = cfg
912 .resolve("alice", Some("liked"), &no_env(), &no_flags())
913 .unwrap();
914 assert_eq!(eff.naming_template, "{handle}/{title} [{id8}]");
915
916 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
918 assert_eq!(eff.naming_template, "{creator}/{title}");
919
920 let env: HashMap<String, String> = [("SUNO_NAMING_TEMPLATE".into(), "{id}".into())]
922 .into_iter()
923 .collect();
924 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
925 assert_eq!(eff.naming_template, "{id}");
926
927 let flags = FlagOverrides {
929 naming_template: Some("{title}/{id8}".into()),
930 ..Default::default()
931 };
932 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
933 assert_eq!(eff.naming_template, "{title}/{id8}");
934 }
935
936 #[test]
937 fn character_set_follows_precedence() {
938 let toml = r#"
939 [defaults]
940 character_set = "ascii"
941
942 [accounts.alice]
943 "#;
944 let cfg = Config::from_toml(toml).unwrap();
945
946 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
948 assert_eq!(eff.character_set, CharacterSet::Ascii);
949
950 let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "unicode".into())]
952 .into_iter()
953 .collect();
954 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
955 assert_eq!(eff.character_set, CharacterSet::Unicode);
956
957 let flags = FlagOverrides {
959 character_set: Some(CharacterSet::Ascii),
960 ..Default::default()
961 };
962 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
963 assert_eq!(eff.character_set, CharacterSet::Ascii);
964 }
965
966 #[test]
967 fn invalid_character_set_env_errors() {
968 let toml = "[accounts.alice]\n";
969 let cfg = Config::from_toml(toml).unwrap();
970 let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "utf8".into())]
971 .into_iter()
972 .collect();
973 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
974 }
975
976 #[test]
977 fn areas_parse_full_table() {
978 let toml = r#"
979 [accounts.alice]
980 token = "t"
981 [accounts.alice.areas]
982 library = "off"
983 liked = "copy"
984 playlists = "mirror"
985 [accounts.alice.areas.playlist]
986 "pl_abc123" = "mirror"
987 "pl_def456" = "copy"
988 "#;
989 let cfg = Config::from_toml(toml).unwrap();
990 let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
991 assert_eq!(areas.library, Some(AreaMode::Off));
992 assert_eq!(areas.liked, Some(SourceMode::Copy));
993 assert_eq!(areas.playlists, Some(SourceMode::Mirror));
994 assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
995 assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
996 }
997
998 #[test]
999 fn areas_library_accepts_copy_and_mirror() {
1000 for (raw, expect) in [
1001 ("copy", AreaMode::Mode(SourceMode::Copy)),
1002 ("mirror", AreaMode::Mode(SourceMode::Mirror)),
1003 ] {
1004 let toml =
1005 format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
1006 let cfg = Config::from_toml(&toml).unwrap();
1007 assert_eq!(
1008 cfg.accounts["a"].areas.as_ref().unwrap().library,
1009 Some(expect)
1010 );
1011 }
1012 }
1013
1014 #[test]
1015 fn areas_bad_mode_errors() {
1016 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
1017 assert!(Config::from_toml(toml).is_err());
1018 }
1019
1020 #[test]
1021 fn areas_bad_playlist_mode_errors() {
1022 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
1023 assert!(Config::from_toml(toml).is_err());
1025 }
1026
1027 #[test]
1028 fn areas_unknown_field_errors() {
1029 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
1031 assert!(Config::from_toml(toml).is_err());
1032 }
1033
1034 #[test]
1035 fn areas_absent_is_none() {
1036 let toml = "[accounts.a]\ntoken = \"t\"\n";
1037 assert!(
1038 Config::from_toml(toml).unwrap().accounts["a"]
1039 .areas
1040 .is_none()
1041 );
1042 }
1043}