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::{BTreeMap, HashMap};
7use std::fmt;
8use std::path::Path;
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14use crate::naming::CharacterSet;
15use crate::reconcile::SourceMode;
16
17/// Audio format for downloaded clips.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "lowercase")]
20pub enum AudioFormat {
21    Mp3,
22    #[default]
23    Flac,
24    Wav,
25}
26
27impl FromStr for AudioFormat {
28    type Err = Error;
29
30    fn from_str(s: &str) -> Result<Self> {
31        match s.to_ascii_lowercase().as_str() {
32            "mp3" => Ok(Self::Mp3),
33            "flac" => Ok(Self::Flac),
34            "wav" => Ok(Self::Wav),
35            other => Err(Error::Config(format!("unknown format '{other}'"))),
36        }
37    }
38}
39
40impl fmt::Display for AudioFormat {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            Self::Mp3 => f.write_str("mp3"),
44            Self::Flac => f.write_str("flac"),
45            Self::Wav => f.write_str("wav"),
46        }
47    }
48}
49
50/// Global default settings applied when no account or source override applies.
51#[derive(Debug, Clone, Default, Deserialize)]
52pub struct Defaults {
53    pub format: Option<AudioFormat>,
54    pub concurrency: Option<u32>,
55    pub retries: Option<u32>,
56    pub min_newest: Option<u32>,
57    pub token_command: Option<String>,
58    pub animated_covers: Option<bool>,
59    pub details_sidecar: Option<bool>,
60    pub lyrics_sidecar: Option<bool>,
61    pub lrc_sidecar: Option<bool>,
62    pub video_mp4: Option<bool>,
63    pub naming_template: Option<String>,
64    pub character_set: Option<CharacterSet>,
65}
66
67/// Per-source overridable settings within an account.
68#[derive(Debug, Clone, Default, Deserialize)]
69pub struct SourceConfig {
70    pub format: Option<AudioFormat>,
71    pub concurrency: Option<u32>,
72    pub retries: Option<u32>,
73    pub min_newest: Option<u32>,
74    pub token_command: Option<String>,
75    pub animated_covers: Option<bool>,
76    pub details_sidecar: Option<bool>,
77    pub lyrics_sidecar: Option<bool>,
78    pub lrc_sidecar: Option<bool>,
79    pub video_mp4: Option<bool>,
80    pub naming_template: Option<String>,
81    pub character_set: Option<CharacterSet>,
82}
83
84/// Configuration for a single named account.
85#[derive(Debug, Clone, Default, Deserialize)]
86pub struct AccountConfig {
87    pub token: Option<String>,
88    pub token_command: Option<String>,
89    pub root: Option<String>,
90    /// Optional Suno user id to assert this account authenticates as, refusing
91    /// to run on a mismatch (a belt-and-braces check alongside the on-disk
92    /// owner pin in the lineage store).
93    pub account_id: Option<String>,
94    pub format: Option<AudioFormat>,
95    pub concurrency: Option<u32>,
96    pub retries: Option<u32>,
97    pub min_newest: Option<u32>,
98    pub animated_covers: Option<bool>,
99    pub details_sidecar: Option<bool>,
100    pub lyrics_sidecar: Option<bool>,
101    pub lrc_sidecar: Option<bool>,
102    pub video_mp4: Option<bool>,
103    pub naming_template: Option<String>,
104    pub character_set: Option<CharacterSet>,
105    #[serde(default)]
106    pub sources: HashMap<String, SourceConfig>,
107    /// Per-area mode selection (`sync` vs `copy`) for this account's library,
108    /// liked feed, and playlists. Absent means the classic single-verb run.
109    pub areas: Option<AreasConfig>,
110    /// Manual album-name overrides, keyed by the album's stable lineage root id
111    /// (`<root_id> = "Preferred Name"`). Album identity is the lineage root, so
112    /// the override is account-wide (like lineage), never per-source: the
113    /// derived title is unstable and is exactly what this replaces. An empty or
114    /// whitespace-only value is ignored, so a stray key cannot blank an album.
115    #[serde(default)]
116    pub albums: HashMap<String, String>,
117}
118
119/// How a single area treats deletion, including the library-only `off` value.
120///
121/// `off` is expressible only for the library area: it deliberately arms deletion
122/// of library-exclusive files by suppressing the implicit copy-protector, so a
123/// typo can never silently disarm that safety. `copy` and `mirror` map straight
124/// onto the matching [`SourceMode`].
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum AreaMode {
127    /// Suppress the implicit library copy-protector (arm library deletions).
128    Off,
129    /// Treat the area with the given [`SourceMode`].
130    Mode(SourceMode),
131}
132
133impl<'de> Deserialize<'de> for AreaMode {
134    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
135    where
136        D: serde::Deserializer<'de>,
137    {
138        let raw = String::deserialize(deserializer)?;
139        match raw.as_str() {
140            "off" => Ok(AreaMode::Off),
141            "copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
142            "mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
143            other => Err(serde::de::Error::custom(format!(
144                "unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
145            ))),
146        }
147    }
148}
149
150/// Per-area mode selection for an account.
151///
152/// `library` accepts `off`/`copy`/`mirror`; `liked` and `playlists` accept
153/// `copy`/`mirror`; `playlist` overrides individual playlists by canonical Suno
154/// id. `deny_unknown_fields` turns a mistyped key (e.g. `libary`) into a parse
155/// error rather than a silent no-op. The `playlist` map cannot carry
156/// `deny_unknown_fields` (its keys are dynamic playlist ids), but every value is
157/// a closed [`SourceMode`], so a bad mode string still errors at parse time.
158#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct AreasConfig {
161    pub library: Option<AreaMode>,
162    pub liked: Option<SourceMode>,
163    pub playlists: Option<SourceMode>,
164    #[serde(default)]
165    pub playlist: HashMap<String, SourceMode>,
166}
167
168/// Top-level configuration parsed from a TOML file.
169#[derive(Debug, Clone, Default, Deserialize)]
170pub struct Config {
171    #[serde(default)]
172    pub defaults: Defaults,
173    #[serde(default)]
174    pub accounts: HashMap<String, AccountConfig>,
175}
176
177impl Config {
178    /// Parse `toml_str` and validate the result.
179    ///
180    /// Validation rejects any pair of accounts whose root directories nest
181    /// inside one another. Duplicate account labels are rejected by the TOML
182    /// parser itself.
183    pub fn from_toml(toml_str: &str) -> Result<Self> {
184        let config: Self = toml::from_str(toml_str).map_err(|e| {
185            // Strip source-context lines (those containing " | ") to prevent
186            // token values from being echoed in error messages.
187            let raw = e.to_string();
188            let msg = raw
189                .lines()
190                .filter(|l| !l.contains(" | "))
191                .collect::<Vec<_>>()
192                .join("\n")
193                .trim()
194                .to_owned();
195            Error::Config(if msg.is_empty() {
196                "parse error".into()
197            } else {
198                msg
199            })
200        })?;
201        config.validate()?;
202        Ok(config)
203    }
204
205    fn validate(&self) -> Result<()> {
206        let roots: Vec<(&str, &str)> = self
207            .accounts
208            .iter()
209            .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
210            .collect();
211
212        for (i, (label_a, root_a)) in roots.iter().enumerate() {
213            for (label_b, root_b) in roots.iter().skip(i + 1) {
214                let a = Path::new(root_a);
215                let b = Path::new(root_b);
216                if a.starts_with(b) || b.starts_with(a) {
217                    return Err(Error::Config(format!(
218                        "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
219                    )));
220                }
221            }
222        }
223
224        let mut prefix_seen: HashMap<String, &str> = HashMap::new();
225        for label in self.accounts.keys() {
226            let prefix = label_to_env(label);
227            if let Some(other) = prefix_seen.get(&prefix) {
228                return Err(Error::Config(format!(
229                    "accounts '{label}' and '{other}' share env prefix '{prefix}'"
230                )));
231            }
232            prefix_seen.insert(prefix, label.as_str());
233        }
234
235        Ok(())
236    }
237
238    /// Compute effective settings for `account`, optionally scoped to `source`.
239    ///
240    /// The caller supplies the full environment map and any CLI flag overrides.
241    /// Precedence per field: flag > per-account env > global env > per-source
242    /// file > per-account file > global file defaults > compiled default.
243    pub fn resolve(
244        &self,
245        account: &str,
246        source: Option<&str>,
247        env: &HashMap<String, String>,
248        flags: &FlagOverrides,
249    ) -> Result<EffectiveSettings> {
250        let acc = self
251            .accounts
252            .get(account)
253            .ok_or_else(|| Error::Config(format!("account '{account}' not found")))?;
254
255        let src = source.and_then(|s| acc.sources.get(s));
256        let label_env = label_to_env(account);
257
258        // Look up per-account env first, falling back to global.
259        let env_val = |suffix: &str| -> Option<&str> {
260            env.get(&format!("SUNO_{label_env}_{suffix}"))
261                .or_else(|| env.get(&format!("SUNO_{suffix}")))
262                .map(String::as_str)
263        };
264
265        let format_from_env = env_val("FORMAT")
266            .map(str::parse::<AudioFormat>)
267            .transpose()?;
268
269        let format = flags
270            .format
271            .or(format_from_env)
272            .or_else(|| src.and_then(|s| s.format))
273            .or(acc.format)
274            .or(self.defaults.format)
275            .unwrap_or(AudioFormat::Flac);
276
277        let concurrency = resolve_u32(
278            flags.concurrency,
279            env_val("CONCURRENCY"),
280            src.and_then(|s| s.concurrency),
281            acc.concurrency,
282            self.defaults.concurrency,
283            4,
284            "CONCURRENCY",
285        )?;
286
287        let retries = resolve_u32(
288            flags.retries,
289            env_val("RETRIES"),
290            src.and_then(|s| s.retries),
291            acc.retries,
292            self.defaults.retries,
293            3,
294            "RETRIES",
295        )?;
296
297        let min_newest = resolve_u32(
298            flags.min_newest,
299            env_val("MIN_NEWEST"),
300            src.and_then(|s| s.min_newest),
301            acc.min_newest,
302            self.defaults.min_newest,
303            1,
304            "MIN_NEWEST",
305        )?;
306
307        let animated_covers = resolve_bool(
308            flags.animated_covers,
309            env_val("ANIMATED_COVERS"),
310            src.and_then(|s| s.animated_covers),
311            acc.animated_covers,
312            self.defaults.animated_covers,
313            false,
314            "ANIMATED_COVERS",
315        )?;
316
317        let details_sidecar = resolve_bool(
318            flags.details_sidecar,
319            env_val("DETAILS_SIDECAR"),
320            src.and_then(|s| s.details_sidecar),
321            acc.details_sidecar,
322            self.defaults.details_sidecar,
323            false,
324            "DETAILS_SIDECAR",
325        )?;
326
327        let lyrics_sidecar = resolve_bool(
328            flags.lyrics_sidecar,
329            env_val("LYRICS_SIDECAR"),
330            src.and_then(|s| s.lyrics_sidecar),
331            acc.lyrics_sidecar,
332            self.defaults.lyrics_sidecar,
333            false,
334            "LYRICS_SIDECAR",
335        )?;
336
337        let lrc_sidecar = resolve_bool(
338            flags.lrc_sidecar,
339            env_val("LRC_SIDECAR"),
340            src.and_then(|s| s.lrc_sidecar),
341            acc.lrc_sidecar,
342            self.defaults.lrc_sidecar,
343            false,
344            "LRC_SIDECAR",
345        )?;
346
347        let video_mp4 = resolve_bool(
348            flags.video_mp4,
349            env_val("VIDEO_MP4"),
350            src.and_then(|s| s.video_mp4),
351            acc.video_mp4,
352            self.defaults.video_mp4,
353            false,
354            "VIDEO_MP4",
355        )?;
356
357        let naming_template_from_env = env_val("NAMING_TEMPLATE").map(str::to_owned);
358        let naming_template = flags
359            .naming_template
360            .clone()
361            .or(naming_template_from_env)
362            .or_else(|| src.and_then(|s| s.naming_template.clone()))
363            .or_else(|| acc.naming_template.clone())
364            .or_else(|| self.defaults.naming_template.clone())
365            .unwrap_or_else(|| crate::naming::DEFAULT_TEMPLATE.to_owned());
366
367        let character_set_from_env = env_val("CHARACTER_SET")
368            .map(str::parse::<CharacterSet>)
369            .transpose()?;
370        let character_set = flags
371            .character_set
372            .or(character_set_from_env)
373            .or_else(|| src.and_then(|s| s.character_set))
374            .or(acc.character_set)
375            .or(self.defaults.character_set)
376            .unwrap_or(CharacterSet::Unicode);
377
378        let token = flags
379            .token
380            .clone()
381            .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
382            .or_else(|| env.get("SUNO_TOKEN").cloned());
383
384        let token_command = env
385            .get(&format!("SUNO_{label_env}_TOKEN_COMMAND"))
386            .cloned()
387            .or_else(|| env.get("SUNO_TOKEN_COMMAND").cloned())
388            .or_else(|| src.and_then(|s| s.token_command.clone()))
389            .or_else(|| acc.token_command.clone())
390            .or_else(|| self.defaults.token_command.clone());
391
392        Ok(EffectiveSettings {
393            token,
394            stored_token: acc.token.clone(),
395            token_command,
396            account_id: acc.account_id.clone(),
397            format,
398            concurrency,
399            retries,
400            min_newest,
401            animated_covers,
402            details_sidecar,
403            lyrics_sidecar,
404            lrc_sidecar,
405            video_mp4,
406            naming_template,
407            character_set,
408            areas: acc.areas.clone(),
409            album_overrides: acc
410                .albums
411                .iter()
412                .filter(|(_, name)| !name.trim().is_empty())
413                .map(|(root_id, name)| (root_id.clone(), name.trim().to_owned()))
414                .collect(),
415        })
416    }
417}
418
419fn resolve_u32(
420    flag: Option<u32>,
421    env_str: Option<&str>,
422    src: Option<u32>,
423    acc: Option<u32>,
424    defaults: Option<u32>,
425    compiled: u32,
426    name: &str,
427) -> Result<u32> {
428    if let Some(v) = flag {
429        return Ok(v);
430    }
431    if let Some(s) = env_str {
432        return s
433            .parse()
434            .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
435    }
436    Ok(src.or(acc).or(defaults).unwrap_or(compiled))
437}
438
439fn resolve_bool(
440    flag: Option<bool>,
441    env_str: Option<&str>,
442    src: Option<bool>,
443    acc: Option<bool>,
444    defaults: Option<bool>,
445    compiled: bool,
446    name: &str,
447) -> Result<bool> {
448    if let Some(v) = flag {
449        return Ok(v);
450    }
451    if let Some(s) = env_str {
452        return s
453            .parse()
454            .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
455    }
456    Ok(src.or(acc).or(defaults).unwrap_or(compiled))
457}
458
459/// Convert an account label to its environment variable prefix, mirroring the
460/// per-account keys the resolver reads: `my-lib` becomes `MY_LIB` for lookups
461/// like `SUNO_MY_LIB_TOKEN`.
462pub fn label_to_env(label: &str) -> String {
463    label.to_ascii_uppercase().replace('-', "_")
464}
465
466/// CLI flag overrides passed to [`Config::resolve`]. `None` means the flag
467/// was not provided.
468#[derive(Debug, Default)]
469pub struct FlagOverrides {
470    pub token: Option<String>,
471    pub format: Option<AudioFormat>,
472    pub concurrency: Option<u32>,
473    pub retries: Option<u32>,
474    pub min_newest: Option<u32>,
475    pub animated_covers: Option<bool>,
476    pub details_sidecar: Option<bool>,
477    pub lyrics_sidecar: Option<bool>,
478    pub lrc_sidecar: Option<bool>,
479    pub video_mp4: Option<bool>,
480    pub naming_template: Option<String>,
481    pub character_set: Option<CharacterSet>,
482}
483
484/// Resolved effective settings for one account/source combination.
485#[derive(Debug, Clone, PartialEq)]
486pub struct EffectiveSettings {
487    /// A direct token from `--token` or `SUNO_*_TOKEN`.
488    pub token: Option<String>,
489    /// A stored token from `[accounts.<label>].token`.
490    pub stored_token: Option<String>,
491    /// A command to run for the token when no direct token was supplied.
492    pub token_command: Option<String>,
493    /// The optional configured account id assertion (see [`AccountConfig`]).
494    pub account_id: Option<String>,
495    pub format: AudioFormat,
496    pub concurrency: u32,
497    pub retries: u32,
498    pub min_newest: u32,
499    pub animated_covers: bool,
500    pub details_sidecar: bool,
501    pub lyrics_sidecar: bool,
502    pub lrc_sidecar: bool,
503    pub video_mp4: bool,
504    pub naming_template: String,
505    pub character_set: CharacterSet,
506    /// The per-account `[areas]` selection table, if configured.
507    pub areas: Option<AreasConfig>,
508    /// Manual album-name overrides, keyed by lineage root id, resolved from the
509    /// account's `[accounts.<label>.albums]` table. Deterministically ordered
510    /// (a [`BTreeMap`]) and pre-trimmed of empty values by [`Config::resolve`].
511    pub album_overrides: BTreeMap<String, String>,
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    fn no_env() -> HashMap<String, String> {
519        HashMap::new()
520    }
521
522    fn no_flags() -> FlagOverrides {
523        FlagOverrides::default()
524    }
525
526    #[test]
527    fn parse_empty_toml() {
528        let cfg = Config::from_toml("").unwrap();
529        assert!(cfg.accounts.is_empty());
530    }
531
532    #[test]
533    fn parse_basic_account() {
534        let toml = r#"
535            [accounts.alice]
536            token = "tok"
537            root = "/music"
538        "#;
539        let cfg = Config::from_toml(toml).unwrap();
540        let acc = &cfg.accounts["alice"];
541        assert_eq!(acc.token.as_deref(), Some("tok"));
542        assert_eq!(acc.root.as_deref(), Some("/music"));
543    }
544
545    #[test]
546    fn account_id_parses_and_resolves() {
547        let toml = r#"
548            [accounts.alice]
549            token = "tok"
550            root = "/music"
551            account_id = "user_abc123"
552        "#;
553        let cfg = Config::from_toml(toml).unwrap();
554        assert_eq!(
555            cfg.accounts["alice"].account_id.as_deref(),
556            Some("user_abc123")
557        );
558        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
559        assert_eq!(eff.account_id.as_deref(), Some("user_abc123"));
560    }
561
562    #[test]
563    fn parse_defaults_section() {
564        let toml = r#"
565            [defaults]
566            format = "mp3"
567            concurrency = 8
568            retries = 5
569            min_newest = 2
570            animated_covers = true
571        "#;
572        let cfg = Config::from_toml(toml).unwrap();
573        assert_eq!(cfg.defaults.format, Some(AudioFormat::Mp3));
574        assert_eq!(cfg.defaults.concurrency, Some(8));
575        assert_eq!(cfg.defaults.retries, Some(5));
576        assert_eq!(cfg.defaults.min_newest, Some(2));
577        assert_eq!(cfg.defaults.animated_covers, Some(true));
578    }
579
580    #[test]
581    fn compiled_defaults_when_nothing_set() {
582        let toml = "[accounts.alice]\n";
583        let cfg = Config::from_toml(toml).unwrap();
584        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
585        assert_eq!(
586            eff,
587            EffectiveSettings {
588                token: None,
589                stored_token: None,
590                token_command: None,
591                account_id: None,
592                format: AudioFormat::Flac,
593                concurrency: 4,
594                retries: 3,
595                min_newest: 1,
596                animated_covers: false,
597                details_sidecar: false,
598                lyrics_sidecar: false,
599                lrc_sidecar: false,
600                video_mp4: false,
601                naming_template: crate::naming::DEFAULT_TEMPLATE.to_owned(),
602                character_set: CharacterSet::Unicode,
603                areas: None,
604                album_overrides: BTreeMap::new(),
605            }
606        );
607    }
608
609    #[test]
610    fn file_defaults_override_compiled() {
611        let toml = r#"
612            [defaults]
613            format = "mp3"
614            concurrency = 8
615
616            [accounts.alice]
617        "#;
618        let cfg = Config::from_toml(toml).unwrap();
619        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
620        assert_eq!(eff.format, AudioFormat::Mp3);
621        assert_eq!(eff.concurrency, 8);
622        assert_eq!(eff.retries, 3); // compiled default
623    }
624
625    #[test]
626    fn account_settings_override_defaults() {
627        let toml = r#"
628            [defaults]
629            format = "mp3"
630
631            [accounts.alice]
632            format = "wav"
633        "#;
634        let cfg = Config::from_toml(toml).unwrap();
635        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
636        assert_eq!(eff.format, AudioFormat::Wav);
637    }
638
639    #[test]
640    fn per_source_overrides_account() {
641        let toml = r#"
642            [accounts.alice]
643            format = "flac"
644
645            [accounts.alice.sources.liked]
646            format = "mp3"
647        "#;
648        let cfg = Config::from_toml(toml).unwrap();
649        let eff = cfg
650            .resolve("alice", Some("liked"), &no_env(), &no_flags())
651            .unwrap();
652        assert_eq!(eff.format, AudioFormat::Mp3);
653    }
654
655    #[test]
656    fn unknown_source_falls_back_to_account() {
657        let toml = r#"
658            [accounts.alice]
659            format = "wav"
660        "#;
661        let cfg = Config::from_toml(toml).unwrap();
662        let eff = cfg
663            .resolve("alice", Some("nonexistent"), &no_env(), &no_flags())
664            .unwrap();
665        assert_eq!(eff.format, AudioFormat::Wav);
666    }
667
668    #[test]
669    fn global_env_overrides_file() {
670        let toml = r#"
671            [accounts.alice]
672            format = "flac"
673        "#;
674        let cfg = Config::from_toml(toml).unwrap();
675        let env: HashMap<String, String> =
676            [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
677        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
678        assert_eq!(eff.format, AudioFormat::Mp3);
679    }
680
681    #[test]
682    fn per_account_env_overrides_global_env() {
683        let toml = "[accounts.alice]\n";
684        let cfg = Config::from_toml(toml).unwrap();
685        let env: HashMap<String, String> = [
686            ("SUNO_FORMAT".into(), "mp3".into()),
687            ("SUNO_ALICE_FORMAT".into(), "wav".into()),
688        ]
689        .into_iter()
690        .collect();
691        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
692        assert_eq!(eff.format, AudioFormat::Wav);
693    }
694
695    #[test]
696    fn per_account_env_label_uppersnakedcase() {
697        let toml = "[accounts.my-lib]\n";
698        let cfg = Config::from_toml(toml).unwrap();
699        let env: HashMap<String, String> = [("SUNO_MY_LIB_FORMAT".into(), "wav".into())]
700            .into_iter()
701            .collect();
702        let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
703        assert_eq!(eff.format, AudioFormat::Wav);
704    }
705
706    #[test]
707    fn flag_overrides_env_and_file() {
708        let toml = r#"
709            [accounts.alice]
710            format = "flac"
711        "#;
712        let cfg = Config::from_toml(toml).unwrap();
713        let env: HashMap<String, String> =
714            [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
715        let flags = FlagOverrides {
716            format: Some(AudioFormat::Wav),
717            ..Default::default()
718        };
719        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
720        assert_eq!(eff.format, AudioFormat::Wav);
721    }
722
723    #[test]
724    fn token_precedence() {
725        let toml = r#"
726            [accounts.alice]
727            token = "file_tok"
728        "#;
729        let cfg = Config::from_toml(toml).unwrap();
730
731        // env overrides file
732        let env: HashMap<String, String> = [("SUNO_TOKEN".into(), "env_tok".into())]
733            .into_iter()
734            .collect();
735        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
736        assert_eq!(eff.token.as_deref(), Some("env_tok"));
737        assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
738
739        // flag overrides env
740        let flags = FlagOverrides {
741            token: Some("flag_tok".into()),
742            ..Default::default()
743        };
744        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
745        assert_eq!(eff.token.as_deref(), Some("flag_tok"));
746        assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
747    }
748
749    #[test]
750    fn stored_token_is_populated_from_config_when_no_override_exists() {
751        let toml = r#"
752            [accounts.alice]
753            token = "file_tok"
754        "#;
755        let cfg = Config::from_toml(toml).unwrap();
756        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
757        assert_eq!(eff.token, None);
758        assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
759        assert_eq!(eff.token_command, None);
760    }
761
762    #[test]
763    fn per_account_token_env_overrides_global() {
764        let toml = "[accounts.alice]\n";
765        let cfg = Config::from_toml(toml).unwrap();
766        let env: HashMap<String, String> = [
767            ("SUNO_TOKEN".into(), "global".into()),
768            ("SUNO_ALICE_TOKEN".into(), "per_account".into()),
769        ]
770        .into_iter()
771        .collect();
772        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
773        assert_eq!(eff.token.as_deref(), Some("per_account"));
774    }
775
776    #[test]
777    fn token_command_resolves_from_defaults_account_source_and_env() {
778        let toml = r#"
779            [defaults]
780            token_command = "defaults"
781
782            [accounts.alice]
783            token_command = "account"
784
785            [accounts.alice.sources.liked]
786            token_command = "source"
787        "#;
788        let cfg = Config::from_toml(toml).unwrap();
789
790        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
791        assert_eq!(eff.token_command.as_deref(), Some("account"));
792
793        let eff = cfg
794            .resolve("alice", Some("liked"), &no_env(), &no_flags())
795            .unwrap();
796        assert_eq!(eff.token_command.as_deref(), Some("source"));
797
798        let env: HashMap<String, String> = [("SUNO_TOKEN_COMMAND".into(), "global".into())]
799            .into_iter()
800            .collect();
801        let eff = cfg
802            .resolve("alice", Some("liked"), &env, &no_flags())
803            .unwrap();
804        assert_eq!(eff.token_command.as_deref(), Some("global"));
805
806        let env: HashMap<String, String> = [
807            ("SUNO_TOKEN_COMMAND".into(), "global".into()),
808            ("SUNO_ALICE_TOKEN_COMMAND".into(), "per_account".into()),
809        ]
810        .into_iter()
811        .collect();
812        let eff = cfg
813            .resolve("alice", Some("liked"), &env, &no_flags())
814            .unwrap();
815        assert_eq!(eff.token_command.as_deref(), Some("per_account"));
816    }
817
818    #[test]
819    fn per_account_token_command_env_label_uppersnakedcase() {
820        let cfg = Config::from_toml("[accounts.my-lib]\n").unwrap();
821        let env: HashMap<String, String> = [("SUNO_MY_LIB_TOKEN_COMMAND".into(), "command".into())]
822            .into_iter()
823            .collect();
824        let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
825        assert_eq!(eff.token_command.as_deref(), Some("command"));
826    }
827
828    #[test]
829    fn invalid_env_u32_errors() {
830        let toml = "[accounts.alice]\n";
831        let cfg = Config::from_toml(toml).unwrap();
832        let env: HashMap<String, String> = [("SUNO_CONCURRENCY".into(), "many".into())]
833            .into_iter()
834            .collect();
835        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
836    }
837
838    #[test]
839    fn animated_covers_defaults_off_and_follows_precedence() {
840        // Compiled default is off.
841        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
842        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
843        assert!(!eff.animated_covers);
844
845        // File default on; per-source off; env on; flag off — flag wins.
846        let toml = r#"
847            [defaults]
848            animated_covers = true
849
850            [accounts.alice.sources.liked]
851            animated_covers = false
852        "#;
853        let cfg = Config::from_toml(toml).unwrap();
854
855        // File default (defaults) turns it on for an unscoped resolve.
856        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
857        assert!(eff.animated_covers);
858
859        // Per-source file setting overrides the file default.
860        let eff = cfg
861            .resolve("alice", Some("liked"), &no_env(), &no_flags())
862            .unwrap();
863        assert!(!eff.animated_covers);
864
865        // Env overrides file (even the per-source off).
866        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "true".into())]
867            .into_iter()
868            .collect();
869        let eff = cfg
870            .resolve("alice", Some("liked"), &env, &no_flags())
871            .unwrap();
872        assert!(eff.animated_covers);
873
874        // Flag overrides env.
875        let flags = FlagOverrides {
876            animated_covers: Some(false),
877            ..Default::default()
878        };
879        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
880        assert!(!eff.animated_covers);
881    }
882
883    #[test]
884    fn video_mp4_defaults_off_and_follows_precedence() {
885        // Compiled default is off.
886        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
887        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
888        assert!(!eff.video_mp4);
889
890        // File default on; per-source off; env on; flag off — flag wins.
891        let toml = r#"
892            [defaults]
893            video_mp4 = true
894
895            [accounts.alice.sources.liked]
896            video_mp4 = false
897        "#;
898        let cfg = Config::from_toml(toml).unwrap();
899        assert!(
900            cfg.resolve("alice", None, &no_env(), &no_flags())
901                .unwrap()
902                .video_mp4
903        );
904        assert!(
905            !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
906                .unwrap()
907                .video_mp4
908        );
909
910        let env: HashMap<String, String> = [("SUNO_VIDEO_MP4".into(), "true".into())]
911            .into_iter()
912            .collect();
913        assert!(
914            cfg.resolve("alice", Some("liked"), &env, &no_flags())
915                .unwrap()
916                .video_mp4
917        );
918
919        let flags = FlagOverrides {
920            video_mp4: Some(false),
921            ..Default::default()
922        };
923        assert!(
924            !cfg.resolve("alice", Some("liked"), &env, &flags)
925                .unwrap()
926                .video_mp4
927        );
928    }
929
930    #[test]
931    fn text_sidecars_default_off_and_follow_precedence() {
932        // Both compiled defaults are off.
933        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
934        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
935        assert!(!eff.details_sidecar);
936        assert!(!eff.lyrics_sidecar);
937
938        let toml = r#"
939            [defaults]
940            details_sidecar = true
941
942            [accounts.alice.sources.liked]
943            details_sidecar = false
944        "#;
945        let cfg = Config::from_toml(toml).unwrap();
946
947        // File default turns details on for an unscoped resolve; lyrics stays off.
948        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
949        assert!(eff.details_sidecar);
950        assert!(!eff.lyrics_sidecar);
951
952        // Per-source file setting overrides the file default.
953        let eff = cfg
954            .resolve("alice", Some("liked"), &no_env(), &no_flags())
955            .unwrap();
956        assert!(!eff.details_sidecar);
957
958        // Env overrides file (both flags), and the flag overrides env.
959        let env: HashMap<String, String> = [
960            ("SUNO_DETAILS_SIDECAR".into(), "true".into()),
961            ("SUNO_LYRICS_SIDECAR".into(), "true".into()),
962        ]
963        .into_iter()
964        .collect();
965        let eff = cfg
966            .resolve("alice", Some("liked"), &env, &no_flags())
967            .unwrap();
968        assert!(eff.details_sidecar);
969        assert!(eff.lyrics_sidecar);
970
971        let flags = FlagOverrides {
972            lyrics_sidecar: Some(false),
973            ..Default::default()
974        };
975        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
976        assert!(eff.details_sidecar);
977        assert!(!eff.lyrics_sidecar);
978    }
979
980    #[test]
981    fn invalid_env_bool_errors() {
982        let toml = "[accounts.alice]\n";
983        let cfg = Config::from_toml(toml).unwrap();
984        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "yes".into())]
985            .into_iter()
986            .collect();
987        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
988    }
989
990    #[test]
991    fn unknown_account_errors() {
992        let cfg = Config::from_toml("").unwrap();
993        assert!(cfg.resolve("nobody", None, &no_env(), &no_flags()).is_err());
994    }
995
996    #[test]
997    fn validation_nested_roots() {
998        let toml = r#"
999            [accounts.alice]
1000            root = "/music"
1001
1002            [accounts.bob]
1003            root = "/music/bob"
1004        "#;
1005        assert!(Config::from_toml(toml).is_err());
1006    }
1007
1008    #[test]
1009    fn validation_non_nested_roots_ok() {
1010        let toml = r#"
1011            [accounts.alice]
1012            root = "/music/alice"
1013
1014            [accounts.bob]
1015            root = "/music/bob"
1016        "#;
1017        assert!(Config::from_toml(toml).is_ok());
1018    }
1019
1020    #[test]
1021    fn invalid_toml_errors() {
1022        assert!(Config::from_toml("not valid toml ][").is_err());
1023    }
1024
1025    #[test]
1026    fn duplicate_account_label_errors() {
1027        // The TOML spec prohibits duplicate keys; the parser must reject this.
1028        let toml = "
1029            [accounts.alice]
1030            token = \"tok1\"
1031
1032            [accounts.alice]
1033            token = \"tok2\"
1034        ";
1035        assert!(Config::from_toml(toml).is_err());
1036    }
1037
1038    #[test]
1039    fn parse_error_does_not_echo_token() {
1040        // A malformed token line must not include the raw value in the error.
1041        let toml = "[accounts.alice]\ntoken = \"unterminated\n";
1042        let err = Config::from_toml(toml).unwrap_err().to_string();
1043        assert!(!err.contains("unterminated"), "error leaked token: {err}");
1044    }
1045
1046    #[test]
1047    fn validation_env_prefix_collision_errors() {
1048        // 'my-lib' and 'my_lib' both map to SUNO_MY_LIB_* and must be rejected.
1049        let toml = "
1050            [accounts.my-lib]
1051            [accounts.my_lib]
1052        ";
1053        assert!(Config::from_toml(toml).is_err());
1054    }
1055
1056    #[test]
1057    fn audio_format_display_roundtrip() {
1058        for fmt in [AudioFormat::Mp3, AudioFormat::Flac, AudioFormat::Wav] {
1059            let s = fmt.to_string();
1060            assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
1061        }
1062    }
1063
1064    #[test]
1065    fn naming_template_follows_precedence() {
1066        let toml = r#"
1067            [defaults]
1068            naming_template = "{title}"
1069
1070            [accounts.alice]
1071            naming_template = "{creator}/{title}"
1072
1073            [accounts.alice.sources.liked]
1074            naming_template = "{handle}/{title} [{id8}]"
1075        "#;
1076        let cfg = Config::from_toml(toml).unwrap();
1077
1078        // Per-source wins over account.
1079        let eff = cfg
1080            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1081            .unwrap();
1082        assert_eq!(eff.naming_template, "{handle}/{title} [{id8}]");
1083
1084        // Account wins over defaults.
1085        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1086        assert_eq!(eff.naming_template, "{creator}/{title}");
1087
1088        // Env overrides file.
1089        let env: HashMap<String, String> = [("SUNO_NAMING_TEMPLATE".into(), "{id}".into())]
1090            .into_iter()
1091            .collect();
1092        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1093        assert_eq!(eff.naming_template, "{id}");
1094
1095        // Flag overrides env.
1096        let flags = FlagOverrides {
1097            naming_template: Some("{title}/{id8}".into()),
1098            ..Default::default()
1099        };
1100        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1101        assert_eq!(eff.naming_template, "{title}/{id8}");
1102    }
1103
1104    #[test]
1105    fn character_set_follows_precedence() {
1106        let toml = r#"
1107            [defaults]
1108            character_set = "ascii"
1109
1110            [accounts.alice]
1111        "#;
1112        let cfg = Config::from_toml(toml).unwrap();
1113
1114        // File default applies.
1115        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1116        assert_eq!(eff.character_set, CharacterSet::Ascii);
1117
1118        // Env overrides file.
1119        let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "unicode".into())]
1120            .into_iter()
1121            .collect();
1122        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1123        assert_eq!(eff.character_set, CharacterSet::Unicode);
1124
1125        // Flag overrides env.
1126        let flags = FlagOverrides {
1127            character_set: Some(CharacterSet::Ascii),
1128            ..Default::default()
1129        };
1130        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1131        assert_eq!(eff.character_set, CharacterSet::Ascii);
1132    }
1133
1134    #[test]
1135    fn invalid_character_set_env_errors() {
1136        let toml = "[accounts.alice]\n";
1137        let cfg = Config::from_toml(toml).unwrap();
1138        let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "utf8".into())]
1139            .into_iter()
1140            .collect();
1141        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1142    }
1143
1144    #[test]
1145    fn areas_parse_full_table() {
1146        let toml = r#"
1147            [accounts.alice]
1148            token = "t"
1149            [accounts.alice.areas]
1150            library = "off"
1151            liked = "copy"
1152            playlists = "mirror"
1153            [accounts.alice.areas.playlist]
1154            "pl_abc123" = "mirror"
1155            "pl_def456" = "copy"
1156        "#;
1157        let cfg = Config::from_toml(toml).unwrap();
1158        let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
1159        assert_eq!(areas.library, Some(AreaMode::Off));
1160        assert_eq!(areas.liked, Some(SourceMode::Copy));
1161        assert_eq!(areas.playlists, Some(SourceMode::Mirror));
1162        assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
1163        assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
1164    }
1165
1166    #[test]
1167    fn album_overrides_parse_and_resolve() {
1168        let toml = r#"
1169            [accounts.alice]
1170            token = "t"
1171            [accounts.alice.albums]
1172            "root_abc123" = "Preferred Name"
1173            "root_def456" = "Another Album"
1174            "root_blank" = "   "
1175        "#;
1176        let cfg = Config::from_toml(toml).unwrap();
1177        assert_eq!(
1178            cfg.accounts["alice"].albums["root_abc123"],
1179            "Preferred Name"
1180        );
1181        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1182        assert_eq!(eff.album_overrides["root_abc123"], "Preferred Name");
1183        assert_eq!(eff.album_overrides["root_def456"], "Another Album");
1184        // A blank value is dropped so it can never blank an album.
1185        assert!(!eff.album_overrides.contains_key("root_blank"));
1186    }
1187
1188    #[test]
1189    fn album_overrides_absent_by_default() {
1190        let cfg = Config::from_toml("[accounts.alice]\ntoken = \"t\"\n").unwrap();
1191        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1192        assert!(eff.album_overrides.is_empty());
1193    }
1194
1195    #[test]
1196    fn areas_library_accepts_copy_and_mirror() {
1197        for (raw, expect) in [
1198            ("copy", AreaMode::Mode(SourceMode::Copy)),
1199            ("mirror", AreaMode::Mode(SourceMode::Mirror)),
1200        ] {
1201            let toml =
1202                format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
1203            let cfg = Config::from_toml(&toml).unwrap();
1204            assert_eq!(
1205                cfg.accounts["a"].areas.as_ref().unwrap().library,
1206                Some(expect)
1207            );
1208        }
1209    }
1210
1211    #[test]
1212    fn areas_bad_mode_errors() {
1213        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
1214        assert!(Config::from_toml(toml).is_err());
1215    }
1216
1217    #[test]
1218    fn areas_bad_playlist_mode_errors() {
1219        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
1220        // `off` is a library-only value; a per-playlist entry must be copy/mirror.
1221        assert!(Config::from_toml(toml).is_err());
1222    }
1223
1224    #[test]
1225    fn areas_unknown_field_errors() {
1226        // D7: a mistyped key (libary) is a parse error, not a silent no-op.
1227        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
1228        assert!(Config::from_toml(toml).is_err());
1229    }
1230
1231    #[test]
1232    fn areas_absent_is_none() {
1233        let toml = "[accounts.a]\ntoken = \"t\"\n";
1234        assert!(
1235            Config::from_toml(toml).unwrap().accounts["a"]
1236                .areas
1237                .is_none()
1238        );
1239    }
1240}