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