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 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
40#[serde(default)]
41pub struct ComponentsConfig {
42 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
58#[serde(default)]
59pub struct PlantumlConfig {
60 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
72pub const DEFAULT_PLANTUML_SERVER: &str = "http://localhost:8080";
75
76#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
79pub struct S3Config {
80 pub bucket: String,
82 pub region: String,
84 #[serde(default)]
86 pub endpoint: Option<String>,
87 #[serde(default)]
89 pub prefix: Option<String>,
90 pub public_url: String,
92 #[serde(default)]
94 pub path_style: bool,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
99#[serde(default)]
100pub struct SiteConfig {
101 pub title: Option<String>,
104 pub base: String,
109 pub features: Features,
110 pub components: ComponentsConfig,
111 pub plantuml: PlantumlConfig,
112 pub s3: Option<S3Config>,
114}
115
116pub fn resolve_plantuml_server(config_server: &str) -> String {
121 resolve_plantuml_server_from(config_server, std::env::var("DOCGEN_PLANTUML_SERVER").ok())
122}
123
124fn 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
151pub 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
171pub 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
184fn 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
198pub 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
218fn 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 assert_eq!(
273 resolve_plantuml_server_from("http://from-toml", Some("http://env:8080/".into())),
274 "http://env:8080"
275 );
276 assert_eq!(
278 resolve_plantuml_server_from("http://from-toml", Some(" ".into())),
279 "http://from-toml"
280 );
281 assert_eq!(
283 resolve_plantuml_server_from("http://from-toml/", None),
284 "http://from-toml"
285 );
286 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 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 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 assert_eq!(url_path("https://group.gitlab.io/project"), "/project");
353 assert_eq!(
355 url_path("https://gitlab.example.com/group/project"),
356 "/group/project"
357 );
358 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 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 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 assert_eq!(
387 resolve_base_from("/from-toml", None, Some("https://x.io/pages"), Some("g/p")),
388 "/from-toml"
389 );
390 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 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 assert_eq!(
412 resolve_base_from("", None, None, Some("group/project")),
413 "/group/project"
414 );
415 assert_eq!(
419 resolve_base_from(
420 "",
421 None,
422 Some("https://docs.example.com"),
423 Some("group/project")
424 ),
425 ""
426 );
427 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 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}