release_kit/
cargo_package.rs1use std::collections::BTreeSet;
15use std::path::Path;
16
17use serde_json::Value;
18
19pub const POLICY_DESTINATION: &str = "SECURITY.md";
25
26const OWN_DIRECTORY: &str = ".release-kit";
29
30#[derive(Debug)]
32pub struct Answer {
33 pub success: bool,
35 pub stdout: Vec<u8>,
37 pub stderr: Vec<u8>,
39}
40
41#[derive(Debug)]
43pub enum Probe {
44 Listed(Listing),
46 OtherShape,
49 MetadataFailed(Vec<u8>),
51 ListingFailed(Vec<u8>),
53}
54
55pub fn probe<E>(
69 root_manifest: &Path,
70 list_flags: &[&str],
71 mut cargo: impl FnMut(&[&str]) -> Result<Answer, E>,
72) -> Result<Probe, E> {
73 let metadata = cargo(&["metadata", "--no-deps", "--format-version", "1"])?;
74 if !metadata.success {
75 return Ok(Probe::MetadataFailed(metadata.stderr));
76 }
77 let Some(manifest) = sole_root_package(&metadata.stdout, root_manifest) else {
78 return Ok(Probe::OtherShape);
79 };
80 let mut args = vec!["package", "--list", "--allow-dirty"];
81 args.extend_from_slice(list_flags);
82 args.extend_from_slice(&["--manifest-path", &manifest]);
83 let listing = cargo(&args)?;
84 Ok(if listing.success {
85 Probe::Listed(Listing::parse(&listing.stdout))
86 } else {
87 Probe::ListingFailed(listing.stderr)
88 })
89}
90
91#[must_use]
94pub fn sole_root_package(metadata: &[u8], root_manifest: &Path) -> Option<String> {
95 let document: Value = serde_json::from_slice(metadata).ok()?;
96 let defaults: Vec<&str> = document
97 .get("workspace_default_members")?
98 .as_array()?
99 .iter()
100 .filter_map(Value::as_str)
101 .collect();
102 let [only] = defaults.as_slice() else {
103 return None;
104 };
105 let manifest = document
106 .get("packages")?
107 .as_array()?
108 .iter()
109 .find(|package| package.get("id").and_then(Value::as_str) == Some(*only))?
110 .get("manifest_path")?
111 .as_str()?;
112 let same =
115 std::fs::canonicalize(manifest).ok()? == std::fs::canonicalize(root_manifest).ok()?;
116 same.then(|| manifest.to_owned())
117}
118
119#[derive(Debug, Default)]
121pub struct Listing(BTreeSet<String>);
122
123impl Listing {
124 #[must_use]
126 pub fn parse(stdout: &[u8]) -> Self {
127 Self(
128 String::from_utf8_lossy(stdout)
129 .lines()
130 .map(str::trim)
131 .filter(|line| !line.is_empty())
132 .map(str::to_owned)
133 .collect(),
134 )
135 }
136
137 #[must_use]
141 pub fn carries(&self, path: &str) -> bool {
142 self.0.contains(path)
143 }
144
145 pub fn touched_by<'a>(&self, changed: impl IntoIterator<Item = &'a str>) -> bool {
148 changed.into_iter().any(|path| self.carries(path))
149 }
150
151 #[must_use]
153 pub fn shipped<'a>(&self, forbidden: &'a BTreeSet<String>) -> Vec<&'a str> {
154 forbidden
155 .iter()
156 .filter(|path| self.carries(path))
157 .map(String::as_str)
158 .collect()
159 }
160}
161
162#[must_use]
171pub fn release_kit_paths(landed: Option<&[String]>) -> BTreeSet<String> {
172 let mut paths: BTreeSet<String> = [
173 crate::config::CONFIG_PATH,
174 crate::landing::manifest::MANIFEST_PATH,
175 ]
176 .into_iter()
177 .map(str::to_owned)
178 .collect();
179 paths.extend(landed.into_iter().flatten().cloned());
180 paths.remove(POLICY_DESTINATION);
181 paths
182}
183
184#[must_use]
190pub fn exclude_entries(shipped: &[&str]) -> Vec<String> {
191 let own = format!("{OWN_DIRECTORY}/");
192 let mut entries = Vec::new();
193 if shipped.iter().any(|path| path.starts_with(&own)) {
194 entries.push(format!("/{OWN_DIRECTORY}"));
195 }
196 let mut rest: Vec<String> = shipped
197 .iter()
198 .filter(|path| !path.starts_with(&own))
199 .map(|path| format!("/{path}"))
200 .collect();
201 rest.sort();
202 rest.dedup();
203 entries.extend(rest);
204 entries
205}
206
207#[cfg(test)]
208mod tests {
209 use super::{Answer, Listing, Probe, exclude_entries, probe, release_kit_paths};
210
211 #[test]
212 fn a_listing_is_trimmed_lines_without_blanks() {
213 let listing = Listing::parse(b" Cargo.toml\n\nsrc/main.rs \n");
214 assert!(listing.carries("Cargo.toml"));
215 assert!(listing.carries("src/main.rs"));
216 assert!(!listing.carries(""));
217 }
218
219 #[test]
220 fn a_shipped_path_is_an_exact_line() {
221 let listing = Listing::parse(b"docs/GLOSSARY.md\nGLOSSARY.md.bak\nnix/package.nix\n");
222 let forbidden = release_kit_paths(Some(&[
223 "GLOSSARY.md".to_owned(),
224 "nix/package.nix".to_owned(),
225 ]));
226 assert_eq!(listing.shipped(&forbidden), vec!["nix/package.nix"]);
227 }
228
229 #[test]
230 fn a_change_touches_the_package_only_through_a_listed_path() {
231 let listing = Listing::parse(b"Cargo.toml\nsrc/main.rs\n");
232 assert!(listing.touched_by(["docs/a.md", "src/main.rs"]));
233 assert!(!listing.touched_by(["docs/a.md", "justfile"]));
234 }
235
236 #[test]
237 fn without_a_record_only_the_two_own_files_are_claimed() {
238 let paths = release_kit_paths(None);
239 assert_eq!(
240 paths.into_iter().collect::<Vec<_>>(),
241 vec![".release-kit/config.toml", ".release-kit/manifest.json"]
242 );
243 }
244
245 #[test]
246 fn the_reporting_policy_is_never_claimed() {
247 let paths = release_kit_paths(Some(&["SECURITY.md".to_owned()]));
248 assert!(!paths.contains("SECURITY.md"));
249 }
250
251 #[test]
252 fn exclude_entries_collapse_the_own_directory_first() {
253 let entries = exclude_entries(&[
254 "GLOSSARY.md",
255 ".release-kit/manifest.json",
256 ".release-kit/config.toml",
257 "nix/package.nix",
258 ]);
259 assert_eq!(
260 entries,
261 vec!["/.release-kit", "/GLOSSARY.md", "/nix/package.nix"]
262 );
263 }
264
265 fn answer(success: bool, stdout: &str) -> Answer {
266 Answer {
267 success,
268 stdout: stdout.as_bytes().to_vec(),
269 stderr: b"boom".to_vec(),
270 }
271 }
272
273 #[test]
274 fn another_shape_runs_no_listing() {
275 let mut calls = Vec::new();
276 let found = probe::<()>(std::path::Path::new("/nowhere/Cargo.toml"), &[], |args| {
277 calls.push(args.join(" "));
278 Ok(answer(
279 true,
280 r#"{"workspace_default_members":[],"packages":[]}"#,
281 ))
282 })
283 .expect("the probe runs");
284 assert!(matches!(found, Probe::OtherShape));
285 assert_eq!(calls, vec!["metadata --no-deps --format-version 1"]);
286 }
287
288 #[test]
289 fn list_flags_sit_between_allow_dirty_and_the_manifest() {
290 let dir = tempfile::tempdir().expect("a scratch directory");
291 let manifest = dir.path().join("Cargo.toml");
292 std::fs::write(&manifest, "").expect("the manifest writes");
293 let metadata = serde_json::json!({
294 "workspace_default_members": ["w 0.1.0"],
295 "packages": [{"id": "w 0.1.0", "manifest_path": manifest}],
296 })
297 .to_string();
298 let mut calls = Vec::new();
299 let found = probe::<()>(&manifest, &["--locked", "--offline"], |args| {
300 calls.push(args.join(" "));
301 Ok(if args[0] == "metadata" {
302 answer(true, &metadata)
303 } else {
304 answer(true, "Cargo.toml\n")
305 })
306 })
307 .expect("the probe runs");
308 assert!(matches!(found, Probe::Listed(listing) if listing.carries("Cargo.toml")));
309 assert_eq!(
310 calls[1],
311 format!(
312 "package --list --allow-dirty --locked --offline --manifest-path {}",
313 manifest.display()
314 )
315 );
316 }
317
318 #[test]
319 fn a_failed_listing_is_named_not_guessed() {
320 let dir = tempfile::tempdir().expect("a scratch directory");
321 let manifest = dir.path().join("Cargo.toml");
322 std::fs::write(&manifest, "").expect("the manifest writes");
323 let metadata = serde_json::json!({
324 "workspace_default_members": ["w 0.1.0"],
325 "packages": [{"id": "w 0.1.0", "manifest_path": manifest}],
326 })
327 .to_string();
328 let found = probe::<()>(&manifest, &[], |args| {
329 Ok(if args[0] == "metadata" {
330 answer(true, &metadata)
331 } else {
332 answer(false, "")
333 })
334 })
335 .expect("the probe runs");
336 assert!(matches!(found, Probe::ListingFailed(stderr) if stderr == b"boom"));
337 }
338}