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::path::Path;
6
7use serde::Deserialize;
8
9/// Feature toggles. All default `true` — the pre-P6 behaviour (every feature on).
10#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
11#[serde(default)]
12pub struct Features {
13    /// Emit the `/graph/` page + its island.
14    pub graph: bool,
15    /// Render math (build-time KaTeX) + link its stylesheet.
16    pub math: bool,
17    /// Allow mermaid diagrams + lazy island.
18    pub mermaid: bool,
19    /// Render PlantUML diagrams (`:::plantuml`) at build time via an external
20    /// server. Inert (zero server contact) unless a diagram is actually present.
21    pub plantuml: bool,
22    /// Render Obsidian Bases: `.base` files become pages, and ` ```base ` fenced
23    /// blocks in markdown render inline. Inert unless a base is present.
24    pub bases: bool,
25    /// Emit the search index + search client.
26    pub search: bool,
27}
28
29impl Default for Features {
30    fn default() -> Self {
31        Self {
32            graph: true,
33            math: true,
34            mermaid: true,
35            plantuml: true,
36            bases: true,
37            search: true,
38        }
39    }
40}
41
42/// `[components]` section.
43#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
44#[serde(default)]
45pub struct ComponentsConfig {
46    /// Project-relative directory holding `<name>/template.html` components.
47    pub dir: String,
48}
49
50impl Default for ComponentsConfig {
51    fn default() -> Self {
52        Self {
53            dir: "components".to_string(),
54        }
55    }
56}
57
58/// `[plantuml]` section — settings for build-time PlantUML rendering. Absent =
59/// all defaults (server `http://localhost:8080`). The server URL is also
60/// overridable by the `DOCGEN_PLANTUML_SERVER` env var (which wins over this).
61#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
62#[serde(default)]
63pub struct PlantumlConfig {
64    /// Base URL of the PlantUML server (SVG endpoint is `{server}/svg/{encoded}`).
65    pub server: String,
66}
67
68impl Default for PlantumlConfig {
69    fn default() -> Self {
70        Self {
71            server: DEFAULT_PLANTUML_SERVER.to_string(),
72        }
73    }
74}
75
76/// The default PlantUML server URL — matches the port `docgen plantuml` binds and
77/// the `plantuml/plantuml-server:jetty` image's root SVG context.
78pub const DEFAULT_PLANTUML_SERVER: &str = "http://localhost:8080";
79
80/// `[s3]` section — optional S3-compatible asset offload. Absent = feature off.
81/// Non-secret settings only; credentials come from the environment.
82#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
83pub struct S3Config {
84    /// Target bucket name.
85    pub bucket: String,
86    /// Region string (e.g. `us-east-1`; use `auto` / any value for R2).
87    pub region: String,
88    /// Custom endpoint for non-AWS S3-compatible services. Omit for AWS.
89    #[serde(default)]
90    pub endpoint: Option<String>,
91    /// Optional key prefix within the bucket (e.g. `docs-assets`).
92    #[serde(default)]
93    pub prefix: Option<String>,
94    /// Base URL that goes into the generated HTML (bucket website or CDN in front).
95    pub public_url: String,
96    /// Path-style addressing (required by MinIO and some S3-compatibles).
97    #[serde(default)]
98    pub path_style: bool,
99}
100
101/// The whole resolved site config. `Default` == pre-P6 behaviour.
102#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
103#[serde(default)]
104pub struct SiteConfig {
105    /// Optional site title; when set, page `<title>` becomes `"{page} — {title}"`
106    /// (home page uses just `title`). When `None`, per-page titles are unchanged.
107    pub title: Option<String>,
108    /// Base path for the deployed site (e.g. `/docs`). Empty = served at root
109    /// (unchanged behaviour). Prefixed onto every emitted asset/nav/wikilink URL
110    /// so a sub-path deployment resolves correctly (no `<base>` tag is used —
111    /// `<base>` only affects relative URLs, but our links are root-absolute).
112    pub base: String,
113    pub features: Features,
114    pub components: ComponentsConfig,
115    pub plantuml: PlantumlConfig,
116    /// Optional S3 asset offload. `None` = disabled (local copy).
117    pub s3: Option<S3Config>,
118}
119
120/// Resolve the effective PlantUML server URL, applying precedence (first match
121/// wins): `DOCGEN_PLANTUML_SERVER` env var → `docgen.toml` `[plantuml] server`
122/// → [`DEFAULT_PLANTUML_SERVER`]. A present-but-empty env var is ignored (falls
123/// through to config). The returned URL has any trailing slash trimmed.
124pub fn resolve_plantuml_server(config_server: &str) -> String {
125    resolve_plantuml_server_from(config_server, std::env::var("DOCGEN_PLANTUML_SERVER").ok())
126}
127
128/// Pure core of [`resolve_plantuml_server`] — env value passed in so precedence
129/// is testable without mutating process-global environment.
130fn resolve_plantuml_server_from(config_server: &str, env: Option<String>) -> String {
131    let chosen = match env {
132        Some(v) if !v.trim().is_empty() => v,
133        _ if !config_server.trim().is_empty() => config_server.to_string(),
134        _ => DEFAULT_PLANTUML_SERVER.to_string(),
135    };
136    chosen.trim().trim_end_matches('/').to_string()
137}
138
139#[derive(Debug, thiserror::Error)]
140pub enum ConfigError {
141    #[error("reading {path}: {source}")]
142    Io {
143        path: String,
144        #[source]
145        source: std::io::Error,
146    },
147    #[error("parsing {path}: {source}")]
148    Parse {
149        path: String,
150        #[source]
151        source: toml::de::Error,
152    },
153}
154
155/// Load `docgen.toml` from `project_root`. Missing file → `SiteConfig::default()`
156/// (not an error). Present-but-malformed → `Err`.
157pub fn load(project_root: &Path) -> Result<SiteConfig, ConfigError> {
158    let path = project_root.join("docgen.toml");
159    let text = match std::fs::read_to_string(&path) {
160        Ok(t) => t,
161        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SiteConfig::default()),
162        Err(e) => {
163            return Err(ConfigError::Io {
164                path: path.display().to_string(),
165                source: e,
166            })
167        }
168    };
169    toml::from_str(&text).map_err(|e| ConfigError::Parse {
170        path: path.display().to_string(),
171        source: e,
172    })
173}
174
175/// Normalize a configured/derived `base` into a leading-slash, no-trailing-slash
176/// form: `""`/`"/"` -> `""`, `"docs"`/`"/docs/"`/`"docs/"` -> `"/docs"`,
177/// `"/group/project/"` -> `"/group/project"`. Interior slashes are preserved so
178/// multi-segment sub-paths (GitLab's `namespace/project`) round-trip correctly.
179pub fn normalize_base(base: &str) -> String {
180    let trimmed = base.trim().trim_matches('/');
181    if trimmed.is_empty() {
182        String::new()
183    } else {
184        format!("/{trimmed}")
185    }
186}
187
188/// Extract the path component of an absolute URL without pulling in a URL parser.
189/// `https://ns.gitlab.io/proj` -> `/proj`; `https://host/a/b` -> `/a/b`;
190/// `https://ns.gitlab.io` (no path) -> `""`. This is what makes GitLab's subdomain
191/// Pages layout (`ns.gitlab.io/project`) and subpath layout (`host/group/project`)
192/// both resolve to the right base: `CI_PAGES_URL` already encodes which one is in
193/// effect, so its path is authoritative.
194fn url_path(url: &str) -> &str {
195    let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
196    match after_scheme.find('/') {
197        Some(i) => &after_scheme[i..],
198        None => "",
199    }
200}
201
202/// Resolve the effective deploy base path from config plus environment, applying
203/// this precedence (first match wins), then [`normalize_base`]:
204///  1. `DOCGEN_BASE` — explicit override. Present-but-empty forces the root
205///     (an escape hatch for a custom-domain deploy under CI).
206///  2. `docgen.toml`'s `base`, when non-empty — the project author's intent.
207///  3. `CI_PAGES_URL` — the *path* of GitLab's actual Pages URL. Correct for both
208///     subdomain (`ns.gitlab.io/project`) and subpath (`host/group/project`)
209///     layouts, with zero CI config.
210///  4. `CI_PROJECT_PATH` — `/<namespace>/<project>`, a fallback for older GitLab
211///     that doesn't expose `CI_PAGES_URL` to the job.
212///  5. `""` — served at the domain root.
213pub fn resolve_base(config_base: &str) -> String {
214    resolve_base_from(
215        config_base,
216        std::env::var("DOCGEN_BASE").ok().as_deref(),
217        std::env::var("CI_PAGES_URL").ok().as_deref(),
218        std::env::var("CI_PROJECT_PATH").ok().as_deref(),
219    )
220}
221
222/// Pure core of [`resolve_base`] — env values are passed in so the precedence
223/// logic is testable without mutating process-global environment.
224fn resolve_base_from(
225    config_base: &str,
226    docgen_base_env: Option<&str>,
227    ci_pages_url: Option<&str>,
228    ci_project_path: Option<&str>,
229) -> String {
230    if let Some(explicit) = docgen_base_env {
231        return normalize_base(explicit);
232    }
233    if !config_base.trim().is_empty() {
234        return normalize_base(config_base);
235    }
236    if let Some(url) = ci_pages_url.filter(|u| !u.trim().is_empty()) {
237        return normalize_base(url_path(url));
238    }
239    if let Some(path) = ci_project_path.filter(|p| !p.trim().is_empty()) {
240        return normalize_base(path);
241    }
242    String::new()
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn default_is_pre_p6_behaviour() {
251        let c = SiteConfig::default();
252        assert_eq!(c.title, None);
253        assert_eq!(c.base, "");
254        assert!(c.features.graph && c.features.math && c.features.mermaid && c.features.search);
255        assert!(c.features.plantuml);
256        assert!(c.features.bases);
257        assert_eq!(c.components.dir, "components");
258        assert_eq!(c.plantuml.server, DEFAULT_PLANTUML_SERVER);
259    }
260
261    #[test]
262    fn parses_plantuml_section_and_feature_toggle() {
263        let dir = tempfile::tempdir().unwrap();
264        std::fs::write(
265            dir.path().join("docgen.toml"),
266            "[features]\nplantuml = false\n[plantuml]\nserver = \"http://uml.local:9000/\"\n",
267        )
268        .unwrap();
269        let c = load(dir.path()).unwrap();
270        assert!(!c.features.plantuml);
271        assert_eq!(c.plantuml.server, "http://uml.local:9000/");
272    }
273
274    #[test]
275    fn resolve_plantuml_server_precedence() {
276        // 1. env var wins over config (and is trimmed of a trailing slash).
277        assert_eq!(
278            resolve_plantuml_server_from("http://from-toml", Some("http://env:8080/".into())),
279            "http://env:8080"
280        );
281        // 1b. present-but-empty env var falls through to config.
282        assert_eq!(
283            resolve_plantuml_server_from("http://from-toml", Some("   ".into())),
284            "http://from-toml"
285        );
286        // 2. config used when env absent.
287        assert_eq!(
288            resolve_plantuml_server_from("http://from-toml/", None),
289            "http://from-toml"
290        );
291        // 3. default when both empty.
292        assert_eq!(
293            resolve_plantuml_server_from("", None),
294            DEFAULT_PLANTUML_SERVER
295        );
296    }
297
298    #[test]
299    fn missing_file_yields_default() {
300        let dir = tempfile::tempdir().unwrap();
301        assert_eq!(load(dir.path()).unwrap(), SiteConfig::default());
302    }
303
304    #[test]
305    fn parses_title_base_and_feature_toggles() {
306        let dir = tempfile::tempdir().unwrap();
307        std::fs::write(
308            dir.path().join("docgen.toml"),
309            "title = \"My Docs\"\nbase = \"/docs\"\n[features]\ngraph = false\nmermaid = false\n",
310        )
311        .unwrap();
312        let c = load(dir.path()).unwrap();
313        assert_eq!(c.title.as_deref(), Some("My Docs"));
314        assert_eq!(c.base, "/docs");
315        assert!(!c.features.graph);
316        assert!(!c.features.mermaid);
317        // Unspecified toggles keep their default (true).
318        assert!(c.features.math);
319        assert!(c.features.search);
320    }
321
322    #[test]
323    fn partial_features_table_keeps_other_defaults() {
324        let dir = tempfile::tempdir().unwrap();
325        std::fs::write(
326            dir.path().join("docgen.toml"),
327            "[features]\nsearch = false\n",
328        )
329        .unwrap();
330        let c = load(dir.path()).unwrap();
331        assert!(!c.features.search);
332        assert!(c.features.graph);
333    }
334
335    #[test]
336    fn malformed_toml_is_an_error() {
337        let dir = tempfile::tempdir().unwrap();
338        std::fs::write(dir.path().join("docgen.toml"), "title = = =\n").unwrap();
339        assert!(load(dir.path()).is_err());
340    }
341
342    #[test]
343    fn normalize_base_canonicalizes() {
344        assert_eq!(normalize_base(""), "");
345        assert_eq!(normalize_base("/"), "");
346        assert_eq!(normalize_base("docs"), "/docs");
347        assert_eq!(normalize_base("/docs/"), "/docs");
348        assert_eq!(normalize_base("docs/"), "/docs");
349        // multi-segment sub-path (GitLab namespace/project) round-trips
350        assert_eq!(normalize_base("/group/project/"), "/group/project");
351        assert_eq!(normalize_base("group/project"), "/group/project");
352    }
353
354    #[test]
355    fn url_path_extracts_path_component() {
356        // subdomain layout -> just the project segment
357        assert_eq!(url_path("https://group.gitlab.io/project"), "/project");
358        // subpath layout -> full group/project path
359        assert_eq!(
360            url_path("https://gitlab.example.com/group/project"),
361            "/group/project"
362        );
363        // custom domain at root -> no path
364        assert_eq!(url_path("https://docs.example.com"), "");
365        assert_eq!(url_path("http://host/a/b/"), "/a/b/");
366    }
367
368    #[test]
369    fn resolve_base_precedence() {
370        // 1. DOCGEN_BASE wins over everything (and is normalized).
371        assert_eq!(
372            resolve_base_from(
373                "/from-toml",
374                Some("/override/"),
375                Some("https://x.io/pages"),
376                Some("g/p")
377            ),
378            "/override"
379        );
380        // 1b. present-but-empty DOCGEN_BASE forces root even when others are set.
381        assert_eq!(
382            resolve_base_from(
383                "/from-toml",
384                Some(""),
385                Some("https://x.io/pages"),
386                Some("g/p")
387            ),
388            ""
389        );
390        // 2. docgen.toml base beats CI auto-detect.
391        assert_eq!(
392            resolve_base_from("/from-toml", None, Some("https://x.io/pages"), Some("g/p")),
393            "/from-toml"
394        );
395        // 3. CI_PAGES_URL path used when config base is empty; subdomain layout.
396        assert_eq!(
397            resolve_base_from(
398                "",
399                None,
400                Some("https://group.gitlab.io/project"),
401                Some("group/project")
402            ),
403            "/project"
404        );
405        // 3b. subpath layout via CI_PAGES_URL.
406        assert_eq!(
407            resolve_base_from(
408                "",
409                None,
410                Some("https://gitlab.example.com/group/project"),
411                Some("group/project")
412            ),
413            "/group/project"
414        );
415        // 4. CI_PROJECT_PATH fallback when CI_PAGES_URL is absent.
416        assert_eq!(
417            resolve_base_from("", None, None, Some("group/project")),
418            "/group/project"
419        );
420        // 4b. CI_PAGES_URL is authoritative when present: a root custom domain
421        // (no path) means the site really is at root, so base is "" — we do NOT
422        // fall through to CI_PROJECT_PATH and wrongly re-add a sub-path.
423        assert_eq!(
424            resolve_base_from(
425                "",
426                None,
427                Some("https://docs.example.com"),
428                Some("group/project")
429            ),
430            ""
431        );
432        // 5. nothing set -> root.
433        assert_eq!(resolve_base_from("", None, None, None), "");
434        assert_eq!(resolve_base_from("  ", None, None, Some("  ")), "");
435    }
436}
437
438#[cfg(test)]
439mod s3_tests {
440    use super::*;
441
442    #[test]
443    fn s3_section_parses_all_fields() {
444        let cfg: SiteConfig = toml::from_str(
445            r#"
446            [s3]
447            bucket = "my-docs-assets"
448            region = "us-east-1"
449            endpoint = "https://minio.local:9000"
450            prefix = "docs-assets"
451            public_url = "https://cdn.example.com"
452            path_style = true
453            "#,
454        )
455        .expect("parse");
456        let s3 = cfg.s3.expect("s3 present");
457        assert_eq!(s3.bucket, "my-docs-assets");
458        assert_eq!(s3.region, "us-east-1");
459        assert_eq!(s3.endpoint.as_deref(), Some("https://minio.local:9000"));
460        assert_eq!(s3.prefix.as_deref(), Some("docs-assets"));
461        assert_eq!(s3.public_url, "https://cdn.example.com");
462        assert!(s3.path_style);
463    }
464
465    #[test]
466    fn s3_optional_fields_default() {
467        let cfg: SiteConfig = toml::from_str(
468            r#"
469            [s3]
470            bucket = "b"
471            region = "auto"
472            public_url = "https://x"
473            "#,
474        )
475        .expect("parse");
476        let s3 = cfg.s3.expect("s3 present");
477        assert_eq!(s3.endpoint, None);
478        assert_eq!(s3.prefix, None);
479        assert!(!s3.path_style);
480    }
481
482    #[test]
483    fn s3_missing_required_field_errors() {
484        // `bucket` omitted -> serde error.
485        let err = toml::from_str::<SiteConfig>(
486            r#"
487            [s3]
488            region = "auto"
489            public_url = "https://x"
490            "#,
491        );
492        assert!(err.is_err(), "expected missing-field error, got {err:?}");
493    }
494
495    #[test]
496    fn no_s3_section_is_none() {
497        let cfg: SiteConfig = toml::from_str(r#"title = "Docs""#).expect("parse");
498        assert_eq!(cfg.s3, None);
499    }
500}