1use std::collections::BTreeMap;
6use std::path::Path;
7
8use serde::Deserialize;
9
10#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
12#[serde(default)]
13pub struct Features {
14 pub graph: bool,
16 pub math: bool,
18 pub mermaid: bool,
20 pub plantuml: bool,
23 pub bases: bool,
26 pub search: bool,
28}
29
30impl Default for Features {
31 fn default() -> Self {
32 Self {
33 graph: true,
34 math: true,
35 mermaid: true,
36 plantuml: true,
37 bases: true,
38 search: true,
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
45#[serde(default)]
46pub struct ComponentsConfig {
47 pub dir: String,
49}
50
51impl Default for ComponentsConfig {
52 fn default() -> Self {
53 Self {
54 dir: "components".to_string(),
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
63#[serde(default)]
64pub struct PlantumlConfig {
65 pub server: String,
67}
68
69impl Default for PlantumlConfig {
70 fn default() -> Self {
71 Self {
72 server: DEFAULT_PLANTUML_SERVER.to_string(),
73 }
74 }
75}
76
77pub const DEFAULT_PLANTUML_SERVER: &str = "http://localhost:8080";
80
81#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
84pub struct S3Config {
85 pub bucket: String,
87 pub region: String,
89 #[serde(default)]
91 pub endpoint: Option<String>,
92 #[serde(default)]
94 pub prefix: Option<String>,
95 pub public_url: String,
97 #[serde(default)]
99 pub path_style: bool,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
105#[serde(default)]
106pub struct LintConfig {
107 pub ignore: Vec<String>,
109 pub rules: BTreeMap<String, String>,
112 pub plantuml: LintPlantumlConfig,
114 pub mermaid: LintMermaidConfig,
116 #[serde(rename = "external-urls")]
118 pub external_urls: LintExternalUrlsConfig,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
123#[serde(default)]
124pub struct LintPlantumlConfig {
125 #[serde(rename = "check-syntax")]
127 pub check_syntax: bool,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
132#[serde(default)]
133pub struct LintMermaidConfig {
134 #[serde(rename = "check-syntax")]
136 pub check_syntax: bool,
137 pub mmdc: String,
139}
140
141impl Default for LintMermaidConfig {
142 fn default() -> Self {
143 Self {
144 check_syntax: false,
145 mmdc: "mmdc".to_string(),
146 }
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
152#[serde(default)]
153pub struct LintExternalUrlsConfig {
154 #[serde(rename = "timeout-secs")]
156 pub timeout_secs: u64,
157 pub exclude: Vec<String>,
159}
160
161impl Default for LintExternalUrlsConfig {
162 fn default() -> Self {
163 Self {
164 timeout_secs: 10,
165 exclude: Vec::new(),
166 }
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
172#[serde(default)]
173pub struct SiteConfig {
174 pub title: Option<String>,
177 pub base: String,
182 pub features: Features,
183 pub components: ComponentsConfig,
184 pub plantuml: PlantumlConfig,
185 pub s3: Option<S3Config>,
187 pub lint: LintConfig,
190}
191
192pub fn resolve_plantuml_server(config_server: &str) -> String {
197 resolve_plantuml_server_from(config_server, std::env::var("DOCGEN_PLANTUML_SERVER").ok())
198}
199
200fn resolve_plantuml_server_from(config_server: &str, env: Option<String>) -> String {
203 let chosen = match env {
204 Some(v) if !v.trim().is_empty() => v,
205 _ if !config_server.trim().is_empty() => config_server.to_string(),
206 _ => DEFAULT_PLANTUML_SERVER.to_string(),
207 };
208 chosen.trim().trim_end_matches('/').to_string()
209}
210
211#[derive(Debug, thiserror::Error)]
212pub enum ConfigError {
213 #[error("reading {path}: {source}")]
214 Io {
215 path: String,
216 #[source]
217 source: std::io::Error,
218 },
219 #[error("parsing {path}: {source}")]
220 Parse {
221 path: String,
222 #[source]
223 source: toml::de::Error,
224 },
225}
226
227pub fn load(project_root: &Path) -> Result<SiteConfig, ConfigError> {
230 let path = project_root.join("docgen.toml");
231 let text = match std::fs::read_to_string(&path) {
232 Ok(t) => t,
233 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SiteConfig::default()),
234 Err(e) => {
235 return Err(ConfigError::Io {
236 path: path.display().to_string(),
237 source: e,
238 })
239 }
240 };
241 toml::from_str(&text).map_err(|e| ConfigError::Parse {
242 path: path.display().to_string(),
243 source: e,
244 })
245}
246
247pub fn normalize_base(base: &str) -> String {
252 let trimmed = base.trim().trim_matches('/');
253 if trimmed.is_empty() {
254 String::new()
255 } else {
256 format!("/{trimmed}")
257 }
258}
259
260fn url_path(url: &str) -> &str {
267 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
268 match after_scheme.find('/') {
269 Some(i) => &after_scheme[i..],
270 None => "",
271 }
272}
273
274pub fn resolve_base(config_base: &str) -> String {
286 resolve_base_from(
287 config_base,
288 std::env::var("DOCGEN_BASE").ok().as_deref(),
289 std::env::var("CI_PAGES_URL").ok().as_deref(),
290 std::env::var("CI_PROJECT_PATH").ok().as_deref(),
291 )
292}
293
294fn resolve_base_from(
297 config_base: &str,
298 docgen_base_env: Option<&str>,
299 ci_pages_url: Option<&str>,
300 ci_project_path: Option<&str>,
301) -> String {
302 if let Some(explicit) = docgen_base_env {
303 return normalize_base(explicit);
304 }
305 if !config_base.trim().is_empty() {
306 return normalize_base(config_base);
307 }
308 if let Some(url) = ci_pages_url.filter(|u| !u.trim().is_empty()) {
309 return normalize_base(url_path(url));
310 }
311 if let Some(path) = ci_project_path.filter(|p| !p.trim().is_empty()) {
312 return normalize_base(path);
313 }
314 String::new()
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn default_is_pre_p6_behaviour() {
323 let c = SiteConfig::default();
324 assert_eq!(c.title, None);
325 assert_eq!(c.base, "");
326 assert!(c.features.graph && c.features.math && c.features.mermaid && c.features.search);
327 assert!(c.features.plantuml);
328 assert!(c.features.bases);
329 assert_eq!(c.components.dir, "components");
330 assert_eq!(c.plantuml.server, DEFAULT_PLANTUML_SERVER);
331 }
332
333 #[test]
334 fn parses_plantuml_section_and_feature_toggle() {
335 let dir = tempfile::tempdir().unwrap();
336 std::fs::write(
337 dir.path().join("docgen.toml"),
338 "[features]\nplantuml = false\n[plantuml]\nserver = \"http://uml.local:9000/\"\n",
339 )
340 .unwrap();
341 let c = load(dir.path()).unwrap();
342 assert!(!c.features.plantuml);
343 assert_eq!(c.plantuml.server, "http://uml.local:9000/");
344 }
345
346 #[test]
347 fn resolve_plantuml_server_precedence() {
348 assert_eq!(
350 resolve_plantuml_server_from("http://from-toml", Some("http://env:8080/".into())),
351 "http://env:8080"
352 );
353 assert_eq!(
355 resolve_plantuml_server_from("http://from-toml", Some(" ".into())),
356 "http://from-toml"
357 );
358 assert_eq!(
360 resolve_plantuml_server_from("http://from-toml/", None),
361 "http://from-toml"
362 );
363 assert_eq!(
365 resolve_plantuml_server_from("", None),
366 DEFAULT_PLANTUML_SERVER
367 );
368 }
369
370 #[test]
371 fn missing_file_yields_default() {
372 let dir = tempfile::tempdir().unwrap();
373 assert_eq!(load(dir.path()).unwrap(), SiteConfig::default());
374 }
375
376 #[test]
377 fn parses_title_base_and_feature_toggles() {
378 let dir = tempfile::tempdir().unwrap();
379 std::fs::write(
380 dir.path().join("docgen.toml"),
381 "title = \"My Docs\"\nbase = \"/docs\"\n[features]\ngraph = false\nmermaid = false\n",
382 )
383 .unwrap();
384 let c = load(dir.path()).unwrap();
385 assert_eq!(c.title.as_deref(), Some("My Docs"));
386 assert_eq!(c.base, "/docs");
387 assert!(!c.features.graph);
388 assert!(!c.features.mermaid);
389 assert!(c.features.math);
391 assert!(c.features.search);
392 }
393
394 #[test]
395 fn partial_features_table_keeps_other_defaults() {
396 let dir = tempfile::tempdir().unwrap();
397 std::fs::write(
398 dir.path().join("docgen.toml"),
399 "[features]\nsearch = false\n",
400 )
401 .unwrap();
402 let c = load(dir.path()).unwrap();
403 assert!(!c.features.search);
404 assert!(c.features.graph);
405 }
406
407 #[test]
408 fn malformed_toml_is_an_error() {
409 let dir = tempfile::tempdir().unwrap();
410 std::fs::write(dir.path().join("docgen.toml"), "title = = =\n").unwrap();
411 assert!(load(dir.path()).is_err());
412 }
413
414 #[test]
415 fn normalize_base_canonicalizes() {
416 assert_eq!(normalize_base(""), "");
417 assert_eq!(normalize_base("/"), "");
418 assert_eq!(normalize_base("docs"), "/docs");
419 assert_eq!(normalize_base("/docs/"), "/docs");
420 assert_eq!(normalize_base("docs/"), "/docs");
421 assert_eq!(normalize_base("/group/project/"), "/group/project");
423 assert_eq!(normalize_base("group/project"), "/group/project");
424 }
425
426 #[test]
427 fn url_path_extracts_path_component() {
428 assert_eq!(url_path("https://group.gitlab.io/project"), "/project");
430 assert_eq!(
432 url_path("https://gitlab.example.com/group/project"),
433 "/group/project"
434 );
435 assert_eq!(url_path("https://docs.example.com"), "");
437 assert_eq!(url_path("http://host/a/b/"), "/a/b/");
438 }
439
440 #[test]
441 fn resolve_base_precedence() {
442 assert_eq!(
444 resolve_base_from(
445 "/from-toml",
446 Some("/override/"),
447 Some("https://x.io/pages"),
448 Some("g/p")
449 ),
450 "/override"
451 );
452 assert_eq!(
454 resolve_base_from(
455 "/from-toml",
456 Some(""),
457 Some("https://x.io/pages"),
458 Some("g/p")
459 ),
460 ""
461 );
462 assert_eq!(
464 resolve_base_from("/from-toml", None, Some("https://x.io/pages"), Some("g/p")),
465 "/from-toml"
466 );
467 assert_eq!(
469 resolve_base_from(
470 "",
471 None,
472 Some("https://group.gitlab.io/project"),
473 Some("group/project")
474 ),
475 "/project"
476 );
477 assert_eq!(
479 resolve_base_from(
480 "",
481 None,
482 Some("https://gitlab.example.com/group/project"),
483 Some("group/project")
484 ),
485 "/group/project"
486 );
487 assert_eq!(
489 resolve_base_from("", None, None, Some("group/project")),
490 "/group/project"
491 );
492 assert_eq!(
496 resolve_base_from(
497 "",
498 None,
499 Some("https://docs.example.com"),
500 Some("group/project")
501 ),
502 ""
503 );
504 assert_eq!(resolve_base_from("", None, None, None), "");
506 assert_eq!(resolve_base_from(" ", None, None, Some(" ")), "");
507 }
508}
509
510#[cfg(test)]
511mod lint_tests {
512 use super::*;
513
514 #[test]
515 fn absent_lint_section_yields_defaults() {
516 let dir = tempfile::tempdir().unwrap();
517 std::fs::write(dir.path().join("docgen.toml"), "title = \"Docs\"\n").unwrap();
518 let c = load(dir.path()).unwrap();
519 assert_eq!(c.lint, LintConfig::default());
520 assert!(c.lint.ignore.is_empty());
521 assert!(c.lint.rules.is_empty());
522 assert!(!c.lint.plantuml.check_syntax);
523 assert!(!c.lint.mermaid.check_syntax);
524 assert_eq!(c.lint.mermaid.mmdc, "mmdc");
525 assert_eq!(c.lint.external_urls.timeout_secs, 10);
526 assert!(c.lint.external_urls.exclude.is_empty());
527 }
528
529 #[test]
530 fn full_lint_section_parses() {
531 let dir = tempfile::tempdir().unwrap();
532 std::fs::write(
533 dir.path().join("docgen.toml"),
534 r#"
535 [lint]
536 ignore = ["drafts/**", "archive/*.md"]
537
538 [lint.rules]
539 orphan-page = "warn"
540 broken-wikilink = "error"
541
542 [lint.plantuml]
543 check-syntax = true
544
545 [lint.mermaid]
546 check-syntax = true
547 mmdc = "/usr/local/bin/mmdc"
548
549 [lint.external-urls]
550 timeout-secs = 5
551 exclude = ["https://intranet.example.com/*"]
552 "#,
553 )
554 .unwrap();
555 let c = load(dir.path()).unwrap();
556 assert_eq!(c.lint.ignore, vec!["drafts/**", "archive/*.md"]);
557 assert_eq!(
558 c.lint.rules.get("orphan-page").map(String::as_str),
559 Some("warn")
560 );
561 assert_eq!(
562 c.lint.rules.get("broken-wikilink").map(String::as_str),
563 Some("error")
564 );
565 assert!(c.lint.plantuml.check_syntax);
566 assert!(c.lint.mermaid.check_syntax);
567 assert_eq!(c.lint.mermaid.mmdc, "/usr/local/bin/mmdc");
568 assert_eq!(c.lint.external_urls.timeout_secs, 5);
569 assert_eq!(
570 c.lint.external_urls.exclude,
571 vec!["https://intranet.example.com/*"]
572 );
573 }
574
575 #[test]
576 fn malformed_severity_is_accepted_at_parse_time() {
577 let dir = tempfile::tempdir().unwrap();
580 std::fs::write(
581 dir.path().join("docgen.toml"),
582 "[lint.rules]\norphan-page = \"loud\"\n",
583 )
584 .unwrap();
585 let c = load(dir.path()).unwrap();
586 assert_eq!(
587 c.lint.rules.get("orphan-page").map(String::as_str),
588 Some("loud")
589 );
590 }
591}
592
593#[cfg(test)]
594mod s3_tests {
595 use super::*;
596
597 #[test]
598 fn s3_section_parses_all_fields() {
599 let cfg: SiteConfig = toml::from_str(
600 r#"
601 [s3]
602 bucket = "my-docs-assets"
603 region = "us-east-1"
604 endpoint = "https://minio.local:9000"
605 prefix = "docs-assets"
606 public_url = "https://cdn.example.com"
607 path_style = true
608 "#,
609 )
610 .expect("parse");
611 let s3 = cfg.s3.expect("s3 present");
612 assert_eq!(s3.bucket, "my-docs-assets");
613 assert_eq!(s3.region, "us-east-1");
614 assert_eq!(s3.endpoint.as_deref(), Some("https://minio.local:9000"));
615 assert_eq!(s3.prefix.as_deref(), Some("docs-assets"));
616 assert_eq!(s3.public_url, "https://cdn.example.com");
617 assert!(s3.path_style);
618 }
619
620 #[test]
621 fn s3_optional_fields_default() {
622 let cfg: SiteConfig = toml::from_str(
623 r#"
624 [s3]
625 bucket = "b"
626 region = "auto"
627 public_url = "https://x"
628 "#,
629 )
630 .expect("parse");
631 let s3 = cfg.s3.expect("s3 present");
632 assert_eq!(s3.endpoint, None);
633 assert_eq!(s3.prefix, None);
634 assert!(!s3.path_style);
635 }
636
637 #[test]
638 fn s3_missing_required_field_errors() {
639 let err = toml::from_str::<SiteConfig>(
641 r#"
642 [s3]
643 region = "auto"
644 public_url = "https://x"
645 "#,
646 );
647 assert!(err.is_err(), "expected missing-field error, got {err:?}");
648 }
649
650 #[test]
651 fn no_s3_section_is_none() {
652 let cfg: SiteConfig = toml::from_str(r#"title = "Docs""#).expect("parse");
653 assert_eq!(cfg.s3, None);
654 }
655}