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