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};
14use crate::naming::CharacterSet;
15
16/// Audio format for downloaded clips.
17#[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/// Global default settings applied when no account or source override applies.
50#[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/// Per-source overridable settings within an account.
65#[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/// Configuration for a single named account.
80#[derive(Debug, Clone, Default, Deserialize)]
81pub struct AccountConfig {
82    pub token: Option<String>,
83    pub root: Option<String>,
84    /// Optional Suno user id to assert this account authenticates as, refusing
85    /// to run on a mismatch (a belt-and-braces check alongside the on-disk
86    /// owner pin in the lineage store).
87    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/// Top-level configuration parsed from a TOML file.
103#[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    /// Parse `toml_str` and validate the result.
113    ///
114    /// Validation rejects any pair of accounts whose root directories nest
115    /// inside one another. Duplicate account labels are rejected by the TOML
116    /// parser itself.
117    pub fn from_toml(toml_str: &str) -> Result<Self> {
118        let config: Self = toml::from_str(toml_str).map_err(|e| {
119            // Strip source-context lines (those containing " | ") to prevent
120            // token values from being echoed in error messages.
121            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    /// Compute effective settings for `account`, optionally scoped to `source`.
173    ///
174    /// The caller supplies the full environment map and any CLI flag overrides.
175    /// Precedence per field: flag > per-account env > global env > per-source
176    /// file > per-account file > global file defaults > compiled default.
177    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        // Look up per-account env first, falling back to global.
193        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
366/// Convert an account label to its environment variable prefix.
367///
368/// `my-lib` becomes `MY_LIB`.
369fn label_to_env(label: &str) -> String {
370    label.to_ascii_uppercase().replace('-', "_")
371}
372
373/// CLI flag overrides passed to [`Config::resolve`]. `None` means the flag
374/// was not provided.
375#[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/// Resolved effective settings for one account/source combination.
391#[derive(Debug, Clone, PartialEq)]
392pub struct EffectiveSettings {
393    pub token: Option<String>,
394    /// The optional configured account id assertion (see [`AccountConfig`]).
395    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); // compiled default
512    }
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        // env overrides file
621        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        // flag overrides env
628        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        // Compiled default is off.
663        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        // File default on; per-source off; env on; flag off — flag wins.
668        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        // File default (defaults) turns it on for an unscoped resolve.
678        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
679        assert!(eff.animated_covers);
680
681        // Per-source file setting overrides the file default.
682        let eff = cfg
683            .resolve("alice", Some("liked"), &no_env(), &no_flags())
684            .unwrap();
685        assert!(!eff.animated_covers);
686
687        // Env overrides file (even the per-source off).
688        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        // Flag overrides env.
697        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        // Both compiled defaults are off.
708        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        // File default turns details on for an unscoped resolve; lyrics stays off.
723        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
724        assert!(eff.details_sidecar);
725        assert!(!eff.lyrics_sidecar);
726
727        // Per-source file setting overrides the file default.
728        let eff = cfg
729            .resolve("alice", Some("liked"), &no_env(), &no_flags())
730            .unwrap();
731        assert!(!eff.details_sidecar);
732
733        // Env overrides file (both flags), and the flag overrides env.
734        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        // The TOML spec prohibits duplicate keys; the parser must reject this.
803        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        // A malformed token line must not include the raw value in the error.
816        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        // 'my-lib' and 'my_lib' both map to SUNO_MY_LIB_* and must be rejected.
824        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        // Per-source wins over account.
854        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        // Account wins over defaults.
860        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
861        assert_eq!(eff.naming_template, "{creator}/{title}");
862
863        // Env overrides file.
864        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        // Flag overrides env.
871        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        // File default applies.
890        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
891        assert_eq!(eff.character_set, CharacterSet::Ascii);
892
893        // Env overrides file.
894        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        // Flag overrides env.
901        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}