release_kit/release/
dir.rs1use 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#[derive(Debug, Clone)]
20pub struct DirReleaseSource {
21 root: PathBuf,
22}
23
24impl DirReleaseSource {
25 #[must_use]
28 pub fn new(root: impl Into<PathBuf>) -> Self {
29 Self { root: root.into() }
30 }
31
32 #[must_use]
34 pub fn root(&self) -> &Path {
35 &self.root
36 }
37
38 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
67fn 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
105pub 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
126pub 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
182const 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 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 #[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}