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::ffmpeg::WebpEncodeSettings;
15use crate::naming::CharacterSet;
16use crate::reconcile::SourceMode;
17
18/// Audio format for downloaded clips.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
20#[serde(rename_all = "lowercase")]
21pub enum AudioFormat {
22    Mp3,
23    #[default]
24    Flac,
25    Wav,
26    Alac,
27}
28
29impl AudioFormat {
30    /// The on-disk file extension for a clip in this format. Kept separate from
31    /// the [`Display`](fmt::Display) token so a codec's container extension need
32    /// not match its config name.
33    pub fn ext(self) -> &'static str {
34        match self {
35            Self::Mp3 => "mp3",
36            Self::Flac => "flac",
37            Self::Wav => "wav",
38            Self::Alac => "m4a",
39        }
40    }
41
42    /// Whether an animated WebP can be embedded as this format's front cover.
43    ///
44    /// FLAC, MP3, and WAV embed an `image/webp` picture; ALAC (`mp4ameta` `covr`)
45    /// supports only JPEG/PNG/BMP artwork, so it always embeds the static JPEG.
46    pub fn embeds_animated_cover(self) -> bool {
47        !matches!(self, Self::Alac)
48    }
49}
50
51impl FromStr for AudioFormat {
52    type Err = Error;
53
54    fn from_str(s: &str) -> Result<Self> {
55        match s.to_ascii_lowercase().as_str() {
56            "mp3" => Ok(Self::Mp3),
57            "flac" => Ok(Self::Flac),
58            "wav" => Ok(Self::Wav),
59            "alac" => Ok(Self::Alac),
60            other => Err(Error::Config(format!("unknown format '{other}'"))),
61        }
62    }
63}
64
65impl fmt::Display for AudioFormat {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            Self::Mp3 => f.write_str("mp3"),
69            Self::Flac => f.write_str("flac"),
70            Self::Wav => f.write_str("wav"),
71            Self::Alac => f.write_str("alac"),
72        }
73    }
74}
75
76/// Container format for a downloaded stem.
77///
78/// Stems are stored RAW in their native container and are never transcoded, so
79/// unlike [`AudioFormat`] there is no lossless-from-lossy render: WAV comes
80/// straight from Suno's free `convert_wav` endpoint and MP3 straight from the
81/// public CDN. FLAC is deliberately unrepresentable — a stem is never
82/// re-encoded to FLAC, even when the song's own format is FLAC.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
84#[serde(rename_all = "lowercase")]
85pub enum StemFormat {
86    /// Lossless WAV via the free `convert_wav` render, stored as delivered.
87    #[default]
88    Wav,
89    /// The public CDN MP3, stored as delivered.
90    Mp3,
91}
92
93impl StemFormat {
94    /// The file extension for a stem stored in this format.
95    pub fn ext(self) -> &'static str {
96        match self {
97            Self::Wav => "wav",
98            Self::Mp3 => "mp3",
99        }
100    }
101}
102
103impl FromStr for StemFormat {
104    type Err = Error;
105
106    fn from_str(s: &str) -> Result<Self> {
107        match s.to_ascii_lowercase().as_str() {
108            "wav" => Ok(Self::Wav),
109            "mp3" => Ok(Self::Mp3),
110            "flac" => Err(Error::Config(
111                "stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
112            )),
113            other => Err(Error::Config(format!("unknown stem format '{other}'"))),
114        }
115    }
116}
117
118impl fmt::Display for StemFormat {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.write_str(self.ext())
121    }
122}
123
124/// Which video-cover artifacts to retain.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
126#[serde(rename_all = "lowercase")]
127pub enum VideoCoverRetention {
128    #[default]
129    Neither,
130    Webp,
131    Mp4,
132    Both,
133}
134
135impl VideoCoverRetention {
136    pub fn keeps_webp(self) -> bool {
137        matches!(self, Self::Webp | Self::Both)
138    }
139
140    pub fn keeps_mp4(self) -> bool {
141        matches!(self, Self::Mp4 | Self::Both)
142    }
143}
144
145impl FromStr for VideoCoverRetention {
146    type Err = Error;
147
148    fn from_str(s: &str) -> Result<Self> {
149        match s.to_ascii_lowercase().as_str() {
150            "neither" => Ok(Self::Neither),
151            "webp" => Ok(Self::Webp),
152            "mp4" => Ok(Self::Mp4),
153            "both" => Ok(Self::Both),
154            other => Err(Error::Config(format!(
155                "unknown video_cover_retention '{other}'"
156            ))),
157        }
158    }
159}
160
161impl fmt::Display for VideoCoverRetention {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        match self {
164            Self::Neither => f.write_str("neither"),
165            Self::Webp => f.write_str("webp"),
166            Self::Mp4 => f.write_str("mp4"),
167            Self::Both => f.write_str("both"),
168        }
169    }
170}
171
172/// The overridable settings block, shared verbatim by every precedence tier.
173///
174/// One declaration is the whole point: a new knob is added here once and every
175/// tier that flattens it (global [`Defaults`], per-account [`AccountConfig`],
176/// per-source [`SourceConfig`], and the CLI [`FlagOverrides`]) gains it, instead
177/// of being mirrored across the structs where forgetting one silently drops the
178/// setting from a tier.
179#[derive(Debug, Clone, Default, Deserialize)]
180pub struct Settings {
181    pub format: Option<AudioFormat>,
182    pub concurrency: Option<u32>,
183    pub retries: Option<u32>,
184    pub min_newest: Option<u32>,
185    /// The command whose stdout mints a token. Resolved from the
186    /// `SUNO_[<LABEL>_]TOKEN_COMMAND` env tiers then the per-source, per-account,
187    /// and global `token_command` config keys. There is deliberately no
188    /// `--token-command` flag, so setting this on [`FlagOverrides::settings`] is
189    /// intentionally never read by [`Config::resolve`]; configure it in
190    /// `[defaults]`/`[accounts.<label>]`/`[sources.<name>]` or the environment.
191    pub token_command: Option<String>,
192    pub animated_covers: Option<bool>,
193    pub video_cover_retention: Option<VideoCoverRetention>,
194    pub animated_cover_quality: Option<u8>,
195    pub animated_cover_max_fps: Option<u32>,
196    pub animated_cover_max_width: Option<u32>,
197    pub animated_cover_compression_level: Option<u8>,
198    pub animated_cover_lossless: Option<bool>,
199    pub details_sidecar: Option<bool>,
200    pub lyrics_sidecar: Option<bool>,
201    pub lrc_sidecar: Option<bool>,
202    pub video_mp4: Option<bool>,
203    pub download_stems: Option<bool>,
204    pub stem_format: Option<StemFormat>,
205    pub naming_template: Option<String>,
206    pub character_set: Option<CharacterSet>,
207}
208
209/// Global default settings applied when no account or source override applies.
210#[derive(Debug, Clone, Default, Deserialize)]
211pub struct Defaults {
212    #[serde(flatten)]
213    pub settings: Settings,
214}
215
216/// Per-source overridable settings within an account.
217#[derive(Debug, Clone, Default, Deserialize)]
218pub struct SourceConfig {
219    #[serde(flatten)]
220    pub settings: Settings,
221}
222
223/// Configuration for a single named account.
224#[derive(Debug, Clone, Default, Deserialize)]
225pub struct AccountConfig {
226    pub token: Option<String>,
227    pub root: Option<String>,
228    /// Optional Suno user id to assert this account authenticates as, refusing
229    /// to run on a mismatch (a belt-and-braces check alongside the on-disk
230    /// owner pin in the lineage store).
231    pub account_id: Option<String>,
232    #[serde(flatten)]
233    pub settings: Settings,
234    #[serde(default)]
235    pub sources: HashMap<String, SourceConfig>,
236    /// Per-area mode selection (`sync` vs `copy`) for this account's library,
237    /// liked feed, and playlists. Absent means the classic single-verb run.
238    pub areas: Option<AreasConfig>,
239    /// Manual album-name overrides, keyed by the album's stable lineage root id
240    /// (`<root_id> = "Preferred Name"`). Album identity is the lineage root, so
241    /// the override is account-wide (like lineage), never per-source: the
242    /// derived title is unstable and is exactly what this replaces. An empty or
243    /// whitespace-only value is ignored, so a stray key cannot blank an album.
244    #[serde(default)]
245    pub albums: HashMap<String, String>,
246}
247
248/// How a single area treats deletion, including the library-only `off` value.
249///
250/// `off` is expressible only for the library area: it deliberately arms deletion
251/// of library-exclusive files by suppressing the implicit copy-protector, so a
252/// typo can never silently disarm that safety. `copy` and `mirror` map straight
253/// onto the matching [`SourceMode`].
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum AreaMode {
256    /// Suppress the implicit library copy-protector (arm library deletions).
257    Off,
258    /// Treat the area with the given [`SourceMode`].
259    Mode(SourceMode),
260}
261
262impl<'de> Deserialize<'de> for AreaMode {
263    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
264    where
265        D: serde::Deserializer<'de>,
266    {
267        let raw = String::deserialize(deserializer)?;
268        match raw.as_str() {
269            "off" => Ok(AreaMode::Off),
270            "copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
271            "mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
272            other => Err(serde::de::Error::custom(format!(
273                "unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
274            ))),
275        }
276    }
277}
278
279/// Per-area mode selection for an account.
280///
281/// `library` accepts `off`/`copy`/`mirror`; `liked` and `playlists` accept
282/// `copy`/`mirror`; `playlist` overrides individual playlists by canonical Suno
283/// id. `deny_unknown_fields` turns a mistyped key (e.g. `libary`) into a parse
284/// error rather than a silent no-op. The `playlist` map cannot carry
285/// `deny_unknown_fields` (its keys are dynamic playlist ids), but every value is
286/// a closed [`SourceMode`], so a bad mode string still errors at parse time.
287#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
288#[serde(deny_unknown_fields)]
289pub struct AreasConfig {
290    pub library: Option<AreaMode>,
291    pub liked: Option<SourceMode>,
292    pub playlists: Option<SourceMode>,
293    #[serde(default)]
294    pub playlist: HashMap<String, SourceMode>,
295}
296
297/// Top-level configuration parsed from a TOML file.
298#[derive(Debug, Clone, Default, Deserialize)]
299pub struct Config {
300    #[serde(default)]
301    pub defaults: Defaults,
302    #[serde(default)]
303    pub accounts: HashMap<String, AccountConfig>,
304}
305
306impl Config {
307    /// Parse `toml_str` and validate the result.
308    ///
309    /// Validation rejects any pair of accounts whose root directories nest
310    /// inside one another. Duplicate account labels are rejected by the TOML
311    /// parser itself.
312    pub fn from_toml(toml_str: &str) -> Result<Self> {
313        let config: Self = toml::from_str(toml_str).map_err(|e| {
314            // Strip source-context lines (those containing " | ") to prevent
315            // token values from being echoed in error messages.
316            let raw = e.to_string();
317            let msg = raw
318                .lines()
319                .filter(|l| !l.contains(" | "))
320                .collect::<Vec<_>>()
321                .join("\n")
322                .trim()
323                .to_owned();
324            Error::Config(if msg.is_empty() {
325                "parse error".into()
326            } else {
327                msg
328            })
329        })?;
330        config.validate()?;
331        Ok(config)
332    }
333
334    fn validate(&self) -> Result<()> {
335        let roots: Vec<(&str, &str)> = self
336            .accounts
337            .iter()
338            .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
339            .collect();
340
341        for (i, (label_a, root_a)) in roots.iter().enumerate() {
342            for (label_b, root_b) in roots.iter().skip(i + 1) {
343                let a = Path::new(root_a);
344                let b = Path::new(root_b);
345                if a.starts_with(b) || b.starts_with(a) {
346                    return Err(Error::Config(format!(
347                        "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
348                    )));
349                }
350            }
351        }
352
353        let mut prefix_seen: HashMap<String, &str> = HashMap::new();
354        for label in self.accounts.keys() {
355            let prefix = label_to_env(label);
356            if let Some(other) = prefix_seen.get(&prefix) {
357                return Err(Error::Config(format!(
358                    "accounts '{label}' and '{other}' share env prefix '{prefix}'"
359                )));
360            }
361            prefix_seen.insert(prefix, label.as_str());
362        }
363
364        Ok(())
365    }
366
367    /// Compute effective settings for `account`, optionally scoped to `source`.
368    ///
369    /// The caller supplies the full environment map and any CLI flag overrides.
370    /// Precedence per field: flag > per-account env > global env > per-source
371    /// file > per-account file > global file defaults > compiled default.
372    pub fn resolve(
373        &self,
374        account: &str,
375        source: Option<&str>,
376        env: &HashMap<String, String>,
377        flags: &FlagOverrides,
378    ) -> Result<EffectiveSettings> {
379        let acc = self
380            .accounts
381            .get(account)
382            .ok_or_else(|| Error::Config(format!("account '{account}' not found")))?;
383
384        let src = source.and_then(|s| acc.sources.get(s));
385        let label_env = label_to_env(account);
386
387        // Look up per-account env first, falling back to global.
388        let env_val = |suffix: &str| -> Option<&str> {
389            env.get(&format!("SUNO_{label_env}_{suffix}"))
390                .or_else(|| env.get(&format!("SUNO_{suffix}")))
391                .map(String::as_str)
392        };
393
394        let format = resolve_enum(
395            flags.settings.format,
396            env_val("FORMAT"),
397            src.and_then(|s| s.settings.format),
398            acc.settings.format,
399            self.defaults.settings.format,
400            None,
401            "FORMAT",
402        )?
403        .unwrap_or(AudioFormat::Flac);
404
405        let concurrency = resolve_parsed(
406            flags.settings.concurrency,
407            env_val("CONCURRENCY"),
408            src.and_then(|s| s.settings.concurrency),
409            acc.settings.concurrency,
410            self.defaults.settings.concurrency,
411            4,
412            "CONCURRENCY",
413        )?;
414
415        let retries = resolve_parsed(
416            flags.settings.retries,
417            env_val("RETRIES"),
418            src.and_then(|s| s.settings.retries),
419            acc.settings.retries,
420            self.defaults.settings.retries,
421            3,
422            "RETRIES",
423        )?;
424
425        let min_newest = resolve_parsed(
426            flags.settings.min_newest,
427            env_val("MIN_NEWEST"),
428            src.and_then(|s| s.settings.min_newest),
429            acc.settings.min_newest,
430            self.defaults.settings.min_newest,
431            1,
432            "MIN_NEWEST",
433        )?;
434
435        let animated_covers = resolve_parsed(
436            flags.settings.animated_covers,
437            env_val("ANIMATED_COVERS"),
438            src.and_then(|s| s.settings.animated_covers),
439            acc.settings.animated_covers,
440            self.defaults.settings.animated_covers,
441            false,
442            "ANIMATED_COVERS",
443        )?;
444
445        let details_sidecar = resolve_parsed(
446            flags.settings.details_sidecar,
447            env_val("DETAILS_SIDECAR"),
448            src.and_then(|s| s.settings.details_sidecar),
449            acc.settings.details_sidecar,
450            self.defaults.settings.details_sidecar,
451            false,
452            "DETAILS_SIDECAR",
453        )?;
454
455        let lyrics_sidecar = resolve_parsed(
456            flags.settings.lyrics_sidecar,
457            env_val("LYRICS_SIDECAR"),
458            src.and_then(|s| s.settings.lyrics_sidecar),
459            acc.settings.lyrics_sidecar,
460            self.defaults.settings.lyrics_sidecar,
461            false,
462            "LYRICS_SIDECAR",
463        )?;
464
465        let lrc_sidecar = resolve_parsed(
466            flags.settings.lrc_sidecar,
467            env_val("LRC_SIDECAR"),
468            src.and_then(|s| s.settings.lrc_sidecar),
469            acc.settings.lrc_sidecar,
470            self.defaults.settings.lrc_sidecar,
471            false,
472            "LRC_SIDECAR",
473        )?;
474
475        let video_mp4 = resolve_parsed(
476            flags.settings.video_mp4,
477            env_val("VIDEO_MP4"),
478            src.and_then(|s| s.settings.video_mp4),
479            acc.settings.video_mp4,
480            self.defaults.settings.video_mp4,
481            false,
482            "VIDEO_MP4",
483        )?;
484
485        let download_stems = resolve_parsed(
486            flags.settings.download_stems,
487            env_val("DOWNLOAD_STEMS"),
488            src.and_then(|s| s.settings.download_stems),
489            acc.settings.download_stems,
490            self.defaults.settings.download_stems,
491            false,
492            "DOWNLOAD_STEMS",
493        )?;
494
495        let stem_format = resolve_enum(
496            flags.settings.stem_format,
497            env_val("STEM_FORMAT"),
498            src.and_then(|s| s.settings.stem_format),
499            acc.settings.stem_format,
500            self.defaults.settings.stem_format,
501            None,
502            "STEM_FORMAT",
503        )?
504        .unwrap_or_default();
505
506        let video_cover_retention = resolve_enum(
507            flags.settings.video_cover_retention,
508            env_val("VIDEO_COVER_RETENTION"),
509            src.and_then(|s| s.settings.video_cover_retention),
510            acc.settings.video_cover_retention,
511            self.defaults.settings.video_cover_retention,
512            None,
513            "VIDEO_COVER_RETENTION",
514        )?;
515        // `video_cover_retention`, when set, is the unified control for the
516        // album video-cover artifacts: `webp`/`both` keep the transcoded
517        // `cover.webp` (and the per-song `.webp`), `mp4`/`both` keep the raw
518        // `cover.mp4` (`video_cover_url` verbatim). The standalone music video
519        // (`video_url`) is a different asset and stays on its own `video_mp4`
520        // toggle, untouched here.
521        let (animated_covers, raw_animated_cover) = match video_cover_retention {
522            Some(retention) => (retention.keeps_webp(), retention.keeps_mp4()),
523            None => (animated_covers, false),
524        };
525
526        let defaults_webp = WebpEncodeSettings::default();
527        let animated_cover_quality = resolve_u8_ranged(
528            flags.settings.animated_cover_quality,
529            env_val("ANIMATED_COVER_QUALITY"),
530            src.and_then(|s| s.settings.animated_cover_quality),
531            acc.settings.animated_cover_quality,
532            self.defaults.settings.animated_cover_quality,
533            defaults_webp.quality,
534            "ANIMATED_COVER_QUALITY",
535            0..=100,
536        )?;
537        let animated_cover_max_fps = resolve_parsed(
538            flags.settings.animated_cover_max_fps,
539            env_val("ANIMATED_COVER_MAX_FPS"),
540            src.and_then(|s| s.settings.animated_cover_max_fps),
541            acc.settings.animated_cover_max_fps,
542            self.defaults.settings.animated_cover_max_fps,
543            defaults_webp.max_fps,
544            "ANIMATED_COVER_MAX_FPS",
545        )?;
546        let animated_cover_max_width = resolve_parsed_opt(
547            flags.settings.animated_cover_max_width,
548            env_val("ANIMATED_COVER_MAX_WIDTH"),
549            src.and_then(|s| s.settings.animated_cover_max_width),
550            acc.settings.animated_cover_max_width,
551            self.defaults.settings.animated_cover_max_width,
552            defaults_webp.max_width,
553            "ANIMATED_COVER_MAX_WIDTH",
554        )?;
555        let animated_cover_compression_level = resolve_u8_ranged(
556            flags.settings.animated_cover_compression_level,
557            env_val("ANIMATED_COVER_COMPRESSION_LEVEL"),
558            src.and_then(|s| s.settings.animated_cover_compression_level),
559            acc.settings.animated_cover_compression_level,
560            self.defaults.settings.animated_cover_compression_level,
561            defaults_webp.compression_level,
562            "ANIMATED_COVER_COMPRESSION_LEVEL",
563            0..=4,
564        )?;
565        let animated_cover_lossless = resolve_parsed(
566            flags.settings.animated_cover_lossless,
567            env_val("ANIMATED_COVER_LOSSLESS"),
568            src.and_then(|s| s.settings.animated_cover_lossless),
569            acc.settings.animated_cover_lossless,
570            self.defaults.settings.animated_cover_lossless,
571            defaults_webp.lossless,
572            "ANIMATED_COVER_LOSSLESS",
573        )?;
574
575        let naming_template = resolve_owned(
576            flags.settings.naming_template.clone(),
577            env_val("NAMING_TEMPLATE"),
578            src.and_then(|s| s.settings.naming_template.clone()),
579            acc.settings.naming_template.clone(),
580            self.defaults.settings.naming_template.clone(),
581        )
582        .unwrap_or_else(|| crate::naming::DEFAULT_TEMPLATE.to_owned());
583
584        let character_set = resolve_enum(
585            flags.settings.character_set,
586            env_val("CHARACTER_SET"),
587            src.and_then(|s| s.settings.character_set),
588            acc.settings.character_set,
589            self.defaults.settings.character_set,
590            None,
591            "CHARACTER_SET",
592        )?
593        .unwrap_or(CharacterSet::Unicode);
594
595        let token = flags
596            .token
597            .clone()
598            .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
599            .or_else(|| env.get("SUNO_TOKEN").cloned());
600
601        let token_command = resolve_owned(
602            None,
603            env_val("TOKEN_COMMAND"),
604            src.and_then(|s| s.settings.token_command.clone()),
605            acc.settings.token_command.clone(),
606            self.defaults.settings.token_command.clone(),
607        );
608
609        Ok(EffectiveSettings {
610            token,
611            stored_token: acc.token.clone(),
612            token_command,
613            account_id: acc.account_id.clone(),
614            format,
615            concurrency,
616            retries,
617            min_newest,
618            animated_covers,
619            raw_animated_cover,
620            video_cover_retention: match (animated_covers, raw_animated_cover) {
621                (false, false) => VideoCoverRetention::Neither,
622                (true, false) => VideoCoverRetention::Webp,
623                (false, true) => VideoCoverRetention::Mp4,
624                (true, true) => VideoCoverRetention::Both,
625            },
626            animated_cover_webp: WebpEncodeSettings {
627                quality: animated_cover_quality,
628                max_fps: animated_cover_max_fps,
629                max_width: animated_cover_max_width,
630                lossless: animated_cover_lossless,
631                compression_level: animated_cover_compression_level,
632            },
633            details_sidecar,
634            lyrics_sidecar,
635            lrc_sidecar,
636            video_mp4,
637            download_stems,
638            stem_format,
639            naming_template,
640            character_set,
641            areas: acc.areas.clone(),
642            album_overrides: acc
643                .albums
644                .iter()
645                .filter(|(_, name)| !name.trim().is_empty())
646                .map(|(root_id, name)| (root_id.clone(), name.trim().to_owned()))
647                .collect(),
648        })
649    }
650}
651
652fn resolve_parsed<T>(
653    flag: Option<T>,
654    env_str: Option<&str>,
655    src: Option<T>,
656    acc: Option<T>,
657    defaults: Option<T>,
658    compiled: T,
659    name: &str,
660) -> Result<T>
661where
662    T: FromStr + Copy,
663{
664    Ok(
665        resolve_parsed_opt(flag, env_str, src, acc, defaults, Some(compiled), name)?
666            .unwrap_or(compiled),
667    )
668}
669
670/// Like [`resolve_parsed`], but the value stays optional at every tier including
671/// the compiled default, so an unset knob resolves to `None` rather than a
672/// scalar fallback. Used where "unset" is itself meaningful (e.g. a native width
673/// with no cap).
674fn resolve_parsed_opt<T>(
675    flag: Option<T>,
676    env_str: Option<&str>,
677    src: Option<T>,
678    acc: Option<T>,
679    defaults: Option<T>,
680    compiled: Option<T>,
681    name: &str,
682) -> Result<Option<T>>
683where
684    T: FromStr + Copy,
685{
686    if let Some(v) = flag {
687        return Ok(Some(v));
688    }
689    if let Some(s) = env_str {
690        return s
691            .parse()
692            .map(Some)
693            .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
694    }
695    Ok(src.or(acc).or(defaults).or(compiled))
696}
697
698#[allow(clippy::too_many_arguments)]
699fn resolve_u8_ranged(
700    flag: Option<u8>,
701    env_str: Option<&str>,
702    src: Option<u8>,
703    acc: Option<u8>,
704    defaults: Option<u8>,
705    compiled: u8,
706    name: &str,
707    range: std::ops::RangeInclusive<u8>,
708) -> Result<u8> {
709    let value = if let Some(v) = flag {
710        v
711    } else if let Some(s) = env_str {
712        s.parse()
713            .map_err(|_| Error::Config(format!("invalid {name}: '{s}' (expected integer)")))?
714    } else {
715        src.or(acc).or(defaults).unwrap_or(compiled)
716    };
717    if range.contains(&value) {
718        Ok(value)
719    } else {
720        Err(Error::Config(format!(
721            "invalid {name}: '{value}' (expected {}..={})",
722            range.start(),
723            range.end()
724        )))
725    }
726}
727
728fn resolve_enum<T>(
729    flag: Option<T>,
730    env_str: Option<&str>,
731    src: Option<T>,
732    acc: Option<T>,
733    defaults: Option<T>,
734    compiled: Option<T>,
735    name: &str,
736) -> Result<Option<T>>
737where
738    T: FromStr<Err = Error> + Copy,
739{
740    if let Some(v) = flag {
741        return Ok(Some(v));
742    }
743    if let Some(s) = env_str {
744        return s
745            .parse()
746            .map(Some)
747            .map_err(|err| Error::Config(format!("invalid {name}: '{s}' ({err})")));
748    }
749    Ok(src.or(acc).or(defaults).or(compiled))
750}
751
752/// Resolve an owned-`String` knob through the standard precedence. The env value
753/// is taken verbatim (no parse), and the result stays optional so both a required
754/// knob (`naming_template`, via `unwrap_or_else`) and an optional one
755/// (`token_command`) share the one ladder. Pass `flag = None` for knobs with no
756/// CLI flag.
757fn resolve_owned(
758    flag: Option<String>,
759    env_str: Option<&str>,
760    src: Option<String>,
761    acc: Option<String>,
762    defaults: Option<String>,
763) -> Option<String> {
764    flag.or_else(|| env_str.map(str::to_owned))
765        .or(src)
766        .or(acc)
767        .or(defaults)
768}
769
770/// Convert an account label to its environment variable prefix, mirroring the
771/// per-account keys the resolver reads: `my-lib` becomes `MY_LIB` for lookups
772/// like `SUNO_MY_LIB_TOKEN`.
773pub fn label_to_env(label: &str) -> String {
774    label.to_ascii_uppercase().replace('-', "_")
775}
776
777/// CLI flag overrides passed to [`Config::resolve`]. `None` means the flag
778/// was not provided.
779///
780/// The shared [`Settings`] block is nested rather than mirrored; only `token`
781/// is carried top-level, because it is the one identity field with a global
782/// `--token` flag. Note there is no `--token-command` flag, so
783/// `settings.token_command` is never populated from the CLI (see [`Settings`]).
784#[derive(Debug, Default)]
785pub struct FlagOverrides {
786    pub token: Option<String>,
787    pub settings: Settings,
788}
789
790/// Resolved effective settings for one account/source combination.
791#[derive(Debug, Clone, PartialEq)]
792pub struct EffectiveSettings {
793    /// A direct token from `--token` or `SUNO_*_TOKEN`.
794    pub token: Option<String>,
795    /// A stored token from `[accounts.<label>].token`.
796    pub stored_token: Option<String>,
797    /// A command to run for the token when no direct token was supplied.
798    pub token_command: Option<String>,
799    /// The optional configured account id assertion (see [`AccountConfig`]).
800    pub account_id: Option<String>,
801    pub format: AudioFormat,
802    pub concurrency: u32,
803    pub retries: u32,
804    pub min_newest: u32,
805    pub animated_covers: bool,
806    /// Keep the raw album `cover.mp4` (`video_cover_url` verbatim, no transcode).
807    /// Driven by [`VideoCoverRetention::keeps_mp4`]; independent of `video_mp4`.
808    pub raw_animated_cover: bool,
809    pub video_cover_retention: VideoCoverRetention,
810    pub animated_cover_webp: WebpEncodeSettings,
811    pub details_sidecar: bool,
812    pub lyrics_sidecar: bool,
813    pub lrc_sidecar: bool,
814    pub video_mp4: bool,
815    pub download_stems: bool,
816    pub stem_format: StemFormat,
817    pub naming_template: String,
818    pub character_set: CharacterSet,
819    /// The per-account `[areas]` selection table, if configured.
820    pub areas: Option<AreasConfig>,
821    /// Manual album-name overrides, keyed by lineage root id, resolved from the
822    /// account's `[accounts.<label>.albums]` table. Deterministically ordered
823    /// (a [`BTreeMap`]) and pre-trimmed of empty values by [`Config::resolve`].
824    pub album_overrides: BTreeMap<String, String>,
825}
826
827impl EffectiveSettings {
828    /// Returns `true` when these settings require ffmpeg to be on `PATH`.
829    ///
830    /// Lossless output (FLAC or ALAC) transcodes from the WAV render, and an
831    /// animated WebP cover transcodes MP4→WebP, so either needs ffmpeg. Keeping
832    /// the raw MP4 alongside the WebP (the `both` retention) still produces the
833    /// WebP, so `animated_covers` alone decides it; a raw-MP4-only run, or a
834    /// plain MP3/WAV run with no animated covers, needs no ffmpeg.
835    pub fn requires_ffmpeg(&self) -> bool {
836        matches!(self.format, AudioFormat::Flac | AudioFormat::Alac) || self.animated_covers
837    }
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    fn no_env() -> HashMap<String, String> {
845        HashMap::new()
846    }
847
848    fn no_flags() -> FlagOverrides {
849        FlagOverrides::default()
850    }
851
852    #[test]
853    fn parse_empty_toml() {
854        let cfg = Config::from_toml("").unwrap();
855        assert!(cfg.accounts.is_empty());
856    }
857
858    #[test]
859    fn parse_basic_account() {
860        let toml = r#"
861            [accounts.alice]
862            token = "tok"
863            root = "/music"
864        "#;
865        let cfg = Config::from_toml(toml).unwrap();
866        let acc = &cfg.accounts["alice"];
867        assert_eq!(acc.token.as_deref(), Some("tok"));
868        assert_eq!(acc.root.as_deref(), Some("/music"));
869    }
870
871    #[test]
872    fn account_id_parses_and_resolves() {
873        let toml = r#"
874            [accounts.alice]
875            token = "tok"
876            root = "/music"
877            account_id = "user_abc123"
878        "#;
879        let cfg = Config::from_toml(toml).unwrap();
880        assert_eq!(
881            cfg.accounts["alice"].account_id.as_deref(),
882            Some("user_abc123")
883        );
884        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
885        assert_eq!(eff.account_id.as_deref(), Some("user_abc123"));
886    }
887
888    #[test]
889    fn parse_defaults_section() {
890        let toml = r#"
891            [defaults]
892            format = "mp3"
893            concurrency = 8
894            retries = 5
895            min_newest = 2
896            animated_covers = true
897            video_cover_retention = "both"
898            animated_cover_quality = 85
899            animated_cover_max_fps = 18
900            animated_cover_max_width = 720
901            animated_cover_compression_level = 4
902        "#;
903        let cfg = Config::from_toml(toml).unwrap();
904        assert_eq!(cfg.defaults.settings.format, Some(AudioFormat::Mp3));
905        assert_eq!(cfg.defaults.settings.concurrency, Some(8));
906        assert_eq!(cfg.defaults.settings.retries, Some(5));
907        assert_eq!(cfg.defaults.settings.min_newest, Some(2));
908        assert_eq!(cfg.defaults.settings.animated_covers, Some(true));
909        assert_eq!(
910            cfg.defaults.settings.video_cover_retention,
911            Some(VideoCoverRetention::Both)
912        );
913        assert_eq!(cfg.defaults.settings.animated_cover_quality, Some(85));
914        assert_eq!(cfg.defaults.settings.animated_cover_max_fps, Some(18));
915        assert_eq!(cfg.defaults.settings.animated_cover_max_width, Some(720));
916        assert_eq!(
917            cfg.defaults.settings.animated_cover_compression_level,
918            Some(4)
919        );
920    }
921
922    #[test]
923    fn compiled_defaults_when_nothing_set() {
924        let toml = "[accounts.alice]\n";
925        let cfg = Config::from_toml(toml).unwrap();
926        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
927        assert_eq!(
928            eff,
929            EffectiveSettings {
930                token: None,
931                stored_token: None,
932                token_command: None,
933                account_id: None,
934                format: AudioFormat::Flac,
935                concurrency: 4,
936                retries: 3,
937                min_newest: 1,
938                animated_covers: false,
939                raw_animated_cover: false,
940                video_cover_retention: VideoCoverRetention::Neither,
941                animated_cover_webp: WebpEncodeSettings::default(),
942                details_sidecar: false,
943                lyrics_sidecar: false,
944                lrc_sidecar: false,
945                video_mp4: false,
946                download_stems: false,
947                stem_format: StemFormat::Wav,
948                naming_template: crate::naming::DEFAULT_TEMPLATE.to_owned(),
949                character_set: CharacterSet::Unicode,
950                areas: None,
951                album_overrides: BTreeMap::new(),
952            }
953        );
954    }
955
956    /// Guards that every [`Settings`] field is threaded through
957    /// [`Config::resolve`]. The `sentinel` literal has no `..Default::default()`,
958    /// so adding a `Settings` field is a compile error here until it is given a
959    /// distinct, non-compiled-default value — forcing the author to this test.
960    /// Resolving with empty account/source/flags then asserts each
961    /// [`EffectiveSettings`] scalar reflects the sentinel.
962    ///
963    /// The per-field `assert_eq!`s below are maintained by hand: this test proves
964    /// a new field is *named* in the sentinel, not that it is *asserted*.
965    /// `EffectiveSettings` deriving no `Default` compile-forces `resolve` to
966    /// populate every output field, so the residual gap is only a field that is
967    /// resolved and constructed but left unasserted here.
968    /// `animated_covers`/`video_cover_retention` are coupled (retention, when
969    /// set, drives both `animated_covers` and `raw_animated_cover`); their
970    /// precedence is proven by the dedicated tests.
971    #[test]
972    fn resolve_reflects_every_settings_field() {
973        let sentinel = Settings {
974            format: Some(AudioFormat::Mp3),
975            concurrency: Some(99),
976            retries: Some(98),
977            min_newest: Some(42),
978            token_command: Some("sentinel-token-cmd".into()),
979            animated_covers: Some(true),
980            video_cover_retention: Some(VideoCoverRetention::Both),
981            animated_cover_quality: Some(77),
982            animated_cover_max_fps: Some(13),
983            animated_cover_max_width: Some(333),
984            animated_cover_compression_level: Some(3),
985            animated_cover_lossless: Some(true),
986            details_sidecar: Some(true),
987            lyrics_sidecar: Some(true),
988            lrc_sidecar: Some(true),
989            video_mp4: Some(true),
990            download_stems: Some(true),
991            stem_format: Some(StemFormat::Mp3),
992            naming_template: Some("SENTINEL/{id}".into()),
993            character_set: Some(CharacterSet::Ascii),
994        };
995        let cfg = Config {
996            defaults: Defaults { settings: sentinel },
997            accounts: HashMap::from([("alice".to_owned(), AccountConfig::default())]),
998        };
999
1000        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1001
1002        assert_eq!(eff.format, AudioFormat::Mp3);
1003        assert_eq!(eff.concurrency, 99);
1004        assert_eq!(eff.retries, 98);
1005        assert_eq!(eff.min_newest, 42);
1006        assert_eq!(eff.token_command.as_deref(), Some("sentinel-token-cmd"));
1007        // Retention `both` drives both webp and mp4 retention.
1008        assert!(eff.animated_covers);
1009        assert!(eff.raw_animated_cover);
1010        assert_eq!(eff.video_cover_retention, VideoCoverRetention::Both);
1011        assert_eq!(eff.animated_cover_webp.quality, 77);
1012        assert_eq!(eff.animated_cover_webp.max_fps, 13);
1013        assert_eq!(eff.animated_cover_webp.max_width, Some(333));
1014        assert_eq!(eff.animated_cover_webp.compression_level, 3);
1015        assert!(eff.animated_cover_webp.lossless);
1016        assert!(eff.details_sidecar);
1017        assert!(eff.lyrics_sidecar);
1018        assert!(eff.lrc_sidecar);
1019        assert!(eff.video_mp4);
1020        assert!(eff.download_stems);
1021        assert_eq!(eff.stem_format, StemFormat::Mp3);
1022        assert_eq!(eff.naming_template, "SENTINEL/{id}");
1023        assert_eq!(eff.character_set, CharacterSet::Ascii);
1024    }
1025
1026    #[test]
1027    fn file_defaults_override_compiled() {
1028        let toml = r#"
1029            [defaults]
1030            format = "mp3"
1031            concurrency = 8
1032
1033            [accounts.alice]
1034        "#;
1035        let cfg = Config::from_toml(toml).unwrap();
1036        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1037        assert_eq!(eff.format, AudioFormat::Mp3);
1038        assert_eq!(eff.concurrency, 8);
1039        assert_eq!(eff.retries, 3); // compiled default
1040    }
1041
1042    #[test]
1043    fn account_settings_override_defaults() {
1044        let toml = r#"
1045            [defaults]
1046            format = "mp3"
1047
1048            [accounts.alice]
1049            format = "wav"
1050        "#;
1051        let cfg = Config::from_toml(toml).unwrap();
1052        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1053        assert_eq!(eff.format, AudioFormat::Wav);
1054    }
1055
1056    #[test]
1057    fn per_source_overrides_account() {
1058        let toml = r#"
1059            [accounts.alice]
1060            format = "flac"
1061
1062            [accounts.alice.sources.liked]
1063            format = "mp3"
1064        "#;
1065        let cfg = Config::from_toml(toml).unwrap();
1066        let eff = cfg
1067            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1068            .unwrap();
1069        assert_eq!(eff.format, AudioFormat::Mp3);
1070    }
1071
1072    #[test]
1073    fn unknown_source_falls_back_to_account() {
1074        let toml = r#"
1075            [accounts.alice]
1076            format = "wav"
1077        "#;
1078        let cfg = Config::from_toml(toml).unwrap();
1079        let eff = cfg
1080            .resolve("alice", Some("nonexistent"), &no_env(), &no_flags())
1081            .unwrap();
1082        assert_eq!(eff.format, AudioFormat::Wav);
1083    }
1084
1085    #[test]
1086    fn global_env_overrides_file() {
1087        let toml = r#"
1088            [accounts.alice]
1089            format = "flac"
1090        "#;
1091        let cfg = Config::from_toml(toml).unwrap();
1092        let env: HashMap<String, String> =
1093            [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
1094        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1095        assert_eq!(eff.format, AudioFormat::Mp3);
1096    }
1097
1098    #[test]
1099    fn per_account_env_overrides_global_env() {
1100        let toml = "[accounts.alice]\n";
1101        let cfg = Config::from_toml(toml).unwrap();
1102        let env: HashMap<String, String> = [
1103            ("SUNO_FORMAT".into(), "mp3".into()),
1104            ("SUNO_ALICE_FORMAT".into(), "wav".into()),
1105        ]
1106        .into_iter()
1107        .collect();
1108        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1109        assert_eq!(eff.format, AudioFormat::Wav);
1110    }
1111
1112    #[test]
1113    fn per_account_env_label_uppersnakedcase() {
1114        let toml = "[accounts.my-lib]\n";
1115        let cfg = Config::from_toml(toml).unwrap();
1116        let env: HashMap<String, String> = [("SUNO_MY_LIB_FORMAT".into(), "wav".into())]
1117            .into_iter()
1118            .collect();
1119        let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
1120        assert_eq!(eff.format, AudioFormat::Wav);
1121    }
1122
1123    #[test]
1124    fn flag_overrides_env_and_file() {
1125        let toml = r#"
1126            [accounts.alice]
1127            format = "flac"
1128        "#;
1129        let cfg = Config::from_toml(toml).unwrap();
1130        let env: HashMap<String, String> =
1131            [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
1132        let flags = FlagOverrides {
1133            settings: Settings {
1134                format: Some(AudioFormat::Wav),
1135                ..Default::default()
1136            },
1137            ..Default::default()
1138        };
1139        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1140        assert_eq!(eff.format, AudioFormat::Wav);
1141    }
1142
1143    #[test]
1144    fn token_precedence() {
1145        let toml = r#"
1146            [accounts.alice]
1147            token = "file_tok"
1148        "#;
1149        let cfg = Config::from_toml(toml).unwrap();
1150
1151        // env overrides file
1152        let env: HashMap<String, String> = [("SUNO_TOKEN".into(), "env_tok".into())]
1153            .into_iter()
1154            .collect();
1155        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1156        assert_eq!(eff.token.as_deref(), Some("env_tok"));
1157        assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1158
1159        // flag overrides env
1160        let flags = FlagOverrides {
1161            token: Some("flag_tok".into()),
1162            ..Default::default()
1163        };
1164        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1165        assert_eq!(eff.token.as_deref(), Some("flag_tok"));
1166        assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1167    }
1168
1169    #[test]
1170    fn stored_token_is_populated_from_config_when_no_override_exists() {
1171        let toml = r#"
1172            [accounts.alice]
1173            token = "file_tok"
1174        "#;
1175        let cfg = Config::from_toml(toml).unwrap();
1176        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1177        assert_eq!(eff.token, None);
1178        assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1179        assert_eq!(eff.token_command, None);
1180    }
1181
1182    #[test]
1183    fn per_account_token_env_overrides_global() {
1184        let toml = "[accounts.alice]\n";
1185        let cfg = Config::from_toml(toml).unwrap();
1186        let env: HashMap<String, String> = [
1187            ("SUNO_TOKEN".into(), "global".into()),
1188            ("SUNO_ALICE_TOKEN".into(), "per_account".into()),
1189        ]
1190        .into_iter()
1191        .collect();
1192        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1193        assert_eq!(eff.token.as_deref(), Some("per_account"));
1194    }
1195
1196    #[test]
1197    fn token_command_resolves_from_defaults_account_source_and_env() {
1198        let toml = r#"
1199            [defaults]
1200            token_command = "defaults"
1201
1202            [accounts.alice]
1203            token_command = "account"
1204
1205            [accounts.alice.sources.liked]
1206            token_command = "source"
1207        "#;
1208        let cfg = Config::from_toml(toml).unwrap();
1209
1210        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1211        assert_eq!(eff.token_command.as_deref(), Some("account"));
1212
1213        let eff = cfg
1214            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1215            .unwrap();
1216        assert_eq!(eff.token_command.as_deref(), Some("source"));
1217
1218        let env: HashMap<String, String> = [("SUNO_TOKEN_COMMAND".into(), "global".into())]
1219            .into_iter()
1220            .collect();
1221        let eff = cfg
1222            .resolve("alice", Some("liked"), &env, &no_flags())
1223            .unwrap();
1224        assert_eq!(eff.token_command.as_deref(), Some("global"));
1225
1226        let env: HashMap<String, String> = [
1227            ("SUNO_TOKEN_COMMAND".into(), "global".into()),
1228            ("SUNO_ALICE_TOKEN_COMMAND".into(), "per_account".into()),
1229        ]
1230        .into_iter()
1231        .collect();
1232        let eff = cfg
1233            .resolve("alice", Some("liked"), &env, &no_flags())
1234            .unwrap();
1235        assert_eq!(eff.token_command.as_deref(), Some("per_account"));
1236    }
1237
1238    #[test]
1239    fn per_account_token_command_env_label_uppersnakedcase() {
1240        let cfg = Config::from_toml("[accounts.my-lib]\n").unwrap();
1241        let env: HashMap<String, String> = [("SUNO_MY_LIB_TOKEN_COMMAND".into(), "command".into())]
1242            .into_iter()
1243            .collect();
1244        let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
1245        assert_eq!(eff.token_command.as_deref(), Some("command"));
1246    }
1247
1248    #[test]
1249    fn invalid_env_u32_errors() {
1250        let toml = "[accounts.alice]\n";
1251        let cfg = Config::from_toml(toml).unwrap();
1252        let env: HashMap<String, String> = [("SUNO_CONCURRENCY".into(), "many".into())]
1253            .into_iter()
1254            .collect();
1255        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1256    }
1257
1258    #[test]
1259    fn animated_covers_defaults_off_and_follows_precedence() {
1260        // Compiled default is off.
1261        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1262        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1263        assert!(!eff.animated_covers);
1264
1265        // File default on; per-source off; env on; flag off — flag wins.
1266        let toml = r#"
1267            [defaults]
1268            animated_covers = true
1269
1270            [accounts.alice.sources.liked]
1271            animated_covers = false
1272        "#;
1273        let cfg = Config::from_toml(toml).unwrap();
1274
1275        // File default (defaults) turns it on for an unscoped resolve.
1276        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1277        assert!(eff.animated_covers);
1278
1279        // Per-source file setting overrides the file default.
1280        let eff = cfg
1281            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1282            .unwrap();
1283        assert!(!eff.animated_covers);
1284
1285        // Env overrides file (even the per-source off).
1286        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "true".into())]
1287            .into_iter()
1288            .collect();
1289        let eff = cfg
1290            .resolve("alice", Some("liked"), &env, &no_flags())
1291            .unwrap();
1292        assert!(eff.animated_covers);
1293
1294        // Flag overrides env.
1295        let flags = FlagOverrides {
1296            settings: Settings {
1297                animated_covers: Some(false),
1298                ..Default::default()
1299            },
1300            ..Default::default()
1301        };
1302        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1303        assert!(!eff.animated_covers);
1304    }
1305
1306    #[test]
1307    fn video_mp4_defaults_off_and_follows_precedence() {
1308        // Compiled default is off.
1309        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1310        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1311        assert!(!eff.video_mp4);
1312
1313        // File default on; per-source off; env on; flag off — flag wins.
1314        let toml = r#"
1315            [defaults]
1316            video_mp4 = true
1317
1318            [accounts.alice.sources.liked]
1319            video_mp4 = false
1320        "#;
1321        let cfg = Config::from_toml(toml).unwrap();
1322        assert!(
1323            cfg.resolve("alice", None, &no_env(), &no_flags())
1324                .unwrap()
1325                .video_mp4
1326        );
1327        assert!(
1328            !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1329                .unwrap()
1330                .video_mp4
1331        );
1332
1333        let env: HashMap<String, String> = [("SUNO_VIDEO_MP4".into(), "true".into())]
1334            .into_iter()
1335            .collect();
1336        assert!(
1337            cfg.resolve("alice", Some("liked"), &env, &no_flags())
1338                .unwrap()
1339                .video_mp4
1340        );
1341
1342        let flags = FlagOverrides {
1343            settings: Settings {
1344                video_mp4: Some(false),
1345                ..Default::default()
1346            },
1347            ..Default::default()
1348        };
1349        assert!(
1350            !cfg.resolve("alice", Some("liked"), &env, &flags)
1351                .unwrap()
1352                .video_mp4
1353        );
1354    }
1355
1356    #[test]
1357    fn download_stems_defaults_off_and_follows_precedence() {
1358        // Compiled default is off (bulk stem mirroring never spends, but it is
1359        // opt-in so it never runs unless asked).
1360        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1361        assert!(
1362            !cfg.resolve("alice", None, &no_env(), &no_flags())
1363                .unwrap()
1364                .download_stems
1365        );
1366
1367        // File default on; per-source off; env on; flag off — flag wins.
1368        let toml = r#"
1369            [defaults]
1370            download_stems = true
1371
1372            [accounts.alice.sources.liked]
1373            download_stems = false
1374        "#;
1375        let cfg = Config::from_toml(toml).unwrap();
1376        assert!(
1377            cfg.resolve("alice", None, &no_env(), &no_flags())
1378                .unwrap()
1379                .download_stems
1380        );
1381        assert!(
1382            !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1383                .unwrap()
1384                .download_stems
1385        );
1386
1387        let env: HashMap<String, String> = [("SUNO_DOWNLOAD_STEMS".into(), "true".into())]
1388            .into_iter()
1389            .collect();
1390        assert!(
1391            cfg.resolve("alice", Some("liked"), &env, &no_flags())
1392                .unwrap()
1393                .download_stems
1394        );
1395
1396        let flags = FlagOverrides {
1397            settings: Settings {
1398                download_stems: Some(false),
1399                ..Default::default()
1400            },
1401            ..Default::default()
1402        };
1403        assert!(
1404            !cfg.resolve("alice", Some("liked"), &env, &flags)
1405                .unwrap()
1406                .download_stems
1407        );
1408    }
1409
1410    #[test]
1411    fn stem_format_defaults_to_wav_and_follows_precedence() {
1412        // Compiled default is WAV (lossless, the safe default for stems).
1413        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1414        assert_eq!(
1415            cfg.resolve("alice", None, &no_env(), &no_flags())
1416                .unwrap()
1417                .stem_format,
1418            StemFormat::Wav
1419        );
1420
1421        // File default mp3; per-source wav; env mp3; flag wav — flag wins.
1422        let toml = r#"
1423            [defaults]
1424            stem_format = "mp3"
1425
1426            [accounts.alice.sources.liked]
1427            stem_format = "wav"
1428        "#;
1429        let cfg = Config::from_toml(toml).unwrap();
1430        assert_eq!(
1431            cfg.resolve("alice", None, &no_env(), &no_flags())
1432                .unwrap()
1433                .stem_format,
1434            StemFormat::Mp3
1435        );
1436        assert_eq!(
1437            cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1438                .unwrap()
1439                .stem_format,
1440            StemFormat::Wav
1441        );
1442
1443        let env: HashMap<String, String> = [("SUNO_STEM_FORMAT".into(), "mp3".into())]
1444            .into_iter()
1445            .collect();
1446        assert_eq!(
1447            cfg.resolve("alice", Some("liked"), &env, &no_flags())
1448                .unwrap()
1449                .stem_format,
1450            StemFormat::Mp3
1451        );
1452
1453        let flags = FlagOverrides {
1454            settings: Settings {
1455                stem_format: Some(StemFormat::Wav),
1456                ..Default::default()
1457            },
1458            ..Default::default()
1459        };
1460        assert_eq!(
1461            cfg.resolve("alice", Some("liked"), &env, &flags)
1462                .unwrap()
1463                .stem_format,
1464            StemFormat::Wav
1465        );
1466    }
1467
1468    #[test]
1469    fn stem_format_rejects_flac_and_unknown() {
1470        // FLAC is deliberately unrepresentable for stems: parsing it is an error,
1471        // so a config or flag can never ask for a FLAC stem.
1472        assert!("flac".parse::<StemFormat>().is_err());
1473        assert!("aac".parse::<StemFormat>().is_err());
1474        assert_eq!("WAV".parse::<StemFormat>().unwrap(), StemFormat::Wav);
1475        assert_eq!("Mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
1476        // A FLAC stem_format in config is a config error, not a silent fallback.
1477        assert!(Config::from_toml("[defaults]\nstem_format = \"flac\"\n").is_err());
1478    }
1479
1480    #[test]
1481    fn video_cover_retention_drives_cover_artifacts_not_the_music_video() {
1482        let resolve = |retention: &str| {
1483            let toml = format!("[accounts.alice]\nvideo_cover_retention = \"{retention}\"\n");
1484            Config::from_toml(&toml)
1485                .unwrap()
1486                .resolve("alice", None, &no_env(), &no_flags())
1487                .unwrap()
1488        };
1489
1490        let neither = resolve("neither");
1491        assert!(!neither.animated_covers && !neither.raw_animated_cover);
1492        assert_eq!(neither.video_cover_retention, VideoCoverRetention::Neither);
1493
1494        let webp = resolve("webp");
1495        assert!(webp.animated_covers && !webp.raw_animated_cover);
1496        assert_eq!(webp.video_cover_retention, VideoCoverRetention::Webp);
1497
1498        // `mp4` keeps the raw album cover (`video_cover_url` verbatim); it does
1499        // NOT switch on the standalone music-video toggle (`video_url`).
1500        let mp4 = resolve("mp4");
1501        assert!(!mp4.animated_covers && mp4.raw_animated_cover);
1502        assert!(!mp4.video_mp4);
1503        assert_eq!(mp4.video_cover_retention, VideoCoverRetention::Mp4);
1504
1505        let both = resolve("both");
1506        assert!(both.animated_covers && both.raw_animated_cover);
1507        assert!(!both.video_mp4);
1508        assert_eq!(both.video_cover_retention, VideoCoverRetention::Both);
1509    }
1510
1511    #[test]
1512    fn video_mp4_is_independent_of_cover_retention() {
1513        // The standalone music video (`video_url`) has its own toggle and is
1514        // never implied by a `video_cover_retention` mode, nor vice versa.
1515        let toml = "[accounts.alice]\nvideo_mp4 = true\nvideo_cover_retention = \"webp\"\n";
1516        let cfg = Config::from_toml(toml).unwrap();
1517        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1518        assert!(eff.video_mp4);
1519        assert!(eff.animated_covers);
1520        assert!(!eff.raw_animated_cover);
1521        assert_eq!(eff.video_cover_retention, VideoCoverRetention::Webp);
1522    }
1523
1524    #[test]
1525    fn animated_cover_webp_knobs_follow_precedence_and_validate_ranges() {
1526        let toml = r#"
1527            [defaults]
1528            animated_cover_quality = 80
1529            animated_cover_max_fps = 20
1530            animated_cover_max_width = 640
1531            animated_cover_compression_level = 3
1532
1533            [accounts.alice.sources.liked]
1534            animated_cover_quality = 75
1535        "#;
1536        let cfg = Config::from_toml(toml).unwrap();
1537        let eff = cfg
1538            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1539            .unwrap();
1540        assert_eq!(eff.animated_cover_webp.quality, 75);
1541        assert_eq!(eff.animated_cover_webp.max_fps, 20);
1542        assert_eq!(eff.animated_cover_webp.max_width, Some(640));
1543        assert_eq!(eff.animated_cover_webp.compression_level, 3);
1544
1545        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVER_QUALITY".into(), "90".into())]
1546            .into_iter()
1547            .collect();
1548        let eff = cfg
1549            .resolve("alice", Some("liked"), &env, &no_flags())
1550            .unwrap();
1551        assert_eq!(eff.animated_cover_webp.quality, 90);
1552
1553        let flags = FlagOverrides {
1554            settings: Settings {
1555                animated_cover_quality: Some(95),
1556                animated_cover_max_width: Some(512),
1557                animated_cover_compression_level: Some(4),
1558                ..Default::default()
1559            },
1560            ..Default::default()
1561        };
1562        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1563        assert_eq!(eff.animated_cover_webp.quality, 95);
1564        assert_eq!(eff.animated_cover_webp.max_width, Some(512));
1565        assert_eq!(eff.animated_cover_webp.compression_level, 4);
1566
1567        let bad_env: HashMap<String, String> =
1568            [("SUNO_ANIMATED_COVER_QUALITY".into(), "101".into())]
1569                .into_iter()
1570                .collect();
1571        assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1572    }
1573
1574    #[test]
1575    fn video_cover_retention_parses_formats_and_reports_kept_artifacts() {
1576        // FromStr is case-insensitive across every variant.
1577        assert_eq!(
1578            "NEITHER".parse::<VideoCoverRetention>().unwrap(),
1579            VideoCoverRetention::Neither
1580        );
1581        assert_eq!(
1582            "WebP".parse::<VideoCoverRetention>().unwrap(),
1583            VideoCoverRetention::Webp
1584        );
1585        assert_eq!(
1586            "mp4".parse::<VideoCoverRetention>().unwrap(),
1587            VideoCoverRetention::Mp4
1588        );
1589        assert_eq!(
1590            "Both".parse::<VideoCoverRetention>().unwrap(),
1591            VideoCoverRetention::Both
1592        );
1593        // An unknown mode is a config error, not a silent fallback.
1594        assert!("mkv".parse::<VideoCoverRetention>().is_err());
1595
1596        // Display round-trips back to a token FromStr accepts.
1597        for mode in [
1598            VideoCoverRetention::Neither,
1599            VideoCoverRetention::Webp,
1600            VideoCoverRetention::Mp4,
1601            VideoCoverRetention::Both,
1602        ] {
1603            assert_eq!(
1604                mode.to_string().parse::<VideoCoverRetention>().unwrap(),
1605                mode
1606            );
1607        }
1608
1609        // keeps_webp / keeps_mp4 truth table.
1610        assert!(!VideoCoverRetention::Neither.keeps_webp());
1611        assert!(!VideoCoverRetention::Neither.keeps_mp4());
1612        assert!(VideoCoverRetention::Webp.keeps_webp());
1613        assert!(!VideoCoverRetention::Webp.keeps_mp4());
1614        assert!(!VideoCoverRetention::Mp4.keeps_webp());
1615        assert!(VideoCoverRetention::Mp4.keeps_mp4());
1616        assert!(VideoCoverRetention::Both.keeps_webp());
1617        assert!(VideoCoverRetention::Both.keeps_mp4());
1618    }
1619
1620    #[test]
1621    fn video_cover_retention_resolves_from_env_and_rejects_unknown() {
1622        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1623
1624        // A valid env value overrides the legacy toggles, just like a flag.
1625        let env: HashMap<String, String> = [("SUNO_VIDEO_COVER_RETENTION".into(), "both".into())]
1626            .into_iter()
1627            .collect();
1628        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1629        assert_eq!(eff.video_cover_retention, VideoCoverRetention::Both);
1630        assert!(eff.animated_covers);
1631        assert!(eff.raw_animated_cover);
1632
1633        // An unknown env value is a config error rather than a silent default.
1634        let bad_env: HashMap<String, String> =
1635            [("SUNO_VIDEO_COVER_RETENTION".into(), "mkv".into())]
1636                .into_iter()
1637                .collect();
1638        assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1639    }
1640
1641    #[test]
1642    fn animated_cover_compression_level_enforces_zero_to_four() {
1643        // The top of the valid range is accepted from the config file. Effort is
1644        // capped at 4 because level 6 costs many times the time for no size gain.
1645        let cfg = Config::from_toml(
1646            "[defaults]\nanimated_cover_compression_level = 4\n[accounts.alice]\n",
1647        )
1648        .unwrap();
1649        assert_eq!(
1650            cfg.resolve("alice", None, &no_env(), &no_flags())
1651                .unwrap()
1652                .animated_cover_webp
1653                .compression_level,
1654            4
1655        );
1656
1657        // One past the top is rejected.
1658        let cfg = Config::from_toml(
1659            "[defaults]\nanimated_cover_compression_level = 5\n[accounts.alice]\n",
1660        )
1661        .unwrap();
1662        assert!(cfg.resolve("alice", None, &no_env(), &no_flags()).is_err());
1663
1664        // The same ceiling is enforced for an env override.
1665        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1666        let bad_env: HashMap<String, String> =
1667            [("SUNO_ANIMATED_COVER_COMPRESSION_LEVEL".into(), "5".into())]
1668                .into_iter()
1669                .collect();
1670        assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1671
1672        // A non-integer env value is a config error, not a panic.
1673        let junk_env: HashMap<String, String> =
1674            [("SUNO_ANIMATED_COVER_MAX_FPS".into(), "abc".into())]
1675                .into_iter()
1676                .collect();
1677        assert!(cfg.resolve("alice", None, &junk_env, &no_flags()).is_err());
1678    }
1679
1680    #[test]
1681    fn animated_cover_lossless_defaults_off_and_follows_precedence() {
1682        // The compiled default is a bounded lossy encode that fits the FLAC
1683        // picture cap: quality 90, effort 4, lossy (not lossless).
1684        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1685        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1686        assert_eq!(eff.animated_cover_webp.quality, 90);
1687        assert_eq!(eff.animated_cover_webp.compression_level, 4);
1688        assert!(!eff.animated_cover_webp.lossless);
1689
1690        // Config opts in; an env value and a flag each override in turn.
1691        let cfg =
1692            Config::from_toml("[defaults]\nanimated_cover_lossless = true\n[accounts.alice]\n")
1693                .unwrap();
1694        assert!(
1695            cfg.resolve("alice", None, &no_env(), &no_flags())
1696                .unwrap()
1697                .animated_cover_webp
1698                .lossless
1699        );
1700        let env: HashMap<String, String> =
1701            [("SUNO_ANIMATED_COVER_LOSSLESS".into(), "false".into())]
1702                .into_iter()
1703                .collect();
1704        assert!(
1705            !cfg.resolve("alice", None, &env, &no_flags())
1706                .unwrap()
1707                .animated_cover_webp
1708                .lossless
1709        );
1710        let flags = FlagOverrides {
1711            settings: Settings {
1712                animated_cover_lossless: Some(true),
1713                ..Default::default()
1714            },
1715            ..Default::default()
1716        };
1717        assert!(
1718            cfg.resolve("alice", None, &env, &flags)
1719                .unwrap()
1720                .animated_cover_webp
1721                .lossless
1722        );
1723
1724        // A non-boolean value is a config error, not a silent default.
1725        let bad_env: HashMap<String, String> =
1726            [("SUNO_ANIMATED_COVER_LOSSLESS".into(), "maybe".into())]
1727                .into_iter()
1728                .collect();
1729        assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1730    }
1731
1732    #[test]
1733    fn animated_cover_max_width_defaults_to_bounded() {
1734        // With nothing configured, the width cap is the bounded default (640 px)
1735        // so the embedded animated cover reliably fits the FLAC picture cap.
1736        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1737        assert_eq!(
1738            cfg.resolve("alice", None, &no_env(), &no_flags())
1739                .unwrap()
1740                .animated_cover_webp
1741                .max_width,
1742            Some(640)
1743        );
1744    }
1745
1746    #[test]
1747    fn text_sidecars_default_off_and_follow_precedence() {
1748        // Both compiled defaults are off.
1749        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1750        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1751        assert!(!eff.details_sidecar);
1752        assert!(!eff.lyrics_sidecar);
1753
1754        let toml = r#"
1755            [defaults]
1756            details_sidecar = true
1757
1758            [accounts.alice.sources.liked]
1759            details_sidecar = false
1760        "#;
1761        let cfg = Config::from_toml(toml).unwrap();
1762
1763        // File default turns details on for an unscoped resolve; lyrics stays off.
1764        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1765        assert!(eff.details_sidecar);
1766        assert!(!eff.lyrics_sidecar);
1767
1768        // Per-source file setting overrides the file default.
1769        let eff = cfg
1770            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1771            .unwrap();
1772        assert!(!eff.details_sidecar);
1773
1774        // Env overrides file (both flags), and the flag overrides env.
1775        let env: HashMap<String, String> = [
1776            ("SUNO_DETAILS_SIDECAR".into(), "true".into()),
1777            ("SUNO_LYRICS_SIDECAR".into(), "true".into()),
1778        ]
1779        .into_iter()
1780        .collect();
1781        let eff = cfg
1782            .resolve("alice", Some("liked"), &env, &no_flags())
1783            .unwrap();
1784        assert!(eff.details_sidecar);
1785        assert!(eff.lyrics_sidecar);
1786
1787        let flags = FlagOverrides {
1788            settings: Settings {
1789                lyrics_sidecar: Some(false),
1790                ..Default::default()
1791            },
1792            ..Default::default()
1793        };
1794        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1795        assert!(eff.details_sidecar);
1796        assert!(!eff.lyrics_sidecar);
1797    }
1798
1799    #[test]
1800    fn invalid_env_bool_errors() {
1801        let toml = "[accounts.alice]\n";
1802        let cfg = Config::from_toml(toml).unwrap();
1803        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "yes".into())]
1804            .into_iter()
1805            .collect();
1806        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1807    }
1808
1809    #[test]
1810    fn unknown_account_errors() {
1811        let cfg = Config::from_toml("").unwrap();
1812        assert!(cfg.resolve("nobody", None, &no_env(), &no_flags()).is_err());
1813    }
1814
1815    #[test]
1816    fn validation_nested_roots() {
1817        let toml = r#"
1818            [accounts.alice]
1819            root = "/music"
1820
1821            [accounts.bob]
1822            root = "/music/bob"
1823        "#;
1824        assert!(Config::from_toml(toml).is_err());
1825    }
1826
1827    #[test]
1828    fn validation_non_nested_roots_ok() {
1829        let toml = r#"
1830            [accounts.alice]
1831            root = "/music/alice"
1832
1833            [accounts.bob]
1834            root = "/music/bob"
1835        "#;
1836        assert!(Config::from_toml(toml).is_ok());
1837    }
1838
1839    #[test]
1840    fn invalid_toml_errors() {
1841        assert!(Config::from_toml("not valid toml ][").is_err());
1842    }
1843
1844    #[test]
1845    fn duplicate_account_label_errors() {
1846        // The TOML spec prohibits duplicate keys; the parser must reject this.
1847        let toml = "
1848            [accounts.alice]
1849            token = \"tok1\"
1850
1851            [accounts.alice]
1852            token = \"tok2\"
1853        ";
1854        assert!(Config::from_toml(toml).is_err());
1855    }
1856
1857    #[test]
1858    fn parse_error_does_not_echo_token() {
1859        // A malformed token line must not include the raw value in the error.
1860        let toml = "[accounts.alice]\ntoken = \"unterminated\n";
1861        let err = Config::from_toml(toml).unwrap_err().to_string();
1862        assert!(!err.contains("unterminated"), "error leaked token: {err}");
1863    }
1864
1865    #[test]
1866    fn validation_env_prefix_collision_errors() {
1867        // 'my-lib' and 'my_lib' both map to SUNO_MY_LIB_* and must be rejected.
1868        let toml = "
1869            [accounts.my-lib]
1870            [accounts.my_lib]
1871        ";
1872        assert!(Config::from_toml(toml).is_err());
1873    }
1874
1875    #[test]
1876    fn audio_format_display_roundtrip() {
1877        for fmt in [
1878            AudioFormat::Mp3,
1879            AudioFormat::Flac,
1880            AudioFormat::Wav,
1881            AudioFormat::Alac,
1882        ] {
1883            let s = fmt.to_string();
1884            assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
1885        }
1886    }
1887
1888    #[test]
1889    fn audio_format_ext() {
1890        assert_eq!(AudioFormat::Mp3.ext(), "mp3");
1891        assert_eq!(AudioFormat::Flac.ext(), "flac");
1892        assert_eq!(AudioFormat::Wav.ext(), "wav");
1893        assert_eq!(AudioFormat::Alac.ext(), "m4a");
1894    }
1895
1896    #[test]
1897    fn format_follows_precedence() {
1898        let toml = r#"
1899            [defaults]
1900            format = "wav"
1901
1902            [accounts.alice]
1903            format = "mp3"
1904
1905            [accounts.alice.sources.liked]
1906            format = "alac"
1907        "#;
1908        let cfg = Config::from_toml(toml).unwrap();
1909
1910        // Compiled default (FLAC) when nothing is set.
1911        let bare = Config::from_toml("[accounts.bob]\n").unwrap();
1912        assert_eq!(
1913            bare.resolve("bob", None, &no_env(), &no_flags())
1914                .unwrap()
1915                .format,
1916            AudioFormat::Flac
1917        );
1918
1919        // Per-source wins over account and defaults.
1920        let eff = cfg
1921            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1922            .unwrap();
1923        assert_eq!(eff.format, AudioFormat::Alac);
1924
1925        // Account wins over defaults.
1926        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1927        assert_eq!(eff.format, AudioFormat::Mp3);
1928
1929        // Global env overrides file.
1930        let env: HashMap<String, String> =
1931            [("SUNO_FORMAT".into(), "wav".into())].into_iter().collect();
1932        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1933        assert_eq!(eff.format, AudioFormat::Wav);
1934
1935        // Per-account env overrides global env.
1936        let env: HashMap<String, String> = [
1937            ("SUNO_FORMAT".into(), "wav".into()),
1938            ("SUNO_ALICE_FORMAT".into(), "flac".into()),
1939        ]
1940        .into_iter()
1941        .collect();
1942        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1943        assert_eq!(eff.format, AudioFormat::Flac);
1944
1945        // Flag overrides env.
1946        let flags = FlagOverrides {
1947            settings: Settings {
1948                format: Some(AudioFormat::Alac),
1949                ..Default::default()
1950            },
1951            ..Default::default()
1952        };
1953        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1954        assert_eq!(eff.format, AudioFormat::Alac);
1955
1956        // An unknown env value is a config error, never a silent default.
1957        let bad_env: HashMap<String, String> = [("SUNO_FORMAT".into(), "aiff".into())]
1958            .into_iter()
1959            .collect();
1960        assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1961    }
1962
1963    #[test]
1964    fn naming_template_follows_precedence() {
1965        let toml = r#"
1966            [defaults]
1967            naming_template = "{title}"
1968
1969            [accounts.alice]
1970            naming_template = "{creator}/{title}"
1971
1972            [accounts.alice.sources.liked]
1973            naming_template = "{handle}/{title} [{id8}]"
1974        "#;
1975        let cfg = Config::from_toml(toml).unwrap();
1976
1977        // Per-source wins over account.
1978        let eff = cfg
1979            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1980            .unwrap();
1981        assert_eq!(eff.naming_template, "{handle}/{title} [{id8}]");
1982
1983        // Account wins over defaults.
1984        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1985        assert_eq!(eff.naming_template, "{creator}/{title}");
1986
1987        // Env overrides file.
1988        let env: HashMap<String, String> = [("SUNO_NAMING_TEMPLATE".into(), "{id}".into())]
1989            .into_iter()
1990            .collect();
1991        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1992        assert_eq!(eff.naming_template, "{id}");
1993
1994        // Flag overrides env.
1995        let flags = FlagOverrides {
1996            settings: Settings {
1997                naming_template: Some("{title}/{id8}".into()),
1998                ..Default::default()
1999            },
2000            ..Default::default()
2001        };
2002        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
2003        assert_eq!(eff.naming_template, "{title}/{id8}");
2004    }
2005
2006    #[test]
2007    fn min_newest_follows_precedence() {
2008        // `min_newest` is the deletion-safety floor: every tier boundary in its
2009        // precedence (flag > per-account env > global env > source > account >
2010        // defaults > compiled 1) must hold exactly.
2011        let toml = r#"
2012            [defaults]
2013            min_newest = 5
2014
2015            [accounts.alice]
2016            min_newest = 7
2017
2018            [accounts.alice.sources.liked]
2019            min_newest = 9
2020        "#;
2021        let cfg = Config::from_toml(toml).unwrap();
2022
2023        // Compiled default when nothing is set.
2024        let bare = Config::from_toml("[accounts.bob]\n").unwrap();
2025        assert_eq!(
2026            bare.resolve("bob", None, &no_env(), &no_flags())
2027                .unwrap()
2028                .min_newest,
2029            1
2030        );
2031
2032        // Per-source wins over account and defaults.
2033        let eff = cfg
2034            .resolve("alice", Some("liked"), &no_env(), &no_flags())
2035            .unwrap();
2036        assert_eq!(eff.min_newest, 9);
2037
2038        // Account wins over defaults.
2039        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
2040        assert_eq!(eff.min_newest, 7);
2041
2042        // Global env overrides file.
2043        let env: HashMap<String, String> = [("SUNO_MIN_NEWEST".into(), "11".into())]
2044            .into_iter()
2045            .collect();
2046        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
2047        assert_eq!(eff.min_newest, 11);
2048
2049        // Per-account env overrides global env.
2050        let env: HashMap<String, String> = [
2051            ("SUNO_MIN_NEWEST".into(), "11".into()),
2052            ("SUNO_ALICE_MIN_NEWEST".into(), "13".into()),
2053        ]
2054        .into_iter()
2055        .collect();
2056        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
2057        assert_eq!(eff.min_newest, 13);
2058
2059        // Flag overrides env.
2060        let flags = FlagOverrides {
2061            settings: Settings {
2062                min_newest: Some(15),
2063                ..Default::default()
2064            },
2065            ..Default::default()
2066        };
2067        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
2068        assert_eq!(eff.min_newest, 15);
2069
2070        // An invalid env value is a config error, never a silently lowered floor.
2071        let bad_env: HashMap<String, String> = [("SUNO_MIN_NEWEST".into(), "notnum".into())]
2072            .into_iter()
2073            .collect();
2074        assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
2075    }
2076
2077    #[test]
2078    fn character_set_follows_precedence() {
2079        let toml = r#"
2080            [defaults]
2081            character_set = "ascii"
2082
2083            [accounts.alice]
2084        "#;
2085        let cfg = Config::from_toml(toml).unwrap();
2086
2087        // File default applies.
2088        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
2089        assert_eq!(eff.character_set, CharacterSet::Ascii);
2090
2091        // Env overrides file.
2092        let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "unicode".into())]
2093            .into_iter()
2094            .collect();
2095        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
2096        assert_eq!(eff.character_set, CharacterSet::Unicode);
2097
2098        // Flag overrides env.
2099        let flags = FlagOverrides {
2100            settings: Settings {
2101                character_set: Some(CharacterSet::Ascii),
2102                ..Default::default()
2103            },
2104            ..Default::default()
2105        };
2106        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
2107        assert_eq!(eff.character_set, CharacterSet::Ascii);
2108    }
2109
2110    #[test]
2111    fn invalid_character_set_env_errors() {
2112        let toml = "[accounts.alice]\n";
2113        let cfg = Config::from_toml(toml).unwrap();
2114        let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "utf8".into())]
2115            .into_iter()
2116            .collect();
2117        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
2118    }
2119
2120    #[test]
2121    fn areas_parse_full_table() {
2122        let toml = r#"
2123            [accounts.alice]
2124            token = "t"
2125            [accounts.alice.areas]
2126            library = "off"
2127            liked = "copy"
2128            playlists = "mirror"
2129            [accounts.alice.areas.playlist]
2130            "pl_abc123" = "mirror"
2131            "pl_def456" = "copy"
2132        "#;
2133        let cfg = Config::from_toml(toml).unwrap();
2134        let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
2135        assert_eq!(areas.library, Some(AreaMode::Off));
2136        assert_eq!(areas.liked, Some(SourceMode::Copy));
2137        assert_eq!(areas.playlists, Some(SourceMode::Mirror));
2138        assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
2139        assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
2140    }
2141
2142    #[test]
2143    fn album_overrides_parse_and_resolve() {
2144        let toml = r#"
2145            [accounts.alice]
2146            token = "t"
2147            [accounts.alice.albums]
2148            "root_abc123" = "Preferred Name"
2149            "root_def456" = "Another Album"
2150            "root_blank" = "   "
2151        "#;
2152        let cfg = Config::from_toml(toml).unwrap();
2153        assert_eq!(
2154            cfg.accounts["alice"].albums["root_abc123"],
2155            "Preferred Name"
2156        );
2157        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
2158        assert_eq!(eff.album_overrides["root_abc123"], "Preferred Name");
2159        assert_eq!(eff.album_overrides["root_def456"], "Another Album");
2160        // A blank value is dropped so it can never blank an album.
2161        assert!(!eff.album_overrides.contains_key("root_blank"));
2162    }
2163
2164    #[test]
2165    fn album_overrides_absent_by_default() {
2166        let cfg = Config::from_toml("[accounts.alice]\ntoken = \"t\"\n").unwrap();
2167        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
2168        assert!(eff.album_overrides.is_empty());
2169    }
2170
2171    #[test]
2172    fn areas_library_accepts_copy_and_mirror() {
2173        for (raw, expect) in [
2174            ("copy", AreaMode::Mode(SourceMode::Copy)),
2175            ("mirror", AreaMode::Mode(SourceMode::Mirror)),
2176        ] {
2177            let toml =
2178                format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
2179            let cfg = Config::from_toml(&toml).unwrap();
2180            assert_eq!(
2181                cfg.accounts["a"].areas.as_ref().unwrap().library,
2182                Some(expect)
2183            );
2184        }
2185    }
2186
2187    #[test]
2188    fn areas_bad_mode_errors() {
2189        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
2190        assert!(Config::from_toml(toml).is_err());
2191    }
2192
2193    #[test]
2194    fn areas_bad_playlist_mode_errors() {
2195        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
2196        // `off` is a library-only value; a per-playlist entry must be copy/mirror.
2197        assert!(Config::from_toml(toml).is_err());
2198    }
2199
2200    #[test]
2201    fn areas_unknown_field_errors() {
2202        // D7: a mistyped key (libary) is a parse error, not a silent no-op.
2203        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
2204        assert!(Config::from_toml(toml).is_err());
2205    }
2206
2207    #[test]
2208    fn areas_absent_is_none() {
2209        let toml = "[accounts.a]\ntoken = \"t\"\n";
2210        assert!(
2211            Config::from_toml(toml).unwrap().accounts["a"]
2212                .areas
2213                .is_none()
2214        );
2215    }
2216
2217    fn base_settings(format: AudioFormat) -> EffectiveSettings {
2218        let toml = "[accounts.a]\n";
2219        let cfg = Config::from_toml(toml).unwrap();
2220        let mut eff = cfg.resolve("a", None, &no_env(), &no_flags()).unwrap();
2221        eff.format = format;
2222        eff
2223    }
2224
2225    #[test]
2226    fn requires_ffmpeg_flac_always_needs_it() {
2227        let mut eff = base_settings(AudioFormat::Flac);
2228        eff.animated_covers = false;
2229        assert!(eff.requires_ffmpeg());
2230        eff.animated_covers = true;
2231        assert!(eff.requires_ffmpeg());
2232    }
2233
2234    #[test]
2235    fn requires_ffmpeg_alac_always_needs_it() {
2236        let mut eff = base_settings(AudioFormat::Alac);
2237        eff.animated_covers = false;
2238        assert!(eff.requires_ffmpeg(), "alac transcodes, so needs ffmpeg");
2239    }
2240
2241    #[test]
2242    fn requires_ffmpeg_mp3_needs_it_only_for_animated_webp() {
2243        let mut eff = base_settings(AudioFormat::Mp3);
2244        assert!(!eff.requires_ffmpeg(), "mp3 + no covers = no ffmpeg");
2245        eff.animated_covers = true;
2246        assert!(eff.requires_ffmpeg(), "mp3 + animated webp = needs ffmpeg");
2247        // `both` retention keeps the raw mp4 AND the transcoded webp, so ffmpeg
2248        // is still required to produce the webp.
2249        eff.raw_animated_cover = true;
2250        assert!(
2251            eff.requires_ffmpeg(),
2252            "mp3 + both (webp + raw mp4) = needs ffmpeg"
2253        );
2254        eff.animated_covers = false;
2255        assert!(!eff.requires_ffmpeg(), "mp3 + raw mp4 only = no ffmpeg");
2256    }
2257}