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 pub diff: bool,
33}
34
35impl Default for Features {
36 fn default() -> Self {
37 Self {
38 graph: true,
39 math: true,
40 mermaid: true,
41 plantuml: true,
42 bases: true,
43 search: true,
44 diff: true,
45 }
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
51#[serde(default)]
52pub struct ComponentsConfig {
53 pub dir: String,
55}
56
57impl Default for ComponentsConfig {
58 fn default() -> Self {
59 Self {
60 dir: "components".to_string(),
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
69#[serde(default)]
70pub struct PlantumlConfig {
71 pub server: String,
73}
74
75impl Default for PlantumlConfig {
76 fn default() -> Self {
77 Self {
78 server: DEFAULT_PLANTUML_SERVER.to_string(),
79 }
80 }
81}
82
83pub const DEFAULT_PLANTUML_SERVER: &str = "http://localhost:8080";
86
87#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
90pub struct S3Config {
91 pub bucket: String,
93 pub region: String,
95 #[serde(default)]
97 pub endpoint: Option<String>,
98 #[serde(default)]
100 pub prefix: Option<String>,
101 pub public_url: String,
103 #[serde(default)]
105 pub path_style: bool,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
111#[serde(default)]
112pub struct LintConfig {
113 pub ignore: Vec<String>,
115 pub rules: BTreeMap<String, String>,
118 pub plantuml: LintPlantumlConfig,
120 pub mermaid: LintMermaidConfig,
122 #[serde(rename = "external-urls")]
124 pub external_urls: LintExternalUrlsConfig,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
129#[serde(default)]
130pub struct LintPlantumlConfig {
131 #[serde(rename = "check-syntax")]
133 pub check_syntax: bool,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
138#[serde(default)]
139pub struct LintMermaidConfig {
140 #[serde(rename = "check-syntax")]
142 pub check_syntax: bool,
143 pub mmdc: String,
145}
146
147impl Default for LintMermaidConfig {
148 fn default() -> Self {
149 Self {
150 check_syntax: false,
151 mmdc: "mmdc".to_string(),
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
158#[serde(default)]
159pub struct LintExternalUrlsConfig {
160 #[serde(rename = "timeout-secs")]
162 pub timeout_secs: u64,
163 pub exclude: Vec<String>,
165}
166
167impl Default for LintExternalUrlsConfig {
168 fn default() -> Self {
169 Self {
170 timeout_secs: 10,
171 exclude: Vec::new(),
172 }
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
178#[serde(default)]
179pub struct SiteConfig {
180 pub title: Option<String>,
183 pub base: String,
188 pub features: Features,
189 pub components: ComponentsConfig,
190 pub plantuml: PlantumlConfig,
191 pub s3: Option<S3Config>,
193 pub lint: LintConfig,
196}
197
198pub fn resolve_plantuml_server(config_server: &str) -> String {
203 resolve_plantuml_server_from(config_server, std::env::var("DOCGEN_PLANTUML_SERVER").ok())
204}
205
206fn resolve_plantuml_server_from(config_server: &str, env: Option<String>) -> String {
209 let chosen = match env {
210 Some(v) if !v.trim().is_empty() => v,
211 _ if !config_server.trim().is_empty() => config_server.to_string(),
212 _ => DEFAULT_PLANTUML_SERVER.to_string(),
213 };
214 chosen.trim().trim_end_matches('/').to_string()
215}
216
217#[derive(Debug, thiserror::Error)]
218pub enum ConfigError {
219 #[error("reading {path}: {source}")]
220 Io {
221 path: String,
222 #[source]
223 source: std::io::Error,
224 },
225 #[error("parsing {path}: {source}")]
226 Parse {
227 path: String,
228 #[source]
229 source: toml::de::Error,
230 },
231}
232
233pub fn load(project_root: &Path) -> Result<SiteConfig, ConfigError> {
236 let path = project_root.join("docgen.toml");
237 let text = match std::fs::read_to_string(&path) {
238 Ok(t) => t,
239 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SiteConfig::default()),
240 Err(e) => {
241 return Err(ConfigError::Io {
242 path: path.display().to_string(),
243 source: e,
244 })
245 }
246 };
247 toml::from_str(&text).map_err(|e| ConfigError::Parse {
248 path: path.display().to_string(),
249 source: e,
250 })
251}
252
253pub fn normalize_base(base: &str) -> String {
258 let trimmed = base.trim().trim_matches('/');
259 if trimmed.is_empty() {
260 String::new()
261 } else {
262 format!("/{trimmed}")
263 }
264}
265
266fn url_path(url: &str) -> &str {
273 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
274 match after_scheme.find('/') {
275 Some(i) => &after_scheme[i..],
276 None => "",
277 }
278}
279
280pub fn resolve_base(config_base: &str) -> String {
292 resolve_base_from(
293 config_base,
294 std::env::var("DOCGEN_BASE").ok().as_deref(),
295 std::env::var("CI_PAGES_URL").ok().as_deref(),
296 std::env::var("CI_PROJECT_PATH").ok().as_deref(),
297 )
298}
299
300fn resolve_base_from(
303 config_base: &str,
304 docgen_base_env: Option<&str>,
305 ci_pages_url: Option<&str>,
306 ci_project_path: Option<&str>,
307) -> String {
308 if let Some(explicit) = docgen_base_env {
309 return normalize_base(explicit);
310 }
311 if !config_base.trim().is_empty() {
312 return normalize_base(config_base);
313 }
314 if let Some(url) = ci_pages_url.filter(|u| !u.trim().is_empty()) {
315 return normalize_base(url_path(url));
316 }
317 if let Some(path) = ci_project_path.filter(|p| !p.trim().is_empty()) {
318 return normalize_base(path);
319 }
320 String::new()
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn default_is_pre_p6_behaviour() {
329 let c = SiteConfig::default();
330 assert_eq!(c.title, None);
331 assert_eq!(c.base, "");
332 assert!(c.features.graph && c.features.math && c.features.mermaid && c.features.search);
333 assert!(c.features.plantuml);
334 assert!(c.features.bases);
335 assert!(c.features.diff);
336 assert_eq!(c.components.dir, "components");
337 assert_eq!(c.plantuml.server, DEFAULT_PLANTUML_SERVER);
338 }
339
340 #[test]
341 fn parses_plantuml_section_and_feature_toggle() {
342 let dir = tempfile::tempdir().unwrap();
343 std::fs::write(
344 dir.path().join("docgen.toml"),
345 "[features]\nplantuml = false\n[plantuml]\nserver = \"http://uml.local:9000/\"\n",
346 )
347 .unwrap();
348 let c = load(dir.path()).unwrap();
349 assert!(!c.features.plantuml);
350 assert_eq!(c.plantuml.server, "http://uml.local:9000/");
351 }
352
353 #[test]
354 fn resolve_plantuml_server_precedence() {
355 assert_eq!(
357 resolve_plantuml_server_from("http://from-toml", Some("http://env:8080/".into())),
358 "http://env:8080"
359 );
360 assert_eq!(
362 resolve_plantuml_server_from("http://from-toml", Some(" ".into())),
363 "http://from-toml"
364 );
365 assert_eq!(
367 resolve_plantuml_server_from("http://from-toml/", None),
368 "http://from-toml"
369 );
370 assert_eq!(
372 resolve_plantuml_server_from("", None),
373 DEFAULT_PLANTUML_SERVER
374 );
375 }
376
377 #[test]
378 fn missing_file_yields_default() {
379 let dir = tempfile::tempdir().unwrap();
380 assert_eq!(load(dir.path()).unwrap(), SiteConfig::default());
381 }
382
383 #[test]
384 fn parses_title_base_and_feature_toggles() {
385 let dir = tempfile::tempdir().unwrap();
386 std::fs::write(
387 dir.path().join("docgen.toml"),
388 "title = \"My Docs\"\nbase = \"/docs\"\n[features]\ngraph = false\nmermaid = false\n",
389 )
390 .unwrap();
391 let c = load(dir.path()).unwrap();
392 assert_eq!(c.title.as_deref(), Some("My Docs"));
393 assert_eq!(c.base, "/docs");
394 assert!(!c.features.graph);
395 assert!(!c.features.mermaid);
396 assert!(c.features.math);
398 assert!(c.features.search);
399 }
400
401 #[test]
402 fn partial_features_table_keeps_other_defaults() {
403 let dir = tempfile::tempdir().unwrap();
404 std::fs::write(
405 dir.path().join("docgen.toml"),
406 "[features]\nsearch = false\n",
407 )
408 .unwrap();
409 let c = load(dir.path()).unwrap();
410 assert!(!c.features.search);
411 assert!(c.features.graph);
412 }
413
414 #[test]
415 fn parses_diff_feature_toggle() {
416 let dir = tempfile::tempdir().unwrap();
417 std::fs::write(dir.path().join("docgen.toml"), "[features]\ndiff = false\n").unwrap();
418 let c = load(dir.path()).unwrap();
419 assert!(!c.features.diff);
420 assert!(c.features.graph);
422 assert!(c.features.search);
423 }
424
425 #[test]
426 fn malformed_toml_is_an_error() {
427 let dir = tempfile::tempdir().unwrap();
428 std::fs::write(dir.path().join("docgen.toml"), "title = = =\n").unwrap();
429 assert!(load(dir.path()).is_err());
430 }
431
432 #[test]
433 fn normalize_base_canonicalizes() {
434 assert_eq!(normalize_base(""), "");
435 assert_eq!(normalize_base("/"), "");
436 assert_eq!(normalize_base("docs"), "/docs");
437 assert_eq!(normalize_base("/docs/"), "/docs");
438 assert_eq!(normalize_base("docs/"), "/docs");
439 assert_eq!(normalize_base("/group/project/"), "/group/project");
441 assert_eq!(normalize_base("group/project"), "/group/project");
442 }
443
444 #[test]
445 fn url_path_extracts_path_component() {
446 assert_eq!(url_path("https://group.gitlab.io/project"), "/project");
448 assert_eq!(
450 url_path("https://gitlab.example.com/group/project"),
451 "/group/project"
452 );
453 assert_eq!(url_path("https://docs.example.com"), "");
455 assert_eq!(url_path("http://host/a/b/"), "/a/b/");
456 }
457
458 #[test]
459 fn resolve_base_precedence() {
460 assert_eq!(
462 resolve_base_from(
463 "/from-toml",
464 Some("/override/"),
465 Some("https://x.io/pages"),
466 Some("g/p")
467 ),
468 "/override"
469 );
470 assert_eq!(
472 resolve_base_from(
473 "/from-toml",
474 Some(""),
475 Some("https://x.io/pages"),
476 Some("g/p")
477 ),
478 ""
479 );
480 assert_eq!(
482 resolve_base_from("/from-toml", None, Some("https://x.io/pages"), Some("g/p")),
483 "/from-toml"
484 );
485 assert_eq!(
487 resolve_base_from(
488 "",
489 None,
490 Some("https://group.gitlab.io/project"),
491 Some("group/project")
492 ),
493 "/project"
494 );
495 assert_eq!(
497 resolve_base_from(
498 "",
499 None,
500 Some("https://gitlab.example.com/group/project"),
501 Some("group/project")
502 ),
503 "/group/project"
504 );
505 assert_eq!(
507 resolve_base_from("", None, None, Some("group/project")),
508 "/group/project"
509 );
510 assert_eq!(
514 resolve_base_from(
515 "",
516 None,
517 Some("https://docs.example.com"),
518 Some("group/project")
519 ),
520 ""
521 );
522 assert_eq!(resolve_base_from("", None, None, None), "");
524 assert_eq!(resolve_base_from(" ", None, None, Some(" ")), "");
525 }
526}
527
528#[cfg(test)]
529mod lint_tests {
530 use super::*;
531
532 #[test]
533 fn absent_lint_section_yields_defaults() {
534 let dir = tempfile::tempdir().unwrap();
535 std::fs::write(dir.path().join("docgen.toml"), "title = \"Docs\"\n").unwrap();
536 let c = load(dir.path()).unwrap();
537 assert_eq!(c.lint, LintConfig::default());
538 assert!(c.lint.ignore.is_empty());
539 assert!(c.lint.rules.is_empty());
540 assert!(!c.lint.plantuml.check_syntax);
541 assert!(!c.lint.mermaid.check_syntax);
542 assert_eq!(c.lint.mermaid.mmdc, "mmdc");
543 assert_eq!(c.lint.external_urls.timeout_secs, 10);
544 assert!(c.lint.external_urls.exclude.is_empty());
545 }
546
547 #[test]
548 fn full_lint_section_parses() {
549 let dir = tempfile::tempdir().unwrap();
550 std::fs::write(
551 dir.path().join("docgen.toml"),
552 r#"
553 [lint]
554 ignore = ["drafts/**", "archive/*.md"]
555
556 [lint.rules]
557 orphan-page = "warn"
558 broken-wikilink = "error"
559
560 [lint.plantuml]
561 check-syntax = true
562
563 [lint.mermaid]
564 check-syntax = true
565 mmdc = "/usr/local/bin/mmdc"
566
567 [lint.external-urls]
568 timeout-secs = 5
569 exclude = ["https://intranet.example.com/*"]
570 "#,
571 )
572 .unwrap();
573 let c = load(dir.path()).unwrap();
574 assert_eq!(c.lint.ignore, vec!["drafts/**", "archive/*.md"]);
575 assert_eq!(
576 c.lint.rules.get("orphan-page").map(String::as_str),
577 Some("warn")
578 );
579 assert_eq!(
580 c.lint.rules.get("broken-wikilink").map(String::as_str),
581 Some("error")
582 );
583 assert!(c.lint.plantuml.check_syntax);
584 assert!(c.lint.mermaid.check_syntax);
585 assert_eq!(c.lint.mermaid.mmdc, "/usr/local/bin/mmdc");
586 assert_eq!(c.lint.external_urls.timeout_secs, 5);
587 assert_eq!(
588 c.lint.external_urls.exclude,
589 vec!["https://intranet.example.com/*"]
590 );
591 }
592
593 #[test]
594 fn malformed_severity_is_accepted_at_parse_time() {
595 let dir = tempfile::tempdir().unwrap();
598 std::fs::write(
599 dir.path().join("docgen.toml"),
600 "[lint.rules]\norphan-page = \"loud\"\n",
601 )
602 .unwrap();
603 let c = load(dir.path()).unwrap();
604 assert_eq!(
605 c.lint.rules.get("orphan-page").map(String::as_str),
606 Some("loud")
607 );
608 }
609}
610
611#[cfg(test)]
612mod s3_tests {
613 use super::*;
614
615 #[test]
616 fn s3_section_parses_all_fields() {
617 let cfg: SiteConfig = toml::from_str(
618 r#"
619 [s3]
620 bucket = "my-docs-assets"
621 region = "us-east-1"
622 endpoint = "https://minio.local:9000"
623 prefix = "docs-assets"
624 public_url = "https://cdn.example.com"
625 path_style = true
626 "#,
627 )
628 .expect("parse");
629 let s3 = cfg.s3.expect("s3 present");
630 assert_eq!(s3.bucket, "my-docs-assets");
631 assert_eq!(s3.region, "us-east-1");
632 assert_eq!(s3.endpoint.as_deref(), Some("https://minio.local:9000"));
633 assert_eq!(s3.prefix.as_deref(), Some("docs-assets"));
634 assert_eq!(s3.public_url, "https://cdn.example.com");
635 assert!(s3.path_style);
636 }
637
638 #[test]
639 fn s3_optional_fields_default() {
640 let cfg: SiteConfig = toml::from_str(
641 r#"
642 [s3]
643 bucket = "b"
644 region = "auto"
645 public_url = "https://x"
646 "#,
647 )
648 .expect("parse");
649 let s3 = cfg.s3.expect("s3 present");
650 assert_eq!(s3.endpoint, None);
651 assert_eq!(s3.prefix, None);
652 assert!(!s3.path_style);
653 }
654
655 #[test]
656 fn s3_missing_required_field_errors() {
657 let err = toml::from_str::<SiteConfig>(
659 r#"
660 [s3]
661 region = "auto"
662 public_url = "https://x"
663 "#,
664 );
665 assert!(err.is_err(), "expected missing-field error, got {err:?}");
666 }
667
668 #[test]
669 fn no_s3_section_is_none() {
670 let cfg: SiteConfig = toml::from_str(r#"title = "Docs""#).expect("parse");
671 assert_eq!(cfg.s3, None);
672 }
673}