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