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