use std::collections::BTreeSet;
use std::path::Path;
use serde_json::Value;
pub const POLICY_DESTINATION: &str = "SECURITY.md";
const OWN_DIRECTORY: &str = ".release-kit";
#[derive(Debug)]
pub struct Answer {
pub success: bool,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
#[derive(Debug)]
pub enum Probe {
Listed(Listing),
OtherShape,
MetadataFailed(Vec<u8>),
ListingFailed(Vec<u8>),
}
pub fn probe<E>(
root_manifest: &Path,
list_flags: &[&str],
mut cargo: impl FnMut(&[&str]) -> Result<Answer, E>,
) -> Result<Probe, E> {
let metadata = cargo(&["metadata", "--no-deps", "--format-version", "1"])?;
if !metadata.success {
return Ok(Probe::MetadataFailed(metadata.stderr));
}
let Some(manifest) = sole_root_package(&metadata.stdout, root_manifest) else {
return Ok(Probe::OtherShape);
};
let mut args = vec!["package", "--list", "--allow-dirty"];
args.extend_from_slice(list_flags);
args.extend_from_slice(&["--manifest-path", &manifest]);
let listing = cargo(&args)?;
Ok(if listing.success {
Probe::Listed(Listing::parse(&listing.stdout))
} else {
Probe::ListingFailed(listing.stderr)
})
}
#[must_use]
pub fn sole_root_package(metadata: &[u8], root_manifest: &Path) -> Option<String> {
let document: Value = serde_json::from_slice(metadata).ok()?;
let defaults: Vec<&str> = document
.get("workspace_default_members")?
.as_array()?
.iter()
.filter_map(Value::as_str)
.collect();
let [only] = defaults.as_slice() else {
return None;
};
let manifest = document
.get("packages")?
.as_array()?
.iter()
.find(|package| package.get("id").and_then(Value::as_str) == Some(*only))?
.get("manifest_path")?
.as_str()?;
let same =
std::fs::canonicalize(manifest).ok()? == std::fs::canonicalize(root_manifest).ok()?;
same.then(|| manifest.to_owned())
}
#[derive(Debug, Default)]
pub struct Listing(BTreeSet<String>);
impl Listing {
#[must_use]
pub fn parse(stdout: &[u8]) -> Self {
Self(
String::from_utf8_lossy(stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect(),
)
}
#[must_use]
pub fn carries(&self, path: &str) -> bool {
self.0.contains(path)
}
pub fn touched_by<'a>(&self, changed: impl IntoIterator<Item = &'a str>) -> bool {
changed.into_iter().any(|path| self.carries(path))
}
#[must_use]
pub fn shipped<'a>(&self, forbidden: &'a BTreeSet<String>) -> Vec<&'a str> {
forbidden
.iter()
.filter(|path| self.carries(path))
.map(String::as_str)
.collect()
}
}
#[must_use]
pub fn release_kit_paths(landed: Option<&[String]>) -> BTreeSet<String> {
let mut paths: BTreeSet<String> = [
crate::config::CONFIG_PATH,
crate::landing::manifest::MANIFEST_PATH,
]
.into_iter()
.map(str::to_owned)
.collect();
paths.extend(landed.into_iter().flatten().cloned());
paths.remove(POLICY_DESTINATION);
paths
}
#[must_use]
pub fn exclude_entries(shipped: &[&str]) -> Vec<String> {
let own = format!("{OWN_DIRECTORY}/");
let mut entries = Vec::new();
if shipped.iter().any(|path| path.starts_with(&own)) {
entries.push(format!("/{OWN_DIRECTORY}"));
}
let mut rest: Vec<String> = shipped
.iter()
.filter(|path| !path.starts_with(&own))
.map(|path| format!("/{path}"))
.collect();
rest.sort();
rest.dedup();
entries.extend(rest);
entries
}
#[cfg(test)]
mod tests {
use super::{Answer, Listing, Probe, exclude_entries, probe, release_kit_paths};
#[test]
fn a_listing_is_trimmed_lines_without_blanks() {
let listing = Listing::parse(b" Cargo.toml\n\nsrc/main.rs \n");
assert!(listing.carries("Cargo.toml"));
assert!(listing.carries("src/main.rs"));
assert!(!listing.carries(""));
}
#[test]
fn a_shipped_path_is_an_exact_line() {
let listing = Listing::parse(b"docs/GLOSSARY.md\nGLOSSARY.md.bak\nnix/package.nix\n");
let forbidden = release_kit_paths(Some(&[
"GLOSSARY.md".to_owned(),
"nix/package.nix".to_owned(),
]));
assert_eq!(listing.shipped(&forbidden), vec!["nix/package.nix"]);
}
#[test]
fn a_change_touches_the_package_only_through_a_listed_path() {
let listing = Listing::parse(b"Cargo.toml\nsrc/main.rs\n");
assert!(listing.touched_by(["docs/a.md", "src/main.rs"]));
assert!(!listing.touched_by(["docs/a.md", "justfile"]));
}
#[test]
fn without_a_record_only_the_two_own_files_are_claimed() {
let paths = release_kit_paths(None);
assert_eq!(
paths.into_iter().collect::<Vec<_>>(),
vec![".release-kit/config.toml", ".release-kit/manifest.json"]
);
}
#[test]
fn the_reporting_policy_is_never_claimed() {
let paths = release_kit_paths(Some(&["SECURITY.md".to_owned()]));
assert!(!paths.contains("SECURITY.md"));
}
#[test]
fn exclude_entries_collapse_the_own_directory_first() {
let entries = exclude_entries(&[
"GLOSSARY.md",
".release-kit/manifest.json",
".release-kit/config.toml",
"nix/package.nix",
]);
assert_eq!(
entries,
vec!["/.release-kit", "/GLOSSARY.md", "/nix/package.nix"]
);
}
fn answer(success: bool, stdout: &str) -> Answer {
Answer {
success,
stdout: stdout.as_bytes().to_vec(),
stderr: b"boom".to_vec(),
}
}
#[test]
fn another_shape_runs_no_listing() {
let mut calls = Vec::new();
let found = probe::<()>(std::path::Path::new("/nowhere/Cargo.toml"), &[], |args| {
calls.push(args.join(" "));
Ok(answer(
true,
r#"{"workspace_default_members":[],"packages":[]}"#,
))
})
.expect("the probe runs");
assert!(matches!(found, Probe::OtherShape));
assert_eq!(calls, vec!["metadata --no-deps --format-version 1"]);
}
#[test]
fn list_flags_sit_between_allow_dirty_and_the_manifest() {
let dir = tempfile::tempdir().expect("a scratch directory");
let manifest = dir.path().join("Cargo.toml");
std::fs::write(&manifest, "").expect("the manifest writes");
let metadata = serde_json::json!({
"workspace_default_members": ["w 0.1.0"],
"packages": [{"id": "w 0.1.0", "manifest_path": manifest}],
})
.to_string();
let mut calls = Vec::new();
let found = probe::<()>(&manifest, &["--locked", "--offline"], |args| {
calls.push(args.join(" "));
Ok(if args[0] == "metadata" {
answer(true, &metadata)
} else {
answer(true, "Cargo.toml\n")
})
})
.expect("the probe runs");
assert!(matches!(found, Probe::Listed(listing) if listing.carries("Cargo.toml")));
assert_eq!(
calls[1],
format!(
"package --list --allow-dirty --locked --offline --manifest-path {}",
manifest.display()
)
);
}
#[test]
fn a_failed_listing_is_named_not_guessed() {
let dir = tempfile::tempdir().expect("a scratch directory");
let manifest = dir.path().join("Cargo.toml");
std::fs::write(&manifest, "").expect("the manifest writes");
let metadata = serde_json::json!({
"workspace_default_members": ["w 0.1.0"],
"packages": [{"id": "w 0.1.0", "manifest_path": manifest}],
})
.to_string();
let found = probe::<()>(&manifest, &[], |args| {
Ok(if args[0] == "metadata" {
answer(true, &metadata)
} else {
answer(false, "")
})
})
.expect("the probe runs");
assert!(matches!(found, Probe::ListingFailed(stderr) if stderr == b"boom"));
}
}