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_u32(
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_u32(
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_u32(
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_bool(
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_bool(
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_bool(
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_bool(
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_bool(
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_bool(
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        let (animated_covers, video_mp4) = match video_cover_retention {
506            Some(retention) => (retention.keeps_webp(), retention.keeps_mp4()),
507            None => (animated_covers, video_mp4),
508        };
509
510        let defaults_webp = WebpEncodeSettings::default();
511        let animated_cover_quality = resolve_u8_ranged(
512            flags.animated_cover_quality,
513            env_val("ANIMATED_COVER_QUALITY"),
514            src.and_then(|s| s.animated_cover_quality),
515            acc.animated_cover_quality,
516            self.defaults.animated_cover_quality,
517            defaults_webp.quality,
518            "ANIMATED_COVER_QUALITY",
519            0..=100,
520        )?;
521        let animated_cover_max_fps = resolve_u32(
522            flags.animated_cover_max_fps,
523            env_val("ANIMATED_COVER_MAX_FPS"),
524            src.and_then(|s| s.animated_cover_max_fps),
525            acc.animated_cover_max_fps,
526            self.defaults.animated_cover_max_fps,
527            defaults_webp.max_fps,
528            "ANIMATED_COVER_MAX_FPS",
529        )?;
530        let animated_cover_max_width_from_env = env_val("ANIMATED_COVER_MAX_WIDTH")
531            .map(|s| {
532                s.parse().map_err(|_| {
533                    Error::Config(format!(
534                        "invalid ANIMATED_COVER_MAX_WIDTH: '{s}' (expected integer)"
535                    ))
536                })
537            })
538            .transpose()?;
539        let animated_cover_max_width = if let Some(v) = flags.animated_cover_max_width {
540            Some(v)
541        } else if let Some(v) = animated_cover_max_width_from_env {
542            Some(v)
543        } else {
544            src.and_then(|s| s.animated_cover_max_width)
545                .or(acc.animated_cover_max_width)
546                .or(self.defaults.animated_cover_max_width)
547                .or(defaults_webp.max_width)
548        };
549        let animated_cover_compression_level = resolve_u8_ranged(
550            flags.animated_cover_compression_level,
551            env_val("ANIMATED_COVER_COMPRESSION_LEVEL"),
552            src.and_then(|s| s.animated_cover_compression_level),
553            acc.animated_cover_compression_level,
554            self.defaults.animated_cover_compression_level,
555            defaults_webp.compression_level,
556            "ANIMATED_COVER_COMPRESSION_LEVEL",
557            0..=6,
558        )?;
559
560        let naming_template_from_env = env_val("NAMING_TEMPLATE").map(str::to_owned);
561        let naming_template = flags
562            .naming_template
563            .clone()
564            .or(naming_template_from_env)
565            .or_else(|| src.and_then(|s| s.naming_template.clone()))
566            .or_else(|| acc.naming_template.clone())
567            .or_else(|| self.defaults.naming_template.clone())
568            .unwrap_or_else(|| crate::naming::DEFAULT_TEMPLATE.to_owned());
569
570        let character_set_from_env = env_val("CHARACTER_SET")
571            .map(str::parse::<CharacterSet>)
572            .transpose()?;
573        let character_set = flags
574            .character_set
575            .or(character_set_from_env)
576            .or_else(|| src.and_then(|s| s.character_set))
577            .or(acc.character_set)
578            .or(self.defaults.character_set)
579            .unwrap_or(CharacterSet::Unicode);
580
581        let token = flags
582            .token
583            .clone()
584            .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
585            .or_else(|| env.get("SUNO_TOKEN").cloned());
586
587        let token_command = env
588            .get(&format!("SUNO_{label_env}_TOKEN_COMMAND"))
589            .cloned()
590            .or_else(|| env.get("SUNO_TOKEN_COMMAND").cloned())
591            .or_else(|| src.and_then(|s| s.token_command.clone()))
592            .or_else(|| acc.token_command.clone())
593            .or_else(|| self.defaults.token_command.clone());
594
595        Ok(EffectiveSettings {
596            token,
597            stored_token: acc.token.clone(),
598            token_command,
599            account_id: acc.account_id.clone(),
600            format,
601            concurrency,
602            retries,
603            min_newest,
604            animated_covers,
605            video_cover_retention: match (animated_covers, video_mp4) {
606                (false, false) => VideoCoverRetention::Neither,
607                (true, false) => VideoCoverRetention::Webp,
608                (false, true) => VideoCoverRetention::Mp4,
609                (true, true) => VideoCoverRetention::Both,
610            },
611            animated_cover_webp: WebpEncodeSettings {
612                quality: animated_cover_quality,
613                max_fps: animated_cover_max_fps,
614                max_width: animated_cover_max_width,
615                lossless: defaults_webp.lossless,
616                compression_level: animated_cover_compression_level,
617            },
618            details_sidecar,
619            lyrics_sidecar,
620            lrc_sidecar,
621            video_mp4,
622            download_stems,
623            stem_format,
624            naming_template,
625            character_set,
626            areas: acc.areas.clone(),
627            album_overrides: acc
628                .albums
629                .iter()
630                .filter(|(_, name)| !name.trim().is_empty())
631                .map(|(root_id, name)| (root_id.clone(), name.trim().to_owned()))
632                .collect(),
633        })
634    }
635}
636
637fn resolve_u32(
638    flag: Option<u32>,
639    env_str: Option<&str>,
640    src: Option<u32>,
641    acc: Option<u32>,
642    defaults: Option<u32>,
643    compiled: u32,
644    name: &str,
645) -> Result<u32> {
646    if let Some(v) = flag {
647        return Ok(v);
648    }
649    if let Some(s) = env_str {
650        return s
651            .parse()
652            .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
653    }
654    Ok(src.or(acc).or(defaults).unwrap_or(compiled))
655}
656
657fn resolve_bool(
658    flag: Option<bool>,
659    env_str: Option<&str>,
660    src: Option<bool>,
661    acc: Option<bool>,
662    defaults: Option<bool>,
663    compiled: bool,
664    name: &str,
665) -> Result<bool> {
666    if let Some(v) = flag {
667        return Ok(v);
668    }
669    if let Some(s) = env_str {
670        return s
671            .parse()
672            .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
673    }
674    Ok(src.or(acc).or(defaults).unwrap_or(compiled))
675}
676
677#[allow(clippy::too_many_arguments)]
678fn resolve_u8_ranged(
679    flag: Option<u8>,
680    env_str: Option<&str>,
681    src: Option<u8>,
682    acc: Option<u8>,
683    defaults: Option<u8>,
684    compiled: u8,
685    name: &str,
686    range: std::ops::RangeInclusive<u8>,
687) -> Result<u8> {
688    let value = if let Some(v) = flag {
689        v
690    } else if let Some(s) = env_str {
691        s.parse()
692            .map_err(|_| Error::Config(format!("invalid {name}: '{s}' (expected integer)")))?
693    } else {
694        src.or(acc).or(defaults).unwrap_or(compiled)
695    };
696    if range.contains(&value) {
697        Ok(value)
698    } else {
699        Err(Error::Config(format!(
700            "invalid {name}: '{value}' (expected {}..={})",
701            range.start(),
702            range.end()
703        )))
704    }
705}
706
707fn resolve_enum<T>(
708    flag: Option<T>,
709    env_str: Option<&str>,
710    src: Option<T>,
711    acc: Option<T>,
712    defaults: Option<T>,
713    compiled: Option<T>,
714    name: &str,
715) -> Result<Option<T>>
716where
717    T: FromStr<Err = Error> + Copy,
718{
719    if let Some(v) = flag {
720        return Ok(Some(v));
721    }
722    if let Some(s) = env_str {
723        return s
724            .parse()
725            .map(Some)
726            .map_err(|err| Error::Config(format!("invalid {name}: '{s}' ({err})")));
727    }
728    Ok(src.or(acc).or(defaults).or(compiled))
729}
730
731/// Convert an account label to its environment variable prefix, mirroring the
732/// per-account keys the resolver reads: `my-lib` becomes `MY_LIB` for lookups
733/// like `SUNO_MY_LIB_TOKEN`.
734pub fn label_to_env(label: &str) -> String {
735    label.to_ascii_uppercase().replace('-', "_")
736}
737
738/// CLI flag overrides passed to [`Config::resolve`]. `None` means the flag
739/// was not provided.
740#[derive(Debug, Default)]
741pub struct FlagOverrides {
742    pub token: Option<String>,
743    pub format: Option<AudioFormat>,
744    pub concurrency: Option<u32>,
745    pub retries: Option<u32>,
746    pub min_newest: Option<u32>,
747    pub animated_covers: Option<bool>,
748    pub video_cover_retention: Option<VideoCoverRetention>,
749    pub animated_cover_quality: Option<u8>,
750    pub animated_cover_max_fps: Option<u32>,
751    pub animated_cover_max_width: Option<u32>,
752    pub animated_cover_compression_level: Option<u8>,
753    pub details_sidecar: Option<bool>,
754    pub lyrics_sidecar: Option<bool>,
755    pub lrc_sidecar: Option<bool>,
756    pub video_mp4: Option<bool>,
757    pub download_stems: Option<bool>,
758    pub stem_format: Option<StemFormat>,
759    pub naming_template: Option<String>,
760    pub character_set: Option<CharacterSet>,
761}
762
763/// Resolved effective settings for one account/source combination.
764#[derive(Debug, Clone, PartialEq)]
765pub struct EffectiveSettings {
766    /// A direct token from `--token` or `SUNO_*_TOKEN`.
767    pub token: Option<String>,
768    /// A stored token from `[accounts.<label>].token`.
769    pub stored_token: Option<String>,
770    /// A command to run for the token when no direct token was supplied.
771    pub token_command: Option<String>,
772    /// The optional configured account id assertion (see [`AccountConfig`]).
773    pub account_id: Option<String>,
774    pub format: AudioFormat,
775    pub concurrency: u32,
776    pub retries: u32,
777    pub min_newest: u32,
778    pub animated_covers: bool,
779    pub video_cover_retention: VideoCoverRetention,
780    pub animated_cover_webp: WebpEncodeSettings,
781    pub details_sidecar: bool,
782    pub lyrics_sidecar: bool,
783    pub lrc_sidecar: bool,
784    pub video_mp4: bool,
785    pub download_stems: bool,
786    pub stem_format: StemFormat,
787    pub naming_template: String,
788    pub character_set: CharacterSet,
789    /// The per-account `[areas]` selection table, if configured.
790    pub areas: Option<AreasConfig>,
791    /// Manual album-name overrides, keyed by lineage root id, resolved from the
792    /// account's `[accounts.<label>.albums]` table. Deterministically ordered
793    /// (a [`BTreeMap`]) and pre-trimmed of empty values by [`Config::resolve`].
794    pub album_overrides: BTreeMap<String, String>,
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800
801    fn no_env() -> HashMap<String, String> {
802        HashMap::new()
803    }
804
805    fn no_flags() -> FlagOverrides {
806        FlagOverrides::default()
807    }
808
809    #[test]
810    fn parse_empty_toml() {
811        let cfg = Config::from_toml("").unwrap();
812        assert!(cfg.accounts.is_empty());
813    }
814
815    #[test]
816    fn parse_basic_account() {
817        let toml = r#"
818            [accounts.alice]
819            token = "tok"
820            root = "/music"
821        "#;
822        let cfg = Config::from_toml(toml).unwrap();
823        let acc = &cfg.accounts["alice"];
824        assert_eq!(acc.token.as_deref(), Some("tok"));
825        assert_eq!(acc.root.as_deref(), Some("/music"));
826    }
827
828    #[test]
829    fn account_id_parses_and_resolves() {
830        let toml = r#"
831            [accounts.alice]
832            token = "tok"
833            root = "/music"
834            account_id = "user_abc123"
835        "#;
836        let cfg = Config::from_toml(toml).unwrap();
837        assert_eq!(
838            cfg.accounts["alice"].account_id.as_deref(),
839            Some("user_abc123")
840        );
841        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
842        assert_eq!(eff.account_id.as_deref(), Some("user_abc123"));
843    }
844
845    #[test]
846    fn parse_defaults_section() {
847        let toml = r#"
848            [defaults]
849            format = "mp3"
850            concurrency = 8
851            retries = 5
852            min_newest = 2
853            animated_covers = true
854            video_cover_retention = "both"
855            animated_cover_quality = 85
856            animated_cover_max_fps = 18
857            animated_cover_max_width = 720
858            animated_cover_compression_level = 4
859        "#;
860        let cfg = Config::from_toml(toml).unwrap();
861        assert_eq!(cfg.defaults.format, Some(AudioFormat::Mp3));
862        assert_eq!(cfg.defaults.concurrency, Some(8));
863        assert_eq!(cfg.defaults.retries, Some(5));
864        assert_eq!(cfg.defaults.min_newest, Some(2));
865        assert_eq!(cfg.defaults.animated_covers, Some(true));
866        assert_eq!(
867            cfg.defaults.video_cover_retention,
868            Some(VideoCoverRetention::Both)
869        );
870        assert_eq!(cfg.defaults.animated_cover_quality, Some(85));
871        assert_eq!(cfg.defaults.animated_cover_max_fps, Some(18));
872        assert_eq!(cfg.defaults.animated_cover_max_width, Some(720));
873        assert_eq!(cfg.defaults.animated_cover_compression_level, Some(4));
874    }
875
876    #[test]
877    fn compiled_defaults_when_nothing_set() {
878        let toml = "[accounts.alice]\n";
879        let cfg = Config::from_toml(toml).unwrap();
880        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
881        assert_eq!(
882            eff,
883            EffectiveSettings {
884                token: None,
885                stored_token: None,
886                token_command: None,
887                account_id: None,
888                format: AudioFormat::Flac,
889                concurrency: 4,
890                retries: 3,
891                min_newest: 1,
892                animated_covers: false,
893                video_cover_retention: VideoCoverRetention::Neither,
894                animated_cover_webp: WebpEncodeSettings::default(),
895                details_sidecar: false,
896                lyrics_sidecar: false,
897                lrc_sidecar: false,
898                video_mp4: false,
899                download_stems: false,
900                stem_format: StemFormat::Wav,
901                naming_template: crate::naming::DEFAULT_TEMPLATE.to_owned(),
902                character_set: CharacterSet::Unicode,
903                areas: None,
904                album_overrides: BTreeMap::new(),
905            }
906        );
907    }
908
909    #[test]
910    fn file_defaults_override_compiled() {
911        let toml = r#"
912            [defaults]
913            format = "mp3"
914            concurrency = 8
915
916            [accounts.alice]
917        "#;
918        let cfg = Config::from_toml(toml).unwrap();
919        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
920        assert_eq!(eff.format, AudioFormat::Mp3);
921        assert_eq!(eff.concurrency, 8);
922        assert_eq!(eff.retries, 3); // compiled default
923    }
924
925    #[test]
926    fn account_settings_override_defaults() {
927        let toml = r#"
928            [defaults]
929            format = "mp3"
930
931            [accounts.alice]
932            format = "wav"
933        "#;
934        let cfg = Config::from_toml(toml).unwrap();
935        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
936        assert_eq!(eff.format, AudioFormat::Wav);
937    }
938
939    #[test]
940    fn per_source_overrides_account() {
941        let toml = r#"
942            [accounts.alice]
943            format = "flac"
944
945            [accounts.alice.sources.liked]
946            format = "mp3"
947        "#;
948        let cfg = Config::from_toml(toml).unwrap();
949        let eff = cfg
950            .resolve("alice", Some("liked"), &no_env(), &no_flags())
951            .unwrap();
952        assert_eq!(eff.format, AudioFormat::Mp3);
953    }
954
955    #[test]
956    fn unknown_source_falls_back_to_account() {
957        let toml = r#"
958            [accounts.alice]
959            format = "wav"
960        "#;
961        let cfg = Config::from_toml(toml).unwrap();
962        let eff = cfg
963            .resolve("alice", Some("nonexistent"), &no_env(), &no_flags())
964            .unwrap();
965        assert_eq!(eff.format, AudioFormat::Wav);
966    }
967
968    #[test]
969    fn global_env_overrides_file() {
970        let toml = r#"
971            [accounts.alice]
972            format = "flac"
973        "#;
974        let cfg = Config::from_toml(toml).unwrap();
975        let env: HashMap<String, String> =
976            [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
977        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
978        assert_eq!(eff.format, AudioFormat::Mp3);
979    }
980
981    #[test]
982    fn per_account_env_overrides_global_env() {
983        let toml = "[accounts.alice]\n";
984        let cfg = Config::from_toml(toml).unwrap();
985        let env: HashMap<String, String> = [
986            ("SUNO_FORMAT".into(), "mp3".into()),
987            ("SUNO_ALICE_FORMAT".into(), "wav".into()),
988        ]
989        .into_iter()
990        .collect();
991        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
992        assert_eq!(eff.format, AudioFormat::Wav);
993    }
994
995    #[test]
996    fn per_account_env_label_uppersnakedcase() {
997        let toml = "[accounts.my-lib]\n";
998        let cfg = Config::from_toml(toml).unwrap();
999        let env: HashMap<String, String> = [("SUNO_MY_LIB_FORMAT".into(), "wav".into())]
1000            .into_iter()
1001            .collect();
1002        let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
1003        assert_eq!(eff.format, AudioFormat::Wav);
1004    }
1005
1006    #[test]
1007    fn flag_overrides_env_and_file() {
1008        let toml = r#"
1009            [accounts.alice]
1010            format = "flac"
1011        "#;
1012        let cfg = Config::from_toml(toml).unwrap();
1013        let env: HashMap<String, String> =
1014            [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
1015        let flags = FlagOverrides {
1016            format: Some(AudioFormat::Wav),
1017            ..Default::default()
1018        };
1019        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1020        assert_eq!(eff.format, AudioFormat::Wav);
1021    }
1022
1023    #[test]
1024    fn token_precedence() {
1025        let toml = r#"
1026            [accounts.alice]
1027            token = "file_tok"
1028        "#;
1029        let cfg = Config::from_toml(toml).unwrap();
1030
1031        // env overrides file
1032        let env: HashMap<String, String> = [("SUNO_TOKEN".into(), "env_tok".into())]
1033            .into_iter()
1034            .collect();
1035        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1036        assert_eq!(eff.token.as_deref(), Some("env_tok"));
1037        assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1038
1039        // flag overrides env
1040        let flags = FlagOverrides {
1041            token: Some("flag_tok".into()),
1042            ..Default::default()
1043        };
1044        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1045        assert_eq!(eff.token.as_deref(), Some("flag_tok"));
1046        assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1047    }
1048
1049    #[test]
1050    fn stored_token_is_populated_from_config_when_no_override_exists() {
1051        let toml = r#"
1052            [accounts.alice]
1053            token = "file_tok"
1054        "#;
1055        let cfg = Config::from_toml(toml).unwrap();
1056        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1057        assert_eq!(eff.token, None);
1058        assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1059        assert_eq!(eff.token_command, None);
1060    }
1061
1062    #[test]
1063    fn per_account_token_env_overrides_global() {
1064        let toml = "[accounts.alice]\n";
1065        let cfg = Config::from_toml(toml).unwrap();
1066        let env: HashMap<String, String> = [
1067            ("SUNO_TOKEN".into(), "global".into()),
1068            ("SUNO_ALICE_TOKEN".into(), "per_account".into()),
1069        ]
1070        .into_iter()
1071        .collect();
1072        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1073        assert_eq!(eff.token.as_deref(), Some("per_account"));
1074    }
1075
1076    #[test]
1077    fn token_command_resolves_from_defaults_account_source_and_env() {
1078        let toml = r#"
1079            [defaults]
1080            token_command = "defaults"
1081
1082            [accounts.alice]
1083            token_command = "account"
1084
1085            [accounts.alice.sources.liked]
1086            token_command = "source"
1087        "#;
1088        let cfg = Config::from_toml(toml).unwrap();
1089
1090        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1091        assert_eq!(eff.token_command.as_deref(), Some("account"));
1092
1093        let eff = cfg
1094            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1095            .unwrap();
1096        assert_eq!(eff.token_command.as_deref(), Some("source"));
1097
1098        let env: HashMap<String, String> = [("SUNO_TOKEN_COMMAND".into(), "global".into())]
1099            .into_iter()
1100            .collect();
1101        let eff = cfg
1102            .resolve("alice", Some("liked"), &env, &no_flags())
1103            .unwrap();
1104        assert_eq!(eff.token_command.as_deref(), Some("global"));
1105
1106        let env: HashMap<String, String> = [
1107            ("SUNO_TOKEN_COMMAND".into(), "global".into()),
1108            ("SUNO_ALICE_TOKEN_COMMAND".into(), "per_account".into()),
1109        ]
1110        .into_iter()
1111        .collect();
1112        let eff = cfg
1113            .resolve("alice", Some("liked"), &env, &no_flags())
1114            .unwrap();
1115        assert_eq!(eff.token_command.as_deref(), Some("per_account"));
1116    }
1117
1118    #[test]
1119    fn per_account_token_command_env_label_uppersnakedcase() {
1120        let cfg = Config::from_toml("[accounts.my-lib]\n").unwrap();
1121        let env: HashMap<String, String> = [("SUNO_MY_LIB_TOKEN_COMMAND".into(), "command".into())]
1122            .into_iter()
1123            .collect();
1124        let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
1125        assert_eq!(eff.token_command.as_deref(), Some("command"));
1126    }
1127
1128    #[test]
1129    fn invalid_env_u32_errors() {
1130        let toml = "[accounts.alice]\n";
1131        let cfg = Config::from_toml(toml).unwrap();
1132        let env: HashMap<String, String> = [("SUNO_CONCURRENCY".into(), "many".into())]
1133            .into_iter()
1134            .collect();
1135        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1136    }
1137
1138    #[test]
1139    fn animated_covers_defaults_off_and_follows_precedence() {
1140        // Compiled default is off.
1141        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1142        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1143        assert!(!eff.animated_covers);
1144
1145        // File default on; per-source off; env on; flag off — flag wins.
1146        let toml = r#"
1147            [defaults]
1148            animated_covers = true
1149
1150            [accounts.alice.sources.liked]
1151            animated_covers = false
1152        "#;
1153        let cfg = Config::from_toml(toml).unwrap();
1154
1155        // File default (defaults) turns it on for an unscoped resolve.
1156        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1157        assert!(eff.animated_covers);
1158
1159        // Per-source file setting overrides the file default.
1160        let eff = cfg
1161            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1162            .unwrap();
1163        assert!(!eff.animated_covers);
1164
1165        // Env overrides file (even the per-source off).
1166        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "true".into())]
1167            .into_iter()
1168            .collect();
1169        let eff = cfg
1170            .resolve("alice", Some("liked"), &env, &no_flags())
1171            .unwrap();
1172        assert!(eff.animated_covers);
1173
1174        // Flag overrides env.
1175        let flags = FlagOverrides {
1176            animated_covers: Some(false),
1177            ..Default::default()
1178        };
1179        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1180        assert!(!eff.animated_covers);
1181    }
1182
1183    #[test]
1184    fn video_mp4_defaults_off_and_follows_precedence() {
1185        // Compiled default is off.
1186        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1187        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1188        assert!(!eff.video_mp4);
1189
1190        // File default on; per-source off; env on; flag off — flag wins.
1191        let toml = r#"
1192            [defaults]
1193            video_mp4 = true
1194
1195            [accounts.alice.sources.liked]
1196            video_mp4 = false
1197        "#;
1198        let cfg = Config::from_toml(toml).unwrap();
1199        assert!(
1200            cfg.resolve("alice", None, &no_env(), &no_flags())
1201                .unwrap()
1202                .video_mp4
1203        );
1204        assert!(
1205            !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1206                .unwrap()
1207                .video_mp4
1208        );
1209
1210        let env: HashMap<String, String> = [("SUNO_VIDEO_MP4".into(), "true".into())]
1211            .into_iter()
1212            .collect();
1213        assert!(
1214            cfg.resolve("alice", Some("liked"), &env, &no_flags())
1215                .unwrap()
1216                .video_mp4
1217        );
1218
1219        let flags = FlagOverrides {
1220            video_mp4: Some(false),
1221            ..Default::default()
1222        };
1223        assert!(
1224            !cfg.resolve("alice", Some("liked"), &env, &flags)
1225                .unwrap()
1226                .video_mp4
1227        );
1228    }
1229
1230    #[test]
1231    fn download_stems_defaults_off_and_follows_precedence() {
1232        // Compiled default is off (bulk stem mirroring never spends, but it is
1233        // opt-in so it never runs unless asked).
1234        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1235        assert!(
1236            !cfg.resolve("alice", None, &no_env(), &no_flags())
1237                .unwrap()
1238                .download_stems
1239        );
1240
1241        // File default on; per-source off; env on; flag off — flag wins.
1242        let toml = r#"
1243            [defaults]
1244            download_stems = true
1245
1246            [accounts.alice.sources.liked]
1247            download_stems = false
1248        "#;
1249        let cfg = Config::from_toml(toml).unwrap();
1250        assert!(
1251            cfg.resolve("alice", None, &no_env(), &no_flags())
1252                .unwrap()
1253                .download_stems
1254        );
1255        assert!(
1256            !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1257                .unwrap()
1258                .download_stems
1259        );
1260
1261        let env: HashMap<String, String> = [("SUNO_DOWNLOAD_STEMS".into(), "true".into())]
1262            .into_iter()
1263            .collect();
1264        assert!(
1265            cfg.resolve("alice", Some("liked"), &env, &no_flags())
1266                .unwrap()
1267                .download_stems
1268        );
1269
1270        let flags = FlagOverrides {
1271            download_stems: Some(false),
1272            ..Default::default()
1273        };
1274        assert!(
1275            !cfg.resolve("alice", Some("liked"), &env, &flags)
1276                .unwrap()
1277                .download_stems
1278        );
1279    }
1280
1281    #[test]
1282    fn stem_format_defaults_to_wav_and_follows_precedence() {
1283        // Compiled default is WAV (lossless, the safe default for stems).
1284        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1285        assert_eq!(
1286            cfg.resolve("alice", None, &no_env(), &no_flags())
1287                .unwrap()
1288                .stem_format,
1289            StemFormat::Wav
1290        );
1291
1292        // File default mp3; per-source wav; env mp3; flag wav — flag wins.
1293        let toml = r#"
1294            [defaults]
1295            stem_format = "mp3"
1296
1297            [accounts.alice.sources.liked]
1298            stem_format = "wav"
1299        "#;
1300        let cfg = Config::from_toml(toml).unwrap();
1301        assert_eq!(
1302            cfg.resolve("alice", None, &no_env(), &no_flags())
1303                .unwrap()
1304                .stem_format,
1305            StemFormat::Mp3
1306        );
1307        assert_eq!(
1308            cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1309                .unwrap()
1310                .stem_format,
1311            StemFormat::Wav
1312        );
1313
1314        let env: HashMap<String, String> = [("SUNO_STEM_FORMAT".into(), "mp3".into())]
1315            .into_iter()
1316            .collect();
1317        assert_eq!(
1318            cfg.resolve("alice", Some("liked"), &env, &no_flags())
1319                .unwrap()
1320                .stem_format,
1321            StemFormat::Mp3
1322        );
1323
1324        let flags = FlagOverrides {
1325            stem_format: Some(StemFormat::Wav),
1326            ..Default::default()
1327        };
1328        assert_eq!(
1329            cfg.resolve("alice", Some("liked"), &env, &flags)
1330                .unwrap()
1331                .stem_format,
1332            StemFormat::Wav
1333        );
1334    }
1335
1336    #[test]
1337    fn stem_format_rejects_flac_and_unknown() {
1338        // FLAC is deliberately unrepresentable for stems: parsing it is an error,
1339        // so a config or flag can never ask for a FLAC stem.
1340        assert!("flac".parse::<StemFormat>().is_err());
1341        assert!("aac".parse::<StemFormat>().is_err());
1342        assert_eq!("WAV".parse::<StemFormat>().unwrap(), StemFormat::Wav);
1343        assert_eq!("Mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
1344        // A FLAC stem_format in config is a config error, not a silent fallback.
1345        assert!(Config::from_toml("[defaults]\nstem_format = \"flac\"\n").is_err());
1346    }
1347
1348    #[test]
1349    fn video_cover_retention_overrides_legacy_toggles() {
1350        let toml = r#"
1351            [defaults]
1352            animated_covers = true
1353            video_mp4 = false
1354            video_cover_retention = "mp4"
1355        "#;
1356        let cfg = Config::from_toml(toml).unwrap();
1357        let eff = cfg.resolve("alice", None, &no_env(), &no_flags());
1358        assert!(eff.is_err());
1359
1360        let cfg = Config::from_toml(format!("{toml}\n[accounts.alice]\n").as_str()).unwrap();
1361        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1362        assert!(!eff.animated_covers);
1363        assert!(eff.video_mp4);
1364        assert_eq!(eff.video_cover_retention, VideoCoverRetention::Mp4);
1365
1366        let flags = FlagOverrides {
1367            video_cover_retention: Some(VideoCoverRetention::Both),
1368            ..Default::default()
1369        };
1370        let eff = cfg.resolve("alice", None, &no_env(), &flags).unwrap();
1371        assert!(eff.animated_covers);
1372        assert!(eff.video_mp4);
1373        assert_eq!(eff.video_cover_retention, VideoCoverRetention::Both);
1374    }
1375
1376    #[test]
1377    fn animated_cover_webp_knobs_follow_precedence_and_validate_ranges() {
1378        let toml = r#"
1379            [defaults]
1380            animated_cover_quality = 80
1381            animated_cover_max_fps = 20
1382            animated_cover_max_width = 640
1383            animated_cover_compression_level = 3
1384
1385            [accounts.alice.sources.liked]
1386            animated_cover_quality = 75
1387        "#;
1388        let cfg = Config::from_toml(toml).unwrap();
1389        let eff = cfg
1390            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1391            .unwrap();
1392        assert_eq!(eff.animated_cover_webp.quality, 75);
1393        assert_eq!(eff.animated_cover_webp.max_fps, 20);
1394        assert_eq!(eff.animated_cover_webp.max_width, Some(640));
1395        assert_eq!(eff.animated_cover_webp.compression_level, 3);
1396
1397        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVER_QUALITY".into(), "90".into())]
1398            .into_iter()
1399            .collect();
1400        let eff = cfg
1401            .resolve("alice", Some("liked"), &env, &no_flags())
1402            .unwrap();
1403        assert_eq!(eff.animated_cover_webp.quality, 90);
1404
1405        let flags = FlagOverrides {
1406            animated_cover_quality: Some(95),
1407            animated_cover_max_width: Some(512),
1408            animated_cover_compression_level: Some(6),
1409            ..Default::default()
1410        };
1411        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1412        assert_eq!(eff.animated_cover_webp.quality, 95);
1413        assert_eq!(eff.animated_cover_webp.max_width, Some(512));
1414        assert_eq!(eff.animated_cover_webp.compression_level, 6);
1415
1416        let bad_env: HashMap<String, String> =
1417            [("SUNO_ANIMATED_COVER_QUALITY".into(), "101".into())]
1418                .into_iter()
1419                .collect();
1420        assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1421    }
1422
1423    #[test]
1424    fn video_cover_retention_parses_formats_and_reports_kept_artifacts() {
1425        // FromStr is case-insensitive across every variant.
1426        assert_eq!(
1427            "NEITHER".parse::<VideoCoverRetention>().unwrap(),
1428            VideoCoverRetention::Neither
1429        );
1430        assert_eq!(
1431            "WebP".parse::<VideoCoverRetention>().unwrap(),
1432            VideoCoverRetention::Webp
1433        );
1434        assert_eq!(
1435            "mp4".parse::<VideoCoverRetention>().unwrap(),
1436            VideoCoverRetention::Mp4
1437        );
1438        assert_eq!(
1439            "Both".parse::<VideoCoverRetention>().unwrap(),
1440            VideoCoverRetention::Both
1441        );
1442        // An unknown mode is a config error, not a silent fallback.
1443        assert!("mkv".parse::<VideoCoverRetention>().is_err());
1444
1445        // Display round-trips back to a token FromStr accepts.
1446        for mode in [
1447            VideoCoverRetention::Neither,
1448            VideoCoverRetention::Webp,
1449            VideoCoverRetention::Mp4,
1450            VideoCoverRetention::Both,
1451        ] {
1452            assert_eq!(
1453                mode.to_string().parse::<VideoCoverRetention>().unwrap(),
1454                mode
1455            );
1456        }
1457
1458        // keeps_webp / keeps_mp4 truth table.
1459        assert!(!VideoCoverRetention::Neither.keeps_webp());
1460        assert!(!VideoCoverRetention::Neither.keeps_mp4());
1461        assert!(VideoCoverRetention::Webp.keeps_webp());
1462        assert!(!VideoCoverRetention::Webp.keeps_mp4());
1463        assert!(!VideoCoverRetention::Mp4.keeps_webp());
1464        assert!(VideoCoverRetention::Mp4.keeps_mp4());
1465        assert!(VideoCoverRetention::Both.keeps_webp());
1466        assert!(VideoCoverRetention::Both.keeps_mp4());
1467    }
1468
1469    #[test]
1470    fn video_cover_retention_resolves_from_env_and_rejects_unknown() {
1471        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1472
1473        // A valid env value overrides the legacy toggles, just like a flag.
1474        let env: HashMap<String, String> = [("SUNO_VIDEO_COVER_RETENTION".into(), "both".into())]
1475            .into_iter()
1476            .collect();
1477        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1478        assert_eq!(eff.video_cover_retention, VideoCoverRetention::Both);
1479        assert!(eff.animated_covers);
1480        assert!(eff.video_mp4);
1481
1482        // An unknown env value is a config error rather than a silent default.
1483        let bad_env: HashMap<String, String> =
1484            [("SUNO_VIDEO_COVER_RETENTION".into(), "mkv".into())]
1485                .into_iter()
1486                .collect();
1487        assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1488    }
1489
1490    #[test]
1491    fn animated_cover_compression_level_enforces_zero_to_six() {
1492        // The top of the valid range is accepted from the config file.
1493        let cfg = Config::from_toml(
1494            "[defaults]\nanimated_cover_compression_level = 6\n[accounts.alice]\n",
1495        )
1496        .unwrap();
1497        assert_eq!(
1498            cfg.resolve("alice", None, &no_env(), &no_flags())
1499                .unwrap()
1500                .animated_cover_webp
1501                .compression_level,
1502            6
1503        );
1504
1505        // One past the top is rejected.
1506        let cfg = Config::from_toml(
1507            "[defaults]\nanimated_cover_compression_level = 7\n[accounts.alice]\n",
1508        )
1509        .unwrap();
1510        assert!(cfg.resolve("alice", None, &no_env(), &no_flags()).is_err());
1511
1512        // The same ceiling is enforced for an env override.
1513        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1514        let bad_env: HashMap<String, String> =
1515            [("SUNO_ANIMATED_COVER_COMPRESSION_LEVEL".into(), "7".into())]
1516                .into_iter()
1517                .collect();
1518        assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1519
1520        // A non-integer env value is a config error, not a panic.
1521        let junk_env: HashMap<String, String> =
1522            [("SUNO_ANIMATED_COVER_MAX_FPS".into(), "abc".into())]
1523                .into_iter()
1524                .collect();
1525        assert!(cfg.resolve("alice", None, &junk_env, &no_flags()).is_err());
1526    }
1527
1528    #[test]
1529    fn animated_cover_max_width_defaults_to_native() {
1530        // With nothing configured, the width cap is None (source width).
1531        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1532        assert_eq!(
1533            cfg.resolve("alice", None, &no_env(), &no_flags())
1534                .unwrap()
1535                .animated_cover_webp
1536                .max_width,
1537            None
1538        );
1539    }
1540
1541    #[test]
1542    fn text_sidecars_default_off_and_follow_precedence() {
1543        // Both compiled defaults are off.
1544        let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1545        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1546        assert!(!eff.details_sidecar);
1547        assert!(!eff.lyrics_sidecar);
1548
1549        let toml = r#"
1550            [defaults]
1551            details_sidecar = true
1552
1553            [accounts.alice.sources.liked]
1554            details_sidecar = false
1555        "#;
1556        let cfg = Config::from_toml(toml).unwrap();
1557
1558        // File default turns details on for an unscoped resolve; lyrics stays off.
1559        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1560        assert!(eff.details_sidecar);
1561        assert!(!eff.lyrics_sidecar);
1562
1563        // Per-source file setting overrides the file default.
1564        let eff = cfg
1565            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1566            .unwrap();
1567        assert!(!eff.details_sidecar);
1568
1569        // Env overrides file (both flags), and the flag overrides env.
1570        let env: HashMap<String, String> = [
1571            ("SUNO_DETAILS_SIDECAR".into(), "true".into()),
1572            ("SUNO_LYRICS_SIDECAR".into(), "true".into()),
1573        ]
1574        .into_iter()
1575        .collect();
1576        let eff = cfg
1577            .resolve("alice", Some("liked"), &env, &no_flags())
1578            .unwrap();
1579        assert!(eff.details_sidecar);
1580        assert!(eff.lyrics_sidecar);
1581
1582        let flags = FlagOverrides {
1583            lyrics_sidecar: Some(false),
1584            ..Default::default()
1585        };
1586        let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1587        assert!(eff.details_sidecar);
1588        assert!(!eff.lyrics_sidecar);
1589    }
1590
1591    #[test]
1592    fn invalid_env_bool_errors() {
1593        let toml = "[accounts.alice]\n";
1594        let cfg = Config::from_toml(toml).unwrap();
1595        let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "yes".into())]
1596            .into_iter()
1597            .collect();
1598        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1599    }
1600
1601    #[test]
1602    fn unknown_account_errors() {
1603        let cfg = Config::from_toml("").unwrap();
1604        assert!(cfg.resolve("nobody", None, &no_env(), &no_flags()).is_err());
1605    }
1606
1607    #[test]
1608    fn validation_nested_roots() {
1609        let toml = r#"
1610            [accounts.alice]
1611            root = "/music"
1612
1613            [accounts.bob]
1614            root = "/music/bob"
1615        "#;
1616        assert!(Config::from_toml(toml).is_err());
1617    }
1618
1619    #[test]
1620    fn validation_non_nested_roots_ok() {
1621        let toml = r#"
1622            [accounts.alice]
1623            root = "/music/alice"
1624
1625            [accounts.bob]
1626            root = "/music/bob"
1627        "#;
1628        assert!(Config::from_toml(toml).is_ok());
1629    }
1630
1631    #[test]
1632    fn invalid_toml_errors() {
1633        assert!(Config::from_toml("not valid toml ][").is_err());
1634    }
1635
1636    #[test]
1637    fn duplicate_account_label_errors() {
1638        // The TOML spec prohibits duplicate keys; the parser must reject this.
1639        let toml = "
1640            [accounts.alice]
1641            token = \"tok1\"
1642
1643            [accounts.alice]
1644            token = \"tok2\"
1645        ";
1646        assert!(Config::from_toml(toml).is_err());
1647    }
1648
1649    #[test]
1650    fn parse_error_does_not_echo_token() {
1651        // A malformed token line must not include the raw value in the error.
1652        let toml = "[accounts.alice]\ntoken = \"unterminated\n";
1653        let err = Config::from_toml(toml).unwrap_err().to_string();
1654        assert!(!err.contains("unterminated"), "error leaked token: {err}");
1655    }
1656
1657    #[test]
1658    fn validation_env_prefix_collision_errors() {
1659        // 'my-lib' and 'my_lib' both map to SUNO_MY_LIB_* and must be rejected.
1660        let toml = "
1661            [accounts.my-lib]
1662            [accounts.my_lib]
1663        ";
1664        assert!(Config::from_toml(toml).is_err());
1665    }
1666
1667    #[test]
1668    fn audio_format_display_roundtrip() {
1669        for fmt in [AudioFormat::Mp3, AudioFormat::Flac, AudioFormat::Wav] {
1670            let s = fmt.to_string();
1671            assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
1672        }
1673    }
1674
1675    #[test]
1676    fn naming_template_follows_precedence() {
1677        let toml = r#"
1678            [defaults]
1679            naming_template = "{title}"
1680
1681            [accounts.alice]
1682            naming_template = "{creator}/{title}"
1683
1684            [accounts.alice.sources.liked]
1685            naming_template = "{handle}/{title} [{id8}]"
1686        "#;
1687        let cfg = Config::from_toml(toml).unwrap();
1688
1689        // Per-source wins over account.
1690        let eff = cfg
1691            .resolve("alice", Some("liked"), &no_env(), &no_flags())
1692            .unwrap();
1693        assert_eq!(eff.naming_template, "{handle}/{title} [{id8}]");
1694
1695        // Account wins over defaults.
1696        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1697        assert_eq!(eff.naming_template, "{creator}/{title}");
1698
1699        // Env overrides file.
1700        let env: HashMap<String, String> = [("SUNO_NAMING_TEMPLATE".into(), "{id}".into())]
1701            .into_iter()
1702            .collect();
1703        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1704        assert_eq!(eff.naming_template, "{id}");
1705
1706        // Flag overrides env.
1707        let flags = FlagOverrides {
1708            naming_template: Some("{title}/{id8}".into()),
1709            ..Default::default()
1710        };
1711        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1712        assert_eq!(eff.naming_template, "{title}/{id8}");
1713    }
1714
1715    #[test]
1716    fn character_set_follows_precedence() {
1717        let toml = r#"
1718            [defaults]
1719            character_set = "ascii"
1720
1721            [accounts.alice]
1722        "#;
1723        let cfg = Config::from_toml(toml).unwrap();
1724
1725        // File default applies.
1726        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1727        assert_eq!(eff.character_set, CharacterSet::Ascii);
1728
1729        // Env overrides file.
1730        let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "unicode".into())]
1731            .into_iter()
1732            .collect();
1733        let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1734        assert_eq!(eff.character_set, CharacterSet::Unicode);
1735
1736        // Flag overrides env.
1737        let flags = FlagOverrides {
1738            character_set: Some(CharacterSet::Ascii),
1739            ..Default::default()
1740        };
1741        let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1742        assert_eq!(eff.character_set, CharacterSet::Ascii);
1743    }
1744
1745    #[test]
1746    fn invalid_character_set_env_errors() {
1747        let toml = "[accounts.alice]\n";
1748        let cfg = Config::from_toml(toml).unwrap();
1749        let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "utf8".into())]
1750            .into_iter()
1751            .collect();
1752        assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1753    }
1754
1755    #[test]
1756    fn areas_parse_full_table() {
1757        let toml = r#"
1758            [accounts.alice]
1759            token = "t"
1760            [accounts.alice.areas]
1761            library = "off"
1762            liked = "copy"
1763            playlists = "mirror"
1764            [accounts.alice.areas.playlist]
1765            "pl_abc123" = "mirror"
1766            "pl_def456" = "copy"
1767        "#;
1768        let cfg = Config::from_toml(toml).unwrap();
1769        let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
1770        assert_eq!(areas.library, Some(AreaMode::Off));
1771        assert_eq!(areas.liked, Some(SourceMode::Copy));
1772        assert_eq!(areas.playlists, Some(SourceMode::Mirror));
1773        assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
1774        assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
1775    }
1776
1777    #[test]
1778    fn album_overrides_parse_and_resolve() {
1779        let toml = r#"
1780            [accounts.alice]
1781            token = "t"
1782            [accounts.alice.albums]
1783            "root_abc123" = "Preferred Name"
1784            "root_def456" = "Another Album"
1785            "root_blank" = "   "
1786        "#;
1787        let cfg = Config::from_toml(toml).unwrap();
1788        assert_eq!(
1789            cfg.accounts["alice"].albums["root_abc123"],
1790            "Preferred Name"
1791        );
1792        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1793        assert_eq!(eff.album_overrides["root_abc123"], "Preferred Name");
1794        assert_eq!(eff.album_overrides["root_def456"], "Another Album");
1795        // A blank value is dropped so it can never blank an album.
1796        assert!(!eff.album_overrides.contains_key("root_blank"));
1797    }
1798
1799    #[test]
1800    fn album_overrides_absent_by_default() {
1801        let cfg = Config::from_toml("[accounts.alice]\ntoken = \"t\"\n").unwrap();
1802        let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1803        assert!(eff.album_overrides.is_empty());
1804    }
1805
1806    #[test]
1807    fn areas_library_accepts_copy_and_mirror() {
1808        for (raw, expect) in [
1809            ("copy", AreaMode::Mode(SourceMode::Copy)),
1810            ("mirror", AreaMode::Mode(SourceMode::Mirror)),
1811        ] {
1812            let toml =
1813                format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
1814            let cfg = Config::from_toml(&toml).unwrap();
1815            assert_eq!(
1816                cfg.accounts["a"].areas.as_ref().unwrap().library,
1817                Some(expect)
1818            );
1819        }
1820    }
1821
1822    #[test]
1823    fn areas_bad_mode_errors() {
1824        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
1825        assert!(Config::from_toml(toml).is_err());
1826    }
1827
1828    #[test]
1829    fn areas_bad_playlist_mode_errors() {
1830        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
1831        // `off` is a library-only value; a per-playlist entry must be copy/mirror.
1832        assert!(Config::from_toml(toml).is_err());
1833    }
1834
1835    #[test]
1836    fn areas_unknown_field_errors() {
1837        // D7: a mistyped key (libary) is a parse error, not a silent no-op.
1838        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
1839        assert!(Config::from_toml(toml).is_err());
1840    }
1841
1842    #[test]
1843    fn areas_absent_is_none() {
1844        let toml = "[accounts.a]\ntoken = \"t\"\n";
1845        assert!(
1846            Config::from_toml(toml).unwrap().accounts["a"]
1847                .areas
1848                .is_none()
1849        );
1850    }
1851}