1use 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 pub fn paths(&self) -> impl Iterator<Item = &str> {
139 self.0.iter().map(String::as_str)
140 }
141
142 #[must_use]
146 pub fn carries(&self, path: &str) -> bool {
147 self.0.contains(path)
148 }
149
150 pub fn touched_by<'a>(&self, changed: impl IntoIterator<Item = &'a str>) -> bool {
153 changed.into_iter().any(|path| self.carries(path))
154 }
155
156 #[must_use]
158 pub fn shipped<'a>(&self, forbidden: &'a BTreeSet<String>) -> Vec<&'a str> {
159 forbidden
160 .iter()
161 .filter(|path| self.carries(path))
162 .map(String::as_str)
163 .collect()
164 }
165}
166
167#[must_use]
176pub fn release_kit_paths(landed: Option<&[String]>) -> BTreeSet<String> {
177 let mut paths: BTreeSet<String> = [
178 crate::config::CONFIG_PATH,
179 crate::landing::manifest::MANIFEST_PATH,
180 ]
181 .into_iter()
182 .map(str::to_owned)
183 .collect();
184 paths.extend(landed.into_iter().flatten().cloned());
185 paths.remove(POLICY_DESTINATION);
186 paths
187}
188
189#[must_use]
195pub fn exclude_entries(shipped: &[&str]) -> Vec<String> {
196 let own = format!("{OWN_DIRECTORY}/");
197 let mut entries = Vec::new();
198 if shipped.iter().any(|path| path.starts_with(&own)) {
199 entries.push(format!("/{OWN_DIRECTORY}"));
200 }
201 let mut rest: Vec<String> = shipped
202 .iter()
203 .filter(|path| !path.starts_with(&own))
204 .map(|path| format!("/{path}"))
205 .collect();
206 rest.sort();
207 rest.dedup();
208 entries.extend(rest);
209 entries
210}
211
212pub const RELEASE_PLZ_CONFIGS: [&str; 3] = [
215 "release-plz.toml",
216 ".release-plz.toml",
217 ".config/release-plz.toml",
218];
219
220#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct Released {
223 pub name: String,
225 pub manifest_path: String,
227 pub binary: bool,
230}
231
232#[must_use]
242pub fn released_packages(metadata: &[u8], config: Option<&str>) -> Vec<Released> {
243 let Ok(document) = serde_json::from_slice::<Value>(metadata) else {
244 return Vec::new();
245 };
246 let table: toml::Table = config
247 .and_then(|text| toml::from_str(text).ok())
248 .unwrap_or_default();
249 let workspace = table.get("workspace");
250 let packages = table.get("package").and_then(toml::Value::as_array);
251 let key = |name: &str, key: &str| -> Option<bool> {
252 packages
253 .and_then(|list| {
254 list.iter()
255 .find(|entry| entry.get("name").and_then(toml::Value::as_str) == Some(name))
256 })
257 .and_then(|entry| entry.get(key))
258 .or_else(|| workspace.and_then(|table| table.get(key)))
259 .and_then(toml::Value::as_bool)
260 };
261 let members: Vec<&str> = document
262 .get("workspace_members")
263 .and_then(Value::as_array)
264 .map(|ids| ids.iter().filter_map(Value::as_str).collect())
265 .unwrap_or_default();
266 document
267 .get("packages")
268 .and_then(Value::as_array)
269 .into_iter()
270 .flatten()
271 .filter(|package| {
272 package
273 .get("id")
274 .and_then(Value::as_str)
275 .is_some_and(|id| members.contains(&id))
276 })
277 .filter_map(|package| {
278 let name = package.get("name")?.as_str()?;
279 let kinds: Vec<Vec<&str>> = package
280 .get("targets")
281 .and_then(Value::as_array)
282 .into_iter()
283 .flatten()
284 .map(|target| {
285 target
286 .get("kind")
287 .and_then(Value::as_array)
288 .into_iter()
289 .flatten()
290 .filter_map(Value::as_str)
291 .collect()
292 })
293 .collect();
294 let example = kinds.iter().all(|kind| kind.as_slice() == ["example"]);
295 let publish = package.get("publish").filter(|value| !value.is_null());
296 let publishable = publish.map_or(!example, |registries| {
297 registries.as_array().is_some_and(|list| !list.is_empty())
298 });
299 let git_only = key(name, "git_only").unwrap_or(false);
300 let unpublished_example = publish.is_none() && example;
301 let takes_part = publishable || (git_only && !unpublished_example);
302 (key(name, "release") != Some(false) && takes_part).then(|| Released {
303 name: name.to_owned(),
304 manifest_path: package
305 .get("manifest_path")
306 .and_then(Value::as_str)
307 .unwrap_or_default()
308 .to_owned(),
309 binary: kinds.iter().any(|kind| kind.contains(&"bin")),
310 })
311 })
312 .collect()
313}
314
315pub const LISTED_NOT_TRACKED: [&str; 3] = ["Cargo.toml.orig", ".cargo_vcs_info.json", "Cargo.lock"];
322
323#[cfg(test)]
324mod tests {
325 use super::{
326 Answer, Listing, Probe, exclude_entries, probe, release_kit_paths, released_packages,
327 };
328
329 fn metadata(members: &[(&str, serde_json::Value, &[&str])]) -> Vec<u8> {
331 let packages: Vec<serde_json::Value> = members
332 .iter()
333 .map(|(name, publish, kinds)| {
334 serde_json::json!({
335 "id": format!("{name} 0.1.0"), "name": name, "publish": publish,
336 "manifest_path": format!("/w/{name}/Cargo.toml"),
337 "targets": kinds.iter().map(|kind| serde_json::json!({"kind": [kind]})).collect::<Vec<_>>(),
338 })
339 })
340 .collect();
341 let ids: Vec<String> = members
342 .iter()
343 .map(|(name, ..)| format!("{name} 0.1.0"))
344 .collect();
345 serde_json::json!({"packages": packages, "workspace_members": ids})
346 .to_string()
347 .into_bytes()
348 }
349
350 fn names(metadata: &[u8], config: Option<&str>) -> Vec<String> {
351 released_packages(metadata, config)
352 .into_iter()
353 .map(|package| package.name)
354 .collect()
355 }
356
357 #[test]
362 fn release_plz_selects_the_released_members() {
363 let null = serde_json::Value::Null;
364 let none = serde_json::json!([]);
365 let helper = metadata(&[
366 ("widget", null.clone(), &["bin"]),
367 ("xtask", none.clone(), &["bin"]),
368 ]);
369 let registry = "[workspace]\npublish = true\n";
370 assert_eq!(names(&helper, Some(registry)), vec!["widget"]);
371 let git_only = "[workspace]\npublish = false\ngit_only = true\n";
372 assert_eq!(names(&helper, Some(git_only)), vec!["widget", "xtask"]);
373 let opted_out = format!("{git_only}[[package]]\nname = \"xtask\"\nrelease = false\n");
374 assert_eq!(names(&helper, Some(&opted_out)), vec!["widget"]);
375 let single = metadata(&[("exobrain", none, &["bin"])]);
376 assert_eq!(names(&single, Some(git_only)), vec!["exobrain"]);
377 assert!(names(&single, Some(registry)).is_empty());
378 let example = metadata(&[("demo", null.clone(), &["example"])]);
379 assert!(names(&example, Some(git_only)).is_empty());
380 let off = "[workspace]\nrelease = false\n";
381 assert!(names(&metadata(&[("widget", null, &["lib"])]), Some(off)).is_empty());
382 let released = released_packages(&helper, Some(registry));
383 assert!(released[0].binary);
384 }
385
386 #[test]
387 fn a_listing_is_trimmed_lines_without_blanks() {
388 let listing = Listing::parse(b" Cargo.toml\n\nsrc/main.rs \n");
389 assert!(listing.carries("Cargo.toml"));
390 assert!(listing.carries("src/main.rs"));
391 assert!(!listing.carries(""));
392 }
393
394 #[test]
395 fn a_shipped_path_is_an_exact_line() {
396 let listing = Listing::parse(b"docs/GLOSSARY.md\nGLOSSARY.md.bak\nnix/package.nix\n");
397 let forbidden = release_kit_paths(Some(&[
398 "GLOSSARY.md".to_owned(),
399 "nix/package.nix".to_owned(),
400 ]));
401 assert_eq!(listing.shipped(&forbidden), vec!["nix/package.nix"]);
402 }
403
404 #[test]
405 fn a_change_touches_the_package_only_through_a_listed_path() {
406 let listing = Listing::parse(b"Cargo.toml\nsrc/main.rs\n");
407 assert!(listing.touched_by(["docs/a.md", "src/main.rs"]));
408 assert!(!listing.touched_by(["docs/a.md", "justfile"]));
409 }
410
411 #[test]
412 fn without_a_record_only_the_two_own_files_are_claimed() {
413 let paths = release_kit_paths(None);
414 assert_eq!(
415 paths.into_iter().collect::<Vec<_>>(),
416 vec![".release-kit/config.toml", ".release-kit/manifest.json"]
417 );
418 }
419
420 #[test]
421 fn the_reporting_policy_is_never_claimed() {
422 let paths = release_kit_paths(Some(&["SECURITY.md".to_owned()]));
423 assert!(!paths.contains("SECURITY.md"));
424 }
425
426 #[test]
427 fn exclude_entries_collapse_the_own_directory_first() {
428 let entries = exclude_entries(&[
429 "GLOSSARY.md",
430 ".release-kit/manifest.json",
431 ".release-kit/config.toml",
432 "nix/package.nix",
433 ]);
434 assert_eq!(
435 entries,
436 vec!["/.release-kit", "/GLOSSARY.md", "/nix/package.nix"]
437 );
438 }
439
440 fn answer(success: bool, stdout: &str) -> Answer {
441 Answer {
442 success,
443 stdout: stdout.as_bytes().to_vec(),
444 stderr: b"boom".to_vec(),
445 }
446 }
447
448 #[test]
449 fn another_shape_runs_no_listing() {
450 let mut calls = Vec::new();
451 let found = probe::<()>(std::path::Path::new("/nowhere/Cargo.toml"), &[], |args| {
452 calls.push(args.join(" "));
453 Ok(answer(
454 true,
455 r#"{"workspace_default_members":[],"packages":[]}"#,
456 ))
457 })
458 .expect("the probe runs");
459 assert!(matches!(found, Probe::OtherShape));
460 assert_eq!(calls, vec!["metadata --no-deps --format-version 1"]);
461 }
462
463 #[test]
464 fn list_flags_sit_between_allow_dirty_and_the_manifest() {
465 let dir = tempfile::tempdir().expect("a scratch directory");
466 let manifest = dir.path().join("Cargo.toml");
467 std::fs::write(&manifest, "").expect("the manifest writes");
468 let metadata = serde_json::json!({
469 "workspace_default_members": ["w 0.1.0"],
470 "packages": [{"id": "w 0.1.0", "manifest_path": manifest}],
471 })
472 .to_string();
473 let mut calls = Vec::new();
474 let found = probe::<()>(&manifest, &["--locked", "--offline"], |args| {
475 calls.push(args.join(" "));
476 Ok(if args[0] == "metadata" {
477 answer(true, &metadata)
478 } else {
479 answer(true, "Cargo.toml\n")
480 })
481 })
482 .expect("the probe runs");
483 assert!(matches!(found, Probe::Listed(listing) if listing.carries("Cargo.toml")));
484 assert_eq!(
485 calls[1],
486 format!(
487 "package --list --allow-dirty --locked --offline --manifest-path {}",
488 manifest.display()
489 )
490 );
491 }
492
493 #[test]
494 fn a_failed_listing_is_named_not_guessed() {
495 let dir = tempfile::tempdir().expect("a scratch directory");
496 let manifest = dir.path().join("Cargo.toml");
497 std::fs::write(&manifest, "").expect("the manifest writes");
498 let metadata = serde_json::json!({
499 "workspace_default_members": ["w 0.1.0"],
500 "packages": [{"id": "w 0.1.0", "manifest_path": manifest}],
501 })
502 .to_string();
503 let found = probe::<()>(&manifest, &[], |args| {
504 Ok(if args[0] == "metadata" {
505 answer(true, &metadata)
506 } else {
507 answer(false, "")
508 })
509 })
510 .expect("the probe runs");
511 assert!(matches!(found, Probe::ListingFailed(stderr) if stderr == b"boom"));
512 }
513}