Skip to main content

docgen_config/
lib.rs

1//! Parses an optional `docgen.toml`. When absent, `SiteConfig::default()`
2//! reproduces docgen's pre-P6 hard-coded behaviour exactly, so a project with
3//! no config builds identically to before.
4
5use std::collections::BTreeMap;
6use std::path::Path;
7
8use serde::Deserialize;
9
10/// Feature toggles. All default `true` — the pre-P6 behaviour (every feature on).
11#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
12#[serde(default)]
13pub struct Features {
14    /// Emit the `/graph/` page + its island.
15    pub graph: bool,
16    /// Render math (build-time KaTeX) + link its stylesheet.
17    pub math: bool,
18    /// Allow mermaid diagrams + lazy island.
19    pub mermaid: bool,
20    /// Render PlantUML diagrams (`:::plantuml`) at build time via an external
21    /// server. Inert (zero server contact) unless a diagram is actually present.
22    pub plantuml: bool,
23    /// Render Obsidian Bases: `.base` files become pages, and ` ```base ` fenced
24    /// blocks in markdown render inline. Inert unless a base is present.
25    pub bases: bool,
26    /// Emit the search index + search client.
27    pub search: bool,
28    /// Emit the `/diff/` git-history timeline workspace + its client assets
29    /// (`diff.css`, `islands/diff.js`). When `false`, none of `dist/diff/` is
30    /// written and the diff assets are excluded — a smaller build. Inert (no
31    /// diff output) outside a git repo regardless.
32    pub diff: bool,
33}
34
35impl Default for Features {
36    fn default() -> Self {
37        Self {
38            graph: true,
39            math: true,
40            mermaid: true,
41            plantuml: true,
42            bases: true,
43            search: true,
44            diff: true,
45        }
46    }
47}
48
49/// `[components]` section.
50#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
51#[serde(default)]
52pub struct ComponentsConfig {
53    /// Project-relative directory holding `<name>/template.html` components.
54    pub dir: String,
55}
56
57impl Default for ComponentsConfig {
58    fn default() -> Self {
59        Self {
60            dir: "components".to_string(),
61        }
62    }
63}
64
65/// `[plantuml]` section — settings for build-time PlantUML rendering. Absent =
66/// all defaults (server `http://localhost:8080`). The server URL is also
67/// overridable by the `DOCGEN_PLANTUML_SERVER` env var (which wins over this).
68#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
69#[serde(default)]
70pub struct PlantumlConfig {
71    /// Base URL of the PlantUML server (SVG endpoint is `{server}/svg/{encoded}`).
72    pub server: String,
73}
74
75impl Default for PlantumlConfig {
76    fn default() -> Self {
77        Self {
78            server: DEFAULT_PLANTUML_SERVER.to_string(),
79        }
80    }
81}
82
83/// The default PlantUML server URL — matches the port `docgen plantuml` binds and
84/// the `plantuml/plantuml-server:jetty` image's root SVG context.
85pub const DEFAULT_PLANTUML_SERVER: &str = "http://localhost:8080";
86
87/// `[s3]` section — optional S3-compatible asset offload. Absent = feature off.
88/// Non-secret settings only; credentials come from the environment.
89#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
90pub struct S3Config {
91    /// Target bucket name.
92    pub bucket: String,
93    /// Region string (e.g. `us-east-1`; use `auto` / any value for R2).
94    pub region: String,
95    /// Custom endpoint for non-AWS S3-compatible services. Omit for AWS.
96    #[serde(default)]
97    pub endpoint: Option<String>,
98    /// Optional key prefix within the bucket (e.g. `docs-assets`).
99    #[serde(default)]
100    pub prefix: Option<String>,
101    /// Base URL that goes into the generated HTML (bucket website or CDN in front).
102    pub public_url: String,
103    /// Path-style addressing (required by MinIO and some S3-compatibles).
104    #[serde(default)]
105    pub path_style: bool,
106}
107
108/// `[lint]` section — settings for `docgen lint`. Absent = all defaults
109/// (nothing ignored, built-in rule severities, syntax checks off).
110#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
111#[serde(default)]
112pub struct LintConfig {
113    /// Globs (relative to `docs/`) of files the linter skips entirely.
114    pub ignore: Vec<String>,
115    /// Per-rule severity overrides: rule-id -> `"error"`/`"warn"`/`"info"`/
116    /// `"allow"`. Stored as raw strings; docgen-lint validates them.
117    pub rules: BTreeMap<String, String>,
118    /// `[lint.plantuml]` — PlantUML-specific lint settings.
119    pub plantuml: LintPlantumlConfig,
120    /// `[lint.mermaid]` — mermaid-specific lint settings.
121    pub mermaid: LintMermaidConfig,
122    /// `[lint.external-urls]` — external-URL check settings.
123    #[serde(rename = "external-urls")]
124    pub external_urls: LintExternalUrlsConfig,
125}
126
127/// `[lint.plantuml]` section.
128#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
129#[serde(default)]
130pub struct LintPlantumlConfig {
131    /// Validate PlantUML diagram syntax (requires a PlantUML server).
132    #[serde(rename = "check-syntax")]
133    pub check_syntax: bool,
134}
135
136/// `[lint.mermaid]` section.
137#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
138#[serde(default)]
139pub struct LintMermaidConfig {
140    /// Validate mermaid diagram syntax (requires the `mmdc` CLI).
141    #[serde(rename = "check-syntax")]
142    pub check_syntax: bool,
143    /// Path to / name of the mermaid CLI binary.
144    pub mmdc: String,
145}
146
147impl Default for LintMermaidConfig {
148    fn default() -> Self {
149        Self {
150            check_syntax: false,
151            mmdc: "mmdc".to_string(),
152        }
153    }
154}
155
156/// `[lint.external-urls]` section.
157#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
158#[serde(default)]
159pub struct LintExternalUrlsConfig {
160    /// Per-request timeout for external-URL checks, in seconds.
161    #[serde(rename = "timeout-secs")]
162    pub timeout_secs: u64,
163    /// URL patterns excluded from external-URL checking.
164    pub exclude: Vec<String>,
165}
166
167impl Default for LintExternalUrlsConfig {
168    fn default() -> Self {
169        Self {
170            timeout_secs: 10,
171            exclude: Vec::new(),
172        }
173    }
174}
175
176/// The whole resolved site config. `Default` == pre-P6 behaviour.
177#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
178#[serde(default)]
179pub struct SiteConfig {
180    /// Optional site title; when set, page `<title>` becomes `"{page} — {title}"`
181    /// (home page uses just `title`). When `None`, per-page titles are unchanged.
182    pub title: Option<String>,
183    /// Base path for the deployed site (e.g. `/docs`). Empty = served at root
184    /// (unchanged behaviour). Prefixed onto every emitted asset/nav/wikilink URL
185    /// so a sub-path deployment resolves correctly (no `<base>` tag is used —
186    /// `<base>` only affects relative URLs, but our links are root-absolute).
187    pub base: String,
188    pub features: Features,
189    pub components: ComponentsConfig,
190    pub plantuml: PlantumlConfig,
191    /// Optional S3 asset offload. `None` = disabled (local copy).
192    pub s3: Option<S3Config>,
193    /// `[lint]` — settings for `docgen lint` (ignore globs, per-rule severity
194    /// overrides, external-check toggles). All defaults when absent.
195    pub lint: LintConfig,
196}
197
198/// Resolve the effective PlantUML server URL, applying precedence (first match
199/// wins): `DOCGEN_PLANTUML_SERVER` env var → `docgen.toml` `[plantuml] server`
200/// → [`DEFAULT_PLANTUML_SERVER`]. A present-but-empty env var is ignored (falls
201/// through to config). The returned URL has any trailing slash trimmed.
202pub fn resolve_plantuml_server(config_server: &str) -> String {
203    resolve_plantuml_server_from(config_server, std::env::var("DOCGEN_PLANTUML_SERVER").ok())
204}
205
206/// Pure core of [`resolve_plantuml_server`] — env value passed in so precedence
207/// is testable without mutating process-global environment.
208fn resolve_plantuml_server_from(config_server: &str, env: Option<String>) -> String {
209    let chosen = match env {
210        Some(v) if !v.trim().is_empty() => v,
211        _ if !config_server.trim().is_empty() => config_server.to_string(),
212        _ => DEFAULT_PLANTUML_SERVER.to_string(),
213    };
214    chosen.trim().trim_end_matches('/').to_string()
215}
216
217#[derive(Debug, thiserror::Error)]
218pub enum ConfigError {
219    #[error("reading {path}: {source}")]
220    Io {
221        path: String,
222        #[source]
223        source: std::io::Error,
224    },
225    #[error("parsing {path}: {source}")]
226    Parse {
227        path: String,
228        #[source]
229        source: toml::de::Error,
230    },
231}
232
233/// Load `docgen.toml` from `project_root`. Missing file → `SiteConfig::default()`
234/// (not an error). Present-but-malformed → `Err`.
235pub fn load(project_root: &Path) -> Result<SiteConfig, ConfigError> {
236    let path = project_root.join("docgen.toml");
237    let text = match std::fs::read_to_string(&path) {
238        Ok(t) => t,
239        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SiteConfig::default()),
240        Err(e) => {
241            return Err(ConfigError::Io {
242                path: path.display().to_string(),
243                source: e,
244            })
245        }
246    };
247    toml::from_str(&text).map_err(|e| ConfigError::Parse {
248        path: path.display().to_string(),
249        source: e,
250    })
251}
252
253/// Normalize a configured/derived `base` into a leading-slash, no-trailing-slash
254/// form: `""`/`"/"` -> `""`, `"docs"`/`"/docs/"`/`"docs/"` -> `"/docs"`,
255/// `"/group/project/"` -> `"/group/project"`. Interior slashes are preserved so
256/// multi-segment sub-paths (GitLab's `namespace/project`) round-trip correctly.
257pub fn normalize_base(base: &str) -> String {
258    let trimmed = base.trim().trim_matches('/');
259    if trimmed.is_empty() {
260        String::new()
261    } else {
262        format!("/{trimmed}")
263    }
264}
265
266/// Extract the path component of an absolute URL without pulling in a URL parser.
267/// `https://ns.gitlab.io/proj` -> `/proj`; `https://host/a/b` -> `/a/b`;
268/// `https://ns.gitlab.io` (no path) -> `""`. This is what makes GitLab's subdomain
269/// Pages layout (`ns.gitlab.io/project`) and subpath layout (`host/group/project`)
270/// both resolve to the right base: `CI_PAGES_URL` already encodes which one is in
271/// effect, so its path is authoritative.
272fn url_path(url: &str) -> &str {
273    let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
274    match after_scheme.find('/') {
275        Some(i) => &after_scheme[i..],
276        None => "",
277    }
278}
279
280/// Resolve the effective deploy base path from config plus environment, applying
281/// this precedence (first match wins), then [`normalize_base`]:
282///  1. `DOCGEN_BASE` — explicit override. Present-but-empty forces the root
283///     (an escape hatch for a custom-domain deploy under CI).
284///  2. `docgen.toml`'s `base`, when non-empty — the project author's intent.
285///  3. `CI_PAGES_URL` — the *path* of GitLab's actual Pages URL. Correct for both
286///     subdomain (`ns.gitlab.io/project`) and subpath (`host/group/project`)
287///     layouts, with zero CI config.
288///  4. `CI_PROJECT_PATH` — `/<namespace>/<project>`, a fallback for older GitLab
289///     that doesn't expose `CI_PAGES_URL` to the job.
290///  5. `""` — served at the domain root.
291pub fn resolve_base(config_base: &str) -> String {
292    resolve_base_from(
293        config_base,
294        std::env::var("DOCGEN_BASE").ok().as_deref(),
295        std::env::var("CI_PAGES_URL").ok().as_deref(),
296        std::env::var("CI_PROJECT_PATH").ok().as_deref(),
297    )
298}
299
300/// Pure core of [`resolve_base`] — env values are passed in so the precedence
301/// logic is testable without mutating process-global environment.
302fn resolve_base_from(
303    config_base: &str,
304    docgen_base_env: Option<&str>,
305    ci_pages_url: Option<&str>,
306    ci_project_path: Option<&str>,
307) -> String {
308    if let Some(explicit) = docgen_base_env {
309        return normalize_base(explicit);
310    }
311    if !config_base.trim().is_empty() {
312        return normalize_base(config_base);
313    }
314    if let Some(url) = ci_pages_url.filter(|u| !u.trim().is_empty()) {
315        return normalize_base(url_path(url));
316    }
317    if let Some(path) = ci_project_path.filter(|p| !p.trim().is_empty()) {
318        return normalize_base(path);
319    }
320    String::new()
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn default_is_pre_p6_behaviour() {
329        let c = SiteConfig::default();
330        assert_eq!(c.title, None);
331        assert_eq!(c.base, "");
332        assert!(c.features.graph && c.features.math && c.features.mermaid && c.features.search);
333        assert!(c.features.plantuml);
334        assert!(c.features.bases);
335        assert!(c.features.diff);
336        assert_eq!(c.components.dir, "components");
337        assert_eq!(c.plantuml.server, DEFAULT_PLANTUML_SERVER);
338    }
339
340    #[test]
341    fn parses_plantuml_section_and_feature_toggle() {
342        let dir = tempfile::tempdir().unwrap();
343        std::fs::write(
344            dir.path().join("docgen.toml"),
345            "[features]\nplantuml = false\n[plantuml]\nserver = \"http://uml.local:9000/\"\n",
346        )
347        .unwrap();
348        let c = load(dir.path()).unwrap();
349        assert!(!c.features.plantuml);
350        assert_eq!(c.plantuml.server, "http://uml.local:9000/");
351    }
352
353    #[test]
354    fn resolve_plantuml_server_precedence() {
355        // 1. env var wins over config (and is trimmed of a trailing slash).
356        assert_eq!(
357            resolve_plantuml_server_from("http://from-toml", Some("http://env:8080/".into())),
358            "http://env:8080"
359        );
360        // 1b. present-but-empty env var falls through to config.
361        assert_eq!(
362            resolve_plantuml_server_from("http://from-toml", Some("   ".into())),
363            "http://from-toml"
364        );
365        // 2. config used when env absent.
366        assert_eq!(
367            resolve_plantuml_server_from("http://from-toml/", None),
368            "http://from-toml"
369        );
370        // 3. default when both empty.
371        assert_eq!(
372            resolve_plantuml_server_from("", None),
373            DEFAULT_PLANTUML_SERVER
374        );
375    }
376
377    #[test]
378    fn missing_file_yields_default() {
379        let dir = tempfile::tempdir().unwrap();
380        assert_eq!(load(dir.path()).unwrap(), SiteConfig::default());
381    }
382
383    #[test]
384    fn parses_title_base_and_feature_toggles() {
385        let dir = tempfile::tempdir().unwrap();
386        std::fs::write(
387            dir.path().join("docgen.toml"),
388            "title = \"My Docs\"\nbase = \"/docs\"\n[features]\ngraph = false\nmermaid = false\n",
389        )
390        .unwrap();
391        let c = load(dir.path()).unwrap();
392        assert_eq!(c.title.as_deref(), Some("My Docs"));
393        assert_eq!(c.base, "/docs");
394        assert!(!c.features.graph);
395        assert!(!c.features.mermaid);
396        // Unspecified toggles keep their default (true).
397        assert!(c.features.math);
398        assert!(c.features.search);
399    }
400
401    #[test]
402    fn partial_features_table_keeps_other_defaults() {
403        let dir = tempfile::tempdir().unwrap();
404        std::fs::write(
405            dir.path().join("docgen.toml"),
406            "[features]\nsearch = false\n",
407        )
408        .unwrap();
409        let c = load(dir.path()).unwrap();
410        assert!(!c.features.search);
411        assert!(c.features.graph);
412    }
413
414    #[test]
415    fn parses_diff_feature_toggle() {
416        let dir = tempfile::tempdir().unwrap();
417        std::fs::write(dir.path().join("docgen.toml"), "[features]\ndiff = false\n").unwrap();
418        let c = load(dir.path()).unwrap();
419        assert!(!c.features.diff);
420        // Unspecified toggles keep their default (true).
421        assert!(c.features.graph);
422        assert!(c.features.search);
423    }
424
425    #[test]
426    fn malformed_toml_is_an_error() {
427        let dir = tempfile::tempdir().unwrap();
428        std::fs::write(dir.path().join("docgen.toml"), "title = = =\n").unwrap();
429        assert!(load(dir.path()).is_err());
430    }
431
432    #[test]
433    fn normalize_base_canonicalizes() {
434        assert_eq!(normalize_base(""), "");
435        assert_eq!(normalize_base("/"), "");
436        assert_eq!(normalize_base("docs"), "/docs");
437        assert_eq!(normalize_base("/docs/"), "/docs");
438        assert_eq!(normalize_base("docs/"), "/docs");
439        // multi-segment sub-path (GitLab namespace/project) round-trips
440        assert_eq!(normalize_base("/group/project/"), "/group/project");
441        assert_eq!(normalize_base("group/project"), "/group/project");
442    }
443
444    #[test]
445    fn url_path_extracts_path_component() {
446        // subdomain layout -> just the project segment
447        assert_eq!(url_path("https://group.gitlab.io/project"), "/project");
448        // subpath layout -> full group/project path
449        assert_eq!(
450            url_path("https://gitlab.example.com/group/project"),
451            "/group/project"
452        );
453        // custom domain at root -> no path
454        assert_eq!(url_path("https://docs.example.com"), "");
455        assert_eq!(url_path("http://host/a/b/"), "/a/b/");
456    }
457
458    #[test]
459    fn resolve_base_precedence() {
460        // 1. DOCGEN_BASE wins over everything (and is normalized).
461        assert_eq!(
462            resolve_base_from(
463                "/from-toml",
464                Some("/override/"),
465                Some("https://x.io/pages"),
466                Some("g/p")
467            ),
468            "/override"
469        );
470        // 1b. present-but-empty DOCGEN_BASE forces root even when others are set.
471        assert_eq!(
472            resolve_base_from(
473                "/from-toml",
474                Some(""),
475                Some("https://x.io/pages"),
476                Some("g/p")
477            ),
478            ""
479        );
480        // 2. docgen.toml base beats CI auto-detect.
481        assert_eq!(
482            resolve_base_from("/from-toml", None, Some("https://x.io/pages"), Some("g/p")),
483            "/from-toml"
484        );
485        // 3. CI_PAGES_URL path used when config base is empty; subdomain layout.
486        assert_eq!(
487            resolve_base_from(
488                "",
489                None,
490                Some("https://group.gitlab.io/project"),
491                Some("group/project")
492            ),
493            "/project"
494        );
495        // 3b. subpath layout via CI_PAGES_URL.
496        assert_eq!(
497            resolve_base_from(
498                "",
499                None,
500                Some("https://gitlab.example.com/group/project"),
501                Some("group/project")
502            ),
503            "/group/project"
504        );
505        // 4. CI_PROJECT_PATH fallback when CI_PAGES_URL is absent.
506        assert_eq!(
507            resolve_base_from("", None, None, Some("group/project")),
508            "/group/project"
509        );
510        // 4b. CI_PAGES_URL is authoritative when present: a root custom domain
511        // (no path) means the site really is at root, so base is "" — we do NOT
512        // fall through to CI_PROJECT_PATH and wrongly re-add a sub-path.
513        assert_eq!(
514            resolve_base_from(
515                "",
516                None,
517                Some("https://docs.example.com"),
518                Some("group/project")
519            ),
520            ""
521        );
522        // 5. nothing set -> root.
523        assert_eq!(resolve_base_from("", None, None, None), "");
524        assert_eq!(resolve_base_from("  ", None, None, Some("  ")), "");
525    }
526}
527
528#[cfg(test)]
529mod lint_tests {
530    use super::*;
531
532    #[test]
533    fn absent_lint_section_yields_defaults() {
534        let dir = tempfile::tempdir().unwrap();
535        std::fs::write(dir.path().join("docgen.toml"), "title = \"Docs\"\n").unwrap();
536        let c = load(dir.path()).unwrap();
537        assert_eq!(c.lint, LintConfig::default());
538        assert!(c.lint.ignore.is_empty());
539        assert!(c.lint.rules.is_empty());
540        assert!(!c.lint.plantuml.check_syntax);
541        assert!(!c.lint.mermaid.check_syntax);
542        assert_eq!(c.lint.mermaid.mmdc, "mmdc");
543        assert_eq!(c.lint.external_urls.timeout_secs, 10);
544        assert!(c.lint.external_urls.exclude.is_empty());
545    }
546
547    #[test]
548    fn full_lint_section_parses() {
549        let dir = tempfile::tempdir().unwrap();
550        std::fs::write(
551            dir.path().join("docgen.toml"),
552            r#"
553            [lint]
554            ignore = ["drafts/**", "archive/*.md"]
555
556            [lint.rules]
557            orphan-page = "warn"
558            broken-wikilink = "error"
559
560            [lint.plantuml]
561            check-syntax = true
562
563            [lint.mermaid]
564            check-syntax = true
565            mmdc = "/usr/local/bin/mmdc"
566
567            [lint.external-urls]
568            timeout-secs = 5
569            exclude = ["https://intranet.example.com/*"]
570            "#,
571        )
572        .unwrap();
573        let c = load(dir.path()).unwrap();
574        assert_eq!(c.lint.ignore, vec!["drafts/**", "archive/*.md"]);
575        assert_eq!(
576            c.lint.rules.get("orphan-page").map(String::as_str),
577            Some("warn")
578        );
579        assert_eq!(
580            c.lint.rules.get("broken-wikilink").map(String::as_str),
581            Some("error")
582        );
583        assert!(c.lint.plantuml.check_syntax);
584        assert!(c.lint.mermaid.check_syntax);
585        assert_eq!(c.lint.mermaid.mmdc, "/usr/local/bin/mmdc");
586        assert_eq!(c.lint.external_urls.timeout_secs, 5);
587        assert_eq!(
588            c.lint.external_urls.exclude,
589            vec!["https://intranet.example.com/*"]
590        );
591    }
592
593    #[test]
594    fn malformed_severity_is_accepted_at_parse_time() {
595        // Severity strings are validated downstream by docgen-lint, so an
596        // unknown value parses fine here.
597        let dir = tempfile::tempdir().unwrap();
598        std::fs::write(
599            dir.path().join("docgen.toml"),
600            "[lint.rules]\norphan-page = \"loud\"\n",
601        )
602        .unwrap();
603        let c = load(dir.path()).unwrap();
604        assert_eq!(
605            c.lint.rules.get("orphan-page").map(String::as_str),
606            Some("loud")
607        );
608    }
609}
610
611#[cfg(test)]
612mod s3_tests {
613    use super::*;
614
615    #[test]
616    fn s3_section_parses_all_fields() {
617        let cfg: SiteConfig = toml::from_str(
618            r#"
619            [s3]
620            bucket = "my-docs-assets"
621            region = "us-east-1"
622            endpoint = "https://minio.local:9000"
623            prefix = "docs-assets"
624            public_url = "https://cdn.example.com"
625            path_style = true
626            "#,
627        )
628        .expect("parse");
629        let s3 = cfg.s3.expect("s3 present");
630        assert_eq!(s3.bucket, "my-docs-assets");
631        assert_eq!(s3.region, "us-east-1");
632        assert_eq!(s3.endpoint.as_deref(), Some("https://minio.local:9000"));
633        assert_eq!(s3.prefix.as_deref(), Some("docs-assets"));
634        assert_eq!(s3.public_url, "https://cdn.example.com");
635        assert!(s3.path_style);
636    }
637
638    #[test]
639    fn s3_optional_fields_default() {
640        let cfg: SiteConfig = toml::from_str(
641            r#"
642            [s3]
643            bucket = "b"
644            region = "auto"
645            public_url = "https://x"
646            "#,
647        )
648        .expect("parse");
649        let s3 = cfg.s3.expect("s3 present");
650        assert_eq!(s3.endpoint, None);
651        assert_eq!(s3.prefix, None);
652        assert!(!s3.path_style);
653    }
654
655    #[test]
656    fn s3_missing_required_field_errors() {
657        // `bucket` omitted -> serde error.
658        let err = toml::from_str::<SiteConfig>(
659            r#"
660            [s3]
661            region = "auto"
662            public_url = "https://x"
663            "#,
664        );
665        assert!(err.is_err(), "expected missing-field error, got {err:?}");
666    }
667
668    #[test]
669    fn no_s3_section_is_none() {
670        let cfg: SiteConfig = toml::from_str(r#"title = "Docs""#).expect("parse");
671        assert_eq!(cfg.s3, None);
672    }
673}