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 search: bool,
}
impl Default for Features {
fn default() -> Self {
Self {
graph: true,
math: true,
mermaid: 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, Default, Deserialize)]
#[serde(default)]
pub struct SiteConfig {
pub title: Option<String>,
pub base: String,
pub features: Features,
pub components: ComponentsConfig,
}
#[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_eq!(c.components.dir, "components");
}
#[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(" ")), "");
}
}