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