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