1use std::collections::{BTreeMap, 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, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
58#[serde(rename_all = "lowercase")]
59pub enum StemFormat {
60 #[default]
62 Wav,
63 Mp3,
65}
66
67impl StemFormat {
68 pub fn ext(self) -> &'static str {
70 match self {
71 Self::Wav => "wav",
72 Self::Mp3 => "mp3",
73 }
74 }
75}
76
77impl FromStr for StemFormat {
78 type Err = Error;
79
80 fn from_str(s: &str) -> Result<Self> {
81 match s.to_ascii_lowercase().as_str() {
82 "wav" => Ok(Self::Wav),
83 "mp3" => Ok(Self::Mp3),
84 "flac" => Err(Error::Config(
85 "stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
86 )),
87 other => Err(Error::Config(format!("unknown stem format '{other}'"))),
88 }
89 }
90}
91
92impl fmt::Display for StemFormat {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 f.write_str(self.ext())
95 }
96}
97
98#[derive(Debug, Clone, Default, Deserialize)]
100pub struct Defaults {
101 pub format: Option<AudioFormat>,
102 pub concurrency: Option<u32>,
103 pub retries: Option<u32>,
104 pub min_newest: Option<u32>,
105 pub token_command: Option<String>,
106 pub animated_covers: Option<bool>,
107 pub details_sidecar: Option<bool>,
108 pub lyrics_sidecar: Option<bool>,
109 pub lrc_sidecar: Option<bool>,
110 pub video_mp4: Option<bool>,
111 pub download_stems: Option<bool>,
112 pub stem_format: Option<StemFormat>,
113 pub naming_template: Option<String>,
114 pub character_set: Option<CharacterSet>,
115}
116
117#[derive(Debug, Clone, Default, Deserialize)]
119pub struct SourceConfig {
120 pub format: Option<AudioFormat>,
121 pub concurrency: Option<u32>,
122 pub retries: Option<u32>,
123 pub min_newest: Option<u32>,
124 pub token_command: Option<String>,
125 pub animated_covers: Option<bool>,
126 pub details_sidecar: Option<bool>,
127 pub lyrics_sidecar: Option<bool>,
128 pub lrc_sidecar: Option<bool>,
129 pub video_mp4: Option<bool>,
130 pub download_stems: Option<bool>,
131 pub stem_format: Option<StemFormat>,
132 pub naming_template: Option<String>,
133 pub character_set: Option<CharacterSet>,
134}
135
136#[derive(Debug, Clone, Default, Deserialize)]
138pub struct AccountConfig {
139 pub token: Option<String>,
140 pub token_command: Option<String>,
141 pub root: Option<String>,
142 pub account_id: Option<String>,
146 pub format: Option<AudioFormat>,
147 pub concurrency: Option<u32>,
148 pub retries: Option<u32>,
149 pub min_newest: Option<u32>,
150 pub animated_covers: Option<bool>,
151 pub details_sidecar: Option<bool>,
152 pub lyrics_sidecar: Option<bool>,
153 pub lrc_sidecar: Option<bool>,
154 pub video_mp4: Option<bool>,
155 pub download_stems: Option<bool>,
156 pub stem_format: Option<StemFormat>,
157 pub naming_template: Option<String>,
158 pub character_set: Option<CharacterSet>,
159 #[serde(default)]
160 pub sources: HashMap<String, SourceConfig>,
161 pub areas: Option<AreasConfig>,
164 #[serde(default)]
170 pub albums: HashMap<String, String>,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum AreaMode {
181 Off,
183 Mode(SourceMode),
185}
186
187impl<'de> Deserialize<'de> for AreaMode {
188 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
189 where
190 D: serde::Deserializer<'de>,
191 {
192 let raw = String::deserialize(deserializer)?;
193 match raw.as_str() {
194 "off" => Ok(AreaMode::Off),
195 "copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
196 "mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
197 other => Err(serde::de::Error::custom(format!(
198 "unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
199 ))),
200 }
201 }
202}
203
204#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct AreasConfig {
215 pub library: Option<AreaMode>,
216 pub liked: Option<SourceMode>,
217 pub playlists: Option<SourceMode>,
218 #[serde(default)]
219 pub playlist: HashMap<String, SourceMode>,
220}
221
222#[derive(Debug, Clone, Default, Deserialize)]
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(|e| {
239 let raw = e.to_string();
242 let msg = raw
243 .lines()
244 .filter(|l| !l.contains(" | "))
245 .collect::<Vec<_>>()
246 .join("\n")
247 .trim()
248 .to_owned();
249 Error::Config(if msg.is_empty() {
250 "parse error".into()
251 } else {
252 msg
253 })
254 })?;
255 config.validate()?;
256 Ok(config)
257 }
258
259 fn validate(&self) -> Result<()> {
260 let roots: Vec<(&str, &str)> = self
261 .accounts
262 .iter()
263 .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
264 .collect();
265
266 for (i, (label_a, root_a)) in roots.iter().enumerate() {
267 for (label_b, root_b) in roots.iter().skip(i + 1) {
268 let a = Path::new(root_a);
269 let b = Path::new(root_b);
270 if a.starts_with(b) || b.starts_with(a) {
271 return Err(Error::Config(format!(
272 "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
273 )));
274 }
275 }
276 }
277
278 let mut prefix_seen: HashMap<String, &str> = HashMap::new();
279 for label in self.accounts.keys() {
280 let prefix = label_to_env(label);
281 if let Some(other) = prefix_seen.get(&prefix) {
282 return Err(Error::Config(format!(
283 "accounts '{label}' and '{other}' share env prefix '{prefix}'"
284 )));
285 }
286 prefix_seen.insert(prefix, label.as_str());
287 }
288
289 Ok(())
290 }
291
292 pub fn resolve(
298 &self,
299 account: &str,
300 source: Option<&str>,
301 env: &HashMap<String, String>,
302 flags: &FlagOverrides,
303 ) -> Result<EffectiveSettings> {
304 let acc = self
305 .accounts
306 .get(account)
307 .ok_or_else(|| Error::Config(format!("account '{account}' not found")))?;
308
309 let src = source.and_then(|s| acc.sources.get(s));
310 let label_env = label_to_env(account);
311
312 let env_val = |suffix: &str| -> Option<&str> {
314 env.get(&format!("SUNO_{label_env}_{suffix}"))
315 .or_else(|| env.get(&format!("SUNO_{suffix}")))
316 .map(String::as_str)
317 };
318
319 let format_from_env = env_val("FORMAT")
320 .map(str::parse::<AudioFormat>)
321 .transpose()?;
322
323 let format = flags
324 .format
325 .or(format_from_env)
326 .or_else(|| src.and_then(|s| s.format))
327 .or(acc.format)
328 .or(self.defaults.format)
329 .unwrap_or(AudioFormat::Flac);
330
331 let concurrency = resolve_u32(
332 flags.concurrency,
333 env_val("CONCURRENCY"),
334 src.and_then(|s| s.concurrency),
335 acc.concurrency,
336 self.defaults.concurrency,
337 4,
338 "CONCURRENCY",
339 )?;
340
341 let retries = resolve_u32(
342 flags.retries,
343 env_val("RETRIES"),
344 src.and_then(|s| s.retries),
345 acc.retries,
346 self.defaults.retries,
347 3,
348 "RETRIES",
349 )?;
350
351 let min_newest = resolve_u32(
352 flags.min_newest,
353 env_val("MIN_NEWEST"),
354 src.and_then(|s| s.min_newest),
355 acc.min_newest,
356 self.defaults.min_newest,
357 1,
358 "MIN_NEWEST",
359 )?;
360
361 let animated_covers = resolve_bool(
362 flags.animated_covers,
363 env_val("ANIMATED_COVERS"),
364 src.and_then(|s| s.animated_covers),
365 acc.animated_covers,
366 self.defaults.animated_covers,
367 false,
368 "ANIMATED_COVERS",
369 )?;
370
371 let details_sidecar = resolve_bool(
372 flags.details_sidecar,
373 env_val("DETAILS_SIDECAR"),
374 src.and_then(|s| s.details_sidecar),
375 acc.details_sidecar,
376 self.defaults.details_sidecar,
377 false,
378 "DETAILS_SIDECAR",
379 )?;
380
381 let lyrics_sidecar = resolve_bool(
382 flags.lyrics_sidecar,
383 env_val("LYRICS_SIDECAR"),
384 src.and_then(|s| s.lyrics_sidecar),
385 acc.lyrics_sidecar,
386 self.defaults.lyrics_sidecar,
387 false,
388 "LYRICS_SIDECAR",
389 )?;
390
391 let lrc_sidecar = resolve_bool(
392 flags.lrc_sidecar,
393 env_val("LRC_SIDECAR"),
394 src.and_then(|s| s.lrc_sidecar),
395 acc.lrc_sidecar,
396 self.defaults.lrc_sidecar,
397 false,
398 "LRC_SIDECAR",
399 )?;
400
401 let video_mp4 = resolve_bool(
402 flags.video_mp4,
403 env_val("VIDEO_MP4"),
404 src.and_then(|s| s.video_mp4),
405 acc.video_mp4,
406 self.defaults.video_mp4,
407 false,
408 "VIDEO_MP4",
409 )?;
410
411 let download_stems = resolve_bool(
412 flags.download_stems,
413 env_val("DOWNLOAD_STEMS"),
414 src.and_then(|s| s.download_stems),
415 acc.download_stems,
416 self.defaults.download_stems,
417 false,
418 "DOWNLOAD_STEMS",
419 )?;
420
421 let stem_format_from_env = env_val("STEM_FORMAT")
422 .map(str::parse::<StemFormat>)
423 .transpose()?;
424 let stem_format = flags
425 .stem_format
426 .or(stem_format_from_env)
427 .or_else(|| src.and_then(|s| s.stem_format))
428 .or(acc.stem_format)
429 .or(self.defaults.stem_format)
430 .unwrap_or_default();
431
432 let naming_template_from_env = env_val("NAMING_TEMPLATE").map(str::to_owned);
433 let naming_template = flags
434 .naming_template
435 .clone()
436 .or(naming_template_from_env)
437 .or_else(|| src.and_then(|s| s.naming_template.clone()))
438 .or_else(|| acc.naming_template.clone())
439 .or_else(|| self.defaults.naming_template.clone())
440 .unwrap_or_else(|| crate::naming::DEFAULT_TEMPLATE.to_owned());
441
442 let character_set_from_env = env_val("CHARACTER_SET")
443 .map(str::parse::<CharacterSet>)
444 .transpose()?;
445 let character_set = flags
446 .character_set
447 .or(character_set_from_env)
448 .or_else(|| src.and_then(|s| s.character_set))
449 .or(acc.character_set)
450 .or(self.defaults.character_set)
451 .unwrap_or(CharacterSet::Unicode);
452
453 let token = flags
454 .token
455 .clone()
456 .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
457 .or_else(|| env.get("SUNO_TOKEN").cloned());
458
459 let token_command = env
460 .get(&format!("SUNO_{label_env}_TOKEN_COMMAND"))
461 .cloned()
462 .or_else(|| env.get("SUNO_TOKEN_COMMAND").cloned())
463 .or_else(|| src.and_then(|s| s.token_command.clone()))
464 .or_else(|| acc.token_command.clone())
465 .or_else(|| self.defaults.token_command.clone());
466
467 Ok(EffectiveSettings {
468 token,
469 stored_token: acc.token.clone(),
470 token_command,
471 account_id: acc.account_id.clone(),
472 format,
473 concurrency,
474 retries,
475 min_newest,
476 animated_covers,
477 details_sidecar,
478 lyrics_sidecar,
479 lrc_sidecar,
480 video_mp4,
481 download_stems,
482 stem_format,
483 naming_template,
484 character_set,
485 areas: acc.areas.clone(),
486 album_overrides: acc
487 .albums
488 .iter()
489 .filter(|(_, name)| !name.trim().is_empty())
490 .map(|(root_id, name)| (root_id.clone(), name.trim().to_owned()))
491 .collect(),
492 })
493 }
494}
495
496fn resolve_u32(
497 flag: Option<u32>,
498 env_str: Option<&str>,
499 src: Option<u32>,
500 acc: Option<u32>,
501 defaults: Option<u32>,
502 compiled: u32,
503 name: &str,
504) -> Result<u32> {
505 if let Some(v) = flag {
506 return Ok(v);
507 }
508 if let Some(s) = env_str {
509 return s
510 .parse()
511 .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
512 }
513 Ok(src.or(acc).or(defaults).unwrap_or(compiled))
514}
515
516fn resolve_bool(
517 flag: Option<bool>,
518 env_str: Option<&str>,
519 src: Option<bool>,
520 acc: Option<bool>,
521 defaults: Option<bool>,
522 compiled: bool,
523 name: &str,
524) -> Result<bool> {
525 if let Some(v) = flag {
526 return Ok(v);
527 }
528 if let Some(s) = env_str {
529 return s
530 .parse()
531 .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
532 }
533 Ok(src.or(acc).or(defaults).unwrap_or(compiled))
534}
535
536pub fn label_to_env(label: &str) -> String {
540 label.to_ascii_uppercase().replace('-', "_")
541}
542
543#[derive(Debug, Default)]
546pub struct FlagOverrides {
547 pub token: Option<String>,
548 pub format: Option<AudioFormat>,
549 pub concurrency: Option<u32>,
550 pub retries: Option<u32>,
551 pub min_newest: Option<u32>,
552 pub animated_covers: Option<bool>,
553 pub details_sidecar: Option<bool>,
554 pub lyrics_sidecar: Option<bool>,
555 pub lrc_sidecar: Option<bool>,
556 pub video_mp4: Option<bool>,
557 pub download_stems: Option<bool>,
558 pub stem_format: Option<StemFormat>,
559 pub naming_template: Option<String>,
560 pub character_set: Option<CharacterSet>,
561}
562
563#[derive(Debug, Clone, PartialEq)]
565pub struct EffectiveSettings {
566 pub token: Option<String>,
568 pub stored_token: Option<String>,
570 pub token_command: Option<String>,
572 pub account_id: Option<String>,
574 pub format: AudioFormat,
575 pub concurrency: u32,
576 pub retries: u32,
577 pub min_newest: u32,
578 pub animated_covers: bool,
579 pub details_sidecar: bool,
580 pub lyrics_sidecar: bool,
581 pub lrc_sidecar: bool,
582 pub video_mp4: bool,
583 pub download_stems: bool,
584 pub stem_format: StemFormat,
585 pub naming_template: String,
586 pub character_set: CharacterSet,
587 pub areas: Option<AreasConfig>,
589 pub album_overrides: BTreeMap<String, String>,
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598
599 fn no_env() -> HashMap<String, String> {
600 HashMap::new()
601 }
602
603 fn no_flags() -> FlagOverrides {
604 FlagOverrides::default()
605 }
606
607 #[test]
608 fn parse_empty_toml() {
609 let cfg = Config::from_toml("").unwrap();
610 assert!(cfg.accounts.is_empty());
611 }
612
613 #[test]
614 fn parse_basic_account() {
615 let toml = r#"
616 [accounts.alice]
617 token = "tok"
618 root = "/music"
619 "#;
620 let cfg = Config::from_toml(toml).unwrap();
621 let acc = &cfg.accounts["alice"];
622 assert_eq!(acc.token.as_deref(), Some("tok"));
623 assert_eq!(acc.root.as_deref(), Some("/music"));
624 }
625
626 #[test]
627 fn account_id_parses_and_resolves() {
628 let toml = r#"
629 [accounts.alice]
630 token = "tok"
631 root = "/music"
632 account_id = "user_abc123"
633 "#;
634 let cfg = Config::from_toml(toml).unwrap();
635 assert_eq!(
636 cfg.accounts["alice"].account_id.as_deref(),
637 Some("user_abc123")
638 );
639 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
640 assert_eq!(eff.account_id.as_deref(), Some("user_abc123"));
641 }
642
643 #[test]
644 fn parse_defaults_section() {
645 let toml = r#"
646 [defaults]
647 format = "mp3"
648 concurrency = 8
649 retries = 5
650 min_newest = 2
651 animated_covers = true
652 "#;
653 let cfg = Config::from_toml(toml).unwrap();
654 assert_eq!(cfg.defaults.format, Some(AudioFormat::Mp3));
655 assert_eq!(cfg.defaults.concurrency, Some(8));
656 assert_eq!(cfg.defaults.retries, Some(5));
657 assert_eq!(cfg.defaults.min_newest, Some(2));
658 assert_eq!(cfg.defaults.animated_covers, Some(true));
659 }
660
661 #[test]
662 fn compiled_defaults_when_nothing_set() {
663 let toml = "[accounts.alice]\n";
664 let cfg = Config::from_toml(toml).unwrap();
665 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
666 assert_eq!(
667 eff,
668 EffectiveSettings {
669 token: None,
670 stored_token: None,
671 token_command: None,
672 account_id: None,
673 format: AudioFormat::Flac,
674 concurrency: 4,
675 retries: 3,
676 min_newest: 1,
677 animated_covers: false,
678 details_sidecar: false,
679 lyrics_sidecar: false,
680 lrc_sidecar: false,
681 video_mp4: false,
682 download_stems: false,
683 stem_format: StemFormat::Wav,
684 naming_template: crate::naming::DEFAULT_TEMPLATE.to_owned(),
685 character_set: CharacterSet::Unicode,
686 areas: None,
687 album_overrides: BTreeMap::new(),
688 }
689 );
690 }
691
692 #[test]
693 fn file_defaults_override_compiled() {
694 let toml = r#"
695 [defaults]
696 format = "mp3"
697 concurrency = 8
698
699 [accounts.alice]
700 "#;
701 let cfg = Config::from_toml(toml).unwrap();
702 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
703 assert_eq!(eff.format, AudioFormat::Mp3);
704 assert_eq!(eff.concurrency, 8);
705 assert_eq!(eff.retries, 3); }
707
708 #[test]
709 fn account_settings_override_defaults() {
710 let toml = r#"
711 [defaults]
712 format = "mp3"
713
714 [accounts.alice]
715 format = "wav"
716 "#;
717 let cfg = Config::from_toml(toml).unwrap();
718 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
719 assert_eq!(eff.format, AudioFormat::Wav);
720 }
721
722 #[test]
723 fn per_source_overrides_account() {
724 let toml = r#"
725 [accounts.alice]
726 format = "flac"
727
728 [accounts.alice.sources.liked]
729 format = "mp3"
730 "#;
731 let cfg = Config::from_toml(toml).unwrap();
732 let eff = cfg
733 .resolve("alice", Some("liked"), &no_env(), &no_flags())
734 .unwrap();
735 assert_eq!(eff.format, AudioFormat::Mp3);
736 }
737
738 #[test]
739 fn unknown_source_falls_back_to_account() {
740 let toml = r#"
741 [accounts.alice]
742 format = "wav"
743 "#;
744 let cfg = Config::from_toml(toml).unwrap();
745 let eff = cfg
746 .resolve("alice", Some("nonexistent"), &no_env(), &no_flags())
747 .unwrap();
748 assert_eq!(eff.format, AudioFormat::Wav);
749 }
750
751 #[test]
752 fn global_env_overrides_file() {
753 let toml = r#"
754 [accounts.alice]
755 format = "flac"
756 "#;
757 let cfg = Config::from_toml(toml).unwrap();
758 let env: HashMap<String, String> =
759 [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
760 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
761 assert_eq!(eff.format, AudioFormat::Mp3);
762 }
763
764 #[test]
765 fn per_account_env_overrides_global_env() {
766 let toml = "[accounts.alice]\n";
767 let cfg = Config::from_toml(toml).unwrap();
768 let env: HashMap<String, String> = [
769 ("SUNO_FORMAT".into(), "mp3".into()),
770 ("SUNO_ALICE_FORMAT".into(), "wav".into()),
771 ]
772 .into_iter()
773 .collect();
774 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
775 assert_eq!(eff.format, AudioFormat::Wav);
776 }
777
778 #[test]
779 fn per_account_env_label_uppersnakedcase() {
780 let toml = "[accounts.my-lib]\n";
781 let cfg = Config::from_toml(toml).unwrap();
782 let env: HashMap<String, String> = [("SUNO_MY_LIB_FORMAT".into(), "wav".into())]
783 .into_iter()
784 .collect();
785 let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
786 assert_eq!(eff.format, AudioFormat::Wav);
787 }
788
789 #[test]
790 fn flag_overrides_env_and_file() {
791 let toml = r#"
792 [accounts.alice]
793 format = "flac"
794 "#;
795 let cfg = Config::from_toml(toml).unwrap();
796 let env: HashMap<String, String> =
797 [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
798 let flags = FlagOverrides {
799 format: Some(AudioFormat::Wav),
800 ..Default::default()
801 };
802 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
803 assert_eq!(eff.format, AudioFormat::Wav);
804 }
805
806 #[test]
807 fn token_precedence() {
808 let toml = r#"
809 [accounts.alice]
810 token = "file_tok"
811 "#;
812 let cfg = Config::from_toml(toml).unwrap();
813
814 let env: HashMap<String, String> = [("SUNO_TOKEN".into(), "env_tok".into())]
816 .into_iter()
817 .collect();
818 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
819 assert_eq!(eff.token.as_deref(), Some("env_tok"));
820 assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
821
822 let flags = FlagOverrides {
824 token: Some("flag_tok".into()),
825 ..Default::default()
826 };
827 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
828 assert_eq!(eff.token.as_deref(), Some("flag_tok"));
829 assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
830 }
831
832 #[test]
833 fn stored_token_is_populated_from_config_when_no_override_exists() {
834 let toml = r#"
835 [accounts.alice]
836 token = "file_tok"
837 "#;
838 let cfg = Config::from_toml(toml).unwrap();
839 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
840 assert_eq!(eff.token, None);
841 assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
842 assert_eq!(eff.token_command, None);
843 }
844
845 #[test]
846 fn per_account_token_env_overrides_global() {
847 let toml = "[accounts.alice]\n";
848 let cfg = Config::from_toml(toml).unwrap();
849 let env: HashMap<String, String> = [
850 ("SUNO_TOKEN".into(), "global".into()),
851 ("SUNO_ALICE_TOKEN".into(), "per_account".into()),
852 ]
853 .into_iter()
854 .collect();
855 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
856 assert_eq!(eff.token.as_deref(), Some("per_account"));
857 }
858
859 #[test]
860 fn token_command_resolves_from_defaults_account_source_and_env() {
861 let toml = r#"
862 [defaults]
863 token_command = "defaults"
864
865 [accounts.alice]
866 token_command = "account"
867
868 [accounts.alice.sources.liked]
869 token_command = "source"
870 "#;
871 let cfg = Config::from_toml(toml).unwrap();
872
873 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
874 assert_eq!(eff.token_command.as_deref(), Some("account"));
875
876 let eff = cfg
877 .resolve("alice", Some("liked"), &no_env(), &no_flags())
878 .unwrap();
879 assert_eq!(eff.token_command.as_deref(), Some("source"));
880
881 let env: HashMap<String, String> = [("SUNO_TOKEN_COMMAND".into(), "global".into())]
882 .into_iter()
883 .collect();
884 let eff = cfg
885 .resolve("alice", Some("liked"), &env, &no_flags())
886 .unwrap();
887 assert_eq!(eff.token_command.as_deref(), Some("global"));
888
889 let env: HashMap<String, String> = [
890 ("SUNO_TOKEN_COMMAND".into(), "global".into()),
891 ("SUNO_ALICE_TOKEN_COMMAND".into(), "per_account".into()),
892 ]
893 .into_iter()
894 .collect();
895 let eff = cfg
896 .resolve("alice", Some("liked"), &env, &no_flags())
897 .unwrap();
898 assert_eq!(eff.token_command.as_deref(), Some("per_account"));
899 }
900
901 #[test]
902 fn per_account_token_command_env_label_uppersnakedcase() {
903 let cfg = Config::from_toml("[accounts.my-lib]\n").unwrap();
904 let env: HashMap<String, String> = [("SUNO_MY_LIB_TOKEN_COMMAND".into(), "command".into())]
905 .into_iter()
906 .collect();
907 let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
908 assert_eq!(eff.token_command.as_deref(), Some("command"));
909 }
910
911 #[test]
912 fn invalid_env_u32_errors() {
913 let toml = "[accounts.alice]\n";
914 let cfg = Config::from_toml(toml).unwrap();
915 let env: HashMap<String, String> = [("SUNO_CONCURRENCY".into(), "many".into())]
916 .into_iter()
917 .collect();
918 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
919 }
920
921 #[test]
922 fn animated_covers_defaults_off_and_follows_precedence() {
923 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
925 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
926 assert!(!eff.animated_covers);
927
928 let toml = r#"
930 [defaults]
931 animated_covers = true
932
933 [accounts.alice.sources.liked]
934 animated_covers = false
935 "#;
936 let cfg = Config::from_toml(toml).unwrap();
937
938 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
940 assert!(eff.animated_covers);
941
942 let eff = cfg
944 .resolve("alice", Some("liked"), &no_env(), &no_flags())
945 .unwrap();
946 assert!(!eff.animated_covers);
947
948 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "true".into())]
950 .into_iter()
951 .collect();
952 let eff = cfg
953 .resolve("alice", Some("liked"), &env, &no_flags())
954 .unwrap();
955 assert!(eff.animated_covers);
956
957 let flags = FlagOverrides {
959 animated_covers: Some(false),
960 ..Default::default()
961 };
962 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
963 assert!(!eff.animated_covers);
964 }
965
966 #[test]
967 fn video_mp4_defaults_off_and_follows_precedence() {
968 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
970 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
971 assert!(!eff.video_mp4);
972
973 let toml = r#"
975 [defaults]
976 video_mp4 = true
977
978 [accounts.alice.sources.liked]
979 video_mp4 = false
980 "#;
981 let cfg = Config::from_toml(toml).unwrap();
982 assert!(
983 cfg.resolve("alice", None, &no_env(), &no_flags())
984 .unwrap()
985 .video_mp4
986 );
987 assert!(
988 !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
989 .unwrap()
990 .video_mp4
991 );
992
993 let env: HashMap<String, String> = [("SUNO_VIDEO_MP4".into(), "true".into())]
994 .into_iter()
995 .collect();
996 assert!(
997 cfg.resolve("alice", Some("liked"), &env, &no_flags())
998 .unwrap()
999 .video_mp4
1000 );
1001
1002 let flags = FlagOverrides {
1003 video_mp4: Some(false),
1004 ..Default::default()
1005 };
1006 assert!(
1007 !cfg.resolve("alice", Some("liked"), &env, &flags)
1008 .unwrap()
1009 .video_mp4
1010 );
1011 }
1012
1013 #[test]
1014 fn download_stems_defaults_off_and_follows_precedence() {
1015 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1018 assert!(
1019 !cfg.resolve("alice", None, &no_env(), &no_flags())
1020 .unwrap()
1021 .download_stems
1022 );
1023
1024 let toml = r#"
1026 [defaults]
1027 download_stems = true
1028
1029 [accounts.alice.sources.liked]
1030 download_stems = false
1031 "#;
1032 let cfg = Config::from_toml(toml).unwrap();
1033 assert!(
1034 cfg.resolve("alice", None, &no_env(), &no_flags())
1035 .unwrap()
1036 .download_stems
1037 );
1038 assert!(
1039 !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1040 .unwrap()
1041 .download_stems
1042 );
1043
1044 let env: HashMap<String, String> = [("SUNO_DOWNLOAD_STEMS".into(), "true".into())]
1045 .into_iter()
1046 .collect();
1047 assert!(
1048 cfg.resolve("alice", Some("liked"), &env, &no_flags())
1049 .unwrap()
1050 .download_stems
1051 );
1052
1053 let flags = FlagOverrides {
1054 download_stems: Some(false),
1055 ..Default::default()
1056 };
1057 assert!(
1058 !cfg.resolve("alice", Some("liked"), &env, &flags)
1059 .unwrap()
1060 .download_stems
1061 );
1062 }
1063
1064 #[test]
1065 fn stem_format_defaults_to_wav_and_follows_precedence() {
1066 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1068 assert_eq!(
1069 cfg.resolve("alice", None, &no_env(), &no_flags())
1070 .unwrap()
1071 .stem_format,
1072 StemFormat::Wav
1073 );
1074
1075 let toml = r#"
1077 [defaults]
1078 stem_format = "mp3"
1079
1080 [accounts.alice.sources.liked]
1081 stem_format = "wav"
1082 "#;
1083 let cfg = Config::from_toml(toml).unwrap();
1084 assert_eq!(
1085 cfg.resolve("alice", None, &no_env(), &no_flags())
1086 .unwrap()
1087 .stem_format,
1088 StemFormat::Mp3
1089 );
1090 assert_eq!(
1091 cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1092 .unwrap()
1093 .stem_format,
1094 StemFormat::Wav
1095 );
1096
1097 let env: HashMap<String, String> = [("SUNO_STEM_FORMAT".into(), "mp3".into())]
1098 .into_iter()
1099 .collect();
1100 assert_eq!(
1101 cfg.resolve("alice", Some("liked"), &env, &no_flags())
1102 .unwrap()
1103 .stem_format,
1104 StemFormat::Mp3
1105 );
1106
1107 let flags = FlagOverrides {
1108 stem_format: Some(StemFormat::Wav),
1109 ..Default::default()
1110 };
1111 assert_eq!(
1112 cfg.resolve("alice", Some("liked"), &env, &flags)
1113 .unwrap()
1114 .stem_format,
1115 StemFormat::Wav
1116 );
1117 }
1118
1119 #[test]
1120 fn stem_format_rejects_flac_and_unknown() {
1121 assert!("flac".parse::<StemFormat>().is_err());
1124 assert!("aac".parse::<StemFormat>().is_err());
1125 assert_eq!("WAV".parse::<StemFormat>().unwrap(), StemFormat::Wav);
1126 assert_eq!("Mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
1127 assert!(Config::from_toml("[defaults]\nstem_format = \"flac\"\n").is_err());
1129 }
1130
1131 #[test]
1132 fn text_sidecars_default_off_and_follow_precedence() {
1133 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1135 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1136 assert!(!eff.details_sidecar);
1137 assert!(!eff.lyrics_sidecar);
1138
1139 let toml = r#"
1140 [defaults]
1141 details_sidecar = true
1142
1143 [accounts.alice.sources.liked]
1144 details_sidecar = false
1145 "#;
1146 let cfg = Config::from_toml(toml).unwrap();
1147
1148 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1150 assert!(eff.details_sidecar);
1151 assert!(!eff.lyrics_sidecar);
1152
1153 let eff = cfg
1155 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1156 .unwrap();
1157 assert!(!eff.details_sidecar);
1158
1159 let env: HashMap<String, String> = [
1161 ("SUNO_DETAILS_SIDECAR".into(), "true".into()),
1162 ("SUNO_LYRICS_SIDECAR".into(), "true".into()),
1163 ]
1164 .into_iter()
1165 .collect();
1166 let eff = cfg
1167 .resolve("alice", Some("liked"), &env, &no_flags())
1168 .unwrap();
1169 assert!(eff.details_sidecar);
1170 assert!(eff.lyrics_sidecar);
1171
1172 let flags = FlagOverrides {
1173 lyrics_sidecar: Some(false),
1174 ..Default::default()
1175 };
1176 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1177 assert!(eff.details_sidecar);
1178 assert!(!eff.lyrics_sidecar);
1179 }
1180
1181 #[test]
1182 fn invalid_env_bool_errors() {
1183 let toml = "[accounts.alice]\n";
1184 let cfg = Config::from_toml(toml).unwrap();
1185 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "yes".into())]
1186 .into_iter()
1187 .collect();
1188 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1189 }
1190
1191 #[test]
1192 fn unknown_account_errors() {
1193 let cfg = Config::from_toml("").unwrap();
1194 assert!(cfg.resolve("nobody", None, &no_env(), &no_flags()).is_err());
1195 }
1196
1197 #[test]
1198 fn validation_nested_roots() {
1199 let toml = r#"
1200 [accounts.alice]
1201 root = "/music"
1202
1203 [accounts.bob]
1204 root = "/music/bob"
1205 "#;
1206 assert!(Config::from_toml(toml).is_err());
1207 }
1208
1209 #[test]
1210 fn validation_non_nested_roots_ok() {
1211 let toml = r#"
1212 [accounts.alice]
1213 root = "/music/alice"
1214
1215 [accounts.bob]
1216 root = "/music/bob"
1217 "#;
1218 assert!(Config::from_toml(toml).is_ok());
1219 }
1220
1221 #[test]
1222 fn invalid_toml_errors() {
1223 assert!(Config::from_toml("not valid toml ][").is_err());
1224 }
1225
1226 #[test]
1227 fn duplicate_account_label_errors() {
1228 let toml = "
1230 [accounts.alice]
1231 token = \"tok1\"
1232
1233 [accounts.alice]
1234 token = \"tok2\"
1235 ";
1236 assert!(Config::from_toml(toml).is_err());
1237 }
1238
1239 #[test]
1240 fn parse_error_does_not_echo_token() {
1241 let toml = "[accounts.alice]\ntoken = \"unterminated\n";
1243 let err = Config::from_toml(toml).unwrap_err().to_string();
1244 assert!(!err.contains("unterminated"), "error leaked token: {err}");
1245 }
1246
1247 #[test]
1248 fn validation_env_prefix_collision_errors() {
1249 let toml = "
1251 [accounts.my-lib]
1252 [accounts.my_lib]
1253 ";
1254 assert!(Config::from_toml(toml).is_err());
1255 }
1256
1257 #[test]
1258 fn audio_format_display_roundtrip() {
1259 for fmt in [AudioFormat::Mp3, AudioFormat::Flac, AudioFormat::Wav] {
1260 let s = fmt.to_string();
1261 assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
1262 }
1263 }
1264
1265 #[test]
1266 fn naming_template_follows_precedence() {
1267 let toml = r#"
1268 [defaults]
1269 naming_template = "{title}"
1270
1271 [accounts.alice]
1272 naming_template = "{creator}/{title}"
1273
1274 [accounts.alice.sources.liked]
1275 naming_template = "{handle}/{title} [{id8}]"
1276 "#;
1277 let cfg = Config::from_toml(toml).unwrap();
1278
1279 let eff = cfg
1281 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1282 .unwrap();
1283 assert_eq!(eff.naming_template, "{handle}/{title} [{id8}]");
1284
1285 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1287 assert_eq!(eff.naming_template, "{creator}/{title}");
1288
1289 let env: HashMap<String, String> = [("SUNO_NAMING_TEMPLATE".into(), "{id}".into())]
1291 .into_iter()
1292 .collect();
1293 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1294 assert_eq!(eff.naming_template, "{id}");
1295
1296 let flags = FlagOverrides {
1298 naming_template: Some("{title}/{id8}".into()),
1299 ..Default::default()
1300 };
1301 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1302 assert_eq!(eff.naming_template, "{title}/{id8}");
1303 }
1304
1305 #[test]
1306 fn character_set_follows_precedence() {
1307 let toml = r#"
1308 [defaults]
1309 character_set = "ascii"
1310
1311 [accounts.alice]
1312 "#;
1313 let cfg = Config::from_toml(toml).unwrap();
1314
1315 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1317 assert_eq!(eff.character_set, CharacterSet::Ascii);
1318
1319 let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "unicode".into())]
1321 .into_iter()
1322 .collect();
1323 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1324 assert_eq!(eff.character_set, CharacterSet::Unicode);
1325
1326 let flags = FlagOverrides {
1328 character_set: Some(CharacterSet::Ascii),
1329 ..Default::default()
1330 };
1331 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1332 assert_eq!(eff.character_set, CharacterSet::Ascii);
1333 }
1334
1335 #[test]
1336 fn invalid_character_set_env_errors() {
1337 let toml = "[accounts.alice]\n";
1338 let cfg = Config::from_toml(toml).unwrap();
1339 let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "utf8".into())]
1340 .into_iter()
1341 .collect();
1342 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1343 }
1344
1345 #[test]
1346 fn areas_parse_full_table() {
1347 let toml = r#"
1348 [accounts.alice]
1349 token = "t"
1350 [accounts.alice.areas]
1351 library = "off"
1352 liked = "copy"
1353 playlists = "mirror"
1354 [accounts.alice.areas.playlist]
1355 "pl_abc123" = "mirror"
1356 "pl_def456" = "copy"
1357 "#;
1358 let cfg = Config::from_toml(toml).unwrap();
1359 let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
1360 assert_eq!(areas.library, Some(AreaMode::Off));
1361 assert_eq!(areas.liked, Some(SourceMode::Copy));
1362 assert_eq!(areas.playlists, Some(SourceMode::Mirror));
1363 assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
1364 assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
1365 }
1366
1367 #[test]
1368 fn album_overrides_parse_and_resolve() {
1369 let toml = r#"
1370 [accounts.alice]
1371 token = "t"
1372 [accounts.alice.albums]
1373 "root_abc123" = "Preferred Name"
1374 "root_def456" = "Another Album"
1375 "root_blank" = " "
1376 "#;
1377 let cfg = Config::from_toml(toml).unwrap();
1378 assert_eq!(
1379 cfg.accounts["alice"].albums["root_abc123"],
1380 "Preferred Name"
1381 );
1382 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1383 assert_eq!(eff.album_overrides["root_abc123"], "Preferred Name");
1384 assert_eq!(eff.album_overrides["root_def456"], "Another Album");
1385 assert!(!eff.album_overrides.contains_key("root_blank"));
1387 }
1388
1389 #[test]
1390 fn album_overrides_absent_by_default() {
1391 let cfg = Config::from_toml("[accounts.alice]\ntoken = \"t\"\n").unwrap();
1392 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1393 assert!(eff.album_overrides.is_empty());
1394 }
1395
1396 #[test]
1397 fn areas_library_accepts_copy_and_mirror() {
1398 for (raw, expect) in [
1399 ("copy", AreaMode::Mode(SourceMode::Copy)),
1400 ("mirror", AreaMode::Mode(SourceMode::Mirror)),
1401 ] {
1402 let toml =
1403 format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
1404 let cfg = Config::from_toml(&toml).unwrap();
1405 assert_eq!(
1406 cfg.accounts["a"].areas.as_ref().unwrap().library,
1407 Some(expect)
1408 );
1409 }
1410 }
1411
1412 #[test]
1413 fn areas_bad_mode_errors() {
1414 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
1415 assert!(Config::from_toml(toml).is_err());
1416 }
1417
1418 #[test]
1419 fn areas_bad_playlist_mode_errors() {
1420 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
1421 assert!(Config::from_toml(toml).is_err());
1423 }
1424
1425 #[test]
1426 fn areas_unknown_field_errors() {
1427 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
1429 assert!(Config::from_toml(toml).is_err());
1430 }
1431
1432 #[test]
1433 fn areas_absent_is_none() {
1434 let toml = "[accounts.a]\ntoken = \"t\"\n";
1435 assert!(
1436 Config::from_toml(toml).unwrap().accounts["a"]
1437 .areas
1438 .is_none()
1439 );
1440 }
1441}