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