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