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