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