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