Skip to main content

suno_core/
config.rs

1//! Configuration model and precedence resolution.
2//!
3//! Parses a TOML string and merges in environment variables and CLI flag
4//! overrides supplied by the caller. Performs no disk or environment IO.
5
6use 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};
14
15/// Audio format for downloaded clips.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
17#[serde(rename_all = "lowercase")]
18pub enum AudioFormat {
19    Mp3,
20    #[default]
21    Flac,
22    Wav,
23}
24
25impl FromStr for AudioFormat {
26    type Err = Error;
27
28    fn from_str(s: &str) -> Result<Self> {
29        match s.to_ascii_lowercase().as_str() {
30            "mp3" => Ok(Self::Mp3),
31            "flac" => Ok(Self::Flac),
32            "wav" => Ok(Self::Wav),
33            other => Err(Error::Config(format!("unknown format '{other}'"))),
34        }
35    }
36}
37
38impl fmt::Display for AudioFormat {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            Self::Mp3 => f.write_str("mp3"),
42            Self::Flac => f.write_str("flac"),
43            Self::Wav => f.write_str("wav"),
44        }
45    }
46}
47
48/// Global default settings applied when no account or source override applies.
49#[derive(Debug, Clone, Default, Deserialize)]
50pub struct Defaults {
51    pub format: Option<AudioFormat>,
52    pub concurrency: Option<u32>,
53    pub retries: Option<u32>,
54    pub min_newest: Option<u32>,
55    pub animated_covers: Option<bool>,
56    pub details_sidecar: Option<bool>,
57    pub lyrics_sidecar: Option<bool>,
58}
59
60/// Per-source overridable settings within an account.
61#[derive(Debug, Clone, Default, Deserialize)]
62pub struct SourceConfig {
63    pub format: Option<AudioFormat>,
64    pub concurrency: Option<u32>,
65    pub retries: Option<u32>,
66    pub min_newest: Option<u32>,
67    pub animated_covers: Option<bool>,
68    pub details_sidecar: Option<bool>,
69    pub lyrics_sidecar: Option<bool>,
70}
71
72/// Configuration for a single named account.
73#[derive(Debug, Clone, Default, Deserialize)]
74pub struct AccountConfig {
75    pub token: Option<String>,
76    pub root: Option<String>,
77    /// Optional Suno user id to assert this account authenticates as, refusing
78    /// to run on a mismatch (a belt-and-braces check alongside the on-disk
79    /// owner pin in the lineage store).
80    pub account_id: Option<String>,
81    pub format: Option<AudioFormat>,
82    pub concurrency: Option<u32>,
83    pub retries: Option<u32>,
84    pub min_newest: Option<u32>,
85    pub animated_covers: Option<bool>,
86    pub details_sidecar: Option<bool>,
87    pub lyrics_sidecar: Option<bool>,
88    #[serde(default)]
89    pub sources: HashMap<String, SourceConfig>,
90}
91
92/// Top-level configuration parsed from a TOML file.
93#[derive(Debug, Clone, Default, Deserialize)]
94pub struct Config {
95    #[serde(default)]
96    pub defaults: Defaults,
97    #[serde(default)]
98    pub accounts: HashMap<String, AccountConfig>,
99}
100
101impl Config {
102    /// Parse `toml_str` and validate the result.
103    ///
104    /// Validation rejects any pair of accounts whose root directories nest
105    /// inside one another. Duplicate account labels are rejected by the TOML
106    /// parser itself.
107    pub fn from_toml(toml_str: &str) -> Result<Self> {
108        let config: Self = toml::from_str(toml_str).map_err(|e| {
109            // Strip source-context lines (those containing " | ") to prevent
110            // token values from being echoed in error messages.
111            let raw = e.to_string();
112            let msg = raw
113                .lines()
114                .filter(|l| !l.contains(" | "))
115                .collect::<Vec<_>>()
116                .join("\n")
117                .trim()
118                .to_owned();
119            Error::Config(if msg.is_empty() {
120                "parse error".into()
121            } else {
122                msg
123            })
124        })?;
125        config.validate()?;
126        Ok(config)
127    }
128
129    fn validate(&self) -> Result<()> {
130        let roots: Vec<(&str, &str)> = self
131            .accounts
132            .iter()
133            .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
134            .collect();
135
136        for (i, (label_a, root_a)) in roots.iter().enumerate() {
137            for (label_b, root_b) in roots.iter().skip(i + 1) {
138                let a = Path::new(root_a);
139                let b = Path::new(root_b);
140                if a.starts_with(b) || b.starts_with(a) {
141                    return Err(Error::Config(format!(
142                        "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
143                    )));
144                }
145            }
146        }
147
148        let mut prefix_seen: HashMap<String, &str> = HashMap::new();
149        for label in self.accounts.keys() {
150            let prefix = label_to_env(label);
151            if let Some(other) = prefix_seen.get(&prefix) {
152                return Err(Error::Config(format!(
153                    "accounts '{label}' and '{other}' share env prefix '{prefix}'"
154                )));
155            }
156            prefix_seen.insert(prefix, label.as_str());
157        }
158
159        Ok(())
160    }
161
162    /// Compute effective settings for `account`, optionally scoped to `source`.
163    ///
164    /// The caller supplies the full environment map and any CLI flag overrides.
165    /// Precedence per field: flag > per-account env > global env > per-source
166    /// file > per-account file > global file defaults > compiled default.
167    pub fn resolve(
168        &self,
169        account: &str,
170        source: Option<&str>,
171        env: &HashMap<String, String>,
172        flags: &FlagOverrides,
173    ) -> Result<EffectiveSettings> {
174        let acc = self
175            .accounts
176            .get(account)
177            .ok_or_else(|| Error::Config(format!("account '{account}' not found")))?;
178
179        let src = source.and_then(|s| acc.sources.get(s));
180        let label_env = label_to_env(account);
181
182        // Look up per-account env first, falling back to global.
183        let env_val = |suffix: &str| -> Option<&str> {
184            env.get(&format!("SUNO_{label_env}_{suffix}"))
185                .or_else(|| env.get(&format!("SUNO_{suffix}")))
186                .map(String::as_str)
187        };
188
189        let format_from_env = env_val("FORMAT")
190            .map(str::parse::<AudioFormat>)
191            .transpose()?;
192
193        let format = flags
194            .format
195            .or(format_from_env)
196            .or_else(|| src.and_then(|s| s.format))
197            .or(acc.format)
198            .or(self.defaults.format)
199            .unwrap_or(AudioFormat::Flac);
200
201        let concurrency = resolve_u32(
202            flags.concurrency,
203            env_val("CONCURRENCY"),
204            src.and_then(|s| s.concurrency),
205            acc.concurrency,
206            self.defaults.concurrency,
207            4,
208            "CONCURRENCY",
209        )?;
210
211        let retries = resolve_u32(
212            flags.retries,
213            env_val("RETRIES"),
214            src.and_then(|s| s.retries),
215            acc.retries,
216            self.defaults.retries,
217            3,
218            "RETRIES",
219        )?;
220
221        let min_newest = resolve_u32(
222            flags.min_newest,
223            env_val("MIN_NEWEST"),
224            src.and_then(|s| s.min_newest),
225            acc.min_newest,
226            self.defaults.min_newest,
227            1,
228            "MIN_NEWEST",
229        )?;
230
231        let animated_covers = resolve_bool(
232            flags.animated_covers,
233            env_val("ANIMATED_COVERS"),
234            src.and_then(|s| s.animated_covers),
235            acc.animated_covers,
236            self.defaults.animated_covers,
237            false,
238            "ANIMATED_COVERS",
239        )?;
240
241        let details_sidecar = resolve_bool(
242            flags.details_sidecar,
243            env_val("DETAILS_SIDECAR"),
244            src.and_then(|s| s.details_sidecar),
245            acc.details_sidecar,
246            self.defaults.details_sidecar,
247            false,
248            "DETAILS_SIDECAR",
249        )?;
250
251        let lyrics_sidecar = resolve_bool(
252            flags.lyrics_sidecar,
253            env_val("LYRICS_SIDECAR"),
254            src.and_then(|s| s.lyrics_sidecar),
255            acc.lyrics_sidecar,
256            self.defaults.lyrics_sidecar,
257            false,
258            "LYRICS_SIDECAR",
259        )?;
260
261        let token = flags
262            .token
263            .clone()
264            .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
265            .or_else(|| env.get("SUNO_TOKEN").cloned())
266            .or_else(|| acc.token.clone());
267
268        Ok(EffectiveSettings {
269            token,
270            account_id: acc.account_id.clone(),
271            format,
272            concurrency,
273            retries,
274            min_newest,
275            animated_covers,
276            details_sidecar,
277            lyrics_sidecar,
278        })
279    }
280}
281
282fn resolve_u32(
283    flag: Option<u32>,
284    env_str: Option<&str>,
285    src: Option<u32>,
286    acc: Option<u32>,
287    defaults: Option<u32>,
288    compiled: u32,
289    name: &str,
290) -> Result<u32> {
291    if let Some(v) = flag {
292        return Ok(v);
293    }
294    if let Some(s) = env_str {
295        return s
296            .parse()
297            .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
298    }
299    Ok(src.or(acc).or(defaults).unwrap_or(compiled))
300}
301
302fn resolve_bool(
303    flag: Option<bool>,
304    env_str: Option<&str>,
305    src: Option<bool>,
306    acc: Option<bool>,
307    defaults: Option<bool>,
308    compiled: bool,
309    name: &str,
310) -> Result<bool> {
311    if let Some(v) = flag {
312        return Ok(v);
313    }
314    if let Some(s) = env_str {
315        return s
316            .parse()
317            .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
318    }
319    Ok(src.or(acc).or(defaults).unwrap_or(compiled))
320}
321
322/// Convert an account label to its environment variable prefix.
323///
324/// `my-lib` becomes `MY_LIB`.
325fn label_to_env(label: &str) -> String {
326    label.to_ascii_uppercase().replace('-', "_")
327}
328
329/// CLI flag overrides passed to [`Config::resolve`]. `None` means the flag
330/// was not provided.
331#[derive(Debug, Default)]
332pub struct FlagOverrides {
333    pub token: Option<String>,
334    pub format: Option<AudioFormat>,
335    pub concurrency: Option<u32>,
336    pub retries: Option<u32>,
337    pub min_newest: Option<u32>,
338    pub animated_covers: Option<bool>,
339    pub details_sidecar: Option<bool>,
340    pub lyrics_sidecar: Option<bool>,
341}
342
343/// Resolved effective settings for one account/source combination.
344#[derive(Debug, Clone, PartialEq)]
345pub struct EffectiveSettings {
346    pub token: Option<String>,
347    /// The optional configured account id assertion (see [`AccountConfig`]).
348    pub account_id: Option<String>,
349    pub format: AudioFormat,
350    pub concurrency: u32,
351    pub retries: u32,
352    pub min_newest: u32,
353    pub animated_covers: bool,
354    pub details_sidecar: bool,
355    pub lyrics_sidecar: bool,
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn no_env() -> HashMap<String, String> {
363        HashMap::new()
364    }
365
366    fn no_flags() -> FlagOverrides {
367        FlagOverrides::default()
368    }
369
370    #[test]
371    fn parse_empty_toml() {
372        let cfg = Config::from_toml("").unwrap();
373        assert!(cfg.accounts.is_empty());
374    }
375
376    #[test]
377    fn parse_basic_account() {
378        let toml = r#"
379            [accounts.alice]
380            token = "tok"
381            root = "/music"
382        "#;
383        let cfg = Config::from_toml(toml).unwrap();
384        let acc = &cfg.accounts["alice"];
385        assert_eq!(acc.token.as_deref(), Some("tok"));
386        assert_eq!(acc.root.as_deref(), Some("/music"));
387    }
388
389    #[test]
390    fn account_id_parses_and_resolves() {
391        let toml = r#"
392            [accounts.alice]
393            token = "tok"
394            root = "/music"
395            account_id = "user_abc123"
396        "#;
397        let cfg = Config::from_toml(toml).unwrap();
398        assert_eq!(
399            cfg.accounts["alice"].account_id.as_deref(),
400            Some("user_abc123")
401        );
402        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
403        assert_eq!(eff.account_id.as_deref(), Some("user_abc123"));
404    }
405
406    #[test]
407    fn parse_defaults_section() {
408        let toml = r#"
409            [defaults]
410            format = "mp3"
411            concurrency = 8
412            retries = 5
413            min_newest = 2
414            animated_covers = true
415        "#;
416        let cfg = Config::from_toml(toml).unwrap();
417        assert_eq!(cfg.defaults.format, Some(AudioFormat::Mp3));
418        assert_eq!(cfg.defaults.concurrency, Some(8));
419        assert_eq!(cfg.defaults.retries, Some(5));
420        assert_eq!(cfg.defaults.min_newest, Some(2));
421        assert_eq!(cfg.defaults.animated_covers, Some(true));
422    }
423
424    #[test]
425    fn compiled_defaults_when_nothing_set() {
426        let toml = "[accounts.alice]\n";
427        let cfg = Config::from_toml(toml).unwrap();
428        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
429        assert_eq!(
430            eff,
431            EffectiveSettings {
432                token: None,
433                account_id: None,
434                format: AudioFormat::Flac,
435                concurrency: 4,
436                retries: 3,
437                min_newest: 1,
438                animated_covers: false,
439                details_sidecar: false,
440                lyrics_sidecar: false,
441            }
442        );
443    }
444
445    #[test]
446    fn file_defaults_override_compiled() {
447        let toml = r#"
448            [defaults]
449            format = "mp3"
450            concurrency = 8
451
452            [accounts.alice]
453        "#;
454        let cfg = Config::from_toml(toml).unwrap();
455        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
456        assert_eq!(eff.format, AudioFormat::Mp3);
457        assert_eq!(eff.concurrency, 8);
458        assert_eq!(eff.retries, 3); // compiled default
459    }
460
461    #[test]
462    fn account_settings_override_defaults() {
463        let toml = r#"
464            [defaults]
465            format = "mp3"
466
467            [accounts.alice]
468            format = "wav"
469        "#;
470        let cfg = Config::from_toml(toml).unwrap();
471        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
472        assert_eq!(eff.format, AudioFormat::Wav);
473    }
474
475    #[test]
476    fn per_source_overrides_account() {
477        let toml = r#"
478            [accounts.alice]
479            format = "flac"
480
481            [accounts.alice.sources.liked]
482            format = "mp3"
483        "#;
484        let cfg = Config::from_toml(toml).unwrap();
485        let eff = cfg
486            .resolve("alice", Some("liked"), &no_env(), &no_flags())
487            .unwrap();
488        assert_eq!(eff.format, AudioFormat::Mp3);
489    }
490
491    #[test]
492    fn unknown_source_falls_back_to_account() {
493        let toml = r#"
494            [accounts.alice]
495            format = "wav"
496        "#;
497        let cfg = Config::from_toml(toml).unwrap();
498        let eff = cfg
499            .resolve("alice", Some("nonexistent"), &no_env(), &no_flags())
500            .unwrap();
501        assert_eq!(eff.format, AudioFormat::Wav);
502    }
503
504    #[test]
505    fn global_env_overrides_file() {
506        let toml = r#"
507            [accounts.alice]
508            format = "flac"
509        "#;
510        let cfg = Config::from_toml(toml).unwrap();
511        let env: HashMap<String, String> =
512            [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
513        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
514        assert_eq!(eff.format, AudioFormat::Mp3);
515    }
516
517    #[test]
518    fn per_account_env_overrides_global_env() {
519        let toml = "[accounts.alice]\n";
520        let cfg = Config::from_toml(toml).unwrap();
521        let env: HashMap<String, String> = [
522            ("SUNO_FORMAT".into(), "mp3".into()),
523            ("SUNO_ALICE_FORMAT".into(), "wav".into()),
524        ]
525        .into_iter()
526        .collect();
527        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
528        assert_eq!(eff.format, AudioFormat::Wav);
529    }
530
531    #[test]
532    fn per_account_env_label_uppersnakedcase() {
533        let toml = "[accounts.my-lib]\n";
534        let cfg = Config::from_toml(toml).unwrap();
535        let env: HashMap<String, String> = [("SUNO_MY_LIB_FORMAT".into(), "wav".into())]
536            .into_iter()
537            .collect();
538        let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
539        assert_eq!(eff.format, AudioFormat::Wav);
540    }
541
542    #[test]
543    fn flag_overrides_env_and_file() {
544        let toml = r#"
545            [accounts.alice]
546            format = "flac"
547        "#;
548        let cfg = Config::from_toml(toml).unwrap();
549        let env: HashMap<String, String> =
550            [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
551        let flags = FlagOverrides {
552            format: Some(AudioFormat::Wav),
553            ..Default::default()
554        };
555        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
556        assert_eq!(eff.format, AudioFormat::Wav);
557    }
558
559    #[test]
560    fn token_precedence() {
561        let toml = r#"
562            [accounts.alice]
563            token = "file_tok"
564        "#;
565        let cfg = Config::from_toml(toml).unwrap();
566
567        // env overrides file
568        let env: HashMap<String, String> = [("SUNO_TOKEN".into(), "env_tok".into())]
569            .into_iter()
570            .collect();
571        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
572        assert_eq!(eff.token.as_deref(), Some("env_tok"));
573
574        // flag overrides env
575        let flags = FlagOverrides {
576            token: Some("flag_tok".into()),
577            ..Default::default()
578        };
579        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
580        assert_eq!(eff.token.as_deref(), Some("flag_tok"));
581    }
582
583    #[test]
584    fn per_account_token_env_overrides_global() {
585        let toml = "[accounts.alice]\n";
586        let cfg = Config::from_toml(toml).unwrap();
587        let env: HashMap<String, String> = [
588            ("SUNO_TOKEN".into(), "global".into()),
589            ("SUNO_ALICE_TOKEN".into(), "per_account".into()),
590        ]
591        .into_iter()
592        .collect();
593        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
594        assert_eq!(eff.token.as_deref(), Some("per_account"));
595    }
596
597    #[test]
598    fn invalid_env_u32_errors() {
599        let toml = "[accounts.alice]\n";
600        let cfg = Config::from_toml(toml).unwrap();
601        let env: HashMap<String, String> = [("SUNO_CONCURRENCY".into(), "many".into())]
602            .into_iter()
603            .collect();
604        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
605    }
606
607    #[test]
608    fn animated_covers_defaults_off_and_follows_precedence() {
609        // Compiled default is off.
610        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
611        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
612        assert!(!eff.animated_covers);
613
614        // File default on; per-source off; env on; flag off — flag wins.
615        let toml = r#"
616            [defaults]
617            animated_covers = true
618
619            [accounts.alice.sources.liked]
620            animated_covers = false
621        "#;
622        let cfg = Config::from_toml(toml).unwrap();
623
624        // File default (defaults) turns it on for an unscoped resolve.
625        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
626        assert!(eff.animated_covers);
627
628        // Per-source file setting overrides the file default.
629        let eff = cfg
630            .resolve("alice", Some("liked"), &no_env(), &no_flags())
631            .unwrap();
632        assert!(!eff.animated_covers);
633
634        // Env overrides file (even the per-source off).
635        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "true".into())]
636            .into_iter()
637            .collect();
638        let eff = cfg
639            .resolve("alice", Some("liked"), &env, &no_flags())
640            .unwrap();
641        assert!(eff.animated_covers);
642
643        // Flag overrides env.
644        let flags = FlagOverrides {
645            animated_covers: Some(false),
646            ..Default::default()
647        };
648        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
649        assert!(!eff.animated_covers);
650    }
651
652    #[test]
653    fn text_sidecars_default_off_and_follow_precedence() {
654        // Both compiled defaults are off.
655        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
656        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
657        assert!(!eff.details_sidecar);
658        assert!(!eff.lyrics_sidecar);
659
660        let toml = r#"
661            [defaults]
662            details_sidecar = true
663
664            [accounts.alice.sources.liked]
665            details_sidecar = false
666        "#;
667        let cfg = Config::from_toml(toml).unwrap();
668
669        // File default turns details on for an unscoped resolve; lyrics stays off.
670        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
671        assert!(eff.details_sidecar);
672        assert!(!eff.lyrics_sidecar);
673
674        // Per-source file setting overrides the file default.
675        let eff = cfg
676            .resolve("alice", Some("liked"), &no_env(), &no_flags())
677            .unwrap();
678        assert!(!eff.details_sidecar);
679
680        // Env overrides file (both flags), and the flag overrides env.
681        let env: HashMap<String, String> = [
682            ("SUNO_DETAILS_SIDECAR".into(), "true".into()),
683            ("SUNO_LYRICS_SIDECAR".into(), "true".into()),
684        ]
685        .into_iter()
686        .collect();
687        let eff = cfg
688            .resolve("alice", Some("liked"), &env, &no_flags())
689            .unwrap();
690        assert!(eff.details_sidecar);
691        assert!(eff.lyrics_sidecar);
692
693        let flags = FlagOverrides {
694            lyrics_sidecar: Some(false),
695            ..Default::default()
696        };
697        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
698        assert!(eff.details_sidecar);
699        assert!(!eff.lyrics_sidecar);
700    }
701
702    #[test]
703    fn invalid_env_bool_errors() {
704        let toml = "[accounts.alice]\n";
705        let cfg = Config::from_toml(toml).unwrap();
706        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "yes".into())]
707            .into_iter()
708            .collect();
709        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
710    }
711
712    #[test]
713    fn unknown_account_errors() {
714        let cfg = Config::from_toml("").unwrap();
715        assert!(cfg.resolve("nobody", None, &no_env(), &no_flags()).is_err());
716    }
717
718    #[test]
719    fn validation_nested_roots() {
720        let toml = r#"
721            [accounts.alice]
722            root = "/music"
723
724            [accounts.bob]
725            root = "/music/bob"
726        "#;
727        assert!(Config::from_toml(toml).is_err());
728    }
729
730    #[test]
731    fn validation_non_nested_roots_ok() {
732        let toml = r#"
733            [accounts.alice]
734            root = "/music/alice"
735
736            [accounts.bob]
737            root = "/music/bob"
738        "#;
739        assert!(Config::from_toml(toml).is_ok());
740    }
741
742    #[test]
743    fn invalid_toml_errors() {
744        assert!(Config::from_toml("not valid toml ][").is_err());
745    }
746
747    #[test]
748    fn duplicate_account_label_errors() {
749        // The TOML spec prohibits duplicate keys; the parser must reject this.
750        let toml = "
751            [accounts.alice]
752            token = \"tok1\"
753
754            [accounts.alice]
755            token = \"tok2\"
756        ";
757        assert!(Config::from_toml(toml).is_err());
758    }
759
760    #[test]
761    fn parse_error_does_not_echo_token() {
762        // A malformed token line must not include the raw value in the error.
763        let toml = "[accounts.alice]\ntoken = \"unterminated\n";
764        let err = Config::from_toml(toml).unwrap_err().to_string();
765        assert!(!err.contains("unterminated"), "error leaked token: {err}");
766    }
767
768    #[test]
769    fn validation_env_prefix_collision_errors() {
770        // 'my-lib' and 'my_lib' both map to SUNO_MY_LIB_* and must be rejected.
771        let toml = "
772            [accounts.my-lib]
773            [accounts.my_lib]
774        ";
775        assert!(Config::from_toml(toml).is_err());
776    }
777
778    #[test]
779    fn audio_format_display_roundtrip() {
780        for fmt in [AudioFormat::Mp3, AudioFormat::Flac, AudioFormat::Wav] {
781            let s = fmt.to_string();
782            assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
783        }
784    }
785}