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/// The whole resolved site config. `Default` == pre-P6 behaviour.
51#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
52#[serde(default)]
53pub struct SiteConfig {
54    /// Optional site title; when set, page `<title>` becomes `"{page} — {title}"`
55    /// (home page uses just `title`). When `None`, per-page titles are unchanged.
56    pub title: Option<String>,
57    /// Base path for the deployed site (e.g. `/docs`). Empty = served at root
58    /// (unchanged behaviour). Prefixed onto every emitted asset/nav/wikilink URL
59    /// so a sub-path deployment resolves correctly (no `<base>` tag is used —
60    /// `<base>` only affects relative URLs, but our links are root-absolute).
61    pub base: String,
62    pub features: Features,
63    pub components: ComponentsConfig,
64}
65
66#[derive(Debug, thiserror::Error)]
67pub enum ConfigError {
68    #[error("reading {path}: {source}")]
69    Io {
70        path: String,
71        #[source]
72        source: std::io::Error,
73    },
74    #[error("parsing {path}: {source}")]
75    Parse {
76        path: String,
77        #[source]
78        source: toml::de::Error,
79    },
80}
81
82/// Load `docgen.toml` from `project_root`. Missing file → `SiteConfig::default()`
83/// (not an error). Present-but-malformed → `Err`.
84pub fn load(project_root: &Path) -> Result<SiteConfig, ConfigError> {
85    let path = project_root.join("docgen.toml");
86    let text = match std::fs::read_to_string(&path) {
87        Ok(t) => t,
88        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SiteConfig::default()),
89        Err(e) => {
90            return Err(ConfigError::Io {
91                path: path.display().to_string(),
92                source: e,
93            })
94        }
95    };
96    toml::from_str(&text).map_err(|e| ConfigError::Parse {
97        path: path.display().to_string(),
98        source: e,
99    })
100}
101
102/// Normalize a configured/derived `base` into a leading-slash, no-trailing-slash
103/// form: `""`/`"/"` -> `""`, `"docs"`/`"/docs/"`/`"docs/"` -> `"/docs"`,
104/// `"/group/project/"` -> `"/group/project"`. Interior slashes are preserved so
105/// multi-segment sub-paths (GitLab's `namespace/project`) round-trip correctly.
106pub fn normalize_base(base: &str) -> String {
107    let trimmed = base.trim().trim_matches('/');
108    if trimmed.is_empty() {
109        String::new()
110    } else {
111        format!("/{trimmed}")
112    }
113}
114
115/// Extract the path component of an absolute URL without pulling in a URL parser.
116/// `https://ns.gitlab.io/proj` -> `/proj`; `https://host/a/b` -> `/a/b`;
117/// `https://ns.gitlab.io` (no path) -> `""`. This is what makes GitLab's subdomain
118/// Pages layout (`ns.gitlab.io/project`) and subpath layout (`host/group/project`)
119/// both resolve to the right base: `CI_PAGES_URL` already encodes which one is in
120/// effect, so its path is authoritative.
121fn url_path(url: &str) -> &str {
122    let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
123    match after_scheme.find('/') {
124        Some(i) => &after_scheme[i..],
125        None => "",
126    }
127}
128
129/// Resolve the effective deploy base path from config plus environment, applying
130/// this precedence (first match wins), then [`normalize_base`]:
131///  1. `DOCGEN_BASE` — explicit override. Present-but-empty forces the root
132///     (an escape hatch for a custom-domain deploy under CI).
133///  2. `docgen.toml`'s `base`, when non-empty — the project author's intent.
134///  3. `CI_PAGES_URL` — the *path* of GitLab's actual Pages URL. Correct for both
135///     subdomain (`ns.gitlab.io/project`) and subpath (`host/group/project`)
136///     layouts, with zero CI config.
137///  4. `CI_PROJECT_PATH` — `/<namespace>/<project>`, a fallback for older GitLab
138///     that doesn't expose `CI_PAGES_URL` to the job.
139///  5. `""` — served at the domain root.
140pub fn resolve_base(config_base: &str) -> String {
141    resolve_base_from(
142        config_base,
143        std::env::var("DOCGEN_BASE").ok().as_deref(),
144        std::env::var("CI_PAGES_URL").ok().as_deref(),
145        std::env::var("CI_PROJECT_PATH").ok().as_deref(),
146    )
147}
148
149/// Pure core of [`resolve_base`] — env values are passed in so the precedence
150/// logic is testable without mutating process-global environment.
151fn resolve_base_from(
152    config_base: &str,
153    docgen_base_env: Option<&str>,
154    ci_pages_url: Option<&str>,
155    ci_project_path: Option<&str>,
156) -> String {
157    if let Some(explicit) = docgen_base_env {
158        return normalize_base(explicit);
159    }
160    if !config_base.trim().is_empty() {
161        return normalize_base(config_base);
162    }
163    if let Some(url) = ci_pages_url.filter(|u| !u.trim().is_empty()) {
164        return normalize_base(url_path(url));
165    }
166    if let Some(path) = ci_project_path.filter(|p| !p.trim().is_empty()) {
167        return normalize_base(path);
168    }
169    String::new()
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn default_is_pre_p6_behaviour() {
178        let c = SiteConfig::default();
179        assert_eq!(c.title, None);
180        assert_eq!(c.base, "");
181        assert!(c.features.graph && c.features.math && c.features.mermaid && c.features.search);
182        assert_eq!(c.components.dir, "components");
183    }
184
185    #[test]
186    fn missing_file_yields_default() {
187        let dir = tempfile::tempdir().unwrap();
188        assert_eq!(load(dir.path()).unwrap(), SiteConfig::default());
189    }
190
191    #[test]
192    fn parses_title_base_and_feature_toggles() {
193        let dir = tempfile::tempdir().unwrap();
194        std::fs::write(
195            dir.path().join("docgen.toml"),
196            "title = \"My Docs\"\nbase = \"/docs\"\n[features]\ngraph = false\nmermaid = false\n",
197        )
198        .unwrap();
199        let c = load(dir.path()).unwrap();
200        assert_eq!(c.title.as_deref(), Some("My Docs"));
201        assert_eq!(c.base, "/docs");
202        assert!(!c.features.graph);
203        assert!(!c.features.mermaid);
204        // Unspecified toggles keep their default (true).
205        assert!(c.features.math);
206        assert!(c.features.search);
207    }
208
209    #[test]
210    fn partial_features_table_keeps_other_defaults() {
211        let dir = tempfile::tempdir().unwrap();
212        std::fs::write(
213            dir.path().join("docgen.toml"),
214            "[features]\nsearch = false\n",
215        )
216        .unwrap();
217        let c = load(dir.path()).unwrap();
218        assert!(!c.features.search);
219        assert!(c.features.graph);
220    }
221
222    #[test]
223    fn malformed_toml_is_an_error() {
224        let dir = tempfile::tempdir().unwrap();
225        std::fs::write(dir.path().join("docgen.toml"), "title = = =\n").unwrap();
226        assert!(load(dir.path()).is_err());
227    }
228
229    #[test]
230    fn normalize_base_canonicalizes() {
231        assert_eq!(normalize_base(""), "");
232        assert_eq!(normalize_base("/"), "");
233        assert_eq!(normalize_base("docs"), "/docs");
234        assert_eq!(normalize_base("/docs/"), "/docs");
235        assert_eq!(normalize_base("docs/"), "/docs");
236        // multi-segment sub-path (GitLab namespace/project) round-trips
237        assert_eq!(normalize_base("/group/project/"), "/group/project");
238        assert_eq!(normalize_base("group/project"), "/group/project");
239    }
240
241    #[test]
242    fn url_path_extracts_path_component() {
243        // subdomain layout -> just the project segment
244        assert_eq!(url_path("https://group.gitlab.io/project"), "/project");
245        // subpath layout -> full group/project path
246        assert_eq!(
247            url_path("https://gitlab.example.com/group/project"),
248            "/group/project"
249        );
250        // custom domain at root -> no path
251        assert_eq!(url_path("https://docs.example.com"), "");
252        assert_eq!(url_path("http://host/a/b/"), "/a/b/");
253    }
254
255    #[test]
256    fn resolve_base_precedence() {
257        // 1. DOCGEN_BASE wins over everything (and is normalized).
258        assert_eq!(
259            resolve_base_from(
260                "/from-toml",
261                Some("/override/"),
262                Some("https://x.io/pages"),
263                Some("g/p")
264            ),
265            "/override"
266        );
267        // 1b. present-but-empty DOCGEN_BASE forces root even when others are set.
268        assert_eq!(
269            resolve_base_from(
270                "/from-toml",
271                Some(""),
272                Some("https://x.io/pages"),
273                Some("g/p")
274            ),
275            ""
276        );
277        // 2. docgen.toml base beats CI auto-detect.
278        assert_eq!(
279            resolve_base_from("/from-toml", None, Some("https://x.io/pages"), Some("g/p")),
280            "/from-toml"
281        );
282        // 3. CI_PAGES_URL path used when config base is empty; subdomain layout.
283        assert_eq!(
284            resolve_base_from(
285                "",
286                None,
287                Some("https://group.gitlab.io/project"),
288                Some("group/project")
289            ),
290            "/project"
291        );
292        // 3b. subpath layout via CI_PAGES_URL.
293        assert_eq!(
294            resolve_base_from(
295                "",
296                None,
297                Some("https://gitlab.example.com/group/project"),
298                Some("group/project")
299            ),
300            "/group/project"
301        );
302        // 4. CI_PROJECT_PATH fallback when CI_PAGES_URL is absent.
303        assert_eq!(
304            resolve_base_from("", None, None, Some("group/project")),
305            "/group/project"
306        );
307        // 4b. CI_PAGES_URL is authoritative when present: a root custom domain
308        // (no path) means the site really is at root, so base is "" — we do NOT
309        // fall through to CI_PROJECT_PATH and wrongly re-add a sub-path.
310        assert_eq!(
311            resolve_base_from(
312                "",
313                None,
314                Some("https://docs.example.com"),
315                Some("group/project")
316            ),
317            ""
318        );
319        // 5. nothing set -> root.
320        assert_eq!(resolve_base_from("", None, None, None), "");
321        assert_eq!(resolve_base_from("  ", None, None, Some("  ")), "");
322    }
323}