1use std::path::Path;
6
7use serde::Deserialize;
8
9#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
11#[serde(default)]
12pub struct Features {
13 pub graph: bool,
15 pub math: bool,
17 pub mermaid: bool,
19 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
36#[serde(default)]
37pub struct ComponentsConfig {
38 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
53pub struct S3Config {
54 pub bucket: String,
56 pub region: String,
58 #[serde(default)]
60 pub endpoint: Option<String>,
61 #[serde(default)]
63 pub prefix: Option<String>,
64 pub public_url: String,
66 #[serde(default)]
68 pub path_style: bool,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
73#[serde(default)]
74pub struct SiteConfig {
75 pub title: Option<String>,
78 pub base: String,
83 pub features: Features,
84 pub components: ComponentsConfig,
85 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
105pub 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
125pub 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
138fn 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
152pub 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
172fn 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 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 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 assert_eq!(url_path("https://group.gitlab.io/project"), "/project");
268 assert_eq!(
270 url_path("https://gitlab.example.com/group/project"),
271 "/group/project"
272 );
273 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 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 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 assert_eq!(
302 resolve_base_from("/from-toml", None, Some("https://x.io/pages"), Some("g/p")),
303 "/from-toml"
304 );
305 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 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 assert_eq!(
327 resolve_base_from("", None, None, Some("group/project")),
328 "/group/project"
329 );
330 assert_eq!(
334 resolve_base_from(
335 "",
336 None,
337 Some("https://docs.example.com"),
338 Some("group/project")
339 ),
340 ""
341 );
342 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 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}