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