Skip to main content

suno_core/config/
shape.rs

1//! The TOML input shape and its parse-time validation.
2//!
3//! These are the deserialisation targets; [`Config`] is the top-level file,
4//! and [`Config::from_toml`] parses and validates it (no IO).
5
6use std::collections::HashMap;
7use std::path::{Component, Path, PathBuf};
8
9use serde::Deserialize;
10
11use crate::error::{Error, Result};
12use crate::naming::CharacterSet;
13use crate::vocab::{AudioFormat, SourceMode, StemFormat, VideoCoverRetention};
14
15use super::label_to_env;
16
17/// The overridable settings block, shared verbatim by every precedence tier.
18///
19/// A new knob is added here once and every tier that flattens it ([`Defaults`],
20/// [`AccountConfig`], [`SourceConfig`], and the CLI [`FlagOverrides`]) gains it,
21/// rather than being mirrored where forgetting one would silently drop it.
22#[derive(Debug, Clone, Default, Deserialize)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24pub struct Settings {
25    pub format: Option<AudioFormat>,
26    #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
27    pub concurrency: Option<u32>,
28    #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
29    pub retries: Option<u32>,
30    #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
31    pub min_newest: Option<u32>,
32    /// The command whose stdout mints a token. Resolved from the
33    /// `SUNO_[<LABEL>_]TOKEN_COMMAND` env tiers then the config keys. There is
34    /// deliberately no `--token-command` flag, so it is never read from
35    /// [`FlagOverrides`]; set it in config or the environment.
36    pub token_command: Option<String>,
37    pub animated_covers: Option<bool>,
38    pub video_cover_retention: Option<VideoCoverRetention>,
39    #[cfg_attr(feature = "schema", schemars(range(min = 0, max = 100)))]
40    pub animated_cover_quality: Option<u8>,
41    #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
42    pub animated_cover_max_fps: Option<u32>,
43    #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
44    pub animated_cover_max_width: Option<u32>,
45    #[cfg_attr(feature = "schema", schemars(range(min = 0, max = 4)))]
46    pub animated_cover_compression_level: Option<u8>,
47    pub animated_cover_lossless: Option<bool>,
48    pub details_sidecar: Option<bool>,
49    pub lyrics_sidecar: Option<bool>,
50    pub lrc_sidecar: Option<bool>,
51    pub video_mp4: Option<bool>,
52    pub download_stems: Option<bool>,
53    pub stem_format: Option<StemFormat>,
54    pub naming_template: Option<String>,
55    pub character_set: Option<CharacterSet>,
56    /// Whether a single-track (lone) lineage album is given a track number. When
57    /// unset it defaults to `true`; `false` leaves singletons unnumbered so a
58    /// `{track2}` prefix does not decorate a standalone song.
59    pub number_singletons: Option<bool>,
60}
61
62/// The TOML keys of every [`Settings`] field, in struct order.
63///
64/// Kept in lockstep with [`Settings`] above: a new knob must be added here too.
65/// `#[serde(flatten)]` embeds `Settings` into each precedence tier, which
66/// disables serde's own `deny_unknown_fields`, so a mistyped knob is otherwise
67/// silently dropped. [`reject_unknown_keys`] uses this set to reject typos the
68/// way `[areas]` already does. The `settings_keys_match_struct` test guards the
69/// mirror against drift.
70const SETTINGS_KEYS: &[&str] = &[
71    "format",
72    "concurrency",
73    "retries",
74    "min_newest",
75    "token_command",
76    "animated_covers",
77    "video_cover_retention",
78    "animated_cover_quality",
79    "animated_cover_max_fps",
80    "animated_cover_max_width",
81    "animated_cover_compression_level",
82    "animated_cover_lossless",
83    "details_sidecar",
84    "lyrics_sidecar",
85    "lrc_sidecar",
86    "video_mp4",
87    "download_stems",
88    "stem_format",
89    "naming_template",
90    "character_set",
91    "number_singletons",
92];
93
94/// The structural (non-[`Settings`]) keys of an `[accounts.<label>]` table.
95const ACCOUNT_STRUCTURAL_KEYS: &[&str] = &[
96    "token",
97    "root",
98    "account_id",
99    "sources",
100    "areas",
101    "albums",
102    "lead_tracks",
103];
104
105/// The keys accepted at the top level of the config document.
106const TOP_LEVEL_KEYS: &[&str] = &["defaults", "accounts"];
107
108/// Global default settings applied when no account or source override applies.
109#[derive(Debug, Clone, Default, Deserialize)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111pub struct Defaults {
112    #[serde(flatten)]
113    pub settings: Settings,
114}
115
116/// Per-source overridable settings within an account.
117#[derive(Debug, Clone, Default, Deserialize)]
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119pub struct SourceConfig {
120    #[serde(flatten)]
121    pub settings: Settings,
122}
123
124/// Configuration for a single named account.
125#[derive(Debug, Clone, Default, Deserialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub struct AccountConfig {
128    pub token: Option<String>,
129    pub root: Option<String>,
130    /// Optional Suno user id to assert this account authenticates as, refusing
131    /// to run on a mismatch (a belt-and-braces check alongside the on-disk
132    /// owner pin in the lineage store).
133    pub account_id: Option<String>,
134    #[serde(flatten)]
135    pub settings: Settings,
136    #[serde(default)]
137    pub sources: HashMap<String, SourceConfig>,
138    /// Per-area mode selection (`sync` vs `copy`) for this account's library,
139    /// liked feed, and playlists. Absent means the classic single-verb run.
140    pub areas: Option<AreasConfig>,
141    /// Manual album-name overrides, keyed by the album's stable lineage root id
142    /// (`<root_id> = "Preferred Name"`). Account-wide, never per-source, since
143    /// album identity is the lineage root. An empty or whitespace-only value is
144    /// ignored, so a stray key cannot blank an album.
145    #[serde(default)]
146    pub albums: HashMap<String, String>,
147    /// Clip ids (or unique id prefixes, e.g. the 8-char code from a filename)
148    /// flagged as their lineage album's lead: each is promoted to track 1,
149    /// shifting the rest down. Account-wide; a clip's album is inferred from its
150    /// resolved root, so the album is never named here.
151    #[serde(default)]
152    pub lead_tracks: Vec<String>,
153}
154
155/// How a single area treats deletion, including the library-only `off` value.
156///
157/// `off` is expressible only for the library area: it deliberately arms deletion
158/// of library-exclusive files by suppressing the implicit copy-protector, so a
159/// typo can never silently disarm that safety. `copy` and `mirror` map straight
160/// onto the matching [`SourceMode`].
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum AreaMode {
163    /// Suppress the implicit library copy-protector (arm library deletions).
164    Off,
165    /// Treat the area with the given [`SourceMode`].
166    Mode(SourceMode),
167}
168
169impl<'de> Deserialize<'de> for AreaMode {
170    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
171    where
172        D: serde::Deserializer<'de>,
173    {
174        let raw = String::deserialize(deserializer)?;
175        match raw.as_str() {
176            "off" => Ok(AreaMode::Off),
177            "copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
178            "mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
179            other => Err(serde::de::Error::custom(format!(
180                "unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
181            ))),
182        }
183    }
184}
185
186#[cfg(feature = "schema")]
187impl schemars::JsonSchema for AreaMode {
188    fn schema_name() -> std::borrow::Cow<'static, str> {
189        "AreaMode".into()
190    }
191
192    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
193        schemars::json_schema!({
194            "type": "string",
195            "enum": ["off", "copy", "mirror"],
196            "description": "Deletion mode for the library area: 'off' arms deletion of \
197                library-exclusive files, 'copy' is additive, 'mirror' deletes.",
198        })
199    }
200}
201
202/// Per-area mode selection for an account.
203///
204/// `library` accepts `off`/`copy`/`mirror`; `liked` and `playlists` accept
205/// `copy`/`mirror`; `playlist` overrides individual playlists by Suno id.
206/// `deny_unknown_fields` turns a mistyped key into a parse error rather than a
207/// silent no-op; the `playlist` map's dynamic ids can't use it, but its closed
208/// [`SourceMode`] values still reject a bad mode at parse time.
209#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
210#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
211#[serde(deny_unknown_fields)]
212pub struct AreasConfig {
213    pub library: Option<AreaMode>,
214    pub liked: Option<SourceMode>,
215    pub playlists: Option<SourceMode>,
216    #[serde(default)]
217    pub playlist: HashMap<String, SourceMode>,
218}
219
220/// Top-level configuration parsed from a TOML file.
221#[derive(Debug, Clone, Default, Deserialize)]
222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223pub struct Config {
224    #[serde(default)]
225    pub defaults: Defaults,
226    #[serde(default)]
227    pub accounts: HashMap<String, AccountConfig>,
228}
229
230impl Config {
231    /// Parse `toml_str` and validate the result.
232    ///
233    /// Validation rejects any pair of accounts whose root directories nest
234    /// inside one another. Duplicate account labels are rejected by the TOML
235    /// parser itself.
236    pub fn from_toml(toml_str: &str) -> Result<Self> {
237        let config: Self = toml::from_str(toml_str).map_err(redact_toml_error)?;
238        reject_unknown_keys(toml_str)?;
239        config.validate()?;
240        Ok(config)
241    }
242
243    fn validate(&self) -> Result<()> {
244        let roots: Vec<(&str, &str)> = self
245            .accounts
246            .iter()
247            .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
248            .collect();
249
250        for (i, (label_a, root_a)) in roots.iter().enumerate() {
251            for (label_b, root_b) in roots.iter().skip(i + 1) {
252                // Compare lexically normalised roots so `./music` and `music`
253                // (the same directory) are not treated as disjoint, which would
254                // punch a cross-account deletion-overlap hole through the guard.
255                let a = normalise_root(root_a);
256                let b = normalise_root(root_b);
257                if a.starts_with(&b) || b.starts_with(&a) {
258                    return Err(Error::Config(format!(
259                        "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
260                    )));
261                }
262            }
263        }
264
265        // Reject an empty or whitespace-only naming template at any tier:
266        // resolution would otherwise carry the empty string through rather than
267        // falling back to the default, yielding blank path components. A numeric
268        // zero (e.g. `min_newest = 0`) stays legal; this guards only the string
269        // template.
270        let templates = std::iter::once(&self.defaults.settings).chain(
271            self.accounts.values().flat_map(|acc| {
272                std::iter::once(&acc.settings).chain(acc.sources.values().map(|s| &s.settings))
273            }),
274        );
275        for settings in templates {
276            if let Some(template) = settings.naming_template.as_deref()
277                && template.trim().is_empty()
278            {
279                return Err(Error::Config(
280                    "naming_template must not be empty or whitespace-only".into(),
281                ));
282            }
283        }
284
285        let mut prefix_seen: HashMap<String, &str> = HashMap::new();
286        for label in self.accounts.keys() {
287            let prefix = label_to_env(label);
288            if let Some(other) = prefix_seen.get(&prefix) {
289                return Err(Error::Config(format!(
290                    "accounts '{label}' and '{other}' share env prefix '{prefix}'"
291                )));
292            }
293            prefix_seen.insert(prefix, label.as_str());
294        }
295
296        Ok(())
297    }
298}
299
300/// Convert a `toml` parse error into an [`Error::Config`], stripping the
301/// source-context lines (those containing `" | "`) so a token value on the
302/// offending line is never echoed back.
303fn redact_toml_error(e: toml::de::Error) -> Error {
304    let msg = e
305        .to_string()
306        .lines()
307        .filter(|l| !l.contains(" | "))
308        .collect::<Vec<_>>()
309        .join("\n")
310        .trim()
311        .to_owned();
312    Error::Config(if msg.is_empty() {
313        "parse error".into()
314    } else {
315        msg
316    })
317}
318
319/// Reject any key not recognised at the top level or at a settings tier.
320///
321/// `#[serde(flatten)]` embeds [`Settings`] into [`Defaults`], [`AccountConfig`],
322/// and [`SourceConfig`], which silently disables serde's `deny_unknown_fields`,
323/// so a mistyped knob (e.g. `min_newst`) would be dropped and its setting revert
324/// to the compiled default. That is unsafe for the deletion floor (`min_newest`)
325/// and misleading for every other knob, so re-parse the document generically and
326/// fail loudly, mirroring the hard error `[areas]` already gives. `[areas]`,
327/// `[...albums]`, and `lead_tracks` carry dynamic keys and are left to their own
328/// typed validation.
329fn reject_unknown_keys(toml_str: &str) -> Result<()> {
330    let doc: toml::Table = toml::from_str(toml_str).map_err(redact_toml_error)?;
331
332    check_known_keys(&doc, "the top-level table", |k| TOP_LEVEL_KEYS.contains(&k))?;
333
334    if let Some(defaults) = doc.get("defaults").and_then(toml::Value::as_table) {
335        check_known_keys(defaults, "[defaults]", |k| SETTINGS_KEYS.contains(&k))?;
336    }
337
338    if let Some(accounts) = doc.get("accounts").and_then(toml::Value::as_table) {
339        for (label, account) in accounts {
340            let Some(account) = account.as_table() else {
341                continue;
342            };
343            let section = format!("[accounts.{label}]");
344            check_known_keys(account, &section, |k| {
345                ACCOUNT_STRUCTURAL_KEYS.contains(&k) || SETTINGS_KEYS.contains(&k)
346            })?;
347
348            if let Some(sources) = account.get("sources").and_then(toml::Value::as_table) {
349                for (name, source) in sources {
350                    let Some(source) = source.as_table() else {
351                        continue;
352                    };
353                    let section = format!("[accounts.{label}.sources.{name}]");
354                    check_known_keys(source, &section, |k| SETTINGS_KEYS.contains(&k))?;
355                }
356            }
357        }
358    }
359
360    Ok(())
361}
362
363/// Fail with a clear [`Error::Config`] naming the first key in `table` the
364/// `allowed` predicate rejects, reported against `section`.
365fn check_known_keys(
366    table: &toml::Table,
367    section: &str,
368    allowed: impl Fn(&str) -> bool,
369) -> Result<()> {
370    for key in table.keys() {
371        if !allowed(key.as_str()) {
372            return Err(Error::Config(format!("unknown key '{key}' in {section}")));
373        }
374    }
375    Ok(())
376}
377
378/// Lexically normalise a configured root for the nesting comparison: strip a
379/// leading `./`, collapse repeated separators, drop a trailing separator, and
380/// resolve `.`/`..` segments purely textually. This never touches the
381/// filesystem (suno-core is IO-free), so it neither follows symlinks nor folds
382/// case. Case-insensitive-filesystem folding (Windows, macOS), where `Music`
383/// and `music` are the same directory, is a genuine FS concern and is
384/// deliberately left as a follow-up.
385fn normalise_root(root: &str) -> PathBuf {
386    let mut out = PathBuf::new();
387    for component in Path::new(root).components() {
388        match component {
389            Component::CurDir => {}
390            Component::ParentDir => match out.components().next_back() {
391                Some(Component::Normal(_)) => {
392                    out.pop();
393                }
394                // Cannot ascend above a root or drive prefix; drop the `..`.
395                Some(Component::RootDir | Component::Prefix(_)) => {}
396                // A leading `..` on a relative path is preserved verbatim.
397                _ => out.push(component.as_os_str()),
398            },
399            other => out.push(other.as_os_str()),
400        }
401    }
402    out
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn parse_empty_toml() {
411        let cfg = Config::from_toml("").unwrap();
412        assert!(cfg.accounts.is_empty());
413    }
414
415    #[test]
416    fn parse_basic_account() {
417        let toml = r#"
418            [accounts.alice]
419            token = "tok"
420            root = "/music"
421        "#;
422        let cfg = Config::from_toml(toml).unwrap();
423        let acc = &cfg.accounts["alice"];
424        assert_eq!(acc.token.as_deref(), Some("tok"));
425        assert_eq!(acc.root.as_deref(), Some("/music"));
426    }
427
428    #[test]
429    fn parse_defaults_section() {
430        let toml = r#"
431            [defaults]
432            format = "mp3"
433            concurrency = 8
434            retries = 5
435            min_newest = 2
436            animated_covers = true
437            video_cover_retention = "both"
438            animated_cover_quality = 85
439            animated_cover_max_fps = 18
440            animated_cover_max_width = 720
441            animated_cover_compression_level = 4
442        "#;
443        let cfg = Config::from_toml(toml).unwrap();
444        assert_eq!(cfg.defaults.settings.format, Some(AudioFormat::Mp3));
445        assert_eq!(cfg.defaults.settings.concurrency, Some(8));
446        assert_eq!(cfg.defaults.settings.retries, Some(5));
447        assert_eq!(cfg.defaults.settings.min_newest, Some(2));
448        assert_eq!(cfg.defaults.settings.animated_covers, Some(true));
449        assert_eq!(
450            cfg.defaults.settings.video_cover_retention,
451            Some(VideoCoverRetention::Both)
452        );
453        assert_eq!(cfg.defaults.settings.animated_cover_quality, Some(85));
454        assert_eq!(cfg.defaults.settings.animated_cover_max_fps, Some(18));
455        assert_eq!(cfg.defaults.settings.animated_cover_max_width, Some(720));
456        assert_eq!(
457            cfg.defaults.settings.animated_cover_compression_level,
458            Some(4)
459        );
460    }
461
462    #[test]
463    fn validation_nested_roots() {
464        let toml = r#"
465            [accounts.alice]
466            root = "/music"
467
468            [accounts.bob]
469            root = "/music/bob"
470        "#;
471        assert!(Config::from_toml(toml).is_err());
472    }
473
474    #[test]
475    fn validation_non_nested_roots_ok() {
476        let toml = r#"
477            [accounts.alice]
478            root = "/music/alice"
479
480            [accounts.bob]
481            root = "/music/bob"
482        "#;
483        assert!(Config::from_toml(toml).is_ok());
484    }
485
486    #[test]
487    fn invalid_toml_errors() {
488        assert!(Config::from_toml("not valid toml ][").is_err());
489    }
490
491    #[test]
492    fn duplicate_account_label_errors() {
493        // The TOML spec prohibits duplicate keys; the parser must reject this.
494        let toml = "
495            [accounts.alice]
496            token = \"tok1\"
497
498            [accounts.alice]
499            token = \"tok2\"
500        ";
501        assert!(Config::from_toml(toml).is_err());
502    }
503
504    #[test]
505    fn parse_error_does_not_echo_token() {
506        // A malformed token line must not include the raw value in the error.
507        let toml = "[accounts.alice]\ntoken = \"unterminated\n";
508        let err = Config::from_toml(toml).unwrap_err().to_string();
509        assert!(!err.contains("unterminated"), "error leaked token: {err}");
510    }
511
512    #[test]
513    fn validation_env_prefix_collision_errors() {
514        // 'my-lib' and 'my_lib' both map to SUNO_MY_LIB_* and must be rejected.
515        let toml = "
516            [accounts.my-lib]
517            [accounts.my_lib]
518        ";
519        assert!(Config::from_toml(toml).is_err());
520    }
521
522    #[test]
523    fn areas_parse_full_table() {
524        let toml = r#"
525            [accounts.alice]
526            token = "t"
527            [accounts.alice.areas]
528            library = "off"
529            liked = "copy"
530            playlists = "mirror"
531            [accounts.alice.areas.playlist]
532            "pl_abc123" = "mirror"
533            "pl_def456" = "copy"
534        "#;
535        let cfg = Config::from_toml(toml).unwrap();
536        let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
537        assert_eq!(areas.library, Some(AreaMode::Off));
538        assert_eq!(areas.liked, Some(SourceMode::Copy));
539        assert_eq!(areas.playlists, Some(SourceMode::Mirror));
540        assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
541        assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
542    }
543
544    #[test]
545    fn areas_library_accepts_copy_and_mirror() {
546        for (raw, expect) in [
547            ("copy", AreaMode::Mode(SourceMode::Copy)),
548            ("mirror", AreaMode::Mode(SourceMode::Mirror)),
549        ] {
550            let toml =
551                format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
552            let cfg = Config::from_toml(&toml).unwrap();
553            assert_eq!(
554                cfg.accounts["a"].areas.as_ref().unwrap().library,
555                Some(expect)
556            );
557        }
558    }
559
560    #[test]
561    fn areas_bad_mode_errors() {
562        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
563        assert!(Config::from_toml(toml).is_err());
564    }
565
566    #[test]
567    fn areas_bad_playlist_mode_errors() {
568        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
569        // `off` is a library-only value; a per-playlist entry must be copy/mirror.
570        assert!(Config::from_toml(toml).is_err());
571    }
572
573    #[test]
574    fn areas_unknown_field_errors() {
575        // D7: a mistyped key (libary) is a parse error, not a silent no-op.
576        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
577        assert!(Config::from_toml(toml).is_err());
578    }
579
580    #[test]
581    fn areas_absent_is_none() {
582        let toml = "[accounts.a]\ntoken = \"t\"\n";
583        assert!(
584            Config::from_toml(toml).unwrap().accounts["a"]
585                .areas
586                .is_none()
587        );
588    }
589
590    // --- cfg-6: unknown settings keys are rejected, not silently dropped. ---
591
592    #[test]
593    fn misspelled_min_newest_key_is_rejected() {
594        // The deletion-floor safety case: a typo must not silently revert
595        // `min_newest` to the default of 1. `#[serde(flatten)]` would drop it;
596        // the sweep names the offending key instead.
597        let err = Config::from_toml("[defaults]\nmin_newst = 50\n")
598            .unwrap_err()
599            .to_string();
600        assert!(
601            err.contains("min_newst"),
602            "error should name the key: {err}"
603        );
604        assert!(
605            err.contains("[defaults]"),
606            "error should name the tier: {err}"
607        );
608    }
609
610    #[test]
611    fn unknown_defaults_key_is_rejected() {
612        assert!(Config::from_toml("[defaults]\nbogus = true\n").is_err());
613    }
614
615    #[test]
616    fn unknown_account_key_is_rejected() {
617        let err = Config::from_toml("[accounts.alice]\nroout = \"/music\"\n")
618            .unwrap_err()
619            .to_string();
620        assert!(err.contains("roout"), "error should name the key: {err}");
621        assert!(
622            err.contains("[accounts.alice]"),
623            "error names the tier: {err}"
624        );
625    }
626
627    #[test]
628    fn unknown_source_key_is_rejected() {
629        let toml = "[accounts.alice.sources.liked]\nformta = \"mp3\"\n";
630        let err = Config::from_toml(toml).unwrap_err().to_string();
631        assert!(err.contains("formta"), "error should name the key: {err}");
632        assert!(
633            err.contains("[accounts.alice.sources.liked]"),
634            "error should name the source tier: {err}"
635        );
636    }
637
638    #[test]
639    fn unknown_top_level_table_is_rejected() {
640        // A misspelt `[defalts]` drops every default (including the deletion
641        // floor), so it too must fail loudly rather than parse to nothing.
642        let err = Config::from_toml("[defalts]\nmin_newest = 50\n")
643            .unwrap_err()
644            .to_string();
645        assert!(err.contains("defalts"), "error should name the key: {err}");
646    }
647
648    #[test]
649    fn every_settings_key_is_accepted() {
650        // Precision guard: no legitimate knob is rejected by the sweep.
651        let toml = r#"
652            [defaults]
653            format = "flac"
654            concurrency = 4
655            retries = 3
656            min_newest = 1
657            token_command = "echo tok"
658            animated_covers = true
659            video_cover_retention = "both"
660            animated_cover_quality = 90
661            animated_cover_max_fps = 24
662            animated_cover_max_width = 640
663            animated_cover_compression_level = 4
664            animated_cover_lossless = false
665            details_sidecar = true
666            lyrics_sidecar = true
667            lrc_sidecar = true
668            video_mp4 = true
669            download_stems = true
670            stem_format = "wav"
671            naming_template = "{title}"
672            character_set = "unicode"
673            number_singletons = false
674        "#;
675        assert!(Config::from_toml(toml).is_ok());
676    }
677
678    #[test]
679    fn known_structural_account_keys_are_accepted() {
680        let toml = r#"
681            [accounts.alice]
682            token = "t"
683            root = "/music/alice"
684            account_id = "user_abc"
685            format = "mp3"
686            lead_tracks = ["abc123"]
687            [accounts.alice.areas]
688            library = "off"
689            [accounts.alice.albums]
690            root_xyz = "Greatest Hits"
691            [accounts.alice.sources.liked]
692            format = "flac"
693        "#;
694        assert!(Config::from_toml(toml).is_ok());
695    }
696
697    #[cfg(feature = "schema")]
698    #[test]
699    fn settings_keys_mirror_the_struct() {
700        // The generated JSON schema enumerates every real `Settings` field, so
701        // it is the source of truth the hand-maintained `SETTINGS_KEYS` mirror
702        // must track. Guards both directions: a new knob added to the struct but
703        // forgotten here (which would wrongly reject a legitimate key), or a key
704        // left here after the field is removed.
705        let schema = serde_json::to_value(schemars::schema_for!(Settings)).unwrap();
706        let schema_keys: std::collections::BTreeSet<&str> = schema["properties"]
707            .as_object()
708            .expect("Settings schema exposes properties")
709            .keys()
710            .map(String::as_str)
711            .collect();
712        let known: std::collections::BTreeSet<&str> = SETTINGS_KEYS.iter().copied().collect();
713        assert_eq!(
714            schema_keys, known,
715            "SETTINGS_KEYS is out of sync with Settings"
716        );
717    }
718
719    // --- cfg-path: roots are lexically normalised before the nesting check. ---
720
721    #[test]
722    fn nested_roots_with_dot_prefix_and_bare_are_rejected() {
723        // `./music` and `music` are the same directory; the raw `starts_with`
724        // guard missed this, leaving a cross-account deletion-overlap hole.
725        let toml = "[accounts.alice]\nroot = \"./music\"\n\n[accounts.bob]\nroot = \"music\"\n";
726        assert!(Config::from_toml(toml).is_err());
727    }
728
729    #[test]
730    fn nested_roots_with_trailing_slash_are_rejected() {
731        let toml =
732            "[accounts.alice]\nroot = \"/music/\"\n\n[accounts.bob]\nroot = \"/music/alice\"\n";
733        assert!(Config::from_toml(toml).is_err());
734    }
735
736    #[test]
737    fn nested_roots_via_parent_segments_are_rejected() {
738        // `sub/../shared` resolves lexically to `shared`, nesting with `shared`.
739        let toml =
740            "[accounts.alice]\nroot = \"sub/../shared\"\n\n[accounts.bob]\nroot = \"shared\"\n";
741        assert!(Config::from_toml(toml).is_err());
742    }
743
744    #[test]
745    fn disjoint_relative_roots_are_accepted() {
746        let toml = "[accounts.alice]\nroot = \"./alice\"\n\n[accounts.bob]\nroot = \"bob\"\n";
747        assert!(Config::from_toml(toml).is_ok());
748    }
749
750    // --- cfg-7: an empty naming template is rejected; numeric zeros stay legal. ---
751
752    #[test]
753    fn empty_naming_template_is_rejected() {
754        assert!(Config::from_toml("[defaults]\nnaming_template = \"\"\n").is_err());
755    }
756
757    #[test]
758    fn whitespace_only_naming_template_is_rejected() {
759        assert!(Config::from_toml("[defaults]\nnaming_template = \"   \"\n").is_err());
760    }
761
762    #[test]
763    fn empty_naming_template_in_account_is_rejected() {
764        assert!(Config::from_toml("[accounts.alice]\nnaming_template = \"\"\n").is_err());
765    }
766
767    #[test]
768    fn empty_naming_template_in_source_is_rejected() {
769        let toml = "[accounts.alice.sources.liked]\nnaming_template = \"\"\n";
770        assert!(Config::from_toml(toml).is_err());
771    }
772
773    #[test]
774    fn non_empty_naming_template_is_accepted() {
775        let toml = "[defaults]\nnaming_template = \"{creator}/{title}\"\n";
776        assert!(Config::from_toml(toml).is_ok());
777    }
778
779    #[test]
780    fn min_newest_zero_stays_legal() {
781        // A deletion floor of 0 is a valid explicit opt-out for additive/copy
782        // runs; the empty-template guard must never reject a numeric zero.
783        let cfg = Config::from_toml("[defaults]\nmin_newest = 0\n").unwrap();
784        assert_eq!(cfg.defaults.settings.min_newest, Some(0));
785    }
786}