use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::path::Path;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::ffmpeg::WebpEncodeSettings;
use crate::naming::CharacterSet;
use crate::reconcile::SourceMode;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AudioFormat {
Mp3,
#[default]
Flac,
Wav,
}
impl FromStr for AudioFormat {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_ascii_lowercase().as_str() {
"mp3" => Ok(Self::Mp3),
"flac" => Ok(Self::Flac),
"wav" => Ok(Self::Wav),
other => Err(Error::Config(format!("unknown format '{other}'"))),
}
}
}
impl fmt::Display for AudioFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Mp3 => f.write_str("mp3"),
Self::Flac => f.write_str("flac"),
Self::Wav => f.write_str("wav"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum StemFormat {
#[default]
Wav,
Mp3,
}
impl StemFormat {
pub fn ext(self) -> &'static str {
match self {
Self::Wav => "wav",
Self::Mp3 => "mp3",
}
}
}
impl FromStr for StemFormat {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_ascii_lowercase().as_str() {
"wav" => Ok(Self::Wav),
"mp3" => Ok(Self::Mp3),
"flac" => Err(Error::Config(
"stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
)),
other => Err(Error::Config(format!("unknown stem format '{other}'"))),
}
}
}
impl fmt::Display for StemFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.ext())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum VideoCoverRetention {
#[default]
Neither,
Webp,
Mp4,
Both,
}
impl VideoCoverRetention {
pub fn keeps_webp(self) -> bool {
matches!(self, Self::Webp | Self::Both)
}
pub fn keeps_mp4(self) -> bool {
matches!(self, Self::Mp4 | Self::Both)
}
}
impl FromStr for VideoCoverRetention {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_ascii_lowercase().as_str() {
"neither" => Ok(Self::Neither),
"webp" => Ok(Self::Webp),
"mp4" => Ok(Self::Mp4),
"both" => Ok(Self::Both),
other => Err(Error::Config(format!(
"unknown video_cover_retention '{other}'"
))),
}
}
}
impl fmt::Display for VideoCoverRetention {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Neither => f.write_str("neither"),
Self::Webp => f.write_str("webp"),
Self::Mp4 => f.write_str("mp4"),
Self::Both => f.write_str("both"),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Defaults {
pub format: Option<AudioFormat>,
pub concurrency: Option<u32>,
pub retries: Option<u32>,
pub min_newest: Option<u32>,
pub token_command: Option<String>,
pub animated_covers: Option<bool>,
pub video_cover_retention: Option<VideoCoverRetention>,
pub animated_cover_quality: Option<u8>,
pub animated_cover_max_fps: Option<u32>,
pub animated_cover_max_width: Option<u32>,
pub animated_cover_compression_level: Option<u8>,
pub details_sidecar: Option<bool>,
pub lyrics_sidecar: Option<bool>,
pub lrc_sidecar: Option<bool>,
pub video_mp4: Option<bool>,
pub download_stems: Option<bool>,
pub stem_format: Option<StemFormat>,
pub naming_template: Option<String>,
pub character_set: Option<CharacterSet>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SourceConfig {
pub format: Option<AudioFormat>,
pub concurrency: Option<u32>,
pub retries: Option<u32>,
pub min_newest: Option<u32>,
pub token_command: Option<String>,
pub animated_covers: Option<bool>,
pub video_cover_retention: Option<VideoCoverRetention>,
pub animated_cover_quality: Option<u8>,
pub animated_cover_max_fps: Option<u32>,
pub animated_cover_max_width: Option<u32>,
pub animated_cover_compression_level: Option<u8>,
pub details_sidecar: Option<bool>,
pub lyrics_sidecar: Option<bool>,
pub lrc_sidecar: Option<bool>,
pub video_mp4: Option<bool>,
pub download_stems: Option<bool>,
pub stem_format: Option<StemFormat>,
pub naming_template: Option<String>,
pub character_set: Option<CharacterSet>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AccountConfig {
pub token: Option<String>,
pub token_command: Option<String>,
pub root: Option<String>,
pub account_id: Option<String>,
pub format: Option<AudioFormat>,
pub concurrency: Option<u32>,
pub retries: Option<u32>,
pub min_newest: Option<u32>,
pub animated_covers: Option<bool>,
pub video_cover_retention: Option<VideoCoverRetention>,
pub animated_cover_quality: Option<u8>,
pub animated_cover_max_fps: Option<u32>,
pub animated_cover_max_width: Option<u32>,
pub animated_cover_compression_level: Option<u8>,
pub details_sidecar: Option<bool>,
pub lyrics_sidecar: Option<bool>,
pub lrc_sidecar: Option<bool>,
pub video_mp4: Option<bool>,
pub download_stems: Option<bool>,
pub stem_format: Option<StemFormat>,
pub naming_template: Option<String>,
pub character_set: Option<CharacterSet>,
#[serde(default)]
pub sources: HashMap<String, SourceConfig>,
pub areas: Option<AreasConfig>,
#[serde(default)]
pub albums: HashMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AreaMode {
Off,
Mode(SourceMode),
}
impl<'de> Deserialize<'de> for AreaMode {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
match raw.as_str() {
"off" => Ok(AreaMode::Off),
"copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
"mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
other => Err(serde::de::Error::custom(format!(
"unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
))),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AreasConfig {
pub library: Option<AreaMode>,
pub liked: Option<SourceMode>,
pub playlists: Option<SourceMode>,
#[serde(default)]
pub playlist: HashMap<String, SourceMode>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Config {
#[serde(default)]
pub defaults: Defaults,
#[serde(default)]
pub accounts: HashMap<String, AccountConfig>,
}
impl Config {
pub fn from_toml(toml_str: &str) -> Result<Self> {
let config: Self = toml::from_str(toml_str).map_err(|e| {
let raw = e.to_string();
let msg = raw
.lines()
.filter(|l| !l.contains(" | "))
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_owned();
Error::Config(if msg.is_empty() {
"parse error".into()
} else {
msg
})
})?;
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<()> {
let roots: Vec<(&str, &str)> = self
.accounts
.iter()
.filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
.collect();
for (i, (label_a, root_a)) in roots.iter().enumerate() {
for (label_b, root_b) in roots.iter().skip(i + 1) {
let a = Path::new(root_a);
let b = Path::new(root_b);
if a.starts_with(b) || b.starts_with(a) {
return Err(Error::Config(format!(
"account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
)));
}
}
}
let mut prefix_seen: HashMap<String, &str> = HashMap::new();
for label in self.accounts.keys() {
let prefix = label_to_env(label);
if let Some(other) = prefix_seen.get(&prefix) {
return Err(Error::Config(format!(
"accounts '{label}' and '{other}' share env prefix '{prefix}'"
)));
}
prefix_seen.insert(prefix, label.as_str());
}
Ok(())
}
pub fn resolve(
&self,
account: &str,
source: Option<&str>,
env: &HashMap<String, String>,
flags: &FlagOverrides,
) -> Result<EffectiveSettings> {
let acc = self
.accounts
.get(account)
.ok_or_else(|| Error::Config(format!("account '{account}' not found")))?;
let src = source.and_then(|s| acc.sources.get(s));
let label_env = label_to_env(account);
let env_val = |suffix: &str| -> Option<&str> {
env.get(&format!("SUNO_{label_env}_{suffix}"))
.or_else(|| env.get(&format!("SUNO_{suffix}")))
.map(String::as_str)
};
let format_from_env = env_val("FORMAT")
.map(str::parse::<AudioFormat>)
.transpose()?;
let format = flags
.format
.or(format_from_env)
.or_else(|| src.and_then(|s| s.format))
.or(acc.format)
.or(self.defaults.format)
.unwrap_or(AudioFormat::Flac);
let concurrency = resolve_u32(
flags.concurrency,
env_val("CONCURRENCY"),
src.and_then(|s| s.concurrency),
acc.concurrency,
self.defaults.concurrency,
4,
"CONCURRENCY",
)?;
let retries = resolve_u32(
flags.retries,
env_val("RETRIES"),
src.and_then(|s| s.retries),
acc.retries,
self.defaults.retries,
3,
"RETRIES",
)?;
let min_newest = resolve_u32(
flags.min_newest,
env_val("MIN_NEWEST"),
src.and_then(|s| s.min_newest),
acc.min_newest,
self.defaults.min_newest,
1,
"MIN_NEWEST",
)?;
let animated_covers = resolve_bool(
flags.animated_covers,
env_val("ANIMATED_COVERS"),
src.and_then(|s| s.animated_covers),
acc.animated_covers,
self.defaults.animated_covers,
false,
"ANIMATED_COVERS",
)?;
let details_sidecar = resolve_bool(
flags.details_sidecar,
env_val("DETAILS_SIDECAR"),
src.and_then(|s| s.details_sidecar),
acc.details_sidecar,
self.defaults.details_sidecar,
false,
"DETAILS_SIDECAR",
)?;
let lyrics_sidecar = resolve_bool(
flags.lyrics_sidecar,
env_val("LYRICS_SIDECAR"),
src.and_then(|s| s.lyrics_sidecar),
acc.lyrics_sidecar,
self.defaults.lyrics_sidecar,
false,
"LYRICS_SIDECAR",
)?;
let lrc_sidecar = resolve_bool(
flags.lrc_sidecar,
env_val("LRC_SIDECAR"),
src.and_then(|s| s.lrc_sidecar),
acc.lrc_sidecar,
self.defaults.lrc_sidecar,
false,
"LRC_SIDECAR",
)?;
let video_mp4 = resolve_bool(
flags.video_mp4,
env_val("VIDEO_MP4"),
src.and_then(|s| s.video_mp4),
acc.video_mp4,
self.defaults.video_mp4,
false,
"VIDEO_MP4",
)?;
let download_stems = resolve_bool(
flags.download_stems,
env_val("DOWNLOAD_STEMS"),
src.and_then(|s| s.download_stems),
acc.download_stems,
self.defaults.download_stems,
false,
"DOWNLOAD_STEMS",
)?;
let stem_format_from_env = env_val("STEM_FORMAT")
.map(str::parse::<StemFormat>)
.transpose()?;
let stem_format = flags
.stem_format
.or(stem_format_from_env)
.or_else(|| src.and_then(|s| s.stem_format))
.or(acc.stem_format)
.or(self.defaults.stem_format)
.unwrap_or_default();
let video_cover_retention = resolve_enum(
flags.video_cover_retention,
env_val("VIDEO_COVER_RETENTION"),
src.and_then(|s| s.video_cover_retention),
acc.video_cover_retention,
self.defaults.video_cover_retention,
None,
"VIDEO_COVER_RETENTION",
)?;
let (animated_covers, raw_animated_cover) = match video_cover_retention {
Some(retention) => (retention.keeps_webp(), retention.keeps_mp4()),
None => (animated_covers, false),
};
let defaults_webp = WebpEncodeSettings::default();
let animated_cover_quality = resolve_u8_ranged(
flags.animated_cover_quality,
env_val("ANIMATED_COVER_QUALITY"),
src.and_then(|s| s.animated_cover_quality),
acc.animated_cover_quality,
self.defaults.animated_cover_quality,
defaults_webp.quality,
"ANIMATED_COVER_QUALITY",
0..=100,
)?;
let animated_cover_max_fps = resolve_u32(
flags.animated_cover_max_fps,
env_val("ANIMATED_COVER_MAX_FPS"),
src.and_then(|s| s.animated_cover_max_fps),
acc.animated_cover_max_fps,
self.defaults.animated_cover_max_fps,
defaults_webp.max_fps,
"ANIMATED_COVER_MAX_FPS",
)?;
let animated_cover_max_width_from_env = env_val("ANIMATED_COVER_MAX_WIDTH")
.map(|s| {
s.parse().map_err(|_| {
Error::Config(format!(
"invalid ANIMATED_COVER_MAX_WIDTH: '{s}' (expected integer)"
))
})
})
.transpose()?;
let animated_cover_max_width = if let Some(v) = flags.animated_cover_max_width {
Some(v)
} else if let Some(v) = animated_cover_max_width_from_env {
Some(v)
} else {
src.and_then(|s| s.animated_cover_max_width)
.or(acc.animated_cover_max_width)
.or(self.defaults.animated_cover_max_width)
.or(defaults_webp.max_width)
};
let animated_cover_compression_level = resolve_u8_ranged(
flags.animated_cover_compression_level,
env_val("ANIMATED_COVER_COMPRESSION_LEVEL"),
src.and_then(|s| s.animated_cover_compression_level),
acc.animated_cover_compression_level,
self.defaults.animated_cover_compression_level,
defaults_webp.compression_level,
"ANIMATED_COVER_COMPRESSION_LEVEL",
0..=6,
)?;
let naming_template_from_env = env_val("NAMING_TEMPLATE").map(str::to_owned);
let naming_template = flags
.naming_template
.clone()
.or(naming_template_from_env)
.or_else(|| src.and_then(|s| s.naming_template.clone()))
.or_else(|| acc.naming_template.clone())
.or_else(|| self.defaults.naming_template.clone())
.unwrap_or_else(|| crate::naming::DEFAULT_TEMPLATE.to_owned());
let character_set_from_env = env_val("CHARACTER_SET")
.map(str::parse::<CharacterSet>)
.transpose()?;
let character_set = flags
.character_set
.or(character_set_from_env)
.or_else(|| src.and_then(|s| s.character_set))
.or(acc.character_set)
.or(self.defaults.character_set)
.unwrap_or(CharacterSet::Unicode);
let token = flags
.token
.clone()
.or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
.or_else(|| env.get("SUNO_TOKEN").cloned());
let token_command = env
.get(&format!("SUNO_{label_env}_TOKEN_COMMAND"))
.cloned()
.or_else(|| env.get("SUNO_TOKEN_COMMAND").cloned())
.or_else(|| src.and_then(|s| s.token_command.clone()))
.or_else(|| acc.token_command.clone())
.or_else(|| self.defaults.token_command.clone());
Ok(EffectiveSettings {
token,
stored_token: acc.token.clone(),
token_command,
account_id: acc.account_id.clone(),
format,
concurrency,
retries,
min_newest,
animated_covers,
raw_animated_cover,
video_cover_retention: match (animated_covers, raw_animated_cover) {
(false, false) => VideoCoverRetention::Neither,
(true, false) => VideoCoverRetention::Webp,
(false, true) => VideoCoverRetention::Mp4,
(true, true) => VideoCoverRetention::Both,
},
animated_cover_webp: WebpEncodeSettings {
quality: animated_cover_quality,
max_fps: animated_cover_max_fps,
max_width: animated_cover_max_width,
lossless: defaults_webp.lossless,
compression_level: animated_cover_compression_level,
},
details_sidecar,
lyrics_sidecar,
lrc_sidecar,
video_mp4,
download_stems,
stem_format,
naming_template,
character_set,
areas: acc.areas.clone(),
album_overrides: acc
.albums
.iter()
.filter(|(_, name)| !name.trim().is_empty())
.map(|(root_id, name)| (root_id.clone(), name.trim().to_owned()))
.collect(),
})
}
}
fn resolve_u32(
flag: Option<u32>,
env_str: Option<&str>,
src: Option<u32>,
acc: Option<u32>,
defaults: Option<u32>,
compiled: u32,
name: &str,
) -> Result<u32> {
if let Some(v) = flag {
return Ok(v);
}
if let Some(s) = env_str {
return s
.parse()
.map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
}
Ok(src.or(acc).or(defaults).unwrap_or(compiled))
}
fn resolve_bool(
flag: Option<bool>,
env_str: Option<&str>,
src: Option<bool>,
acc: Option<bool>,
defaults: Option<bool>,
compiled: bool,
name: &str,
) -> Result<bool> {
if let Some(v) = flag {
return Ok(v);
}
if let Some(s) = env_str {
return s
.parse()
.map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
}
Ok(src.or(acc).or(defaults).unwrap_or(compiled))
}
#[allow(clippy::too_many_arguments)]
fn resolve_u8_ranged(
flag: Option<u8>,
env_str: Option<&str>,
src: Option<u8>,
acc: Option<u8>,
defaults: Option<u8>,
compiled: u8,
name: &str,
range: std::ops::RangeInclusive<u8>,
) -> Result<u8> {
let value = if let Some(v) = flag {
v
} else if let Some(s) = env_str {
s.parse()
.map_err(|_| Error::Config(format!("invalid {name}: '{s}' (expected integer)")))?
} else {
src.or(acc).or(defaults).unwrap_or(compiled)
};
if range.contains(&value) {
Ok(value)
} else {
Err(Error::Config(format!(
"invalid {name}: '{value}' (expected {}..={})",
range.start(),
range.end()
)))
}
}
fn resolve_enum<T>(
flag: Option<T>,
env_str: Option<&str>,
src: Option<T>,
acc: Option<T>,
defaults: Option<T>,
compiled: Option<T>,
name: &str,
) -> Result<Option<T>>
where
T: FromStr<Err = Error> + Copy,
{
if let Some(v) = flag {
return Ok(Some(v));
}
if let Some(s) = env_str {
return s
.parse()
.map(Some)
.map_err(|err| Error::Config(format!("invalid {name}: '{s}' ({err})")));
}
Ok(src.or(acc).or(defaults).or(compiled))
}
pub fn label_to_env(label: &str) -> String {
label.to_ascii_uppercase().replace('-', "_")
}
#[derive(Debug, Default)]
pub struct FlagOverrides {
pub token: Option<String>,
pub format: Option<AudioFormat>,
pub concurrency: Option<u32>,
pub retries: Option<u32>,
pub min_newest: Option<u32>,
pub animated_covers: Option<bool>,
pub video_cover_retention: Option<VideoCoverRetention>,
pub animated_cover_quality: Option<u8>,
pub animated_cover_max_fps: Option<u32>,
pub animated_cover_max_width: Option<u32>,
pub animated_cover_compression_level: Option<u8>,
pub details_sidecar: Option<bool>,
pub lyrics_sidecar: Option<bool>,
pub lrc_sidecar: Option<bool>,
pub video_mp4: Option<bool>,
pub download_stems: Option<bool>,
pub stem_format: Option<StemFormat>,
pub naming_template: Option<String>,
pub character_set: Option<CharacterSet>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EffectiveSettings {
pub token: Option<String>,
pub stored_token: Option<String>,
pub token_command: Option<String>,
pub account_id: Option<String>,
pub format: AudioFormat,
pub concurrency: u32,
pub retries: u32,
pub min_newest: u32,
pub animated_covers: bool,
pub raw_animated_cover: bool,
pub video_cover_retention: VideoCoverRetention,
pub animated_cover_webp: WebpEncodeSettings,
pub details_sidecar: bool,
pub lyrics_sidecar: bool,
pub lrc_sidecar: bool,
pub video_mp4: bool,
pub download_stems: bool,
pub stem_format: StemFormat,
pub naming_template: String,
pub character_set: CharacterSet,
pub areas: Option<AreasConfig>,
pub album_overrides: BTreeMap<String, String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn no_env() -> HashMap<String, String> {
HashMap::new()
}
fn no_flags() -> FlagOverrides {
FlagOverrides::default()
}
#[test]
fn parse_empty_toml() {
let cfg = Config::from_toml("").unwrap();
assert!(cfg.accounts.is_empty());
}
#[test]
fn parse_basic_account() {
let toml = r#"
[accounts.alice]
token = "tok"
root = "/music"
"#;
let cfg = Config::from_toml(toml).unwrap();
let acc = &cfg.accounts["alice"];
assert_eq!(acc.token.as_deref(), Some("tok"));
assert_eq!(acc.root.as_deref(), Some("/music"));
}
#[test]
fn account_id_parses_and_resolves() {
let toml = r#"
[accounts.alice]
token = "tok"
root = "/music"
account_id = "user_abc123"
"#;
let cfg = Config::from_toml(toml).unwrap();
assert_eq!(
cfg.accounts["alice"].account_id.as_deref(),
Some("user_abc123")
);
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert_eq!(eff.account_id.as_deref(), Some("user_abc123"));
}
#[test]
fn parse_defaults_section() {
let toml = r#"
[defaults]
format = "mp3"
concurrency = 8
retries = 5
min_newest = 2
animated_covers = true
video_cover_retention = "both"
animated_cover_quality = 85
animated_cover_max_fps = 18
animated_cover_max_width = 720
animated_cover_compression_level = 4
"#;
let cfg = Config::from_toml(toml).unwrap();
assert_eq!(cfg.defaults.format, Some(AudioFormat::Mp3));
assert_eq!(cfg.defaults.concurrency, Some(8));
assert_eq!(cfg.defaults.retries, Some(5));
assert_eq!(cfg.defaults.min_newest, Some(2));
assert_eq!(cfg.defaults.animated_covers, Some(true));
assert_eq!(
cfg.defaults.video_cover_retention,
Some(VideoCoverRetention::Both)
);
assert_eq!(cfg.defaults.animated_cover_quality, Some(85));
assert_eq!(cfg.defaults.animated_cover_max_fps, Some(18));
assert_eq!(cfg.defaults.animated_cover_max_width, Some(720));
assert_eq!(cfg.defaults.animated_cover_compression_level, Some(4));
}
#[test]
fn compiled_defaults_when_nothing_set() {
let toml = "[accounts.alice]\n";
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert_eq!(
eff,
EffectiveSettings {
token: None,
stored_token: None,
token_command: None,
account_id: None,
format: AudioFormat::Flac,
concurrency: 4,
retries: 3,
min_newest: 1,
animated_covers: false,
raw_animated_cover: false,
video_cover_retention: VideoCoverRetention::Neither,
animated_cover_webp: WebpEncodeSettings::default(),
details_sidecar: false,
lyrics_sidecar: false,
lrc_sidecar: false,
video_mp4: false,
download_stems: false,
stem_format: StemFormat::Wav,
naming_template: crate::naming::DEFAULT_TEMPLATE.to_owned(),
character_set: CharacterSet::Unicode,
areas: None,
album_overrides: BTreeMap::new(),
}
);
}
#[test]
fn file_defaults_override_compiled() {
let toml = r#"
[defaults]
format = "mp3"
concurrency = 8
[accounts.alice]
"#;
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert_eq!(eff.format, AudioFormat::Mp3);
assert_eq!(eff.concurrency, 8);
assert_eq!(eff.retries, 3); }
#[test]
fn account_settings_override_defaults() {
let toml = r#"
[defaults]
format = "mp3"
[accounts.alice]
format = "wav"
"#;
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert_eq!(eff.format, AudioFormat::Wav);
}
#[test]
fn per_source_overrides_account() {
let toml = r#"
[accounts.alice]
format = "flac"
[accounts.alice.sources.liked]
format = "mp3"
"#;
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg
.resolve("alice", Some("liked"), &no_env(), &no_flags())
.unwrap();
assert_eq!(eff.format, AudioFormat::Mp3);
}
#[test]
fn unknown_source_falls_back_to_account() {
let toml = r#"
[accounts.alice]
format = "wav"
"#;
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg
.resolve("alice", Some("nonexistent"), &no_env(), &no_flags())
.unwrap();
assert_eq!(eff.format, AudioFormat::Wav);
}
#[test]
fn global_env_overrides_file() {
let toml = r#"
[accounts.alice]
format = "flac"
"#;
let cfg = Config::from_toml(toml).unwrap();
let env: HashMap<String, String> =
[("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
assert_eq!(eff.format, AudioFormat::Mp3);
}
#[test]
fn per_account_env_overrides_global_env() {
let toml = "[accounts.alice]\n";
let cfg = Config::from_toml(toml).unwrap();
let env: HashMap<String, String> = [
("SUNO_FORMAT".into(), "mp3".into()),
("SUNO_ALICE_FORMAT".into(), "wav".into()),
]
.into_iter()
.collect();
let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
assert_eq!(eff.format, AudioFormat::Wav);
}
#[test]
fn per_account_env_label_uppersnakedcase() {
let toml = "[accounts.my-lib]\n";
let cfg = Config::from_toml(toml).unwrap();
let env: HashMap<String, String> = [("SUNO_MY_LIB_FORMAT".into(), "wav".into())]
.into_iter()
.collect();
let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
assert_eq!(eff.format, AudioFormat::Wav);
}
#[test]
fn flag_overrides_env_and_file() {
let toml = r#"
[accounts.alice]
format = "flac"
"#;
let cfg = Config::from_toml(toml).unwrap();
let env: HashMap<String, String> =
[("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
let flags = FlagOverrides {
format: Some(AudioFormat::Wav),
..Default::default()
};
let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
assert_eq!(eff.format, AudioFormat::Wav);
}
#[test]
fn token_precedence() {
let toml = r#"
[accounts.alice]
token = "file_tok"
"#;
let cfg = Config::from_toml(toml).unwrap();
let env: HashMap<String, String> = [("SUNO_TOKEN".into(), "env_tok".into())]
.into_iter()
.collect();
let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
assert_eq!(eff.token.as_deref(), Some("env_tok"));
assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
let flags = FlagOverrides {
token: Some("flag_tok".into()),
..Default::default()
};
let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
assert_eq!(eff.token.as_deref(), Some("flag_tok"));
assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
}
#[test]
fn stored_token_is_populated_from_config_when_no_override_exists() {
let toml = r#"
[accounts.alice]
token = "file_tok"
"#;
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert_eq!(eff.token, None);
assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
assert_eq!(eff.token_command, None);
}
#[test]
fn per_account_token_env_overrides_global() {
let toml = "[accounts.alice]\n";
let cfg = Config::from_toml(toml).unwrap();
let env: HashMap<String, String> = [
("SUNO_TOKEN".into(), "global".into()),
("SUNO_ALICE_TOKEN".into(), "per_account".into()),
]
.into_iter()
.collect();
let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
assert_eq!(eff.token.as_deref(), Some("per_account"));
}
#[test]
fn token_command_resolves_from_defaults_account_source_and_env() {
let toml = r#"
[defaults]
token_command = "defaults"
[accounts.alice]
token_command = "account"
[accounts.alice.sources.liked]
token_command = "source"
"#;
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert_eq!(eff.token_command.as_deref(), Some("account"));
let eff = cfg
.resolve("alice", Some("liked"), &no_env(), &no_flags())
.unwrap();
assert_eq!(eff.token_command.as_deref(), Some("source"));
let env: HashMap<String, String> = [("SUNO_TOKEN_COMMAND".into(), "global".into())]
.into_iter()
.collect();
let eff = cfg
.resolve("alice", Some("liked"), &env, &no_flags())
.unwrap();
assert_eq!(eff.token_command.as_deref(), Some("global"));
let env: HashMap<String, String> = [
("SUNO_TOKEN_COMMAND".into(), "global".into()),
("SUNO_ALICE_TOKEN_COMMAND".into(), "per_account".into()),
]
.into_iter()
.collect();
let eff = cfg
.resolve("alice", Some("liked"), &env, &no_flags())
.unwrap();
assert_eq!(eff.token_command.as_deref(), Some("per_account"));
}
#[test]
fn per_account_token_command_env_label_uppersnakedcase() {
let cfg = Config::from_toml("[accounts.my-lib]\n").unwrap();
let env: HashMap<String, String> = [("SUNO_MY_LIB_TOKEN_COMMAND".into(), "command".into())]
.into_iter()
.collect();
let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
assert_eq!(eff.token_command.as_deref(), Some("command"));
}
#[test]
fn invalid_env_u32_errors() {
let toml = "[accounts.alice]\n";
let cfg = Config::from_toml(toml).unwrap();
let env: HashMap<String, String> = [("SUNO_CONCURRENCY".into(), "many".into())]
.into_iter()
.collect();
assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
}
#[test]
fn animated_covers_defaults_off_and_follows_precedence() {
let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert!(!eff.animated_covers);
let toml = r#"
[defaults]
animated_covers = true
[accounts.alice.sources.liked]
animated_covers = false
"#;
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert!(eff.animated_covers);
let eff = cfg
.resolve("alice", Some("liked"), &no_env(), &no_flags())
.unwrap();
assert!(!eff.animated_covers);
let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "true".into())]
.into_iter()
.collect();
let eff = cfg
.resolve("alice", Some("liked"), &env, &no_flags())
.unwrap();
assert!(eff.animated_covers);
let flags = FlagOverrides {
animated_covers: Some(false),
..Default::default()
};
let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
assert!(!eff.animated_covers);
}
#[test]
fn video_mp4_defaults_off_and_follows_precedence() {
let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert!(!eff.video_mp4);
let toml = r#"
[defaults]
video_mp4 = true
[accounts.alice.sources.liked]
video_mp4 = false
"#;
let cfg = Config::from_toml(toml).unwrap();
assert!(
cfg.resolve("alice", None, &no_env(), &no_flags())
.unwrap()
.video_mp4
);
assert!(
!cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
.unwrap()
.video_mp4
);
let env: HashMap<String, String> = [("SUNO_VIDEO_MP4".into(), "true".into())]
.into_iter()
.collect();
assert!(
cfg.resolve("alice", Some("liked"), &env, &no_flags())
.unwrap()
.video_mp4
);
let flags = FlagOverrides {
video_mp4: Some(false),
..Default::default()
};
assert!(
!cfg.resolve("alice", Some("liked"), &env, &flags)
.unwrap()
.video_mp4
);
}
#[test]
fn download_stems_defaults_off_and_follows_precedence() {
let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
assert!(
!cfg.resolve("alice", None, &no_env(), &no_flags())
.unwrap()
.download_stems
);
let toml = r#"
[defaults]
download_stems = true
[accounts.alice.sources.liked]
download_stems = false
"#;
let cfg = Config::from_toml(toml).unwrap();
assert!(
cfg.resolve("alice", None, &no_env(), &no_flags())
.unwrap()
.download_stems
);
assert!(
!cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
.unwrap()
.download_stems
);
let env: HashMap<String, String> = [("SUNO_DOWNLOAD_STEMS".into(), "true".into())]
.into_iter()
.collect();
assert!(
cfg.resolve("alice", Some("liked"), &env, &no_flags())
.unwrap()
.download_stems
);
let flags = FlagOverrides {
download_stems: Some(false),
..Default::default()
};
assert!(
!cfg.resolve("alice", Some("liked"), &env, &flags)
.unwrap()
.download_stems
);
}
#[test]
fn stem_format_defaults_to_wav_and_follows_precedence() {
let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
assert_eq!(
cfg.resolve("alice", None, &no_env(), &no_flags())
.unwrap()
.stem_format,
StemFormat::Wav
);
let toml = r#"
[defaults]
stem_format = "mp3"
[accounts.alice.sources.liked]
stem_format = "wav"
"#;
let cfg = Config::from_toml(toml).unwrap();
assert_eq!(
cfg.resolve("alice", None, &no_env(), &no_flags())
.unwrap()
.stem_format,
StemFormat::Mp3
);
assert_eq!(
cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
.unwrap()
.stem_format,
StemFormat::Wav
);
let env: HashMap<String, String> = [("SUNO_STEM_FORMAT".into(), "mp3".into())]
.into_iter()
.collect();
assert_eq!(
cfg.resolve("alice", Some("liked"), &env, &no_flags())
.unwrap()
.stem_format,
StemFormat::Mp3
);
let flags = FlagOverrides {
stem_format: Some(StemFormat::Wav),
..Default::default()
};
assert_eq!(
cfg.resolve("alice", Some("liked"), &env, &flags)
.unwrap()
.stem_format,
StemFormat::Wav
);
}
#[test]
fn stem_format_rejects_flac_and_unknown() {
assert!("flac".parse::<StemFormat>().is_err());
assert!("aac".parse::<StemFormat>().is_err());
assert_eq!("WAV".parse::<StemFormat>().unwrap(), StemFormat::Wav);
assert_eq!("Mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
assert!(Config::from_toml("[defaults]\nstem_format = \"flac\"\n").is_err());
}
#[test]
fn video_cover_retention_drives_cover_artifacts_not_the_music_video() {
let resolve = |retention: &str| {
let toml = format!("[accounts.alice]\nvideo_cover_retention = \"{retention}\"\n");
Config::from_toml(&toml)
.unwrap()
.resolve("alice", None, &no_env(), &no_flags())
.unwrap()
};
let neither = resolve("neither");
assert!(!neither.animated_covers && !neither.raw_animated_cover);
assert_eq!(neither.video_cover_retention, VideoCoverRetention::Neither);
let webp = resolve("webp");
assert!(webp.animated_covers && !webp.raw_animated_cover);
assert_eq!(webp.video_cover_retention, VideoCoverRetention::Webp);
let mp4 = resolve("mp4");
assert!(!mp4.animated_covers && mp4.raw_animated_cover);
assert!(!mp4.video_mp4);
assert_eq!(mp4.video_cover_retention, VideoCoverRetention::Mp4);
let both = resolve("both");
assert!(both.animated_covers && both.raw_animated_cover);
assert!(!both.video_mp4);
assert_eq!(both.video_cover_retention, VideoCoverRetention::Both);
}
#[test]
fn video_mp4_is_independent_of_cover_retention() {
let toml = "[accounts.alice]\nvideo_mp4 = true\nvideo_cover_retention = \"webp\"\n";
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert!(eff.video_mp4);
assert!(eff.animated_covers);
assert!(!eff.raw_animated_cover);
assert_eq!(eff.video_cover_retention, VideoCoverRetention::Webp);
}
#[test]
fn animated_cover_webp_knobs_follow_precedence_and_validate_ranges() {
let toml = r#"
[defaults]
animated_cover_quality = 80
animated_cover_max_fps = 20
animated_cover_max_width = 640
animated_cover_compression_level = 3
[accounts.alice.sources.liked]
animated_cover_quality = 75
"#;
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg
.resolve("alice", Some("liked"), &no_env(), &no_flags())
.unwrap();
assert_eq!(eff.animated_cover_webp.quality, 75);
assert_eq!(eff.animated_cover_webp.max_fps, 20);
assert_eq!(eff.animated_cover_webp.max_width, Some(640));
assert_eq!(eff.animated_cover_webp.compression_level, 3);
let env: HashMap<String, String> = [("SUNO_ANIMATED_COVER_QUALITY".into(), "90".into())]
.into_iter()
.collect();
let eff = cfg
.resolve("alice", Some("liked"), &env, &no_flags())
.unwrap();
assert_eq!(eff.animated_cover_webp.quality, 90);
let flags = FlagOverrides {
animated_cover_quality: Some(95),
animated_cover_max_width: Some(512),
animated_cover_compression_level: Some(6),
..Default::default()
};
let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
assert_eq!(eff.animated_cover_webp.quality, 95);
assert_eq!(eff.animated_cover_webp.max_width, Some(512));
assert_eq!(eff.animated_cover_webp.compression_level, 6);
let bad_env: HashMap<String, String> =
[("SUNO_ANIMATED_COVER_QUALITY".into(), "101".into())]
.into_iter()
.collect();
assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
}
#[test]
fn video_cover_retention_parses_formats_and_reports_kept_artifacts() {
assert_eq!(
"NEITHER".parse::<VideoCoverRetention>().unwrap(),
VideoCoverRetention::Neither
);
assert_eq!(
"WebP".parse::<VideoCoverRetention>().unwrap(),
VideoCoverRetention::Webp
);
assert_eq!(
"mp4".parse::<VideoCoverRetention>().unwrap(),
VideoCoverRetention::Mp4
);
assert_eq!(
"Both".parse::<VideoCoverRetention>().unwrap(),
VideoCoverRetention::Both
);
assert!("mkv".parse::<VideoCoverRetention>().is_err());
for mode in [
VideoCoverRetention::Neither,
VideoCoverRetention::Webp,
VideoCoverRetention::Mp4,
VideoCoverRetention::Both,
] {
assert_eq!(
mode.to_string().parse::<VideoCoverRetention>().unwrap(),
mode
);
}
assert!(!VideoCoverRetention::Neither.keeps_webp());
assert!(!VideoCoverRetention::Neither.keeps_mp4());
assert!(VideoCoverRetention::Webp.keeps_webp());
assert!(!VideoCoverRetention::Webp.keeps_mp4());
assert!(!VideoCoverRetention::Mp4.keeps_webp());
assert!(VideoCoverRetention::Mp4.keeps_mp4());
assert!(VideoCoverRetention::Both.keeps_webp());
assert!(VideoCoverRetention::Both.keeps_mp4());
}
#[test]
fn video_cover_retention_resolves_from_env_and_rejects_unknown() {
let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
let env: HashMap<String, String> = [("SUNO_VIDEO_COVER_RETENTION".into(), "both".into())]
.into_iter()
.collect();
let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
assert_eq!(eff.video_cover_retention, VideoCoverRetention::Both);
assert!(eff.animated_covers);
assert!(eff.raw_animated_cover);
let bad_env: HashMap<String, String> =
[("SUNO_VIDEO_COVER_RETENTION".into(), "mkv".into())]
.into_iter()
.collect();
assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
}
#[test]
fn animated_cover_compression_level_enforces_zero_to_six() {
let cfg = Config::from_toml(
"[defaults]\nanimated_cover_compression_level = 6\n[accounts.alice]\n",
)
.unwrap();
assert_eq!(
cfg.resolve("alice", None, &no_env(), &no_flags())
.unwrap()
.animated_cover_webp
.compression_level,
6
);
let cfg = Config::from_toml(
"[defaults]\nanimated_cover_compression_level = 7\n[accounts.alice]\n",
)
.unwrap();
assert!(cfg.resolve("alice", None, &no_env(), &no_flags()).is_err());
let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
let bad_env: HashMap<String, String> =
[("SUNO_ANIMATED_COVER_COMPRESSION_LEVEL".into(), "7".into())]
.into_iter()
.collect();
assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
let junk_env: HashMap<String, String> =
[("SUNO_ANIMATED_COVER_MAX_FPS".into(), "abc".into())]
.into_iter()
.collect();
assert!(cfg.resolve("alice", None, &junk_env, &no_flags()).is_err());
}
#[test]
fn animated_cover_max_width_defaults_to_native() {
let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
assert_eq!(
cfg.resolve("alice", None, &no_env(), &no_flags())
.unwrap()
.animated_cover_webp
.max_width,
None
);
}
#[test]
fn text_sidecars_default_off_and_follow_precedence() {
let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert!(!eff.details_sidecar);
assert!(!eff.lyrics_sidecar);
let toml = r#"
[defaults]
details_sidecar = true
[accounts.alice.sources.liked]
details_sidecar = false
"#;
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert!(eff.details_sidecar);
assert!(!eff.lyrics_sidecar);
let eff = cfg
.resolve("alice", Some("liked"), &no_env(), &no_flags())
.unwrap();
assert!(!eff.details_sidecar);
let env: HashMap<String, String> = [
("SUNO_DETAILS_SIDECAR".into(), "true".into()),
("SUNO_LYRICS_SIDECAR".into(), "true".into()),
]
.into_iter()
.collect();
let eff = cfg
.resolve("alice", Some("liked"), &env, &no_flags())
.unwrap();
assert!(eff.details_sidecar);
assert!(eff.lyrics_sidecar);
let flags = FlagOverrides {
lyrics_sidecar: Some(false),
..Default::default()
};
let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
assert!(eff.details_sidecar);
assert!(!eff.lyrics_sidecar);
}
#[test]
fn invalid_env_bool_errors() {
let toml = "[accounts.alice]\n";
let cfg = Config::from_toml(toml).unwrap();
let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "yes".into())]
.into_iter()
.collect();
assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
}
#[test]
fn unknown_account_errors() {
let cfg = Config::from_toml("").unwrap();
assert!(cfg.resolve("nobody", None, &no_env(), &no_flags()).is_err());
}
#[test]
fn validation_nested_roots() {
let toml = r#"
[accounts.alice]
root = "/music"
[accounts.bob]
root = "/music/bob"
"#;
assert!(Config::from_toml(toml).is_err());
}
#[test]
fn validation_non_nested_roots_ok() {
let toml = r#"
[accounts.alice]
root = "/music/alice"
[accounts.bob]
root = "/music/bob"
"#;
assert!(Config::from_toml(toml).is_ok());
}
#[test]
fn invalid_toml_errors() {
assert!(Config::from_toml("not valid toml ][").is_err());
}
#[test]
fn duplicate_account_label_errors() {
let toml = "
[accounts.alice]
token = \"tok1\"
[accounts.alice]
token = \"tok2\"
";
assert!(Config::from_toml(toml).is_err());
}
#[test]
fn parse_error_does_not_echo_token() {
let toml = "[accounts.alice]\ntoken = \"unterminated\n";
let err = Config::from_toml(toml).unwrap_err().to_string();
assert!(!err.contains("unterminated"), "error leaked token: {err}");
}
#[test]
fn validation_env_prefix_collision_errors() {
let toml = "
[accounts.my-lib]
[accounts.my_lib]
";
assert!(Config::from_toml(toml).is_err());
}
#[test]
fn audio_format_display_roundtrip() {
for fmt in [AudioFormat::Mp3, AudioFormat::Flac, AudioFormat::Wav] {
let s = fmt.to_string();
assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
}
}
#[test]
fn naming_template_follows_precedence() {
let toml = r#"
[defaults]
naming_template = "{title}"
[accounts.alice]
naming_template = "{creator}/{title}"
[accounts.alice.sources.liked]
naming_template = "{handle}/{title} [{id8}]"
"#;
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg
.resolve("alice", Some("liked"), &no_env(), &no_flags())
.unwrap();
assert_eq!(eff.naming_template, "{handle}/{title} [{id8}]");
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert_eq!(eff.naming_template, "{creator}/{title}");
let env: HashMap<String, String> = [("SUNO_NAMING_TEMPLATE".into(), "{id}".into())]
.into_iter()
.collect();
let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
assert_eq!(eff.naming_template, "{id}");
let flags = FlagOverrides {
naming_template: Some("{title}/{id8}".into()),
..Default::default()
};
let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
assert_eq!(eff.naming_template, "{title}/{id8}");
}
#[test]
fn character_set_follows_precedence() {
let toml = r#"
[defaults]
character_set = "ascii"
[accounts.alice]
"#;
let cfg = Config::from_toml(toml).unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert_eq!(eff.character_set, CharacterSet::Ascii);
let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "unicode".into())]
.into_iter()
.collect();
let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
assert_eq!(eff.character_set, CharacterSet::Unicode);
let flags = FlagOverrides {
character_set: Some(CharacterSet::Ascii),
..Default::default()
};
let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
assert_eq!(eff.character_set, CharacterSet::Ascii);
}
#[test]
fn invalid_character_set_env_errors() {
let toml = "[accounts.alice]\n";
let cfg = Config::from_toml(toml).unwrap();
let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "utf8".into())]
.into_iter()
.collect();
assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
}
#[test]
fn areas_parse_full_table() {
let toml = r#"
[accounts.alice]
token = "t"
[accounts.alice.areas]
library = "off"
liked = "copy"
playlists = "mirror"
[accounts.alice.areas.playlist]
"pl_abc123" = "mirror"
"pl_def456" = "copy"
"#;
let cfg = Config::from_toml(toml).unwrap();
let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
assert_eq!(areas.library, Some(AreaMode::Off));
assert_eq!(areas.liked, Some(SourceMode::Copy));
assert_eq!(areas.playlists, Some(SourceMode::Mirror));
assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
}
#[test]
fn album_overrides_parse_and_resolve() {
let toml = r#"
[accounts.alice]
token = "t"
[accounts.alice.albums]
"root_abc123" = "Preferred Name"
"root_def456" = "Another Album"
"root_blank" = " "
"#;
let cfg = Config::from_toml(toml).unwrap();
assert_eq!(
cfg.accounts["alice"].albums["root_abc123"],
"Preferred Name"
);
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert_eq!(eff.album_overrides["root_abc123"], "Preferred Name");
assert_eq!(eff.album_overrides["root_def456"], "Another Album");
assert!(!eff.album_overrides.contains_key("root_blank"));
}
#[test]
fn album_overrides_absent_by_default() {
let cfg = Config::from_toml("[accounts.alice]\ntoken = \"t\"\n").unwrap();
let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
assert!(eff.album_overrides.is_empty());
}
#[test]
fn areas_library_accepts_copy_and_mirror() {
for (raw, expect) in [
("copy", AreaMode::Mode(SourceMode::Copy)),
("mirror", AreaMode::Mode(SourceMode::Mirror)),
] {
let toml =
format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
let cfg = Config::from_toml(&toml).unwrap();
assert_eq!(
cfg.accounts["a"].areas.as_ref().unwrap().library,
Some(expect)
);
}
}
#[test]
fn areas_bad_mode_errors() {
let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
assert!(Config::from_toml(toml).is_err());
}
#[test]
fn areas_bad_playlist_mode_errors() {
let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
assert!(Config::from_toml(toml).is_err());
}
#[test]
fn areas_unknown_field_errors() {
let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
assert!(Config::from_toml(toml).is_err());
}
#[test]
fn areas_absent_is_none() {
let toml = "[accounts.a]\ntoken = \"t\"\n";
assert!(
Config::from_toml(toml).unwrap().accounts["a"]
.areas
.is_none()
);
}
}