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#[derive(serde::Serialize)]
34struct VersionEntry {
35 version: String,
37 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#[derive(serde::Serialize)]
52struct DistFileInfo {
53 name: String,
55 size_bytes: u64,
57 size_human: String,
59 sha256: String,
61}
62
63pub 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 let version_info = config.version_extractor.extract().await?;
87 let version = version_info.version.clone();
88
89 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 theme_path.join("static").is_dir() {
114 copy_dir_recursive(theme_path.join("static"), output_dir.join("static")).await?;
115 }
116
117 let mut dist_artifacts = Vec::new();
119 let mut doc_artifacts = Vec::new();
120
121 {
122 const COLORS: &[&str] = &[
124 "\x1b[36m", "\x1b[32m", "\x1b[33m", "\x1b[35m", "\x1b[34m", "\x1b[31m", ];
131 const RESET: &str = "\x1b[0m";
132
133 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 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 {
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 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 let total = config.builders.len();
211 let multi = MultiProgress::new();
212
213 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 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 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 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 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 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 for (dep_id, mut rx) in dep_receivers {
326 pb_task.set_message(format!("waiting for '{dep_id}'…"));
327
328 let resolved = rx.wait_for(|v| v.is_some()).await;
331
332 let succeeded = match resolved {
333 Err(_) => false, 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 Ok(vec![]);
348 }
349 }
350
351 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 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 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 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 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_dir_recursive(artifact.path.clone(), docs_dir.clone()).await?;
444 }
445
446 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 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 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 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 let changelog_html = match ChangelogExtractor
500 .section(config.changelog.clone(), &version)
501 .await
502 {
503 Ok(section) => render_markdown(§ion),
504 Err(_) => {
505 warn!("No changelog entry found for version {version}");
506 String::new()
507 }
508 };
509
510 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 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 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 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 if let Some(latest) = all_versions.first() {
590 update_latest_symlink(output_dir, &latest.version)?;
591 }
592
593 Ok(())
594}
595
596fn 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 Event::End(TagEnd::Image | TagEnd::Link) => None,
612 _ => None,
613 };
614 if let Some(url) = url {
615 let s = url.as_ref();
616 if !s.contains("://") && !s.starts_with('#') && !s.is_empty() {
618 refs.push(s.to_owned());
619 }
620 }
621 }
622 refs
623}
624
625fn 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
634async 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
649async 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
677fn 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
702fn 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
723fn 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
739fn 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
750fn 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
766fn xml_escape(s: &str) -> String {
768 s.replace('&', "&")
769 .replace('<', "<")
770 .replace('>', ">")
771 .replace('"', """)
772 .replace('\'', "'")
773}
774
775fn 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 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 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 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
874fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<()> {
879 let link = output_dir.join("latest");
880
881 #[cfg(unix)]
882 {
883 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}