Skip to main content

release_kit/release/
dir.rs

1//! A release bundle laid out as a directory, behind the seam.
2//!
3//! The directory is the unpacked crate of one release: `Cargo.toml` at its
4//! root, the payload roots beside it, and the sources the crate ships.
5//! The crate source reads its cache through this type, and a test writes
6//! a directory to serve any bundle it needs, so the two share one reader
7//! and one set of failures. Nothing here is executed: the directory is
8//! data, read root by root in inventory order.
9
10use std::path::{Path, PathBuf};
11
12use crate::digest::Digest;
13use crate::error::RkError;
14use crate::payload_roots::PAYLOAD_ROOTS;
15
16use super::{Artifact, ReleaseManifest, ReleaseSource, unknown_digest};
17
18/// One bundle under a directory.
19#[derive(Debug, Clone)]
20pub struct DirReleaseSource {
21    root: PathBuf,
22}
23
24impl DirReleaseSource {
25    /// A source over `root`, which is read on each call and never cached:
26    /// the callers hold the manifest they need, and a directory is cheap.
27    #[must_use]
28    pub fn new(root: impl Into<PathBuf>) -> Self {
29        Self { root: root.into() }
30    }
31
32    /// The directory the bundle is read from.
33    #[must_use]
34    pub fn root(&self) -> &Path {
35        &self.root
36    }
37
38    /// Every artifact under the payload roots, root by root in inventory
39    /// order, sorted by path within each root, with its bytes.
40    fn artifacts(&self) -> Result<Vec<(String, Vec<u8>)>, RkError> {
41        let mut out = Vec::new();
42        for root in PAYLOAD_ROOTS {
43            let path = self.root.join(root);
44            if path.is_file() {
45                out.push((root.to_string(), std::fs::read(&path)?));
46                continue;
47            }
48            if !path.is_dir() {
49                continue;
50            }
51            let mut files = Vec::new();
52            walk(&path, &mut files)?;
53            files.sort();
54            for file in files {
55                let rel = file
56                    .strip_prefix(&self.root)
57                    .map_err(|_| anyhow::anyhow!("{} is outside the bundle", file.display()))?
58                    .to_string_lossy()
59                    .replace('\\', "/");
60                out.push((rel, std::fs::read(&file)?));
61            }
62        }
63        Ok(out)
64    }
65}
66
67/// Every regular file below `dir`, recursively.
68fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
69    for entry in std::fs::read_dir(dir)? {
70        let entry = entry?;
71        let path = entry.path();
72        if path.is_dir() {
73            walk(&path, out)?;
74        } else if path.is_file() {
75            out.push(path);
76        }
77    }
78    Ok(())
79}
80
81impl ReleaseSource for DirReleaseSource {
82    fn manifest(&self) -> Result<ReleaseManifest, RkError> {
83        let version = declared_version(&self.root)?;
84        let schema = declared_schema(&self.root)?;
85        let artifacts = self
86            .artifacts()?
87            .into_iter()
88            .map(|(path, bytes)| Artifact {
89                path,
90                sha256: Digest::of(&bytes),
91            })
92            .collect();
93        Ok(ReleaseManifest::new(version, schema, artifacts))
94    }
95
96    fn blob(&self, digest: &Digest) -> Result<Vec<u8>, RkError> {
97        self.artifacts()?
98            .into_iter()
99            .map(|(_, bytes)| bytes)
100            .find(|bytes| Digest::of(bytes) == *digest)
101            .ok_or_else(|| unknown_digest(digest))
102    }
103}
104
105/// The version the bundle's `Cargo.toml` declares.
106///
107/// # Errors
108///
109/// Returns [`RkError::Other`] when the manifest is absent, unparsable, or
110/// names no package version.
111pub fn declared_version(root: &Path) -> Result<String, RkError> {
112    let path = root.join("Cargo.toml");
113    let text = std::fs::read_to_string(&path)
114        .map_err(|source| anyhow::anyhow!("{}: {source}", path.display()))?;
115    let table: toml::Table = text
116        .parse()
117        .map_err(|source| anyhow::anyhow!("{}: {source}", path.display()))?;
118    table
119        .get("package")
120        .and_then(|package| package.get("version"))
121        .and_then(toml::Value::as_str)
122        .map(str::to_owned)
123        .ok_or_else(|| anyhow::anyhow!("{}: no package version", path.display()).into())
124}
125
126/// The `payload_schema` the bundle's own sources declare.
127///
128/// A bundle carries no schema file: the number is the `PAYLOAD_SCHEMA`
129/// constant of the release it is, declared in exactly one source file
130/// under `src/` with the spelling `const PAYLOAD_SCHEMA: u32 = <n>;`. This
131/// reads it back, so the same rule reaches every release that ever
132/// declared the constant, this one included; a test in this crate holds
133/// the spelling.
134///
135/// # Errors
136///
137/// Returns [`RkError::Other`] when no source declares the constant or
138/// when two do.
139pub fn declared_schema(root: &Path) -> Result<u32, RkError> {
140    let src = root.join("src");
141    let mut files = Vec::new();
142    if src.is_dir() {
143        walk(&src, &mut files)?;
144    }
145    files.sort();
146    let mut found = Vec::new();
147    for file in files {
148        if file.extension().is_none_or(|ext| ext != "rs") {
149            continue;
150        }
151        let text = std::fs::read_to_string(&file)?;
152        for line in text.lines() {
153            let bare = line.trim_start();
154            let bare = bare.strip_prefix("pub ").unwrap_or(bare);
155            let Some(rest) = bare.strip_prefix(SCHEMA_DECLARATION) else {
156                continue;
157            };
158            let digits: String = rest
159                .trim_start()
160                .chars()
161                .take_while(char::is_ascii_digit)
162                .collect();
163            if let Ok(value) = digits.parse::<u32>() {
164                found.push(value);
165            }
166        }
167    }
168    match found.as_slice() {
169        [one] => Ok(*one),
170        [] => {
171            Err(anyhow::anyhow!("{}: no source declares the payload schema", root.display()).into())
172        }
173        many => Err(anyhow::anyhow!(
174            "{}: {} sources declare the payload schema",
175            root.display(),
176            many.len()
177        )
178        .into()),
179    }
180}
181
182/// The declaration's spelling, up to the number, with or without `pub`:
183/// the releases before the seam declared it private in the payload verb.
184const SCHEMA_DECLARATION: &str = "const PAYLOAD_SCHEMA: u32 =";
185
186#[cfg(test)]
187mod tests {
188    use super::{DirReleaseSource, SCHEMA_DECLARATION, declared_schema, declared_version};
189    use crate::digest::Digest;
190    use crate::release::ReleaseSource;
191
192    /// A bundle directory with the minimum a source needs: a manifest, one
193    /// source declaring the schema, and a few payload files.
194    fn bundle(version: &str, schema: u32) -> tempfile::TempDir {
195        let dir = tempfile::tempdir().expect("a scratch bundle exists");
196        let root = dir.path();
197        std::fs::write(
198            root.join("Cargo.toml"),
199            format!("[package]\nname = \"release-kit\"\nversion = \"{version}\"\n"),
200        )
201        .expect("the manifest writes");
202        std::fs::create_dir_all(root.join("src")).expect("src exists");
203        std::fs::write(
204            root.join("src/lib.rs"),
205            format!("pub {SCHEMA_DECLARATION} {schema};\n"),
206        )
207        .expect("the source writes");
208        std::fs::write(root.join("versions.toml"), "schema = 1\n").expect("the registry writes");
209        for (path, body) in [
210            ("snippets/_shared/github/SECURITY.md", "# Security\n"),
211            ("snippets/rust/github/release-plz.toml", "[workspace]\n"),
212            (
213                "blocks/agents-block.md.in",
214                "<!-- BEGIN release-kit -->\n<!-- END release-kit -->\n",
215            ),
216        ] {
217            let file = root.join(path);
218            std::fs::create_dir_all(file.parent().expect("a parent")).expect("dirs exist");
219            std::fs::write(file, body).expect("the file writes");
220        }
221        dir
222    }
223
224    #[test]
225    fn the_fixture_source_serves_a_directory() {
226        let dir = bundle("1.2.3", 1);
227        let source = DirReleaseSource::new(dir.path());
228        let manifest = source.manifest().expect("the directory describes itself");
229        assert_eq!(manifest.release_kit_version, "1.2.3");
230        assert_eq!(manifest.payload_schema, 1);
231        let paths: Vec<&str> = manifest.artifacts.iter().map(|a| a.path.as_str()).collect();
232        assert_eq!(
233            paths,
234            [
235                "snippets/_shared/github/SECURITY.md",
236                "snippets/rust/github/release-plz.toml",
237                "blocks/agents-block.md.in",
238                "versions.toml",
239            ],
240            "roots in inventory order, files sorted within each"
241        );
242        let bytes = source
243            .blob(&Digest::of(b"[workspace]\n"))
244            .expect("a named blob reads");
245        assert_eq!(bytes, b"[workspace]\n");
246        assert!(source.blob(&Digest::of(b"absent")).is_err());
247    }
248
249    #[test]
250    fn the_declarations_are_read_back_from_the_bundle() {
251        let dir = bundle("0.9.0", 7);
252        assert_eq!(declared_version(dir.path()).expect("a version"), "0.9.0");
253        assert_eq!(declared_schema(dir.path()).expect("a schema"), 7);
254        std::fs::write(
255            dir.path().join("src/other.rs"),
256            format!("{SCHEMA_DECLARATION} 8;\n"),
257        )
258        .expect("the second source writes");
259        assert!(
260            declared_schema(dir.path()).is_err(),
261            "two declarations refuse"
262        );
263    }
264
265    /// This crate declares the constant the way the reader expects, so
266    /// the bundle this release publishes reads back its own schema.
267    #[test]
268    fn this_crate_declares_its_schema_readably() {
269        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
270        assert_eq!(
271            declared_schema(root).expect("this crate declares its schema once"),
272            crate::release::PAYLOAD_SCHEMA
273        );
274        assert_eq!(
275            declared_version(root).expect("this crate declares its version"),
276            env!("CARGO_PKG_VERSION")
277        );
278    }
279}