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