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 search: bool,
21}
22
23impl Default for Features {
24 fn default() -> Self {
25 Self {
26 graph: true,
27 math: true,
28 mermaid: true,
29 search: true,
30 }
31 }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
36#[serde(default)]
37pub struct ComponentsConfig {
38 pub dir: String,
40}
41
42impl Default for ComponentsConfig {
43 fn default() -> Self {
44 Self {
45 dir: "components".to_string(),
46 }
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
52#[serde(default)]
53pub struct SiteConfig {
54 pub title: Option<String>,
57 pub base: String,
62 pub features: Features,
63 pub components: ComponentsConfig,
64}
65
66#[derive(Debug, thiserror::Error)]
67pub enum ConfigError {
68 #[error("reading {path}: {source}")]
69 Io {
70 path: String,
71 #[source]
72 source: std::io::Error,
73 },
74 #[error("parsing {path}: {source}")]
75 Parse {
76 path: String,
77 #[source]
78 source: toml::de::Error,
79 },
80}
81
82pub fn load(project_root: &Path) -> Result<SiteConfig, ConfigError> {
85 let path = project_root.join("docgen.toml");
86 let text = match std::fs::read_to_string(&path) {
87 Ok(t) => t,
88 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SiteConfig::default()),
89 Err(e) => {
90 return Err(ConfigError::Io {
91 path: path.display().to_string(),
92 source: e,
93 })
94 }
95 };
96 toml::from_str(&text).map_err(|e| ConfigError::Parse {
97 path: path.display().to_string(),
98 source: e,
99 })
100}
101
102pub fn normalize_base(base: &str) -> String {
107 let trimmed = base.trim().trim_matches('/');
108 if trimmed.is_empty() {
109 String::new()
110 } else {
111 format!("/{trimmed}")
112 }
113}
114
115fn url_path(url: &str) -> &str {
122 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
123 match after_scheme.find('/') {
124 Some(i) => &after_scheme[i..],
125 None => "",
126 }
127}
128
129pub fn resolve_base(config_base: &str) -> String {
141 resolve_base_from(
142 config_base,
143 std::env::var("DOCGEN_BASE").ok().as_deref(),
144 std::env::var("CI_PAGES_URL").ok().as_deref(),
145 std::env::var("CI_PROJECT_PATH").ok().as_deref(),
146 )
147}
148
149fn resolve_base_from(
152 config_base: &str,
153 docgen_base_env: Option<&str>,
154 ci_pages_url: Option<&str>,
155 ci_project_path: Option<&str>,
156) -> String {
157 if let Some(explicit) = docgen_base_env {
158 return normalize_base(explicit);
159 }
160 if !config_base.trim().is_empty() {
161 return normalize_base(config_base);
162 }
163 if let Some(url) = ci_pages_url.filter(|u| !u.trim().is_empty()) {
164 return normalize_base(url_path(url));
165 }
166 if let Some(path) = ci_project_path.filter(|p| !p.trim().is_empty()) {
167 return normalize_base(path);
168 }
169 String::new()
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn default_is_pre_p6_behaviour() {
178 let c = SiteConfig::default();
179 assert_eq!(c.title, None);
180 assert_eq!(c.base, "");
181 assert!(c.features.graph && c.features.math && c.features.mermaid && c.features.search);
182 assert_eq!(c.components.dir, "components");
183 }
184
185 #[test]
186 fn missing_file_yields_default() {
187 let dir = tempfile::tempdir().unwrap();
188 assert_eq!(load(dir.path()).unwrap(), SiteConfig::default());
189 }
190
191 #[test]
192 fn parses_title_base_and_feature_toggles() {
193 let dir = tempfile::tempdir().unwrap();
194 std::fs::write(
195 dir.path().join("docgen.toml"),
196 "title = \"My Docs\"\nbase = \"/docs\"\n[features]\ngraph = false\nmermaid = false\n",
197 )
198 .unwrap();
199 let c = load(dir.path()).unwrap();
200 assert_eq!(c.title.as_deref(), Some("My Docs"));
201 assert_eq!(c.base, "/docs");
202 assert!(!c.features.graph);
203 assert!(!c.features.mermaid);
204 assert!(c.features.math);
206 assert!(c.features.search);
207 }
208
209 #[test]
210 fn partial_features_table_keeps_other_defaults() {
211 let dir = tempfile::tempdir().unwrap();
212 std::fs::write(
213 dir.path().join("docgen.toml"),
214 "[features]\nsearch = false\n",
215 )
216 .unwrap();
217 let c = load(dir.path()).unwrap();
218 assert!(!c.features.search);
219 assert!(c.features.graph);
220 }
221
222 #[test]
223 fn malformed_toml_is_an_error() {
224 let dir = tempfile::tempdir().unwrap();
225 std::fs::write(dir.path().join("docgen.toml"), "title = = =\n").unwrap();
226 assert!(load(dir.path()).is_err());
227 }
228
229 #[test]
230 fn normalize_base_canonicalizes() {
231 assert_eq!(normalize_base(""), "");
232 assert_eq!(normalize_base("/"), "");
233 assert_eq!(normalize_base("docs"), "/docs");
234 assert_eq!(normalize_base("/docs/"), "/docs");
235 assert_eq!(normalize_base("docs/"), "/docs");
236 assert_eq!(normalize_base("/group/project/"), "/group/project");
238 assert_eq!(normalize_base("group/project"), "/group/project");
239 }
240
241 #[test]
242 fn url_path_extracts_path_component() {
243 assert_eq!(url_path("https://group.gitlab.io/project"), "/project");
245 assert_eq!(
247 url_path("https://gitlab.example.com/group/project"),
248 "/group/project"
249 );
250 assert_eq!(url_path("https://docs.example.com"), "");
252 assert_eq!(url_path("http://host/a/b/"), "/a/b/");
253 }
254
255 #[test]
256 fn resolve_base_precedence() {
257 assert_eq!(
259 resolve_base_from(
260 "/from-toml",
261 Some("/override/"),
262 Some("https://x.io/pages"),
263 Some("g/p")
264 ),
265 "/override"
266 );
267 assert_eq!(
269 resolve_base_from(
270 "/from-toml",
271 Some(""),
272 Some("https://x.io/pages"),
273 Some("g/p")
274 ),
275 ""
276 );
277 assert_eq!(
279 resolve_base_from("/from-toml", None, Some("https://x.io/pages"), Some("g/p")),
280 "/from-toml"
281 );
282 assert_eq!(
284 resolve_base_from(
285 "",
286 None,
287 Some("https://group.gitlab.io/project"),
288 Some("group/project")
289 ),
290 "/project"
291 );
292 assert_eq!(
294 resolve_base_from(
295 "",
296 None,
297 Some("https://gitlab.example.com/group/project"),
298 Some("group/project")
299 ),
300 "/group/project"
301 );
302 assert_eq!(
304 resolve_base_from("", None, None, Some("group/project")),
305 "/group/project"
306 );
307 assert_eq!(
311 resolve_base_from(
312 "",
313 None,
314 Some("https://docs.example.com"),
315 Some("group/project")
316 ),
317 ""
318 );
319 assert_eq!(resolve_base_from("", None, None, None), "");
321 assert_eq!(resolve_base_from(" ", None, None, Some(" ")), "");
322 }
323}