Skip to main content

abbaye/
site.rs

1use std::{
2    future::Future,
3    path::{Path, PathBuf},
4    pin::Pin,
5    time::Duration,
6};
7
8use chrono::{DateTime, SecondsFormat, Utc};
9use sha2::{Digest, Sha256};
10
11use flate2::{Compression, write::GzEncoder};
12use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
13use miette::{IntoDiagnostic, Result};
14use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};
15use std::collections::HashMap;
16use tera::{Context, Tera};
17use tokio::sync::{mpsc, watch};
18use tokio::task::JoinSet;
19use tracing::warn;
20
21use crate::{
22    builders::LogEvent, changelog::ChangelogExtractor, config::AbbayeConfig,
23    version_extractors::VersionInfo,
24};
25
26pub const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html.j2");
27pub const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html.j2");
28pub const SITE_CSS: &str = include_str!("templates/site.css");
29const ATOM_FEED_FILENAME: &str = "releases.atom";
30
31// ── Types ───────────────────────────────────────────────────────────────────
32
33/// A version entry as passed to Tera templates.
34#[derive(serde::Serialize)]
35struct VersionEntry {
36    /// Version string (e.g. `"1.2.3"`).
37    version: String,
38    /// ISO-8601 date string (e.g. `"2024-01-15"`) when the release date is known.
39    date: Option<String>,
40}
41
42impl VersionEntry {
43    fn from_info(info: &VersionInfo) -> Self {
44        Self {
45            version: info.version.clone(),
46            date: info.date.map(|dt| dt.format("%Y-%m-%d").to_string()),
47        }
48    }
49}
50
51/// Metadata about a single file-type dist artifact, passed to Tera templates.
52#[derive(serde::Serialize)]
53struct DistFileInfo {
54    /// File name (relative to `dist/`).
55    name: String,
56    /// Raw byte size of the file.
57    size_bytes: u64,
58    /// Human-readable size (e.g. "1.4 MB").
59    size_human: String,
60    /// Lowercase hex-encoded SHA-256 digest of the file contents.
61    sha256: String,
62}
63
64/// Build the full website into `config.output_dir` (defaults to `public/`).
65///
66/// # Steps
67///
68/// 1. Extract the current version via the configured [`crate::version_extractors::AnyVersionExtractor`].
69/// 2. Run every configured builder and split the resulting [`crate::builders::ArtifactPath`]s
70///    into *dist* (regular files) and *docs* (directories).
71/// 3. Copy dist artifacts to `<output>/<version>/dist/`.
72/// 4. Copy doc directories to `<output>/<version>/docs/<crate>/` and archive
73///    the whole `docs/` tree as `docs.tar.gz`.
74/// 5. Render `<output>/<version>/index.html` from the project README and the
75///    matching changelog section.
76/// 6. Re-render the root `<output>/index.html`, listing all known versions
77///    (newest first).
78/// 7. Update the `<output>/latest` symlink (Unix) or redirect page (other).
79pub async fn build_site(config: AbbayeConfig) -> Result<()> {
80    let output_dir = &config.site.output_dir;
81
82    tokio::fs::create_dir_all(output_dir)
83        .await
84        .into_diagnostic()?;
85
86    // ── 1. Version ────────────────────────────────────────────────────────────
87    let version_info = config.version_extractor.extract().await?;
88    let version = version_info.version.clone();
89
90    // ── 2. Tera setup ─────────────────────────────────────────────────────────
91    let mut tera = Tera::default();
92    let theme_path = PathBuf::from(".abbaye").join("theme");
93    if theme_path.join("root_index.html.j2").is_file() {
94        tera.add_template_file(
95            theme_path.join("root_index.html.j2"),
96            Some("root_index.html"),
97        )
98        .into_diagnostic()?;
99    } else {
100        tera.add_raw_template("root_index.html", TEMPLATE_ROOT_INDEX)
101            .into_diagnostic()?;
102    }
103    if theme_path.join("version_index.html.j2").is_file() {
104        tera.add_template_file(
105            theme_path.join("version_index.html.j2"),
106            Some("version_index.html"),
107        )
108        .into_diagnostic()?;
109    } else {
110        tera.add_raw_template("version_index.html", TEMPLATE_VERSION_INDEX)
111            .into_diagnostic()?;
112    }
113    load_extra_theme_templates(
114        &mut tera,
115        &theme_path,
116        &["root_index.html", "version_index.html"],
117    )?;
118    // Write shared CSS to static/ so the git UI templates can reference it.
119    {
120        let static_dir = output_dir.join("static");
121        tokio::fs::create_dir_all(&static_dir)
122            .await
123            .into_diagnostic()?;
124        tokio::fs::write(static_dir.join("site.css"), SITE_CSS)
125            .await
126            .into_diagnostic()?;
127    }
128    // If there's a `static` directory in the theme, copy it over (may overwrite site.css).
129    if theme_path.join("static").is_dir() {
130        copy_dir_recursive(theme_path.join("static"), output_dir.join("static")).await?;
131    }
132
133    // ── 3. Builders (run in parallel, respecting depends_on ordering) ─────────
134    let mut dist_artifacts = Vec::new();
135    let mut doc_artifacts = Vec::new();
136
137    {
138        use crate::cli::{COLOURS, GREEN, RED, RESET, YELLOW};
139
140        // ── Dependency validation ─────────────────────────────────────────────
141        //
142        // Build a map from id → index so we can reference builders by name.
143        let id_to_idx: HashMap<&str, usize> = config
144            .builders
145            .iter()
146            .enumerate()
147            .filter_map(|(i, e)| e.id.as_deref().map(|id| (id, i)))
148            .collect();
149
150        // Check that every depends_on reference resolves to a known id.
151        for (i, entry) in config.builders.iter().enumerate() {
152            for dep in &entry.depends_on {
153                if !id_to_idx.contains_key(dep.as_str()) {
154                    return Err(miette::miette!(
155                        "builder #{i} ({}) lists '{}' in depends_on, \
156                         but no builder has that id",
157                        entry.label(),
158                        dep
159                    ));
160                }
161            }
162        }
163
164        // Cycle detection via iterative DFS (0=unvisited, 1=in-stack, 2=done).
165        {
166            let n = config.builders.len();
167            let mut state = vec![0u8; n];
168
169            fn dfs(
170                idx: usize,
171                id_to_idx: &HashMap<&str, usize>,
172                builders: &[crate::builders::BuilderEntry],
173                state: &mut Vec<u8>,
174            ) -> Result<()> {
175                if state[idx] == 1 {
176                    return Err(miette::miette!(
177                        "dependency cycle detected involving builder #{idx} ({})",
178                        builders[idx].id.as_deref().unwrap_or(builders[idx].label())
179                    ));
180                }
181                if state[idx] == 2 {
182                    return Ok(());
183                }
184                state[idx] = 1;
185                for dep in &builders[idx].depends_on {
186                    if let Some(&dep_idx) = id_to_idx.get(dep.as_str()) {
187                        dfs(dep_idx, id_to_idx, builders, state)?;
188                    }
189                }
190                state[idx] = 2;
191                Ok(())
192            }
193
194            for i in 0..n {
195                dfs(i, &id_to_idx, &config.builders, &mut state)?;
196            }
197        }
198
199        // ── Completion signals ────────────────────────────────────────────────
200        //
201        // For every builder that carries an `id` we open a watch channel.
202        // Dependents receive a clone of the receiver and wait until the value
203        // transitions from `None` (pending) to `Some(true)` (success) or
204        // `Some(false)` (failure / dependency failure).
205        let mut completion_txs: HashMap<String, watch::Sender<Option<bool>>> = HashMap::new();
206        let mut completion_rxs: HashMap<String, watch::Receiver<Option<bool>>> = HashMap::new();
207
208        for entry in &config.builders {
209            if let Some(id) = &entry.id {
210                let (tx, rx) = watch::channel(None::<bool>);
211                completion_txs.insert(id.clone(), tx);
212                completion_rxs.insert(id.clone(), rx);
213            }
214        }
215
216        // ── Progress bars & task spawning ─────────────────────────────────────
217        let total = config.builders.len();
218        let multi = MultiProgress::new();
219
220        // Bottom bar: overall completion counter.
221        let summary = multi.add(ProgressBar::new(total as u64));
222        summary.set_style(
223            ProgressStyle::with_template("{pos}/{len} builders  {bar:20.green/white}  {msg}")
224                .expect("valid template"),
225        );
226        summary.set_message("building…");
227
228        let mut join_set: JoinSet<miette::Result<Vec<crate::builders::ArtifactPath>>> =
229            JoinSet::new();
230
231        for (i, entry) in config.builders.iter().enumerate() {
232            let color = COLOURS[i % COLOURS.len()];
233            let label = entry.id.as_deref().unwrap_or(entry.label());
234            let colored_prefix = format!("{color}[{label}]{RESET}");
235
236            // Spinner inserted above the summary bar.
237            let pb = multi.insert_before(&summary, ProgressBar::new_spinner());
238            pb.set_style(
239                ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}")
240                    .expect("valid template")
241                    .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
242            );
243            pb.set_prefix(colored_prefix);
244            pb.set_message("starting…");
245            pb.enable_steady_tick(Duration::from_millis(100));
246
247            let (log_tx, mut log_rx) = mpsc::unbounded_channel::<LogEvent>();
248
249            // Task: receive LogEvents and update spinners.
250            // ChildStart creates a new sub-spinner inserted right below the
251            // parent (and below any previously created siblings, via
252            // `last_child_pb`).  ChildLine / ChildFinish update or finish it.
253            let pb_log = pb.clone();
254            let multi_log = multi.clone();
255            let parent_color_idx = i;
256            tokio::spawn(async move {
257                let mut child_pbs: HashMap<String, ProgressBar> = HashMap::new();
258                // Track insertion point so siblings stack in order.
259                let mut last_child_pb = pb_log.clone();
260                let mut child_color_idx = parent_color_idx + 1;
261
262                while let Some(event) = log_rx.recv().await {
263                    match event {
264                        LogEvent::Line(line) => {
265                            pb_log.set_message(line);
266                        }
267                        LogEvent::ChildStart { id, label } => {
268                            let child_color = COLOURS[child_color_idx % COLOURS.len()];
269                            child_color_idx += 1;
270                            let child_pb =
271                                multi_log.insert_after(&last_child_pb, ProgressBar::new_spinner());
272                            child_pb.set_style(
273                                ProgressStyle::with_template("  {spinner:.bold} {prefix} {msg}")
274                                    .expect("valid template")
275                                    .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
276                            );
277                            child_pb.set_prefix(format!("{child_color}[{label}]{RESET}"));
278                            child_pb.set_message("starting…");
279                            child_pb.enable_steady_tick(Duration::from_millis(100));
280                            last_child_pb = child_pb.clone();
281                            child_pbs.insert(id, child_pb);
282                        }
283                        LogEvent::ChildLine { id, line } => {
284                            if let Some(child_pb) = child_pbs.get(&id) {
285                                child_pb.set_message(line);
286                            }
287                        }
288                        LogEvent::ChildFinish {
289                            id,
290                            success,
291                            summary,
292                        } => {
293                            if let Some(child_pb) = child_pbs.remove(&id) {
294                                if success {
295                                    child_pb.finish_with_message(format!(
296                                        "{GREEN}\u{2713}{RESET} {summary}"
297                                    ));
298                                } else {
299                                    child_pb.finish_with_message(format!(
300                                        "{RED}\u{2717}{RESET} {summary}"
301                                    ));
302                                }
303                            }
304                        }
305                    }
306                }
307            });
308
309            // Collect the watch receivers for every declared dependency.
310            let dep_receivers: Vec<(String, watch::Receiver<Option<bool>>)> = entry
311                .depends_on
312                .iter()
313                .filter_map(|dep_id| {
314                    completion_rxs
315                        .get(dep_id)
316                        .map(|rx| (dep_id.clone(), rx.clone()))
317                })
318                .collect();
319
320            // Take ownership of the completion sender for this builder's own id
321            // (if it has one) so the task can signal its outcome.
322            let my_tx: Option<watch::Sender<Option<bool>>> =
323                entry.id.as_ref().and_then(|id| completion_txs.remove(id));
324
325            let entry = entry.clone();
326            let version = version.clone();
327            let pb_task = pb.clone();
328            let summary_task = summary.clone();
329
330            join_set.spawn(async move {
331                // ── Wait for dependencies ─────────────────────────────────────
332                for (dep_id, mut rx) in dep_receivers {
333                    pb_task.set_message(format!("waiting for '{dep_id}'…"));
334
335                    // Block until the dependency resolves (Some(_)) or its
336                    // sender is dropped (which we treat as a failure).
337                    let resolved = rx.wait_for(|v| v.is_some()).await;
338
339                    let succeeded = match resolved {
340                        Err(_) => false, // sender dropped unexpectedly
341                        Ok(r) => r.unwrap_or(false),
342                    };
343
344                    if !succeeded {
345                        summary_task.inc(1);
346                        pb_task.finish_with_message(format!(
347                            "{YELLOW}\u{29B8} skipped{RESET} (dependency '{dep_id}' failed)"
348                        ));
349                        if let Some(tx) = &my_tx {
350                            let _ = tx.send(Some(false));
351                        }
352                        // Return an empty artifact list; the dependency error
353                        // itself will surface from the dependency's own task.
354                        return Ok(vec![]);
355                    }
356                }
357
358                // ── Run the builder ───────────────────────────────────────────
359                pb_task.set_message("running…");
360                let result = entry.build(&version, log_tx).await;
361                let succeeded = result.is_ok();
362
363                if let Some(tx) = my_tx {
364                    let _ = tx.send(Some(succeeded));
365                }
366
367                summary_task.inc(1);
368                match &result {
369                    Ok(artifacts) => pb_task.finish_with_message(format!(
370                        "{GREEN}\u{2713} done{RESET} ({} artifact(s))",
371                        artifacts.len()
372                    )),
373                    Err(e) => {
374                        pb_task.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}"))
375                    }
376                }
377                result
378            });
379        }
380
381        // Collect all results; continue even when some builders fail so every
382        // spinner reaches its final state before we return an error.
383        let mut errors: Vec<miette::Report> = Vec::new();
384        while let Some(res) = join_set.join_next().await {
385            match res.into_diagnostic()? {
386                Ok(artifacts) => {
387                    for artifact in artifacts {
388                        if artifact.path.is_dir() {
389                            doc_artifacts.push(artifact);
390                        } else {
391                            dist_artifacts.push(artifact);
392                        }
393                    }
394                }
395                Err(e) => errors.push(e),
396            }
397        }
398
399        let summary_msg = if errors.is_empty() {
400            format!("{GREEN}all done{RESET}")
401        } else {
402            format!("{RED}some builders failed{RESET}")
403        };
404        summary.finish_with_message(summary_msg);
405
406        if let Some(first_err) = errors.into_iter().next() {
407            return Err(first_err);
408        }
409    }
410
411    // ── 4. Lay out dist/ ──────────────────────────────────────────────────────
412    let version_dir = output_dir.join(&version);
413    let dist_dir = version_dir.join("dist");
414    tokio::fs::create_dir_all(&dist_dir)
415        .await
416        .into_diagnostic()?;
417
418    for artifact in &dist_artifacts {
419        tokio::fs::copy(&artifact.path, dist_dir.join(&artifact.name))
420            .await
421            .into_diagnostic()?;
422    }
423
424    // Compute size + SHA-256 for each copied dist artifact.
425    let mut dist_file_infos: Vec<DistFileInfo> = Vec::new();
426    for artifact in &dist_artifacts {
427        let dest = dist_dir.join(&artifact.name);
428        let bytes = tokio::fs::read(&dest).await.into_diagnostic()?;
429        let size_bytes = bytes.len() as u64;
430        let sha256 = hex_sha256(&bytes);
431        dist_file_infos.push(DistFileInfo {
432            name: artifact.name.clone(),
433            size_bytes,
434            size_human: human_size(size_bytes),
435            sha256,
436        });
437    }
438    let has_dist = !dist_file_infos.is_empty();
439
440    // ── 5. Lay out docs/ ──────────────────────────────────────────────────────
441    let has_docs = !doc_artifacts.is_empty();
442    let has_docs_tarball;
443
444    if has_docs {
445        let docs_dir = version_dir.join("docs");
446
447        for artifact in &doc_artifacts {
448            // Copy the complete target/doc tree — this includes the shared
449            // rustdoc CSS, JS, fonts and search indices at the root of the
450            // directory in addition to the per-crate HTML subdirectories.
451            copy_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
452        }
453
454        // Rustdoc writes index.html at the root for single-crate projects.
455        // For workspaces it may be absent; generate a fallback listing then.
456        if !docs_dir.join("index.html").exists() {
457            let crate_names = find_doc_crates(&docs_dir).await?;
458            write_docs_index(&docs_dir, &crate_names).await?;
459        }
460
461        let tarball = version_dir.join("docs.tar.gz");
462        let docs_dir_c = docs_dir.clone();
463        let tarball_c = tarball.clone();
464        tokio::task::spawn_blocking(move || archive_dir(&docs_dir_c, &tarball_c))
465            .await
466            .into_diagnostic()??;
467
468        has_docs_tarball = true;
469    } else {
470        has_docs_tarball = false;
471    }
472
473    // ── 6. README ─────────────────────────────────────────────────────────────
474    let readme_path = config
475        .site
476        .readme
477        .as_deref()
478        .unwrap_or(Path::new("README.md"));
479
480    let readme_html = match tokio::fs::read_to_string(readme_path).await {
481        Ok(content) => render_markdown(&content),
482        Err(_) => {
483            warn!("README not found at {}", readme_path.display());
484            String::new()
485        }
486    };
487
488    // Copy any locally-referenced files (e.g. images) from the README's
489    // directory into the version directory so they resolve correctly from
490    // the generated index.html.
491    let readme_dir = readme_path.parent().unwrap_or_else(|| Path::new("."));
492    if let Ok(content) = tokio::fs::read_to_string(readme_path).await {
493        for rel in extract_local_refs(&content) {
494            let src = readme_dir.join(&rel);
495            if src.is_file() {
496                // Preserve any sub-directory structure relative to the README.
497                let dest = version_dir.join(&rel);
498                if let Some(parent) = dest.parent() {
499                    tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
500                }
501                tokio::fs::copy(&src, &dest).await.into_diagnostic()?;
502            }
503        }
504    }
505
506    // ── 7. Changelog section ──────────────────────────────────────────────────
507    let changelog_html = match ChangelogExtractor
508        .section(config.changelog.clone(), &version)
509        .await
510    {
511        Ok(section) => render_markdown(&section),
512        Err(_) => {
513            warn!("No changelog entry found for version {version}");
514            String::new()
515        }
516    };
517
518    // ── 8. Version index.html ─────────────────────────────────────────────────
519
520    // Git UI integration: derive the clone URL once and compute the tag name
521    // for the current version so the templates can link into the repository UI.
522    let git_ui_clone_url: Option<String> = config.git_ui.as_ref().and_then(|cfg| {
523        cfg.clone_url.clone().or_else(|| {
524            config
525                .site
526                .base_url
527                .as_ref()
528                .map(|b| format!("{}/repository.git", b.trim_end_matches('/')))
529        })
530    });
531    let version_tag = config.version_extractor.tag_name(&version);
532
533    let mut version_ctx = Context::new();
534    version_ctx.insert("config", &config);
535    version_ctx.insert("project_name", &config.site.name);
536    version_ctx.insert("lang", &config.site.lang);
537    version_ctx.insert("repo_url", &config.site.repo_url);
538    version_ctx.insert("version", &version);
539    version_ctx.insert("readme_html", &readme_html);
540    version_ctx.insert("changelog_html", &changelog_html);
541    version_ctx.insert("has_docs", &has_docs);
542    version_ctx.insert("has_docs_tarball", &has_docs_tarball);
543    version_ctx.insert("has_dist", &has_dist);
544    version_ctx.insert("dist_files", &dist_file_infos);
545    version_ctx.insert("git_ui_enabled", &config.git_ui.is_some());
546    version_ctx.insert("git_ui_clone_url", &git_ui_clone_url);
547    version_ctx.insert("version_tag", &version_tag);
548
549    let version_html = tera
550        .render("version_index.html", &version_ctx)
551        .into_diagnostic()?;
552    tokio::fs::write(version_dir.join("index.html"), version_html)
553        .await
554        .into_diagnostic()?;
555
556    // ── 9. Root index.html + Atom feed ──────────────────────────────────────
557    // Collect every known version from the version extractor, ensure the
558    // current version is present (handles untagged builds), then sort newest-first.
559    let mut all_versions = config.version_extractor.extract_all().await?;
560    if !all_versions.iter().any(|v| v.version == version) {
561        all_versions.push(version_info.clone());
562    }
563    all_versions.sort_by(|a, b| compare_versions(&b.version, &a.version));
564
565    // Build the template-friendly list (version string + optional date string).
566    let version_entries: Vec<VersionEntry> =
567        all_versions.iter().map(VersionEntry::from_info).collect();
568
569    let base_url = config
570        .site
571        .base_url
572        .as_deref()
573        .map(|u| u.trim_end_matches('/'));
574
575    let mut root_ctx = Context::new();
576    root_ctx.insert("config", &config);
577    root_ctx.insert("project_name", &config.site.name);
578    root_ctx.insert("lang", &config.site.lang);
579    root_ctx.insert("repo_url", &config.site.repo_url);
580    root_ctx.insert("versions", &version_entries);
581    root_ctx.insert("atom_feed", ATOM_FEED_FILENAME);
582    root_ctx.insert("git_ui_enabled", &config.git_ui.is_some());
583    root_ctx.insert("git_ui_clone_url", &git_ui_clone_url);
584
585    let root_html = tera
586        .render("root_index.html", &root_ctx)
587        .into_diagnostic()?;
588    tokio::fs::write(output_dir.join("index.html"), root_html)
589        .await
590        .into_diagnostic()?;
591
592    // ── 10. Atom feed ─────────────────────────────────────────────────────────
593    // Load all changelog sections once so we can embed release notes in feed entries.
594    let changelog_sections = match ChangelogExtractor
595        .all_sections(config.changelog.clone())
596        .await
597    {
598        Ok(map) => map,
599        Err(_) => {
600            warn!("Could not load changelog for Atom feed; entries will have no content");
601            std::collections::HashMap::new()
602        }
603    };
604
605    let atom_xml = generate_atom_feed(
606        &config.site.name,
607        &all_versions,
608        base_url,
609        &changelog_sections,
610    );
611    tokio::fs::write(output_dir.join(ATOM_FEED_FILENAME), atom_xml)
612        .await
613        .into_diagnostic()?;
614
615    // ── 11. `latest` symlink ──────────────────────────────────────────────────
616    if let Some(latest) = all_versions.first() {
617        update_latest_symlink(output_dir, &latest.version)?;
618    }
619
620    Ok(())
621}
622
623// ── Private helpers ───────────────────────────────────────────────────────────
624
625/// Extract URLs of locally-referenced files from a Markdown document.
626///
627/// Returns relative paths that are referenced as images or links and that do
628/// not look like remote URLs (no `://` scheme) or bare fragment anchors
629/// (starting with `#`).
630fn extract_local_refs(md: &str) -> Vec<String> {
631    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
632    let mut refs = Vec::new();
633    for event in Parser::new_ext(md, opts) {
634        let url: Option<pulldown_cmark::CowStr> = match event {
635            Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url),
636            Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url),
637            // pulldown-cmark emits End events with a TagEnd — ignore those.
638            Event::End(TagEnd::Image | TagEnd::Link) => None,
639            _ => None,
640        };
641        if let Some(url) = url {
642            let s = url.as_ref();
643            // Skip remote URLs, data URIs, and fragment-only links.
644            if !s.contains("://") && !s.starts_with('#') && !s.is_empty() {
645                refs.push(s.to_owned());
646            }
647        }
648    }
649    refs
650}
651
652/// Render Markdown to an HTML string.
653fn render_markdown(md: &str) -> String {
654    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
655    let parser = Parser::new_ext(md, opts);
656    let mut buf = String::new();
657    html::push_html(&mut buf, parser);
658    buf
659}
660
661/// Scan `docs_dir` for subdirectories that contain an `index.html` and return
662/// their names. Used to build a fallback listing when rustdoc itself did not
663/// generate a root `index.html` (typical for multi-crate workspaces).
664async fn find_doc_crates(docs_dir: &Path) -> Result<Vec<String>> {
665    let mut names = Vec::new();
666    let mut entries = tokio::fs::read_dir(docs_dir).await.into_diagnostic()?;
667    while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
668        let path = entry.path();
669        if path.is_dir() && path.join("index.html").exists() {
670            names.push(entry.file_name().to_string_lossy().into_owned());
671        }
672    }
673    Ok(names)
674}
675
676/// Write a `docs/index.html` that either redirects straight to the single
677/// crate's docs (one crate) or lists all crates (multiple crates).
678async fn write_docs_index(docs_dir: &Path, crate_names: &[String]) -> Result<()> {
679    let html = if crate_names.len() == 1 {
680        format!(
681            "<!DOCTYPE html><html><head>\
682            <meta http-equiv=\"refresh\" content=\"0;url={}/index.html\">\
683            </head></html>",
684            crate_names[0]
685        )
686    } else {
687        let items = crate_names
688            .iter()
689            .map(|n| format!("  <li><a href=\"{n}/index.html\">{n}</a></li>"))
690            .collect::<Vec<_>>()
691            .join("\n");
692        format!(
693            "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\
694            <title>Documentation</title></head>\
695            <body><h1>Documentation</h1><ul>\n{items}\n</ul></body></html>"
696        )
697    };
698
699    tokio::fs::write(docs_dir.join("index.html"), html)
700        .await
701        .into_diagnostic()
702}
703
704/// Recursively copy the contents of `src` into `dst`.
705///
706/// Uses explicit boxing to satisfy the compiler for the async recursive call.
707fn copy_dir_recursive(
708    src: PathBuf,
709    dst: PathBuf,
710) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
711    Box::pin(async move {
712        tokio::fs::create_dir_all(&dst).await.into_diagnostic()?;
713        let mut entries = tokio::fs::read_dir(&src).await.into_diagnostic()?;
714        while let Some(entry) = entries.next_entry().await.into_diagnostic()? {
715            let src_path = entry.path();
716            let dst_path = dst.join(entry.file_name());
717            if src_path.is_dir() {
718                copy_dir_recursive(src_path, dst_path).await?;
719            } else {
720                tokio::fs::copy(&src_path, &dst_path)
721                    .await
722                    .into_diagnostic()?;
723            }
724        }
725        Ok(())
726    })
727}
728
729/// Pack `src` directory into a `.tar.gz` archive at `dest`.
730///
731/// The top-level entry inside the archive is named after the source directory.
732fn archive_dir(src: &Path, dest: &Path) -> Result<()> {
733    let dir_name = src
734        .file_name()
735        .map(|n| n.to_string_lossy().into_owned())
736        .unwrap_or_else(|| "docs".to_owned());
737
738    let file = std::fs::File::create(dest).into_diagnostic()?;
739    let enc = GzEncoder::new(file, Compression::default());
740    let mut archive = tar::Builder::new(enc);
741    archive.append_dir_all(&dir_name, src).into_diagnostic()?;
742    archive
743        .into_inner()
744        .into_diagnostic()?
745        .finish()
746        .into_diagnostic()?;
747    Ok(())
748}
749
750/// Compare two version strings, preferring semver ordering and falling back
751/// to lexicographic comparison for non-semver strings (e.g. git describe output).
752fn strip_v(s: &str) -> &str {
753    s.strip_prefix('v').unwrap_or(s)
754}
755
756fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
757    match (
758        semver::Version::parse(strip_v(a)),
759        semver::Version::parse(strip_v(b)),
760    ) {
761        (Ok(va), Ok(vb)) => va.cmp(&vb),
762        _ => a.cmp(b),
763    }
764}
765
766/// Compute a lowercase hex-encoded SHA-256 digest of `data`.
767fn hex_sha256(data: &[u8]) -> String {
768    let mut hasher = Sha256::new();
769    hasher.update(data);
770    hasher
771        .finalize()
772        .iter()
773        .map(|b| format!("{b:02x}"))
774        .collect()
775}
776
777/// Format a byte count as a human-readable string (e.g. "1.4 MB").
778fn human_size(bytes: u64) -> String {
779    const KIB: u64 = 1024;
780    const MIB: u64 = KIB * 1024;
781    const GIB: u64 = MIB * 1024;
782    if bytes >= GIB {
783        format!("{:.1} GB", bytes as f64 / GIB as f64)
784    } else if bytes >= MIB {
785        format!("{:.1} MB", bytes as f64 / MIB as f64)
786    } else if bytes >= KIB {
787        format!("{:.1} KB", bytes as f64 / KIB as f64)
788    } else {
789        format!("{bytes} B")
790    }
791}
792
793/// Escape the five XML predefined characters in a string.
794fn xml_escape(s: &str) -> String {
795    s.replace('&', "&amp;")
796        .replace('<', "&lt;")
797        .replace('>', "&gt;")
798        .replace('"', "&quot;")
799        .replace('\'', "&apos;")
800}
801
802/// Generate an Atom 1.0 feed listing all known releases.
803///
804/// - `base_url`: when provided (already stripped of trailing `/`), used to
805///   build `<link>` and `<id>` elements with absolute URLs.  When absent,
806///   a `urn:` based ID is used and no `<link>` elements are emitted.
807/// - `changelog_sections`: map from version string to Markdown release-note
808///   body.  When a matching entry is found it is rendered to HTML and included
809///   as a `<content type="html">` element in the Atom entry.
810fn generate_atom_feed(
811    project_name: &str,
812    versions: &[VersionInfo],
813    base_url: Option<&str>,
814    changelog_sections: &std::collections::HashMap<String, String>,
815) -> String {
816    let now: DateTime<Utc> = Utc::now();
817
818    // The feed's <updated> is the most-recent entry date, or now as fallback.
819    let feed_updated = versions.iter().filter_map(|v| v.date).max().unwrap_or(now);
820
821    let feed_updated_str = feed_updated.to_rfc3339_opts(SecondsFormat::Secs, true);
822
823    // Feed-level <id> and self-link.
824    let feed_id = match base_url {
825        Some(base) => format!("{base}/{ATOM_FEED_FILENAME}"),
826        None => format!("urn:abbaye:feed:{}", xml_escape(project_name)),
827    };
828
829    let self_link = match base_url {
830        Some(base) => format!(
831            "  <link rel=\"self\" href=\"{}/{ATOM_FEED_FILENAME}\"/>\n",
832            xml_escape(base)
833        ),
834        None => String::new(),
835    };
836    let alt_link = match base_url {
837        Some(base) => format!("  <link href=\"{}\"/>\n", xml_escape(base)),
838        None => String::new(),
839    };
840
841    let entries: String = versions
842        .iter()
843        .map(|vi| {
844            let entry_date = vi.date.unwrap_or(now);
845            let entry_date_str = entry_date.to_rfc3339_opts(SecondsFormat::Secs, true);
846            let v_escaped = xml_escape(&vi.version);
847
848            let entry_id = match base_url {
849                Some(base) => format!("{base}/{v_escaped}/"),
850                None => format!(
851                    "urn:abbaye:release:{}:{v_escaped}",
852                    xml_escape(project_name)
853                ),
854            };
855
856            let entry_link = match base_url {
857                Some(base) => format!("    <link href=\"{base}/{v_escaped}/\"/>\n"),
858                None => String::new(),
859            };
860
861            // Render the changelog section (if any) to HTML, then XML-escape
862            // it for embedding inside <content type="html">.
863            let content_element = match changelog_sections.get(&vi.version) {
864                Some(md) if !md.is_empty() => {
865                    let html = render_markdown(md);
866                    format!(
867                        "    <content type=\"html\">{}</content>\n",
868                        xml_escape(&html)
869                    )
870                }
871                _ => String::new(),
872            };
873
874            format!(
875                "  <entry>\n\
876                 \x20   <title>{v_escaped}</title>\n\
877                 \x20   <id>{entry_id}</id>\n\
878                 \x20   <updated>{entry_date_str}</updated>\n\
879                 {entry_link}\
880                 {content_element}\
881                 \x20 </entry>"
882            )
883        })
884        .collect::<Vec<_>>()
885        .join("\n");
886
887    format!(
888        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
889         <feed xmlns=\"http://www.w3.org/2005/Atom\">\n\
890         \x20 <title>{name} Releases</title>\n\
891         {self_link}\
892         {alt_link}\
893         \x20 <updated>{feed_updated_str}</updated>\n\
894         \x20 <id>{feed_id}</id>\n\
895         {entries}\n\
896         </feed>\n",
897        name = xml_escape(project_name),
898    )
899}
900
901/// Create or replace the `latest` symlink in `output_dir`, pointing to
902/// `version_dir_name`.
903///
904/// On non-Unix platforms a meta-refresh redirect page is written instead.
905fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
906    let link = output_dir.join("latest");
907
908    #[cfg(unix)]
909    {
910        // Remove any stale symlink or file before (re-)creating it.
911        if link.exists() || link.is_symlink() {
912            std::fs::remove_file(&link).into_diagnostic()?;
913        }
914        std::os::unix::fs::symlink(version_dir_name, &link).into_diagnostic()?;
915    }
916
917    #[cfg(not(unix))]
918    {
919        std::fs::create_dir_all(&link).into_diagnostic()?;
920        std::fs::write(
921            link.join("index.html"),
922            format!(
923                "<!DOCTYPE html><html><head>\
924                <meta http-equiv=\"refresh\" content=\"0;url=../{version_dir_name}/\">\
925                </head></html>"
926            ),
927        )
928        .into_diagnostic()?;
929    }
930
931    Ok(())
932}
933
934/// Scans `theme_path` for any `*.j2` files whose stem (the name without the
935/// `.j2` suffix, e.g. `"base.html"`) is **not** already listed in `skip`, and
936/// loads each one into `tera` under that stem name.
937///
938/// This makes user-supplied helper or base templates — e.g. a `base.html.j2`
939/// referenced by `{% extends "base.html" %}` in a customised main template —
940/// available at render time without the caller needing to enumerate them.
941pub(crate) fn load_extra_theme_templates(
942    tera: &mut tera::Tera,
943    theme_path: &std::path::Path,
944    skip: &[&str],
945) -> miette::Result<()> {
946    let entries = match std::fs::read_dir(theme_path) {
947        Ok(e) => e,
948        Err(_) => return Ok(()), // theme dir absent — nothing to do
949    };
950    for entry in entries.flatten() {
951        let path = entry.path();
952        if path.extension().and_then(|e| e.to_str()) != Some("j2") {
953            continue;
954        }
955        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
956            continue;
957        };
958        if skip.contains(&stem) {
959            continue;
960        }
961        tera.add_template_file(&path, Some(stem)).map_err(|e| {
962            miette::miette!("failed to load theme template {}: {e}", path.display())
963        })?;
964    }
965    Ok(())
966}