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 plantuml: bool,
22 pub bases: bool,
25 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
44#[serde(default)]
45pub struct ComponentsConfig {
46 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
62#[serde(default)]
63pub struct PlantumlConfig {
64 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
76pub const DEFAULT_PLANTUML_SERVER: &str = "http://localhost:8080";
79
80#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
83pub struct S3Config {
84 pub bucket: String,
86 pub region: String,
88 #[serde(default)]
90 pub endpoint: Option<String>,
91 #[serde(default)]
93 pub prefix: Option<String>,
94 pub public_url: String,
96 #[serde(default)]
98 pub path_style: bool,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
103#[serde(default)]
104pub struct SiteConfig {
105 pub title: Option<String>,
108 pub base: String,
113 pub features: Features,
114 pub components: ComponentsConfig,
115 pub plantuml: PlantumlConfig,
116 pub s3: Option<S3Config>,
118}
119
120pub fn resolve_plantuml_server(config_server: &str) -> String {
125 resolve_plantuml_server_from(config_server, std::env::var("DOCGEN_PLANTUML_SERVER").ok())
126}
127
128fn 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
155pub 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
175pub 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
188fn 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
202pub 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
222fn 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 assert_eq!(
278 resolve_plantuml_server_from("http://from-toml", Some("http://env:8080/".into())),
279 "http://env:8080"
280 );
281 assert_eq!(
283 resolve_plantuml_server_from("http://from-toml", Some(" ".into())),
284 "http://from-toml"
285 );
286 assert_eq!(
288 resolve_plantuml_server_from("http://from-toml/", None),
289 "http://from-toml"
290 );
291 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 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 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 assert_eq!(url_path("https://group.gitlab.io/project"), "/project");
358 assert_eq!(
360 url_path("https://gitlab.example.com/group/project"),
361 "/group/project"
362 );
363 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 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 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 assert_eq!(
392 resolve_base_from("/from-toml", None, Some("https://x.io/pages"), Some("g/p")),
393 "/from-toml"
394 );
395 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 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 assert_eq!(
417 resolve_base_from("", None, None, Some("group/project")),
418 "/group/project"
419 );
420 assert_eq!(
424 resolve_base_from(
425 "",
426 None,
427 Some("https://docs.example.com"),
428 Some("group/project")
429 ),
430 ""
431 );
432 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 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}