use std::path::Path;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Features {
pub graph: bool,
pub math: bool,
pub mermaid: bool,
pub plantuml: bool,
pub search: bool,
}
impl Default for Features {
fn default() -> Self {
Self {
graph: true,
math: true,
mermaid: true,
plantuml: true,
search: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct ComponentsConfig {
pub dir: String,
}
impl Default for ComponentsConfig {
fn default() -> Self {
Self {
dir: "components".to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct PlantumlConfig {
pub server: String,
}
impl Default for PlantumlConfig {
fn default() -> Self {
Self {
server: DEFAULT_PLANTUML_SERVER.to_string(),
}
}
}
pub const DEFAULT_PLANTUML_SERVER: &str = "http://localhost:8080";
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct S3Config {
pub bucket: String,
pub region: String,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub prefix: Option<String>,
pub public_url: String,
#[serde(default)]
pub path_style: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(default)]
pub struct SiteConfig {
pub title: Option<String>,
pub base: String,
pub features: Features,
pub components: ComponentsConfig,
pub plantuml: PlantumlConfig,
pub s3: Option<S3Config>,
}
pub fn resolve_plantuml_server(config_server: &str) -> String {
resolve_plantuml_server_from(config_server, std::env::var("DOCGEN_PLANTUML_SERVER").ok())
}
fn resolve_plantuml_server_from(config_server: &str, env: Option<String>) -> String {
let chosen = match env {
Some(v) if !v.trim().is_empty() => v,
_ if !config_server.trim().is_empty() => config_server.to_string(),
_ => DEFAULT_PLANTUML_SERVER.to_string(),
};
chosen.trim().trim_end_matches('/').to_string()
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("reading {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("parsing {path}: {source}")]
Parse {
path: String,
#[source]
source: toml::de::Error,
},
}
pub fn load(project_root: &Path) -> Result<SiteConfig, ConfigError> {
let path = project_root.join("docgen.toml");
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SiteConfig::default()),
Err(e) => {
return Err(ConfigError::Io {
path: path.display().to_string(),
source: e,
})
}
};
toml::from_str(&text).map_err(|e| ConfigError::Parse {
path: path.display().to_string(),
source: e,
})
}
pub fn normalize_base(base: &str) -> String {
let trimmed = base.trim().trim_matches('/');
if trimmed.is_empty() {
String::new()
} else {
format!("/{trimmed}")
}
}
fn url_path(url: &str) -> &str {
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
match after_scheme.find('/') {
Some(i) => &after_scheme[i..],
None => "",
}
}
pub fn resolve_base(config_base: &str) -> String {
resolve_base_from(
config_base,
std::env::var("DOCGEN_BASE").ok().as_deref(),
std::env::var("CI_PAGES_URL").ok().as_deref(),
std::env::var("CI_PROJECT_PATH").ok().as_deref(),
)
}
fn resolve_base_from(
config_base: &str,
docgen_base_env: Option<&str>,
ci_pages_url: Option<&str>,
ci_project_path: Option<&str>,
) -> String {
if let Some(explicit) = docgen_base_env {
return normalize_base(explicit);
}
if !config_base.trim().is_empty() {
return normalize_base(config_base);
}
if let Some(url) = ci_pages_url.filter(|u| !u.trim().is_empty()) {
return normalize_base(url_path(url));
}
if let Some(path) = ci_project_path.filter(|p| !p.trim().is_empty()) {
return normalize_base(path);
}
String::new()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_pre_p6_behaviour() {
let c = SiteConfig::default();
assert_eq!(c.title, None);
assert_eq!(c.base, "");
assert!(c.features.graph && c.features.math && c.features.mermaid && c.features.search);
assert!(c.features.plantuml);
assert_eq!(c.components.dir, "components");
assert_eq!(c.plantuml.server, DEFAULT_PLANTUML_SERVER);
}
#[test]
fn parses_plantuml_section_and_feature_toggle() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("docgen.toml"),
"[features]\nplantuml = false\n[plantuml]\nserver = \"http://uml.local:9000/\"\n",
)
.unwrap();
let c = load(dir.path()).unwrap();
assert!(!c.features.plantuml);
assert_eq!(c.plantuml.server, "http://uml.local:9000/");
}
#[test]
fn resolve_plantuml_server_precedence() {
assert_eq!(
resolve_plantuml_server_from("http://from-toml", Some("http://env:8080/".into())),
"http://env:8080"
);
assert_eq!(
resolve_plantuml_server_from("http://from-toml", Some(" ".into())),
"http://from-toml"
);
assert_eq!(
resolve_plantuml_server_from("http://from-toml/", None),
"http://from-toml"
);
assert_eq!(
resolve_plantuml_server_from("", None),
DEFAULT_PLANTUML_SERVER
);
}
#[test]
fn missing_file_yields_default() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(load(dir.path()).unwrap(), SiteConfig::default());
}
#[test]
fn parses_title_base_and_feature_toggles() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("docgen.toml"),
"title = \"My Docs\"\nbase = \"/docs\"\n[features]\ngraph = false\nmermaid = false\n",
)
.unwrap();
let c = load(dir.path()).unwrap();
assert_eq!(c.title.as_deref(), Some("My Docs"));
assert_eq!(c.base, "/docs");
assert!(!c.features.graph);
assert!(!c.features.mermaid);
assert!(c.features.math);
assert!(c.features.search);
}
#[test]
fn partial_features_table_keeps_other_defaults() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("docgen.toml"),
"[features]\nsearch = false\n",
)
.unwrap();
let c = load(dir.path()).unwrap();
assert!(!c.features.search);
assert!(c.features.graph);
}
#[test]
fn malformed_toml_is_an_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("docgen.toml"), "title = = =\n").unwrap();
assert!(load(dir.path()).is_err());
}
#[test]
fn normalize_base_canonicalizes() {
assert_eq!(normalize_base(""), "");
assert_eq!(normalize_base("/"), "");
assert_eq!(normalize_base("docs"), "/docs");
assert_eq!(normalize_base("/docs/"), "/docs");
assert_eq!(normalize_base("docs/"), "/docs");
assert_eq!(normalize_base("/group/project/"), "/group/project");
assert_eq!(normalize_base("group/project"), "/group/project");
}
#[test]
fn url_path_extracts_path_component() {
assert_eq!(url_path("https://group.gitlab.io/project"), "/project");
assert_eq!(
url_path("https://gitlab.example.com/group/project"),
"/group/project"
);
assert_eq!(url_path("https://docs.example.com"), "");
assert_eq!(url_path("http://host/a/b/"), "/a/b/");
}
#[test]
fn resolve_base_precedence() {
assert_eq!(
resolve_base_from(
"/from-toml",
Some("/override/"),
Some("https://x.io/pages"),
Some("g/p")
),
"/override"
);
assert_eq!(
resolve_base_from(
"/from-toml",
Some(""),
Some("https://x.io/pages"),
Some("g/p")
),
""
);
assert_eq!(
resolve_base_from("/from-toml", None, Some("https://x.io/pages"), Some("g/p")),
"/from-toml"
);
assert_eq!(
resolve_base_from(
"",
None,
Some("https://group.gitlab.io/project"),
Some("group/project")
),
"/project"
);
assert_eq!(
resolve_base_from(
"",
None,
Some("https://gitlab.example.com/group/project"),
Some("group/project")
),
"/group/project"
);
assert_eq!(
resolve_base_from("", None, None, Some("group/project")),
"/group/project"
);
assert_eq!(
resolve_base_from(
"",
None,
Some("https://docs.example.com"),
Some("group/project")
),
""
);
assert_eq!(resolve_base_from("", None, None, None), "");
assert_eq!(resolve_base_from(" ", None, None, Some(" ")), "");
}
}
#[cfg(test)]
mod s3_tests {
use super::*;
#[test]
fn s3_section_parses_all_fields() {
let cfg: SiteConfig = toml::from_str(
r#"
[s3]
bucket = "my-docs-assets"
region = "us-east-1"
endpoint = "https://minio.local:9000"
prefix = "docs-assets"
public_url = "https://cdn.example.com"
path_style = true
"#,
)
.expect("parse");
let s3 = cfg.s3.expect("s3 present");
assert_eq!(s3.bucket, "my-docs-assets");
assert_eq!(s3.region, "us-east-1");
assert_eq!(s3.endpoint.as_deref(), Some("https://minio.local:9000"));
assert_eq!(s3.prefix.as_deref(), Some("docs-assets"));
assert_eq!(s3.public_url, "https://cdn.example.com");
assert!(s3.path_style);
}
#[test]
fn s3_optional_fields_default() {
let cfg: SiteConfig = toml::from_str(
r#"
[s3]
bucket = "b"
region = "auto"
public_url = "https://x"
"#,
)
.expect("parse");
let s3 = cfg.s3.expect("s3 present");
assert_eq!(s3.endpoint, None);
assert_eq!(s3.prefix, None);
assert!(!s3.path_style);
}
#[test]
fn s3_missing_required_field_errors() {
let err = toml::from_str::<SiteConfig>(
r#"
[s3]
region = "auto"
public_url = "https://x"
"#,
);
assert!(err.is_err(), "expected missing-field error, got {err:?}");
}
#[test]
fn no_s3_section_is_none() {
let cfg: SiteConfig = toml::from_str(r#"title = "Docs""#).expect("parse");
assert_eq!(cfg.s3, None);
}
}