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::ffmpeg::WebpEncodeSettings;
15use crate::naming::CharacterSet;
16use crate::reconcile::SourceMode;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
20#[serde(rename_all = "lowercase")]
21pub enum AudioFormat {
22 Mp3,
23 #[default]
24 Flac,
25 Wav,
26}
27
28impl FromStr for AudioFormat {
29 type Err = Error;
30
31 fn from_str(s: &str) -> Result<Self> {
32 match s.to_ascii_lowercase().as_str() {
33 "mp3" => Ok(Self::Mp3),
34 "flac" => Ok(Self::Flac),
35 "wav" => Ok(Self::Wav),
36 other => Err(Error::Config(format!("unknown format '{other}'"))),
37 }
38 }
39}
40
41impl fmt::Display for AudioFormat {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 match self {
44 Self::Mp3 => f.write_str("mp3"),
45 Self::Flac => f.write_str("flac"),
46 Self::Wav => f.write_str("wav"),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
59#[serde(rename_all = "lowercase")]
60pub enum StemFormat {
61 #[default]
63 Wav,
64 Mp3,
66}
67
68impl StemFormat {
69 pub fn ext(self) -> &'static str {
71 match self {
72 Self::Wav => "wav",
73 Self::Mp3 => "mp3",
74 }
75 }
76}
77
78impl FromStr for StemFormat {
79 type Err = Error;
80
81 fn from_str(s: &str) -> Result<Self> {
82 match s.to_ascii_lowercase().as_str() {
83 "wav" => Ok(Self::Wav),
84 "mp3" => Ok(Self::Mp3),
85 "flac" => Err(Error::Config(
86 "stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
87 )),
88 other => Err(Error::Config(format!("unknown stem format '{other}'"))),
89 }
90 }
91}
92
93impl fmt::Display for StemFormat {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 f.write_str(self.ext())
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
101#[serde(rename_all = "lowercase")]
102pub enum VideoCoverRetention {
103 #[default]
104 Neither,
105 Webp,
106 Mp4,
107 Both,
108}
109
110impl VideoCoverRetention {
111 pub fn keeps_webp(self) -> bool {
112 matches!(self, Self::Webp | Self::Both)
113 }
114
115 pub fn keeps_mp4(self) -> bool {
116 matches!(self, Self::Mp4 | Self::Both)
117 }
118}
119
120impl FromStr for VideoCoverRetention {
121 type Err = Error;
122
123 fn from_str(s: &str) -> Result<Self> {
124 match s.to_ascii_lowercase().as_str() {
125 "neither" => Ok(Self::Neither),
126 "webp" => Ok(Self::Webp),
127 "mp4" => Ok(Self::Mp4),
128 "both" => Ok(Self::Both),
129 other => Err(Error::Config(format!(
130 "unknown video_cover_retention '{other}'"
131 ))),
132 }
133 }
134}
135
136impl fmt::Display for VideoCoverRetention {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 match self {
139 Self::Neither => f.write_str("neither"),
140 Self::Webp => f.write_str("webp"),
141 Self::Mp4 => f.write_str("mp4"),
142 Self::Both => f.write_str("both"),
143 }
144 }
145}
146
147#[derive(Debug, Clone, Default, Deserialize)]
149pub struct Defaults {
150 pub format: Option<AudioFormat>,
151 pub concurrency: Option<u32>,
152 pub retries: Option<u32>,
153 pub min_newest: Option<u32>,
154 pub token_command: Option<String>,
155 pub animated_covers: Option<bool>,
156 pub video_cover_retention: Option<VideoCoverRetention>,
157 pub animated_cover_quality: Option<u8>,
158 pub animated_cover_max_fps: Option<u32>,
159 pub animated_cover_max_width: Option<u32>,
160 pub animated_cover_compression_level: Option<u8>,
161 pub details_sidecar: Option<bool>,
162 pub lyrics_sidecar: Option<bool>,
163 pub lrc_sidecar: Option<bool>,
164 pub video_mp4: Option<bool>,
165 pub download_stems: Option<bool>,
166 pub stem_format: Option<StemFormat>,
167 pub naming_template: Option<String>,
168 pub character_set: Option<CharacterSet>,
169}
170
171#[derive(Debug, Clone, Default, Deserialize)]
173pub struct SourceConfig {
174 pub format: Option<AudioFormat>,
175 pub concurrency: Option<u32>,
176 pub retries: Option<u32>,
177 pub min_newest: Option<u32>,
178 pub token_command: Option<String>,
179 pub animated_covers: Option<bool>,
180 pub video_cover_retention: Option<VideoCoverRetention>,
181 pub animated_cover_quality: Option<u8>,
182 pub animated_cover_max_fps: Option<u32>,
183 pub animated_cover_max_width: Option<u32>,
184 pub animated_cover_compression_level: Option<u8>,
185 pub details_sidecar: Option<bool>,
186 pub lyrics_sidecar: Option<bool>,
187 pub lrc_sidecar: Option<bool>,
188 pub video_mp4: Option<bool>,
189 pub download_stems: Option<bool>,
190 pub stem_format: Option<StemFormat>,
191 pub naming_template: Option<String>,
192 pub character_set: Option<CharacterSet>,
193}
194
195#[derive(Debug, Clone, Default, Deserialize)]
197pub struct AccountConfig {
198 pub token: Option<String>,
199 pub token_command: Option<String>,
200 pub root: Option<String>,
201 pub account_id: Option<String>,
205 pub format: Option<AudioFormat>,
206 pub concurrency: Option<u32>,
207 pub retries: Option<u32>,
208 pub min_newest: Option<u32>,
209 pub animated_covers: Option<bool>,
210 pub video_cover_retention: Option<VideoCoverRetention>,
211 pub animated_cover_quality: Option<u8>,
212 pub animated_cover_max_fps: Option<u32>,
213 pub animated_cover_max_width: Option<u32>,
214 pub animated_cover_compression_level: Option<u8>,
215 pub details_sidecar: Option<bool>,
216 pub lyrics_sidecar: Option<bool>,
217 pub lrc_sidecar: Option<bool>,
218 pub video_mp4: Option<bool>,
219 pub download_stems: Option<bool>,
220 pub stem_format: Option<StemFormat>,
221 pub naming_template: Option<String>,
222 pub character_set: Option<CharacterSet>,
223 #[serde(default)]
224 pub sources: HashMap<String, SourceConfig>,
225 pub areas: Option<AreasConfig>,
228 #[serde(default)]
234 pub albums: HashMap<String, String>,
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum AreaMode {
245 Off,
247 Mode(SourceMode),
249}
250
251impl<'de> Deserialize<'de> for AreaMode {
252 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
253 where
254 D: serde::Deserializer<'de>,
255 {
256 let raw = String::deserialize(deserializer)?;
257 match raw.as_str() {
258 "off" => Ok(AreaMode::Off),
259 "copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
260 "mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
261 other => Err(serde::de::Error::custom(format!(
262 "unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
263 ))),
264 }
265 }
266}
267
268#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct AreasConfig {
279 pub library: Option<AreaMode>,
280 pub liked: Option<SourceMode>,
281 pub playlists: Option<SourceMode>,
282 #[serde(default)]
283 pub playlist: HashMap<String, SourceMode>,
284}
285
286#[derive(Debug, Clone, Default, Deserialize)]
288pub struct Config {
289 #[serde(default)]
290 pub defaults: Defaults,
291 #[serde(default)]
292 pub accounts: HashMap<String, AccountConfig>,
293}
294
295impl Config {
296 pub fn from_toml(toml_str: &str) -> Result<Self> {
302 let config: Self = toml::from_str(toml_str).map_err(|e| {
303 let raw = e.to_string();
306 let msg = raw
307 .lines()
308 .filter(|l| !l.contains(" | "))
309 .collect::<Vec<_>>()
310 .join("\n")
311 .trim()
312 .to_owned();
313 Error::Config(if msg.is_empty() {
314 "parse error".into()
315 } else {
316 msg
317 })
318 })?;
319 config.validate()?;
320 Ok(config)
321 }
322
323 fn validate(&self) -> Result<()> {
324 let roots: Vec<(&str, &str)> = self
325 .accounts
326 .iter()
327 .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
328 .collect();
329
330 for (i, (label_a, root_a)) in roots.iter().enumerate() {
331 for (label_b, root_b) in roots.iter().skip(i + 1) {
332 let a = Path::new(root_a);
333 let b = Path::new(root_b);
334 if a.starts_with(b) || b.starts_with(a) {
335 return Err(Error::Config(format!(
336 "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
337 )));
338 }
339 }
340 }
341
342 let mut prefix_seen: HashMap<String, &str> = HashMap::new();
343 for label in self.accounts.keys() {
344 let prefix = label_to_env(label);
345 if let Some(other) = prefix_seen.get(&prefix) {
346 return Err(Error::Config(format!(
347 "accounts '{label}' and '{other}' share env prefix '{prefix}'"
348 )));
349 }
350 prefix_seen.insert(prefix, label.as_str());
351 }
352
353 Ok(())
354 }
355
356 pub fn resolve(
362 &self,
363 account: &str,
364 source: Option<&str>,
365 env: &HashMap<String, String>,
366 flags: &FlagOverrides,
367 ) -> Result<EffectiveSettings> {
368 let acc = self
369 .accounts
370 .get(account)
371 .ok_or_else(|| Error::Config(format!("account '{account}' not found")))?;
372
373 let src = source.and_then(|s| acc.sources.get(s));
374 let label_env = label_to_env(account);
375
376 let env_val = |suffix: &str| -> Option<&str> {
378 env.get(&format!("SUNO_{label_env}_{suffix}"))
379 .or_else(|| env.get(&format!("SUNO_{suffix}")))
380 .map(String::as_str)
381 };
382
383 let format_from_env = env_val("FORMAT")
384 .map(str::parse::<AudioFormat>)
385 .transpose()?;
386
387 let format = flags
388 .format
389 .or(format_from_env)
390 .or_else(|| src.and_then(|s| s.format))
391 .or(acc.format)
392 .or(self.defaults.format)
393 .unwrap_or(AudioFormat::Flac);
394
395 let concurrency = resolve_u32(
396 flags.concurrency,
397 env_val("CONCURRENCY"),
398 src.and_then(|s| s.concurrency),
399 acc.concurrency,
400 self.defaults.concurrency,
401 4,
402 "CONCURRENCY",
403 )?;
404
405 let retries = resolve_u32(
406 flags.retries,
407 env_val("RETRIES"),
408 src.and_then(|s| s.retries),
409 acc.retries,
410 self.defaults.retries,
411 3,
412 "RETRIES",
413 )?;
414
415 let min_newest = resolve_u32(
416 flags.min_newest,
417 env_val("MIN_NEWEST"),
418 src.and_then(|s| s.min_newest),
419 acc.min_newest,
420 self.defaults.min_newest,
421 1,
422 "MIN_NEWEST",
423 )?;
424
425 let animated_covers = resolve_bool(
426 flags.animated_covers,
427 env_val("ANIMATED_COVERS"),
428 src.and_then(|s| s.animated_covers),
429 acc.animated_covers,
430 self.defaults.animated_covers,
431 false,
432 "ANIMATED_COVERS",
433 )?;
434
435 let details_sidecar = resolve_bool(
436 flags.details_sidecar,
437 env_val("DETAILS_SIDECAR"),
438 src.and_then(|s| s.details_sidecar),
439 acc.details_sidecar,
440 self.defaults.details_sidecar,
441 false,
442 "DETAILS_SIDECAR",
443 )?;
444
445 let lyrics_sidecar = resolve_bool(
446 flags.lyrics_sidecar,
447 env_val("LYRICS_SIDECAR"),
448 src.and_then(|s| s.lyrics_sidecar),
449 acc.lyrics_sidecar,
450 self.defaults.lyrics_sidecar,
451 false,
452 "LYRICS_SIDECAR",
453 )?;
454
455 let lrc_sidecar = resolve_bool(
456 flags.lrc_sidecar,
457 env_val("LRC_SIDECAR"),
458 src.and_then(|s| s.lrc_sidecar),
459 acc.lrc_sidecar,
460 self.defaults.lrc_sidecar,
461 false,
462 "LRC_SIDECAR",
463 )?;
464
465 let video_mp4 = resolve_bool(
466 flags.video_mp4,
467 env_val("VIDEO_MP4"),
468 src.and_then(|s| s.video_mp4),
469 acc.video_mp4,
470 self.defaults.video_mp4,
471 false,
472 "VIDEO_MP4",
473 )?;
474
475 let download_stems = resolve_bool(
476 flags.download_stems,
477 env_val("DOWNLOAD_STEMS"),
478 src.and_then(|s| s.download_stems),
479 acc.download_stems,
480 self.defaults.download_stems,
481 false,
482 "DOWNLOAD_STEMS",
483 )?;
484
485 let stem_format_from_env = env_val("STEM_FORMAT")
486 .map(str::parse::<StemFormat>)
487 .transpose()?;
488 let stem_format = flags
489 .stem_format
490 .or(stem_format_from_env)
491 .or_else(|| src.and_then(|s| s.stem_format))
492 .or(acc.stem_format)
493 .or(self.defaults.stem_format)
494 .unwrap_or_default();
495
496 let video_cover_retention = resolve_enum(
497 flags.video_cover_retention,
498 env_val("VIDEO_COVER_RETENTION"),
499 src.and_then(|s| s.video_cover_retention),
500 acc.video_cover_retention,
501 self.defaults.video_cover_retention,
502 None,
503 "VIDEO_COVER_RETENTION",
504 )?;
505 let (animated_covers, video_mp4) = match video_cover_retention {
506 Some(retention) => (retention.keeps_webp(), retention.keeps_mp4()),
507 None => (animated_covers, video_mp4),
508 };
509
510 let defaults_webp = WebpEncodeSettings::default();
511 let animated_cover_quality = resolve_u8_ranged(
512 flags.animated_cover_quality,
513 env_val("ANIMATED_COVER_QUALITY"),
514 src.and_then(|s| s.animated_cover_quality),
515 acc.animated_cover_quality,
516 self.defaults.animated_cover_quality,
517 defaults_webp.quality,
518 "ANIMATED_COVER_QUALITY",
519 0..=100,
520 )?;
521 let animated_cover_max_fps = resolve_u32(
522 flags.animated_cover_max_fps,
523 env_val("ANIMATED_COVER_MAX_FPS"),
524 src.and_then(|s| s.animated_cover_max_fps),
525 acc.animated_cover_max_fps,
526 self.defaults.animated_cover_max_fps,
527 defaults_webp.max_fps,
528 "ANIMATED_COVER_MAX_FPS",
529 )?;
530 let animated_cover_max_width_from_env = env_val("ANIMATED_COVER_MAX_WIDTH")
531 .map(|s| {
532 s.parse().map_err(|_| {
533 Error::Config(format!(
534 "invalid ANIMATED_COVER_MAX_WIDTH: '{s}' (expected integer)"
535 ))
536 })
537 })
538 .transpose()?;
539 let animated_cover_max_width = if let Some(v) = flags.animated_cover_max_width {
540 Some(v)
541 } else if let Some(v) = animated_cover_max_width_from_env {
542 Some(v)
543 } else {
544 src.and_then(|s| s.animated_cover_max_width)
545 .or(acc.animated_cover_max_width)
546 .or(self.defaults.animated_cover_max_width)
547 .or(defaults_webp.max_width)
548 };
549 let animated_cover_compression_level = resolve_u8_ranged(
550 flags.animated_cover_compression_level,
551 env_val("ANIMATED_COVER_COMPRESSION_LEVEL"),
552 src.and_then(|s| s.animated_cover_compression_level),
553 acc.animated_cover_compression_level,
554 self.defaults.animated_cover_compression_level,
555 defaults_webp.compression_level,
556 "ANIMATED_COVER_COMPRESSION_LEVEL",
557 0..=6,
558 )?;
559
560 let naming_template_from_env = env_val("NAMING_TEMPLATE").map(str::to_owned);
561 let naming_template = flags
562 .naming_template
563 .clone()
564 .or(naming_template_from_env)
565 .or_else(|| src.and_then(|s| s.naming_template.clone()))
566 .or_else(|| acc.naming_template.clone())
567 .or_else(|| self.defaults.naming_template.clone())
568 .unwrap_or_else(|| crate::naming::DEFAULT_TEMPLATE.to_owned());
569
570 let character_set_from_env = env_val("CHARACTER_SET")
571 .map(str::parse::<CharacterSet>)
572 .transpose()?;
573 let character_set = flags
574 .character_set
575 .or(character_set_from_env)
576 .or_else(|| src.and_then(|s| s.character_set))
577 .or(acc.character_set)
578 .or(self.defaults.character_set)
579 .unwrap_or(CharacterSet::Unicode);
580
581 let token = flags
582 .token
583 .clone()
584 .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
585 .or_else(|| env.get("SUNO_TOKEN").cloned());
586
587 let token_command = env
588 .get(&format!("SUNO_{label_env}_TOKEN_COMMAND"))
589 .cloned()
590 .or_else(|| env.get("SUNO_TOKEN_COMMAND").cloned())
591 .or_else(|| src.and_then(|s| s.token_command.clone()))
592 .or_else(|| acc.token_command.clone())
593 .or_else(|| self.defaults.token_command.clone());
594
595 Ok(EffectiveSettings {
596 token,
597 stored_token: acc.token.clone(),
598 token_command,
599 account_id: acc.account_id.clone(),
600 format,
601 concurrency,
602 retries,
603 min_newest,
604 animated_covers,
605 video_cover_retention: match (animated_covers, video_mp4) {
606 (false, false) => VideoCoverRetention::Neither,
607 (true, false) => VideoCoverRetention::Webp,
608 (false, true) => VideoCoverRetention::Mp4,
609 (true, true) => VideoCoverRetention::Both,
610 },
611 animated_cover_webp: WebpEncodeSettings {
612 quality: animated_cover_quality,
613 max_fps: animated_cover_max_fps,
614 max_width: animated_cover_max_width,
615 lossless: defaults_webp.lossless,
616 compression_level: animated_cover_compression_level,
617 },
618 details_sidecar,
619 lyrics_sidecar,
620 lrc_sidecar,
621 video_mp4,
622 download_stems,
623 stem_format,
624 naming_template,
625 character_set,
626 areas: acc.areas.clone(),
627 album_overrides: acc
628 .albums
629 .iter()
630 .filter(|(_, name)| !name.trim().is_empty())
631 .map(|(root_id, name)| (root_id.clone(), name.trim().to_owned()))
632 .collect(),
633 })
634 }
635}
636
637fn resolve_u32(
638 flag: Option<u32>,
639 env_str: Option<&str>,
640 src: Option<u32>,
641 acc: Option<u32>,
642 defaults: Option<u32>,
643 compiled: u32,
644 name: &str,
645) -> Result<u32> {
646 if let Some(v) = flag {
647 return Ok(v);
648 }
649 if let Some(s) = env_str {
650 return s
651 .parse()
652 .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
653 }
654 Ok(src.or(acc).or(defaults).unwrap_or(compiled))
655}
656
657fn resolve_bool(
658 flag: Option<bool>,
659 env_str: Option<&str>,
660 src: Option<bool>,
661 acc: Option<bool>,
662 defaults: Option<bool>,
663 compiled: bool,
664 name: &str,
665) -> Result<bool> {
666 if let Some(v) = flag {
667 return Ok(v);
668 }
669 if let Some(s) = env_str {
670 return s
671 .parse()
672 .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
673 }
674 Ok(src.or(acc).or(defaults).unwrap_or(compiled))
675}
676
677#[allow(clippy::too_many_arguments)]
678fn resolve_u8_ranged(
679 flag: Option<u8>,
680 env_str: Option<&str>,
681 src: Option<u8>,
682 acc: Option<u8>,
683 defaults: Option<u8>,
684 compiled: u8,
685 name: &str,
686 range: std::ops::RangeInclusive<u8>,
687) -> Result<u8> {
688 let value = if let Some(v) = flag {
689 v
690 } else if let Some(s) = env_str {
691 s.parse()
692 .map_err(|_| Error::Config(format!("invalid {name}: '{s}' (expected integer)")))?
693 } else {
694 src.or(acc).or(defaults).unwrap_or(compiled)
695 };
696 if range.contains(&value) {
697 Ok(value)
698 } else {
699 Err(Error::Config(format!(
700 "invalid {name}: '{value}' (expected {}..={})",
701 range.start(),
702 range.end()
703 )))
704 }
705}
706
707fn resolve_enum<T>(
708 flag: Option<T>,
709 env_str: Option<&str>,
710 src: Option<T>,
711 acc: Option<T>,
712 defaults: Option<T>,
713 compiled: Option<T>,
714 name: &str,
715) -> Result<Option<T>>
716where
717 T: FromStr<Err = Error> + Copy,
718{
719 if let Some(v) = flag {
720 return Ok(Some(v));
721 }
722 if let Some(s) = env_str {
723 return s
724 .parse()
725 .map(Some)
726 .map_err(|err| Error::Config(format!("invalid {name}: '{s}' ({err})")));
727 }
728 Ok(src.or(acc).or(defaults).or(compiled))
729}
730
731pub fn label_to_env(label: &str) -> String {
735 label.to_ascii_uppercase().replace('-', "_")
736}
737
738#[derive(Debug, Default)]
741pub struct FlagOverrides {
742 pub token: Option<String>,
743 pub format: Option<AudioFormat>,
744 pub concurrency: Option<u32>,
745 pub retries: Option<u32>,
746 pub min_newest: Option<u32>,
747 pub animated_covers: Option<bool>,
748 pub video_cover_retention: Option<VideoCoverRetention>,
749 pub animated_cover_quality: Option<u8>,
750 pub animated_cover_max_fps: Option<u32>,
751 pub animated_cover_max_width: Option<u32>,
752 pub animated_cover_compression_level: Option<u8>,
753 pub details_sidecar: Option<bool>,
754 pub lyrics_sidecar: Option<bool>,
755 pub lrc_sidecar: Option<bool>,
756 pub video_mp4: Option<bool>,
757 pub download_stems: Option<bool>,
758 pub stem_format: Option<StemFormat>,
759 pub naming_template: Option<String>,
760 pub character_set: Option<CharacterSet>,
761}
762
763#[derive(Debug, Clone, PartialEq)]
765pub struct EffectiveSettings {
766 pub token: Option<String>,
768 pub stored_token: Option<String>,
770 pub token_command: Option<String>,
772 pub account_id: Option<String>,
774 pub format: AudioFormat,
775 pub concurrency: u32,
776 pub retries: u32,
777 pub min_newest: u32,
778 pub animated_covers: bool,
779 pub video_cover_retention: VideoCoverRetention,
780 pub animated_cover_webp: WebpEncodeSettings,
781 pub details_sidecar: bool,
782 pub lyrics_sidecar: bool,
783 pub lrc_sidecar: bool,
784 pub video_mp4: bool,
785 pub download_stems: bool,
786 pub stem_format: StemFormat,
787 pub naming_template: String,
788 pub character_set: CharacterSet,
789 pub areas: Option<AreasConfig>,
791 pub album_overrides: BTreeMap<String, String>,
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800
801 fn no_env() -> HashMap<String, String> {
802 HashMap::new()
803 }
804
805 fn no_flags() -> FlagOverrides {
806 FlagOverrides::default()
807 }
808
809 #[test]
810 fn parse_empty_toml() {
811 let cfg = Config::from_toml("").unwrap();
812 assert!(cfg.accounts.is_empty());
813 }
814
815 #[test]
816 fn parse_basic_account() {
817 let toml = r#"
818 [accounts.alice]
819 token = "tok"
820 root = "/music"
821 "#;
822 let cfg = Config::from_toml(toml).unwrap();
823 let acc = &cfg.accounts["alice"];
824 assert_eq!(acc.token.as_deref(), Some("tok"));
825 assert_eq!(acc.root.as_deref(), Some("/music"));
826 }
827
828 #[test]
829 fn account_id_parses_and_resolves() {
830 let toml = r#"
831 [accounts.alice]
832 token = "tok"
833 root = "/music"
834 account_id = "user_abc123"
835 "#;
836 let cfg = Config::from_toml(toml).unwrap();
837 assert_eq!(
838 cfg.accounts["alice"].account_id.as_deref(),
839 Some("user_abc123")
840 );
841 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
842 assert_eq!(eff.account_id.as_deref(), Some("user_abc123"));
843 }
844
845 #[test]
846 fn parse_defaults_section() {
847 let toml = r#"
848 [defaults]
849 format = "mp3"
850 concurrency = 8
851 retries = 5
852 min_newest = 2
853 animated_covers = true
854 video_cover_retention = "both"
855 animated_cover_quality = 85
856 animated_cover_max_fps = 18
857 animated_cover_max_width = 720
858 animated_cover_compression_level = 4
859 "#;
860 let cfg = Config::from_toml(toml).unwrap();
861 assert_eq!(cfg.defaults.format, Some(AudioFormat::Mp3));
862 assert_eq!(cfg.defaults.concurrency, Some(8));
863 assert_eq!(cfg.defaults.retries, Some(5));
864 assert_eq!(cfg.defaults.min_newest, Some(2));
865 assert_eq!(cfg.defaults.animated_covers, Some(true));
866 assert_eq!(
867 cfg.defaults.video_cover_retention,
868 Some(VideoCoverRetention::Both)
869 );
870 assert_eq!(cfg.defaults.animated_cover_quality, Some(85));
871 assert_eq!(cfg.defaults.animated_cover_max_fps, Some(18));
872 assert_eq!(cfg.defaults.animated_cover_max_width, Some(720));
873 assert_eq!(cfg.defaults.animated_cover_compression_level, Some(4));
874 }
875
876 #[test]
877 fn compiled_defaults_when_nothing_set() {
878 let toml = "[accounts.alice]\n";
879 let cfg = Config::from_toml(toml).unwrap();
880 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
881 assert_eq!(
882 eff,
883 EffectiveSettings {
884 token: None,
885 stored_token: None,
886 token_command: None,
887 account_id: None,
888 format: AudioFormat::Flac,
889 concurrency: 4,
890 retries: 3,
891 min_newest: 1,
892 animated_covers: false,
893 video_cover_retention: VideoCoverRetention::Neither,
894 animated_cover_webp: WebpEncodeSettings::default(),
895 details_sidecar: false,
896 lyrics_sidecar: false,
897 lrc_sidecar: false,
898 video_mp4: false,
899 download_stems: false,
900 stem_format: StemFormat::Wav,
901 naming_template: crate::naming::DEFAULT_TEMPLATE.to_owned(),
902 character_set: CharacterSet::Unicode,
903 areas: None,
904 album_overrides: BTreeMap::new(),
905 }
906 );
907 }
908
909 #[test]
910 fn file_defaults_override_compiled() {
911 let toml = r#"
912 [defaults]
913 format = "mp3"
914 concurrency = 8
915
916 [accounts.alice]
917 "#;
918 let cfg = Config::from_toml(toml).unwrap();
919 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
920 assert_eq!(eff.format, AudioFormat::Mp3);
921 assert_eq!(eff.concurrency, 8);
922 assert_eq!(eff.retries, 3); }
924
925 #[test]
926 fn account_settings_override_defaults() {
927 let toml = r#"
928 [defaults]
929 format = "mp3"
930
931 [accounts.alice]
932 format = "wav"
933 "#;
934 let cfg = Config::from_toml(toml).unwrap();
935 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
936 assert_eq!(eff.format, AudioFormat::Wav);
937 }
938
939 #[test]
940 fn per_source_overrides_account() {
941 let toml = r#"
942 [accounts.alice]
943 format = "flac"
944
945 [accounts.alice.sources.liked]
946 format = "mp3"
947 "#;
948 let cfg = Config::from_toml(toml).unwrap();
949 let eff = cfg
950 .resolve("alice", Some("liked"), &no_env(), &no_flags())
951 .unwrap();
952 assert_eq!(eff.format, AudioFormat::Mp3);
953 }
954
955 #[test]
956 fn unknown_source_falls_back_to_account() {
957 let toml = r#"
958 [accounts.alice]
959 format = "wav"
960 "#;
961 let cfg = Config::from_toml(toml).unwrap();
962 let eff = cfg
963 .resolve("alice", Some("nonexistent"), &no_env(), &no_flags())
964 .unwrap();
965 assert_eq!(eff.format, AudioFormat::Wav);
966 }
967
968 #[test]
969 fn global_env_overrides_file() {
970 let toml = r#"
971 [accounts.alice]
972 format = "flac"
973 "#;
974 let cfg = Config::from_toml(toml).unwrap();
975 let env: HashMap<String, String> =
976 [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
977 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
978 assert_eq!(eff.format, AudioFormat::Mp3);
979 }
980
981 #[test]
982 fn per_account_env_overrides_global_env() {
983 let toml = "[accounts.alice]\n";
984 let cfg = Config::from_toml(toml).unwrap();
985 let env: HashMap<String, String> = [
986 ("SUNO_FORMAT".into(), "mp3".into()),
987 ("SUNO_ALICE_FORMAT".into(), "wav".into()),
988 ]
989 .into_iter()
990 .collect();
991 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
992 assert_eq!(eff.format, AudioFormat::Wav);
993 }
994
995 #[test]
996 fn per_account_env_label_uppersnakedcase() {
997 let toml = "[accounts.my-lib]\n";
998 let cfg = Config::from_toml(toml).unwrap();
999 let env: HashMap<String, String> = [("SUNO_MY_LIB_FORMAT".into(), "wav".into())]
1000 .into_iter()
1001 .collect();
1002 let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
1003 assert_eq!(eff.format, AudioFormat::Wav);
1004 }
1005
1006 #[test]
1007 fn flag_overrides_env_and_file() {
1008 let toml = r#"
1009 [accounts.alice]
1010 format = "flac"
1011 "#;
1012 let cfg = Config::from_toml(toml).unwrap();
1013 let env: HashMap<String, String> =
1014 [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
1015 let flags = FlagOverrides {
1016 format: Some(AudioFormat::Wav),
1017 ..Default::default()
1018 };
1019 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1020 assert_eq!(eff.format, AudioFormat::Wav);
1021 }
1022
1023 #[test]
1024 fn token_precedence() {
1025 let toml = r#"
1026 [accounts.alice]
1027 token = "file_tok"
1028 "#;
1029 let cfg = Config::from_toml(toml).unwrap();
1030
1031 let env: HashMap<String, String> = [("SUNO_TOKEN".into(), "env_tok".into())]
1033 .into_iter()
1034 .collect();
1035 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1036 assert_eq!(eff.token.as_deref(), Some("env_tok"));
1037 assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1038
1039 let flags = FlagOverrides {
1041 token: Some("flag_tok".into()),
1042 ..Default::default()
1043 };
1044 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1045 assert_eq!(eff.token.as_deref(), Some("flag_tok"));
1046 assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1047 }
1048
1049 #[test]
1050 fn stored_token_is_populated_from_config_when_no_override_exists() {
1051 let toml = r#"
1052 [accounts.alice]
1053 token = "file_tok"
1054 "#;
1055 let cfg = Config::from_toml(toml).unwrap();
1056 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1057 assert_eq!(eff.token, None);
1058 assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1059 assert_eq!(eff.token_command, None);
1060 }
1061
1062 #[test]
1063 fn per_account_token_env_overrides_global() {
1064 let toml = "[accounts.alice]\n";
1065 let cfg = Config::from_toml(toml).unwrap();
1066 let env: HashMap<String, String> = [
1067 ("SUNO_TOKEN".into(), "global".into()),
1068 ("SUNO_ALICE_TOKEN".into(), "per_account".into()),
1069 ]
1070 .into_iter()
1071 .collect();
1072 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1073 assert_eq!(eff.token.as_deref(), Some("per_account"));
1074 }
1075
1076 #[test]
1077 fn token_command_resolves_from_defaults_account_source_and_env() {
1078 let toml = r#"
1079 [defaults]
1080 token_command = "defaults"
1081
1082 [accounts.alice]
1083 token_command = "account"
1084
1085 [accounts.alice.sources.liked]
1086 token_command = "source"
1087 "#;
1088 let cfg = Config::from_toml(toml).unwrap();
1089
1090 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1091 assert_eq!(eff.token_command.as_deref(), Some("account"));
1092
1093 let eff = cfg
1094 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1095 .unwrap();
1096 assert_eq!(eff.token_command.as_deref(), Some("source"));
1097
1098 let env: HashMap<String, String> = [("SUNO_TOKEN_COMMAND".into(), "global".into())]
1099 .into_iter()
1100 .collect();
1101 let eff = cfg
1102 .resolve("alice", Some("liked"), &env, &no_flags())
1103 .unwrap();
1104 assert_eq!(eff.token_command.as_deref(), Some("global"));
1105
1106 let env: HashMap<String, String> = [
1107 ("SUNO_TOKEN_COMMAND".into(), "global".into()),
1108 ("SUNO_ALICE_TOKEN_COMMAND".into(), "per_account".into()),
1109 ]
1110 .into_iter()
1111 .collect();
1112 let eff = cfg
1113 .resolve("alice", Some("liked"), &env, &no_flags())
1114 .unwrap();
1115 assert_eq!(eff.token_command.as_deref(), Some("per_account"));
1116 }
1117
1118 #[test]
1119 fn per_account_token_command_env_label_uppersnakedcase() {
1120 let cfg = Config::from_toml("[accounts.my-lib]\n").unwrap();
1121 let env: HashMap<String, String> = [("SUNO_MY_LIB_TOKEN_COMMAND".into(), "command".into())]
1122 .into_iter()
1123 .collect();
1124 let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
1125 assert_eq!(eff.token_command.as_deref(), Some("command"));
1126 }
1127
1128 #[test]
1129 fn invalid_env_u32_errors() {
1130 let toml = "[accounts.alice]\n";
1131 let cfg = Config::from_toml(toml).unwrap();
1132 let env: HashMap<String, String> = [("SUNO_CONCURRENCY".into(), "many".into())]
1133 .into_iter()
1134 .collect();
1135 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1136 }
1137
1138 #[test]
1139 fn animated_covers_defaults_off_and_follows_precedence() {
1140 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1142 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1143 assert!(!eff.animated_covers);
1144
1145 let toml = r#"
1147 [defaults]
1148 animated_covers = true
1149
1150 [accounts.alice.sources.liked]
1151 animated_covers = false
1152 "#;
1153 let cfg = Config::from_toml(toml).unwrap();
1154
1155 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1157 assert!(eff.animated_covers);
1158
1159 let eff = cfg
1161 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1162 .unwrap();
1163 assert!(!eff.animated_covers);
1164
1165 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "true".into())]
1167 .into_iter()
1168 .collect();
1169 let eff = cfg
1170 .resolve("alice", Some("liked"), &env, &no_flags())
1171 .unwrap();
1172 assert!(eff.animated_covers);
1173
1174 let flags = FlagOverrides {
1176 animated_covers: Some(false),
1177 ..Default::default()
1178 };
1179 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1180 assert!(!eff.animated_covers);
1181 }
1182
1183 #[test]
1184 fn video_mp4_defaults_off_and_follows_precedence() {
1185 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1187 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1188 assert!(!eff.video_mp4);
1189
1190 let toml = r#"
1192 [defaults]
1193 video_mp4 = true
1194
1195 [accounts.alice.sources.liked]
1196 video_mp4 = false
1197 "#;
1198 let cfg = Config::from_toml(toml).unwrap();
1199 assert!(
1200 cfg.resolve("alice", None, &no_env(), &no_flags())
1201 .unwrap()
1202 .video_mp4
1203 );
1204 assert!(
1205 !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1206 .unwrap()
1207 .video_mp4
1208 );
1209
1210 let env: HashMap<String, String> = [("SUNO_VIDEO_MP4".into(), "true".into())]
1211 .into_iter()
1212 .collect();
1213 assert!(
1214 cfg.resolve("alice", Some("liked"), &env, &no_flags())
1215 .unwrap()
1216 .video_mp4
1217 );
1218
1219 let flags = FlagOverrides {
1220 video_mp4: Some(false),
1221 ..Default::default()
1222 };
1223 assert!(
1224 !cfg.resolve("alice", Some("liked"), &env, &flags)
1225 .unwrap()
1226 .video_mp4
1227 );
1228 }
1229
1230 #[test]
1231 fn download_stems_defaults_off_and_follows_precedence() {
1232 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1235 assert!(
1236 !cfg.resolve("alice", None, &no_env(), &no_flags())
1237 .unwrap()
1238 .download_stems
1239 );
1240
1241 let toml = r#"
1243 [defaults]
1244 download_stems = true
1245
1246 [accounts.alice.sources.liked]
1247 download_stems = false
1248 "#;
1249 let cfg = Config::from_toml(toml).unwrap();
1250 assert!(
1251 cfg.resolve("alice", None, &no_env(), &no_flags())
1252 .unwrap()
1253 .download_stems
1254 );
1255 assert!(
1256 !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1257 .unwrap()
1258 .download_stems
1259 );
1260
1261 let env: HashMap<String, String> = [("SUNO_DOWNLOAD_STEMS".into(), "true".into())]
1262 .into_iter()
1263 .collect();
1264 assert!(
1265 cfg.resolve("alice", Some("liked"), &env, &no_flags())
1266 .unwrap()
1267 .download_stems
1268 );
1269
1270 let flags = FlagOverrides {
1271 download_stems: Some(false),
1272 ..Default::default()
1273 };
1274 assert!(
1275 !cfg.resolve("alice", Some("liked"), &env, &flags)
1276 .unwrap()
1277 .download_stems
1278 );
1279 }
1280
1281 #[test]
1282 fn stem_format_defaults_to_wav_and_follows_precedence() {
1283 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1285 assert_eq!(
1286 cfg.resolve("alice", None, &no_env(), &no_flags())
1287 .unwrap()
1288 .stem_format,
1289 StemFormat::Wav
1290 );
1291
1292 let toml = r#"
1294 [defaults]
1295 stem_format = "mp3"
1296
1297 [accounts.alice.sources.liked]
1298 stem_format = "wav"
1299 "#;
1300 let cfg = Config::from_toml(toml).unwrap();
1301 assert_eq!(
1302 cfg.resolve("alice", None, &no_env(), &no_flags())
1303 .unwrap()
1304 .stem_format,
1305 StemFormat::Mp3
1306 );
1307 assert_eq!(
1308 cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1309 .unwrap()
1310 .stem_format,
1311 StemFormat::Wav
1312 );
1313
1314 let env: HashMap<String, String> = [("SUNO_STEM_FORMAT".into(), "mp3".into())]
1315 .into_iter()
1316 .collect();
1317 assert_eq!(
1318 cfg.resolve("alice", Some("liked"), &env, &no_flags())
1319 .unwrap()
1320 .stem_format,
1321 StemFormat::Mp3
1322 );
1323
1324 let flags = FlagOverrides {
1325 stem_format: Some(StemFormat::Wav),
1326 ..Default::default()
1327 };
1328 assert_eq!(
1329 cfg.resolve("alice", Some("liked"), &env, &flags)
1330 .unwrap()
1331 .stem_format,
1332 StemFormat::Wav
1333 );
1334 }
1335
1336 #[test]
1337 fn stem_format_rejects_flac_and_unknown() {
1338 assert!("flac".parse::<StemFormat>().is_err());
1341 assert!("aac".parse::<StemFormat>().is_err());
1342 assert_eq!("WAV".parse::<StemFormat>().unwrap(), StemFormat::Wav);
1343 assert_eq!("Mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
1344 assert!(Config::from_toml("[defaults]\nstem_format = \"flac\"\n").is_err());
1346 }
1347
1348 #[test]
1349 fn video_cover_retention_overrides_legacy_toggles() {
1350 let toml = r#"
1351 [defaults]
1352 animated_covers = true
1353 video_mp4 = false
1354 video_cover_retention = "mp4"
1355 "#;
1356 let cfg = Config::from_toml(toml).unwrap();
1357 let eff = cfg.resolve("alice", None, &no_env(), &no_flags());
1358 assert!(eff.is_err());
1359
1360 let cfg = Config::from_toml(format!("{toml}\n[accounts.alice]\n").as_str()).unwrap();
1361 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1362 assert!(!eff.animated_covers);
1363 assert!(eff.video_mp4);
1364 assert_eq!(eff.video_cover_retention, VideoCoverRetention::Mp4);
1365
1366 let flags = FlagOverrides {
1367 video_cover_retention: Some(VideoCoverRetention::Both),
1368 ..Default::default()
1369 };
1370 let eff = cfg.resolve("alice", None, &no_env(), &flags).unwrap();
1371 assert!(eff.animated_covers);
1372 assert!(eff.video_mp4);
1373 assert_eq!(eff.video_cover_retention, VideoCoverRetention::Both);
1374 }
1375
1376 #[test]
1377 fn animated_cover_webp_knobs_follow_precedence_and_validate_ranges() {
1378 let toml = r#"
1379 [defaults]
1380 animated_cover_quality = 80
1381 animated_cover_max_fps = 20
1382 animated_cover_max_width = 640
1383 animated_cover_compression_level = 3
1384
1385 [accounts.alice.sources.liked]
1386 animated_cover_quality = 75
1387 "#;
1388 let cfg = Config::from_toml(toml).unwrap();
1389 let eff = cfg
1390 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1391 .unwrap();
1392 assert_eq!(eff.animated_cover_webp.quality, 75);
1393 assert_eq!(eff.animated_cover_webp.max_fps, 20);
1394 assert_eq!(eff.animated_cover_webp.max_width, Some(640));
1395 assert_eq!(eff.animated_cover_webp.compression_level, 3);
1396
1397 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVER_QUALITY".into(), "90".into())]
1398 .into_iter()
1399 .collect();
1400 let eff = cfg
1401 .resolve("alice", Some("liked"), &env, &no_flags())
1402 .unwrap();
1403 assert_eq!(eff.animated_cover_webp.quality, 90);
1404
1405 let flags = FlagOverrides {
1406 animated_cover_quality: Some(95),
1407 animated_cover_max_width: Some(512),
1408 animated_cover_compression_level: Some(6),
1409 ..Default::default()
1410 };
1411 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1412 assert_eq!(eff.animated_cover_webp.quality, 95);
1413 assert_eq!(eff.animated_cover_webp.max_width, Some(512));
1414 assert_eq!(eff.animated_cover_webp.compression_level, 6);
1415
1416 let bad_env: HashMap<String, String> =
1417 [("SUNO_ANIMATED_COVER_QUALITY".into(), "101".into())]
1418 .into_iter()
1419 .collect();
1420 assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1421 }
1422
1423 #[test]
1424 fn video_cover_retention_parses_formats_and_reports_kept_artifacts() {
1425 assert_eq!(
1427 "NEITHER".parse::<VideoCoverRetention>().unwrap(),
1428 VideoCoverRetention::Neither
1429 );
1430 assert_eq!(
1431 "WebP".parse::<VideoCoverRetention>().unwrap(),
1432 VideoCoverRetention::Webp
1433 );
1434 assert_eq!(
1435 "mp4".parse::<VideoCoverRetention>().unwrap(),
1436 VideoCoverRetention::Mp4
1437 );
1438 assert_eq!(
1439 "Both".parse::<VideoCoverRetention>().unwrap(),
1440 VideoCoverRetention::Both
1441 );
1442 assert!("mkv".parse::<VideoCoverRetention>().is_err());
1444
1445 for mode in [
1447 VideoCoverRetention::Neither,
1448 VideoCoverRetention::Webp,
1449 VideoCoverRetention::Mp4,
1450 VideoCoverRetention::Both,
1451 ] {
1452 assert_eq!(
1453 mode.to_string().parse::<VideoCoverRetention>().unwrap(),
1454 mode
1455 );
1456 }
1457
1458 assert!(!VideoCoverRetention::Neither.keeps_webp());
1460 assert!(!VideoCoverRetention::Neither.keeps_mp4());
1461 assert!(VideoCoverRetention::Webp.keeps_webp());
1462 assert!(!VideoCoverRetention::Webp.keeps_mp4());
1463 assert!(!VideoCoverRetention::Mp4.keeps_webp());
1464 assert!(VideoCoverRetention::Mp4.keeps_mp4());
1465 assert!(VideoCoverRetention::Both.keeps_webp());
1466 assert!(VideoCoverRetention::Both.keeps_mp4());
1467 }
1468
1469 #[test]
1470 fn video_cover_retention_resolves_from_env_and_rejects_unknown() {
1471 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1472
1473 let env: HashMap<String, String> = [("SUNO_VIDEO_COVER_RETENTION".into(), "both".into())]
1475 .into_iter()
1476 .collect();
1477 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1478 assert_eq!(eff.video_cover_retention, VideoCoverRetention::Both);
1479 assert!(eff.animated_covers);
1480 assert!(eff.video_mp4);
1481
1482 let bad_env: HashMap<String, String> =
1484 [("SUNO_VIDEO_COVER_RETENTION".into(), "mkv".into())]
1485 .into_iter()
1486 .collect();
1487 assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1488 }
1489
1490 #[test]
1491 fn animated_cover_compression_level_enforces_zero_to_six() {
1492 let cfg = Config::from_toml(
1494 "[defaults]\nanimated_cover_compression_level = 6\n[accounts.alice]\n",
1495 )
1496 .unwrap();
1497 assert_eq!(
1498 cfg.resolve("alice", None, &no_env(), &no_flags())
1499 .unwrap()
1500 .animated_cover_webp
1501 .compression_level,
1502 6
1503 );
1504
1505 let cfg = Config::from_toml(
1507 "[defaults]\nanimated_cover_compression_level = 7\n[accounts.alice]\n",
1508 )
1509 .unwrap();
1510 assert!(cfg.resolve("alice", None, &no_env(), &no_flags()).is_err());
1511
1512 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1514 let bad_env: HashMap<String, String> =
1515 [("SUNO_ANIMATED_COVER_COMPRESSION_LEVEL".into(), "7".into())]
1516 .into_iter()
1517 .collect();
1518 assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1519
1520 let junk_env: HashMap<String, String> =
1522 [("SUNO_ANIMATED_COVER_MAX_FPS".into(), "abc".into())]
1523 .into_iter()
1524 .collect();
1525 assert!(cfg.resolve("alice", None, &junk_env, &no_flags()).is_err());
1526 }
1527
1528 #[test]
1529 fn animated_cover_max_width_defaults_to_native() {
1530 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1532 assert_eq!(
1533 cfg.resolve("alice", None, &no_env(), &no_flags())
1534 .unwrap()
1535 .animated_cover_webp
1536 .max_width,
1537 None
1538 );
1539 }
1540
1541 #[test]
1542 fn text_sidecars_default_off_and_follow_precedence() {
1543 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1545 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1546 assert!(!eff.details_sidecar);
1547 assert!(!eff.lyrics_sidecar);
1548
1549 let toml = r#"
1550 [defaults]
1551 details_sidecar = true
1552
1553 [accounts.alice.sources.liked]
1554 details_sidecar = false
1555 "#;
1556 let cfg = Config::from_toml(toml).unwrap();
1557
1558 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1560 assert!(eff.details_sidecar);
1561 assert!(!eff.lyrics_sidecar);
1562
1563 let eff = cfg
1565 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1566 .unwrap();
1567 assert!(!eff.details_sidecar);
1568
1569 let env: HashMap<String, String> = [
1571 ("SUNO_DETAILS_SIDECAR".into(), "true".into()),
1572 ("SUNO_LYRICS_SIDECAR".into(), "true".into()),
1573 ]
1574 .into_iter()
1575 .collect();
1576 let eff = cfg
1577 .resolve("alice", Some("liked"), &env, &no_flags())
1578 .unwrap();
1579 assert!(eff.details_sidecar);
1580 assert!(eff.lyrics_sidecar);
1581
1582 let flags = FlagOverrides {
1583 lyrics_sidecar: Some(false),
1584 ..Default::default()
1585 };
1586 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1587 assert!(eff.details_sidecar);
1588 assert!(!eff.lyrics_sidecar);
1589 }
1590
1591 #[test]
1592 fn invalid_env_bool_errors() {
1593 let toml = "[accounts.alice]\n";
1594 let cfg = Config::from_toml(toml).unwrap();
1595 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "yes".into())]
1596 .into_iter()
1597 .collect();
1598 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1599 }
1600
1601 #[test]
1602 fn unknown_account_errors() {
1603 let cfg = Config::from_toml("").unwrap();
1604 assert!(cfg.resolve("nobody", None, &no_env(), &no_flags()).is_err());
1605 }
1606
1607 #[test]
1608 fn validation_nested_roots() {
1609 let toml = r#"
1610 [accounts.alice]
1611 root = "/music"
1612
1613 [accounts.bob]
1614 root = "/music/bob"
1615 "#;
1616 assert!(Config::from_toml(toml).is_err());
1617 }
1618
1619 #[test]
1620 fn validation_non_nested_roots_ok() {
1621 let toml = r#"
1622 [accounts.alice]
1623 root = "/music/alice"
1624
1625 [accounts.bob]
1626 root = "/music/bob"
1627 "#;
1628 assert!(Config::from_toml(toml).is_ok());
1629 }
1630
1631 #[test]
1632 fn invalid_toml_errors() {
1633 assert!(Config::from_toml("not valid toml ][").is_err());
1634 }
1635
1636 #[test]
1637 fn duplicate_account_label_errors() {
1638 let toml = "
1640 [accounts.alice]
1641 token = \"tok1\"
1642
1643 [accounts.alice]
1644 token = \"tok2\"
1645 ";
1646 assert!(Config::from_toml(toml).is_err());
1647 }
1648
1649 #[test]
1650 fn parse_error_does_not_echo_token() {
1651 let toml = "[accounts.alice]\ntoken = \"unterminated\n";
1653 let err = Config::from_toml(toml).unwrap_err().to_string();
1654 assert!(!err.contains("unterminated"), "error leaked token: {err}");
1655 }
1656
1657 #[test]
1658 fn validation_env_prefix_collision_errors() {
1659 let toml = "
1661 [accounts.my-lib]
1662 [accounts.my_lib]
1663 ";
1664 assert!(Config::from_toml(toml).is_err());
1665 }
1666
1667 #[test]
1668 fn audio_format_display_roundtrip() {
1669 for fmt in [AudioFormat::Mp3, AudioFormat::Flac, AudioFormat::Wav] {
1670 let s = fmt.to_string();
1671 assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
1672 }
1673 }
1674
1675 #[test]
1676 fn naming_template_follows_precedence() {
1677 let toml = r#"
1678 [defaults]
1679 naming_template = "{title}"
1680
1681 [accounts.alice]
1682 naming_template = "{creator}/{title}"
1683
1684 [accounts.alice.sources.liked]
1685 naming_template = "{handle}/{title} [{id8}]"
1686 "#;
1687 let cfg = Config::from_toml(toml).unwrap();
1688
1689 let eff = cfg
1691 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1692 .unwrap();
1693 assert_eq!(eff.naming_template, "{handle}/{title} [{id8}]");
1694
1695 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1697 assert_eq!(eff.naming_template, "{creator}/{title}");
1698
1699 let env: HashMap<String, String> = [("SUNO_NAMING_TEMPLATE".into(), "{id}".into())]
1701 .into_iter()
1702 .collect();
1703 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1704 assert_eq!(eff.naming_template, "{id}");
1705
1706 let flags = FlagOverrides {
1708 naming_template: Some("{title}/{id8}".into()),
1709 ..Default::default()
1710 };
1711 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1712 assert_eq!(eff.naming_template, "{title}/{id8}");
1713 }
1714
1715 #[test]
1716 fn character_set_follows_precedence() {
1717 let toml = r#"
1718 [defaults]
1719 character_set = "ascii"
1720
1721 [accounts.alice]
1722 "#;
1723 let cfg = Config::from_toml(toml).unwrap();
1724
1725 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1727 assert_eq!(eff.character_set, CharacterSet::Ascii);
1728
1729 let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "unicode".into())]
1731 .into_iter()
1732 .collect();
1733 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1734 assert_eq!(eff.character_set, CharacterSet::Unicode);
1735
1736 let flags = FlagOverrides {
1738 character_set: Some(CharacterSet::Ascii),
1739 ..Default::default()
1740 };
1741 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1742 assert_eq!(eff.character_set, CharacterSet::Ascii);
1743 }
1744
1745 #[test]
1746 fn invalid_character_set_env_errors() {
1747 let toml = "[accounts.alice]\n";
1748 let cfg = Config::from_toml(toml).unwrap();
1749 let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "utf8".into())]
1750 .into_iter()
1751 .collect();
1752 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1753 }
1754
1755 #[test]
1756 fn areas_parse_full_table() {
1757 let toml = r#"
1758 [accounts.alice]
1759 token = "t"
1760 [accounts.alice.areas]
1761 library = "off"
1762 liked = "copy"
1763 playlists = "mirror"
1764 [accounts.alice.areas.playlist]
1765 "pl_abc123" = "mirror"
1766 "pl_def456" = "copy"
1767 "#;
1768 let cfg = Config::from_toml(toml).unwrap();
1769 let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
1770 assert_eq!(areas.library, Some(AreaMode::Off));
1771 assert_eq!(areas.liked, Some(SourceMode::Copy));
1772 assert_eq!(areas.playlists, Some(SourceMode::Mirror));
1773 assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
1774 assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
1775 }
1776
1777 #[test]
1778 fn album_overrides_parse_and_resolve() {
1779 let toml = r#"
1780 [accounts.alice]
1781 token = "t"
1782 [accounts.alice.albums]
1783 "root_abc123" = "Preferred Name"
1784 "root_def456" = "Another Album"
1785 "root_blank" = " "
1786 "#;
1787 let cfg = Config::from_toml(toml).unwrap();
1788 assert_eq!(
1789 cfg.accounts["alice"].albums["root_abc123"],
1790 "Preferred Name"
1791 );
1792 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1793 assert_eq!(eff.album_overrides["root_abc123"], "Preferred Name");
1794 assert_eq!(eff.album_overrides["root_def456"], "Another Album");
1795 assert!(!eff.album_overrides.contains_key("root_blank"));
1797 }
1798
1799 #[test]
1800 fn album_overrides_absent_by_default() {
1801 let cfg = Config::from_toml("[accounts.alice]\ntoken = \"t\"\n").unwrap();
1802 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1803 assert!(eff.album_overrides.is_empty());
1804 }
1805
1806 #[test]
1807 fn areas_library_accepts_copy_and_mirror() {
1808 for (raw, expect) in [
1809 ("copy", AreaMode::Mode(SourceMode::Copy)),
1810 ("mirror", AreaMode::Mode(SourceMode::Mirror)),
1811 ] {
1812 let toml =
1813 format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
1814 let cfg = Config::from_toml(&toml).unwrap();
1815 assert_eq!(
1816 cfg.accounts["a"].areas.as_ref().unwrap().library,
1817 Some(expect)
1818 );
1819 }
1820 }
1821
1822 #[test]
1823 fn areas_bad_mode_errors() {
1824 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
1825 assert!(Config::from_toml(toml).is_err());
1826 }
1827
1828 #[test]
1829 fn areas_bad_playlist_mode_errors() {
1830 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
1831 assert!(Config::from_toml(toml).is_err());
1833 }
1834
1835 #[test]
1836 fn areas_unknown_field_errors() {
1837 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
1839 assert!(Config::from_toml(toml).is_err());
1840 }
1841
1842 #[test]
1843 fn areas_absent_is_none() {
1844 let toml = "[accounts.a]\ntoken = \"t\"\n";
1845 assert!(
1846 Config::from_toml(toml).unwrap().accounts["a"]
1847 .areas
1848 .is_none()
1849 );
1850 }
1851}