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