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