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`]) 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 validation_nested_roots() {
475        let toml = r#"
476            [accounts.alice]
477            root = "/music"
478
479            [accounts.bob]
480            root = "/music/bob"
481        "#;
482        assert!(Config::from_toml(toml).is_err());
483    }
484
485    #[test]
486    fn validation_non_nested_roots_ok() {
487        let toml = r#"
488            [accounts.alice]
489            root = "/music/alice"
490
491            [accounts.bob]
492            root = "/music/bob"
493        "#;
494        assert!(Config::from_toml(toml).is_ok());
495    }
496
497    #[test]
498    fn invalid_toml_errors() {
499        assert!(Config::from_toml("not valid toml ][").is_err());
500    }
501
502    #[test]
503    fn duplicate_account_label_errors() {
504        // The TOML spec prohibits duplicate keys; the parser must reject this.
505        let toml = "
506            [accounts.alice]
507            token = \"tok1\"
508
509            [accounts.alice]
510            token = \"tok2\"
511        ";
512        assert!(Config::from_toml(toml).is_err());
513    }
514
515    #[test]
516    fn parse_error_does_not_echo_token() {
517        // A malformed token line must not include the raw value in the error.
518        let toml = "[accounts.alice]\ntoken = \"unterminated\n";
519        let err = Config::from_toml(toml).unwrap_err().to_string();
520        assert!(!err.contains("unterminated"), "error leaked token: {err}");
521    }
522
523    #[test]
524    fn validation_env_prefix_collision_errors() {
525        // 'my-lib' and 'my_lib' both map to SUNO_MY_LIB_* and must be rejected.
526        let toml = "
527            [accounts.my-lib]
528            [accounts.my_lib]
529        ";
530        assert!(Config::from_toml(toml).is_err());
531    }
532
533    #[test]
534    fn areas_parse_full_table() {
535        let toml = r#"
536            [accounts.alice]
537            token = "t"
538            [accounts.alice.areas]
539            library = "off"
540            liked = "copy"
541            playlists = "mirror"
542            [accounts.alice.areas.playlist]
543            "pl_abc123" = "mirror"
544            "pl_def456" = "copy"
545        "#;
546        let cfg = Config::from_toml(toml).unwrap();
547        let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
548        assert_eq!(areas.library, Some(AreaMode::Off));
549        assert_eq!(areas.liked, Some(SourceMode::Copy));
550        assert_eq!(areas.playlists, Some(SourceMode::Mirror));
551        assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
552        assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
553    }
554
555    #[test]
556    fn areas_library_accepts_copy_and_mirror() {
557        for (raw, expect) in [
558            ("copy", AreaMode::Mode(SourceMode::Copy)),
559            ("mirror", AreaMode::Mode(SourceMode::Mirror)),
560        ] {
561            let toml =
562                format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
563            let cfg = Config::from_toml(&toml).unwrap();
564            assert_eq!(
565                cfg.accounts["a"].areas.as_ref().unwrap().library,
566                Some(expect)
567            );
568        }
569    }
570
571    #[test]
572    fn areas_bad_mode_errors() {
573        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
574        assert!(Config::from_toml(toml).is_err());
575    }
576
577    #[test]
578    fn areas_bad_playlist_mode_errors() {
579        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
580        // `off` is a library-only value; a per-playlist entry must be copy/mirror.
581        assert!(Config::from_toml(toml).is_err());
582    }
583
584    #[test]
585    fn areas_unknown_field_errors() {
586        // D7: a mistyped key (libary) is a parse error, not a silent no-op.
587        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
588        assert!(Config::from_toml(toml).is_err());
589    }
590
591    #[test]
592    fn areas_absent_is_none() {
593        let toml = "[accounts.a]\ntoken = \"t\"\n";
594        assert!(
595            Config::from_toml(toml).unwrap().accounts["a"]
596                .areas
597                .is_none()
598        );
599    }
600
601    // --- cfg-6: unknown settings keys are rejected, not silently dropped. ---
602
603    #[test]
604    fn misspelled_min_newest_key_is_rejected() {
605        // The deletion-floor safety case: a typo must not silently revert
606        // `min_newest` to the default of 1. `#[serde(flatten)]` would drop it;
607        // the sweep names the offending key instead.
608        let err = Config::from_toml("[defaults]\nmin_newst = 50\n")
609            .unwrap_err()
610            .to_string();
611        assert!(
612            err.contains("min_newst"),
613            "error should name the key: {err}"
614        );
615        assert!(
616            err.contains("[defaults]"),
617            "error should name the tier: {err}"
618        );
619    }
620
621    #[test]
622    fn unknown_defaults_key_is_rejected() {
623        assert!(Config::from_toml("[defaults]\nbogus = true\n").is_err());
624    }
625
626    #[test]
627    fn unknown_account_key_is_rejected() {
628        let err = Config::from_toml("[accounts.alice]\nroout = \"/music\"\n")
629            .unwrap_err()
630            .to_string();
631        assert!(err.contains("roout"), "error should name the key: {err}");
632        assert!(
633            err.contains("[accounts.alice]"),
634            "error names the tier: {err}"
635        );
636    }
637
638    #[test]
639    fn unknown_source_key_is_rejected() {
640        let toml = "[accounts.alice.sources.liked]\nformta = \"mp3\"\n";
641        let err = Config::from_toml(toml).unwrap_err().to_string();
642        assert!(err.contains("formta"), "error should name the key: {err}");
643        assert!(
644            err.contains("[accounts.alice.sources.liked]"),
645            "error should name the source tier: {err}"
646        );
647    }
648
649    #[test]
650    fn unknown_top_level_table_is_rejected() {
651        // A misspelt `[defalts]` drops every default (including the deletion
652        // floor), so it too must fail loudly rather than parse to nothing.
653        let err = Config::from_toml("[defalts]\nmin_newest = 50\n")
654            .unwrap_err()
655            .to_string();
656        assert!(err.contains("defalts"), "error should name the key: {err}");
657    }
658
659    #[test]
660    fn every_settings_key_is_accepted() {
661        // Precision guard: no legitimate knob is rejected by the sweep.
662        let toml = r#"
663            [defaults]
664            format = "flac"
665            concurrency = 4
666            retries = 3
667            min_newest = 1
668            token_command = "echo tok"
669            animated_covers = true
670            video_cover_retention = "both"
671            animated_cover_quality = 90
672            animated_cover_max_fps = 24
673            animated_cover_max_width = 640
674            animated_cover_compression_level = 4
675            animated_cover_lossless = false
676            details_sidecar = true
677            lyrics_sidecar = true
678            lrc_sidecar = true
679            video_mp4 = true
680            download_stems = true
681            stem_format = "wav"
682            naming_template = "{title}"
683            character_set = "unicode"
684            number_singletons = false
685        "#;
686        assert!(Config::from_toml(toml).is_ok());
687    }
688
689    #[test]
690    fn known_structural_account_keys_are_accepted() {
691        let toml = r#"
692            [accounts.alice]
693            token = "t"
694            root = "/music/alice"
695            account_id = "user_abc"
696            format = "mp3"
697            lead_tracks = ["abc123"]
698            [accounts.alice.areas]
699            library = "off"
700            [accounts.alice.albums]
701            root_xyz = "Greatest Hits"
702            [accounts.alice.sources.liked]
703            format = "flac"
704        "#;
705        assert!(Config::from_toml(toml).is_ok());
706    }
707
708    #[cfg(feature = "schema")]
709    #[test]
710    fn settings_keys_mirror_the_struct() {
711        // The generated JSON schema enumerates every real `Settings` field, so
712        // it is the source of truth the hand-maintained `SETTINGS_KEYS` mirror
713        // must track. Guards both directions: a new knob added to the struct but
714        // forgotten here (which would wrongly reject a legitimate key), or a key
715        // left here after the field is removed.
716        let schema = serde_json::to_value(schemars::schema_for!(Settings)).unwrap();
717        let schema_keys: std::collections::BTreeSet<&str> = schema["properties"]
718            .as_object()
719            .expect("Settings schema exposes properties")
720            .keys()
721            .map(String::as_str)
722            .collect();
723        let known: std::collections::BTreeSet<&str> = SETTINGS_KEYS.iter().copied().collect();
724        assert_eq!(
725            schema_keys, known,
726            "SETTINGS_KEYS is out of sync with Settings"
727        );
728    }
729
730    // --- cfg-path: roots are lexically normalised before the nesting check. ---
731
732    #[test]
733    fn nested_roots_with_dot_prefix_and_bare_are_rejected() {
734        // `./music` and `music` are the same directory; the raw `starts_with`
735        // guard missed this, leaving a cross-account deletion-overlap hole.
736        let toml = "[accounts.alice]\nroot = \"./music\"\n\n[accounts.bob]\nroot = \"music\"\n";
737        assert!(Config::from_toml(toml).is_err());
738    }
739
740    #[test]
741    fn nested_roots_with_trailing_slash_are_rejected() {
742        let toml =
743            "[accounts.alice]\nroot = \"/music/\"\n\n[accounts.bob]\nroot = \"/music/alice\"\n";
744        assert!(Config::from_toml(toml).is_err());
745    }
746
747    #[test]
748    fn nested_roots_via_parent_segments_are_rejected() {
749        // `sub/../shared` resolves lexically to `shared`, nesting with `shared`.
750        let toml =
751            "[accounts.alice]\nroot = \"sub/../shared\"\n\n[accounts.bob]\nroot = \"shared\"\n";
752        assert!(Config::from_toml(toml).is_err());
753    }
754
755    #[test]
756    fn disjoint_relative_roots_are_accepted() {
757        let toml = "[accounts.alice]\nroot = \"./alice\"\n\n[accounts.bob]\nroot = \"bob\"\n";
758        assert!(Config::from_toml(toml).is_ok());
759    }
760
761    #[test]
762    fn case_only_differing_roots_are_rejected() {
763        // On a case-insensitive filesystem `Music` and `music` are one
764        // directory; the nesting guard must fold case so this pair cannot open
765        // a cross-account deletion-overlap hole.
766        let toml = "[accounts.alice]\nroot = \"/Music\"\n\n[accounts.bob]\nroot = \"/music\"\n";
767        assert!(Config::from_toml(toml).is_err());
768    }
769
770    #[test]
771    fn case_only_nested_roots_are_rejected() {
772        // The nested child differs from its parent only by case.
773        let toml = "[accounts.alice]\nroot = \"/Music\"\n\n[accounts.bob]\nroot = \"/music/bob\"\n";
774        assert!(Config::from_toml(toml).is_err());
775    }
776
777    #[test]
778    fn nfc_nfd_differing_roots_are_rejected() {
779        // "é" as NFC (U+00E9) vs NFD (e + U+0301) name one directory on a
780        // normalising filesystem, so they must be detected as nesting.
781        let toml = "[accounts.alice]\nroot = \"/\u{00e9}toile\"\n\n[accounts.bob]\nroot = \"/e\u{0301}toile\"\n";
782        assert!(Config::from_toml(toml).is_err());
783    }
784
785    #[test]
786    fn genuinely_distinct_roots_are_still_accepted() {
787        // Folding must not over-reject: names that differ by more than case
788        // stay disjoint.
789        let toml = "[accounts.alice]\nroot = \"/Music\"\n\n[accounts.bob]\nroot = \"/Videos\"\n";
790        assert!(Config::from_toml(toml).is_ok());
791    }
792
793    #[test]
794    fn normalise_root_folds_case_and_normalisation() {
795        // Case and NFC/NFD spellings of the same root collapse to one key, so
796        // the nesting comparison treats them as the same directory. `musicfoo`
797        // must not fold to a prefix of `music` — components stay whole.
798        assert_eq!(normalise_root("Foo/BAR"), normalise_root("foo/bar"));
799        assert_eq!(normalise_root("/\u{00e9}"), normalise_root("/e\u{0301}"));
800        assert_ne!(normalise_root("/music"), normalise_root("/musicfoo"));
801        assert!(!normalise_root("/musicfoo").starts_with(&normalise_root("/music")));
802    }
803
804    // --- cfg-7: an empty naming template is rejected; numeric zeros stay legal. ---
805
806    #[test]
807    fn empty_naming_template_is_rejected() {
808        assert!(Config::from_toml("[defaults]\nnaming_template = \"\"\n").is_err());
809    }
810
811    #[test]
812    fn whitespace_only_naming_template_is_rejected() {
813        assert!(Config::from_toml("[defaults]\nnaming_template = \"   \"\n").is_err());
814    }
815
816    #[test]
817    fn empty_naming_template_in_account_is_rejected() {
818        assert!(Config::from_toml("[accounts.alice]\nnaming_template = \"\"\n").is_err());
819    }
820
821    #[test]
822    fn empty_naming_template_in_source_is_rejected() {
823        let toml = "[accounts.alice.sources.liked]\nnaming_template = \"\"\n";
824        assert!(Config::from_toml(toml).is_err());
825    }
826
827    #[test]
828    fn non_empty_naming_template_is_accepted() {
829        let toml = "[defaults]\nnaming_template = \"{creator}/{title}\"\n";
830        assert!(Config::from_toml(toml).is_ok());
831    }
832
833    #[test]
834    fn min_newest_zero_stays_legal() {
835        // A deletion floor of 0 is a valid explicit opt-out for additive/copy
836        // runs; the empty-template guard must never reject a numeric zero.
837        let cfg = Config::from_toml("[defaults]\nmin_newest = 0\n").unwrap();
838        assert_eq!(cfg.defaults.settings.min_newest, Some(0));
839    }
840}