Skip to main content

bake_releases/
lib.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4//! Reusable release-document tasks, inspired by Samuel Williams's Ruby
5//! `bake-releases`: <https://github.com/ioquatix/bake-releases> (MIT).
6//! This implementation preserves Markdown bytes rather than re-rendering them.
7mod document;
8
9pub use document::{extract_notes, update_document};
10
11use bake::{Context, Error, Result};
12use std::fs;
13use std::fs::{File, Permissions};
14use std::io::{self, Write};
15use std::path::{Path, PathBuf};
16
17fn persist_file(temporary: tempfile::NamedTempFile, path: &Path) -> Result<()> {
18    temporary
19        .persist(path)
20        .map(|_| ())
21        .map_err(|error| Error::from(error.error))
22}
23
24trait UpdateIo {
25    fn create_temporary(&mut self, directory: &Path) -> io::Result<tempfile::NamedTempFile>;
26    fn write(&mut self, file: &mut File, contents: &[u8]) -> io::Result<()>;
27    fn permissions(&mut self, path: &Path) -> io::Result<Permissions>;
28    fn set_permissions(&mut self, file: &File, permissions: Permissions) -> io::Result<()>;
29    fn sync_all(&mut self, file: &File) -> io::Result<()>;
30    fn persist(&mut self, temporary: tempfile::NamedTempFile, path: &Path) -> Result<()>;
31}
32
33struct SystemUpdateIo;
34
35impl UpdateIo for SystemUpdateIo {
36    fn create_temporary(&mut self, directory: &Path) -> io::Result<tempfile::NamedTempFile> {
37        tempfile::NamedTempFile::new_in(directory)
38    }
39
40    fn write(&mut self, file: &mut File, contents: &[u8]) -> io::Result<()> {
41        file.write_all(contents)
42    }
43
44    fn permissions(&mut self, path: &Path) -> io::Result<Permissions> {
45        fs::metadata(path).map(|metadata| metadata.permissions())
46    }
47
48    fn set_permissions(&mut self, file: &File, permissions: Permissions) -> io::Result<()> {
49        file.set_permissions(permissions)
50    }
51
52    fn sync_all(&mut self, file: &File) -> io::Result<()> {
53        file.sync_all()
54    }
55
56    fn persist(&mut self, temporary: tempfile::NamedTempFile, path: &Path) -> Result<()> {
57        persist_file(temporary, path)
58    }
59}
60
61fn update_file_with(
62    directory: &Path,
63    path: &Path,
64    updated: &str,
65    operations: &mut impl UpdateIo,
66) -> Result<()> {
67    let mut temporary = operations.create_temporary(directory)?;
68    operations.write(temporary.as_file_mut(), updated.as_bytes())?;
69    let permissions = operations.permissions(path)?;
70    operations.set_permissions(temporary.as_file(), permissions)?;
71    operations.sync_all(temporary.as_file())?;
72    operations.persist(temporary, path)?;
73    Ok(())
74}
75
76/// Extract the Markdown body beneath an exact release heading.
77#[bake::task]
78pub fn notes(
79    context: &mut Context,
80    version: String,
81    #[bake(
82        default = "releases.md",
83        help = "Release document relative to the project root."
84    )]
85    path: PathBuf,
86) -> Result<String> {
87    let path = context.root().join(path);
88    let document = fs::read_to_string(&path)
89        .map_err(|error| Error::new(format!("{}: {error}", path.display())))?;
90    Ok(extract_notes(&document, &version)?.to_owned())
91}
92
93/// Rename Unreleased to a release version, preserving the rest of the document.
94#[bake::task]
95pub fn update(
96    context: &mut Context,
97    version: String,
98    #[bake(default = "releases.md")] path: PathBuf,
99) -> Result<()> {
100    let path = context.root().join(path).canonicalize()?;
101    let document = fs::read_to_string(&path)?;
102    let updated = update_document(&document, &version)?;
103    let directory = path.parent().unwrap_or(path.as_path());
104    update_file_with(directory, &path, &updated, &mut SystemUpdateIo)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn persist_file_reports_a_missing_destination_directory() {
113        let directory = tempfile::tempdir().unwrap();
114        let temporary = tempfile::NamedTempFile::new_in(directory.path()).unwrap();
115        let destination = directory.path().join("missing/releases.md");
116
117        assert!(persist_file(temporary, &destination).is_err());
118    }
119
120    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
121    enum UpdateOperation {
122        CreateTemporary,
123        Write,
124        Permissions,
125        SetPermissions,
126        Sync,
127        Persist,
128    }
129
130    struct FailingUpdateIo {
131        fail_at: Option<UpdateOperation>,
132        system: SystemUpdateIo,
133    }
134
135    impl FailingUpdateIo {
136        fn new(fail_at: Option<UpdateOperation>) -> Self {
137            Self {
138                fail_at,
139                system: SystemUpdateIo,
140            }
141        }
142
143        fn fail_if(&self, operation: UpdateOperation) -> io::Result<()> {
144            if self.fail_at == Some(operation) {
145                Err(io::Error::other("simulated update failure"))
146            } else {
147                Ok(())
148            }
149        }
150    }
151
152    impl UpdateIo for FailingUpdateIo {
153        fn create_temporary(&mut self, directory: &Path) -> io::Result<tempfile::NamedTempFile> {
154            self.fail_if(UpdateOperation::CreateTemporary)?;
155            self.system.create_temporary(directory)
156        }
157
158        fn write(&mut self, file: &mut File, contents: &[u8]) -> io::Result<()> {
159            self.fail_if(UpdateOperation::Write)?;
160            self.system.write(file, contents)
161        }
162
163        fn permissions(&mut self, path: &Path) -> io::Result<Permissions> {
164            self.fail_if(UpdateOperation::Permissions)?;
165            self.system.permissions(path)
166        }
167
168        fn set_permissions(&mut self, file: &File, permissions: Permissions) -> io::Result<()> {
169            self.fail_if(UpdateOperation::SetPermissions)?;
170            self.system.set_permissions(file, permissions)
171        }
172
173        fn sync_all(&mut self, file: &File) -> io::Result<()> {
174            self.fail_if(UpdateOperation::Sync)?;
175            self.system.sync_all(file)
176        }
177
178        fn persist(&mut self, temporary: tempfile::NamedTempFile, path: &Path) -> Result<()> {
179            self.fail_if(UpdateOperation::Persist)?;
180            self.system.persist(temporary, path)
181        }
182    }
183
184    #[test]
185    fn update_file_failures_preserve_the_original_document() {
186        for operation in [
187            UpdateOperation::CreateTemporary,
188            UpdateOperation::Write,
189            UpdateOperation::Permissions,
190            UpdateOperation::SetPermissions,
191            UpdateOperation::Sync,
192            UpdateOperation::Persist,
193        ] {
194            let directory = tempfile::tempdir().unwrap();
195            let destination = directory.path().join("releases.md");
196            fs::write(&destination, "## Unreleased\n").unwrap();
197            let mut operations = FailingUpdateIo::new(Some(operation));
198
199            assert!(
200                update_file_with(directory.path(), &destination, "## v1\n", &mut operations)
201                    .is_err()
202            );
203            assert_eq!(fs::read_to_string(destination).unwrap(), "## Unreleased\n");
204        }
205    }
206
207    #[test]
208    fn update_file_succeeds_when_all_operations_succeed() {
209        let directory = tempfile::tempdir().unwrap();
210        let destination = directory.path().join("releases.md");
211        fs::write(&destination, "## Unreleased\n").unwrap();
212        let mut operations = FailingUpdateIo::new(None);
213
214        update_file_with(directory.path(), &destination, "## v1\n", &mut operations).unwrap();
215
216        assert_eq!(fs::read_to_string(destination).unwrap(), "## v1\n");
217    }
218}