1use std::path::Path;
18
19use anyhow::{Context, Result};
20use clap::Args;
21use serde::Deserialize;
22use walkdir::WalkDir;
23
24use super::util::{cmd, workspace};
25
26#[derive(Debug, serde::Serialize)]
28struct LocaleEntry {
29 code: String,
30 label: String,
31}
32
33#[derive(Debug, Args)]
35pub struct DocsArgs {
36 #[arg(long, default_value = "http://localhost")]
40 pub base_url: String,
41
42 #[arg(long)]
45 pub mdbook_only: bool,
46}
47
48#[derive(Debug, Deserialize)]
49struct CargoMetadata {
50 packages: Vec<CargoPackage>,
51 target_directory: String,
52}
53
54#[derive(Debug, Deserialize)]
55struct CargoPackage {
56 name: String,
57 version: String,
58}
59
60pub fn run(args: DocsArgs) -> Result<()> {
67 let root = workspace::root()?;
68
69 install_mermaid_assets(&root)?;
70 build_mdbook(&root)?;
71 build_translated_books(&root)?;
72
73 if args.mdbook_only {
74 return Ok(());
75 }
76
77 build_cargo_doc(&root)?;
78 inject_git_version(&root)?;
79 write_locales_json(&root)?;
80 create_portal_symlinks(&root)?;
81 build_zola(&root, &args.base_url)?;
82
83 println!("\nDocumentation built successfully!");
84 println!("Output: docs-portal/public/");
85
86 Ok(())
87}
88
89fn install_mermaid_assets(root: &Path) -> Result<()> {
93 println!("Installing mdbook-mermaid assets…");
94 cmd::run("mdbook-mermaid", &["install", "docs"], root, &[])
95}
96
97fn build_mdbook(root: &Path) -> Result<()> {
99 println!("Building mdBook documentation…");
100 cmd::run("mdbook", &["build"], &root.join("docs"), &[])
101}
102
103fn build_cargo_doc(root: &Path) -> Result<()> {
105 println!("Building Rust API documentation…");
106 cmd::run(
107 "cargo",
108 &["doc", "--no-deps", "--document-private-items"],
109 root,
110 &[],
111 )
112}
113
114fn inject_git_version(root: &Path) -> Result<()> {
121 let workspace_version = read_workspace_version(root)?;
122 let git_version = read_git_version(root)?;
123
124 if workspace_version == git_version {
125 return Ok(());
126 }
127
128 println!("Injecting git version '{git_version}' into Rust docs…");
129
130 let doc_dir = root.join("target/doc");
131 if !doc_dir.exists() {
132 return Ok(());
133 }
134
135 replace_version_in_html(&doc_dir, &workspace_version, &git_version)
136}
137
138fn read_workspace_version(root: &Path) -> Result<String> {
140 let metadata = cargo_metadata(root)?;
141 metadata
142 .packages
143 .into_iter()
144 .find(|p| p.name == "cadmus")
145 .map(|p| p.version)
146 .context("cadmus package not found in cargo metadata")
147}
148
149fn read_git_version(root: &Path) -> Result<String> {
151 cmd::output(
152 "git",
153 &["describe", "--tags", "--always", "--dirty"],
154 root,
155 &[],
156 )
157}
158
159fn replace_version_in_html(
162 doc_dir: &Path,
163 workspace_version: &str,
164 git_version: &str,
165) -> Result<()> {
166 let old = format!(r#"<span class="version">{workspace_version}</span>"#);
167 let new = format!(r#"<span class="version">{git_version}</span>"#);
168
169 for entry in WalkDir::new(doc_dir) {
170 let entry = entry.with_context(|| format!("failed to walk {}", doc_dir.display()))?;
171 let path = entry.path();
172
173 if !path.extension().is_some_and(|ext| ext == "html") {
174 continue;
175 }
176
177 let content = std::fs::read_to_string(path)
178 .with_context(|| format!("failed to read {}", path.display()))?;
179
180 if content.contains(&old) {
181 let updated = content.replace(&old, &new);
182 std::fs::write(path, updated)
183 .with_context(|| format!("failed to write {}", path.display()))?;
184 }
185 }
186
187 Ok(())
188}
189
190fn build_zola(root: &Path, base_url: &str) -> Result<()> {
192 println!("Building Zola documentation portal…");
193 cmd::run(
194 "zola",
195 &["build", "--base-url", base_url],
196 &root.join("docs-portal"),
197 &[],
198 )
199}
200
201fn cargo_metadata(root: &Path) -> Result<CargoMetadata> {
203 let json = cmd::output(
204 "cargo",
205 &["metadata", "--format-version=1", "--no-deps"],
206 root,
207 &[],
208 )?;
209 serde_json::from_str(&json).context("failed to parse cargo metadata JSON")
210}
211
212fn symlink_force(target: &Path, link: &Path) -> Result<()> {
213 if link.exists() || link.symlink_metadata().is_ok() {
214 std::fs::remove_file(link)
215 .with_context(|| format!("failed to remove {}", link.display()))?;
216 }
217
218 #[cfg(unix)]
219 std::os::unix::fs::symlink(target, link)
220 .with_context(|| format!("failed to create symlink {}", link.display()))?;
221
222 #[cfg(not(unix))]
223 {
224 if target.is_dir() {
225 std::os::windows::fs::symlink_dir(target, link)
226 .with_context(|| format!("failed to create dir symlink {}", link.display()))?;
227 } else {
228 std::fs::copy(target, link)
229 .with_context(|| format!("failed to copy {}", target.display()))?;
230 }
231 }
232
233 Ok(())
234}
235
236fn build_translated_books(root: &Path) -> Result<()> {
238 let po_dir = root.join("docs/po");
239 if !po_dir.exists() {
240 println!("No PO directory found, skipping translated books.");
241 return Ok(());
242 }
243
244 println!("Building translated books…");
245 for entry in WalkDir::new(&po_dir)
246 .min_depth(1)
247 .max_depth(1)
248 .into_iter()
249 .filter_map(|e| e.ok())
250 {
251 let path = entry.path();
252 if path.extension().is_some_and(|ext| ext == "po") {
253 let lang = path
254 .file_stem()
255 .and_then(|s| s.to_str())
256 .context("Invalid locale filename")?;
257
258 println!("Building {lang} translation…");
259 cmd::run(
260 "mdbook",
261 &["build", "-d", &format!("book/{lang}")],
262 &root.join("docs"),
263 &[("MDBOOK_BOOK__LANGUAGE", lang)],
264 )?;
265 }
266 }
267
268 Ok(())
269}
270
271fn write_locales_json(root: &Path) -> Result<()> {
273 let po_dir = root.join("docs/po");
274 if !po_dir.exists() {
275 return Ok(());
276 }
277
278 let mut locales = Vec::new();
279 for entry in WalkDir::new(&po_dir)
280 .min_depth(1)
281 .max_depth(1)
282 .into_iter()
283 .filter_map(|e| e.ok())
284 {
285 let path = entry.path();
286 if path.extension().is_some_and(|ext| ext == "po") {
287 let lang = path
288 .file_stem()
289 .and_then(|s| s.to_str())
290 .context("Invalid locale filename")?;
291
292 let label = extract_lang_name(path).unwrap_or_else(|| lang.to_string());
293 locales.push(LocaleEntry {
294 code: lang.to_string(),
295 label,
296 });
297 }
298 }
299
300 locales.sort_by(|a, b| a.code.cmp(&b.code));
302
303 let output_path = root.join("docs/book/html/locales.json");
304 std::fs::create_dir_all(output_path.parent().context("no parent dir")?)?;
305 let json = serde_json::to_string_pretty(&locales)?;
306 std::fs::write(&output_path, json).context("failed to write locales.json")?;
307
308 println!(
309 "Wrote {} locales to {}",
310 locales.len(),
311 output_path.display()
312 );
313 Ok(())
314}
315
316fn extract_lang_name(po_path: &Path) -> Option<String> {
318 let content = std::fs::read_to_string(po_path).ok()?;
319 for line in content.lines() {
320 let line = line.trim_start();
321 if let Some(rest) = line.strip_prefix("Language-Name:") {
322 let name = rest.trim();
323 if name.is_empty() {
324 return None;
325 } else {
326 return Some(name.to_string());
327 }
328 }
329 }
330 None
331}
332
333fn create_portal_symlinks(root: &Path) -> Result<()> {
336 let metadata = cargo_metadata(root)?;
337
338 let api_link = root.join("docs-portal/static/api");
339 let guide_link = root.join("docs-portal/static/guide");
340
341 symlink_force(
342 Path::new(&format!("{}/doc", metadata.target_directory)),
343 &api_link,
344 )?;
345 symlink_force(&root.join("docs/book/html"), &guide_link)?;
346
347 let po_dir = root.join("docs/po");
349 if po_dir.exists() {
350 for entry in WalkDir::new(&po_dir)
351 .min_depth(1)
352 .max_depth(1)
353 .into_iter()
354 .filter_map(|e| e.ok())
355 {
356 let path = entry.path();
357 if path.extension().is_some_and(|ext| ext == "po") {
358 let lang = path
359 .file_stem()
360 .and_then(|s| s.to_str())
361 .context("Invalid locale filename")?;
362
363 let target = root.join("docs/book").join(lang).join("html");
364 let link = root.join("docs-portal/static/guide").join(lang);
365 symlink_force(&target, &link)?;
366 }
367 }
368 }
369
370 Ok(())
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use std::fs;
377 use tempfile::TempDir;
378
379 #[test]
380 fn extract_lang_name_with_valid_header() {
381 let temp_dir = TempDir::new().unwrap();
382 let po_file = temp_dir.path().join("test.po");
383 fs::write(
384 &po_file,
385 r#"msgid ""
386msgstr ""
387Language-Name: Español
388
389msgid "hello"
390msgstr "hola"
391"#,
392 )
393 .unwrap();
394
395 let result = extract_lang_name(&po_file);
396 assert_eq!(result, Some("Español".to_string()));
397 }
398
399 #[test]
400 fn extract_lang_name_with_leading_whitespace() {
401 let temp_dir = TempDir::new().unwrap();
402 let po_file = temp_dir.path().join("test.po");
403 fs::write(
404 &po_file,
405 r#"msgid ""
406msgstr ""
407Language-Name: Français
408
409msgid "hello"
410msgstr "bonjour"
411"#,
412 )
413 .unwrap();
414
415 let result = extract_lang_name(&po_file);
416 assert_eq!(result, Some("Français".to_string()));
417 }
418
419 #[test]
420 fn extract_lang_name_missing_header() {
421 let temp_dir = TempDir::new().unwrap();
422 let po_file = temp_dir.path().join("test.po");
423 fs::write(
424 &po_file,
425 r#"msgid "hello"
426msgstr "hola"
427"#,
428 )
429 .unwrap();
430
431 let result = extract_lang_name(&po_file);
432 assert_eq!(result, None);
433 }
434
435 #[test]
436 fn extract_lang_name_empty_language_name() {
437 let temp_dir = TempDir::new().unwrap();
438 let po_file = temp_dir.path().join("test.po");
439 fs::write(
440 &po_file,
441 r#"msgid ""
442msgstr ""
443Language-Name:
444
445msgid "hello"
446msgstr "hola"
447"#,
448 )
449 .unwrap();
450
451 let result = extract_lang_name(&po_file);
452 assert_eq!(result, None);
453 }
454}