Skip to main content

release_kit/
cargo_package.rs

1//! What one Cargo package ships, read from `cargo package --list`.
2//!
3//! release-plz attributes a commit to a package only where the commit
4//! changes a file this listing prints, for the changelog and for the
5//! decision to release alike. Two callers read the listing for that reason:
6//! the `package-check` setup step, which faults a crate that ships
7//! release-kit's own files, and `rk integrate`, which warns when a message
8//! states release intent over a change the listing does not reach.
9//!
10//! The module spawns nothing. Each caller passes its own cargo call, so the
11//! setup keeps its journaled executor and integrate keeps a plain process,
12//! while the shape of the answer and every match rule live here once.
13
14use std::collections::BTreeSet;
15use std::path::Path;
16
17use serde_json::Value;
18
19/// The one landed file a crate must ship: the reporting policy.
20///
21/// A consumer must find it in the artifact they downloaded. A reporting
22/// policy readable on the forge alone is a policy the consumer who has only
23/// the package cannot follow.
24pub const POLICY_DESTINATION: &str = "SECURITY.md";
25
26/// The directory release-kit owns by name: the target configuration and
27/// the landing record live under it and nothing else does.
28const OWN_DIRECTORY: &str = ".release-kit";
29
30/// What one cargo call answered.
31#[derive(Debug)]
32pub struct Answer {
33    /// Whether the call succeeded.
34    pub success: bool,
35    /// Its standard output.
36    pub stdout: Vec<u8>,
37    /// Its standard error.
38    pub stderr: Vec<u8>,
39}
40
41/// What reading the package's file list found.
42#[derive(Debug)]
43pub enum Probe {
44    /// The sole default package rooted at the target, and what it ships.
45    Listed(Listing),
46    /// Another workspace shape, whose listing no command answers
47    /// unambiguously: no listing ran.
48    OtherShape,
49    /// `cargo metadata` failed, with its standard error.
50    MetadataFailed(Vec<u8>),
51    /// `cargo package --list` failed, with its standard error.
52    ListingFailed(Vec<u8>),
53}
54
55/// Read the file list of the sole default package rooted at
56/// `root_manifest`.
57///
58/// `cargo package --list` prints one path per line for one package and
59/// emits no stable delimiter when it selects several, and a nested package
60/// cannot include a file above its own root. So the listing runs only for a
61/// sole selected default member whose manifest is the target's own
62/// `Cargo.toml`. `list_flags` go after `--allow-dirty`, for a caller that
63/// must keep the listing from writing.
64///
65/// # Errors
66///
67/// Propagates the caller's own failure to run cargo at all.
68pub 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/// The manifest path of the one selected default package rooted at the
92/// target, or `None` for every other workspace shape.
93#[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    // Compare what each path resolves to, so a symlinked or
113    // differently-spelled target directory still reads as the root.
114    let same =
115        std::fs::canonicalize(manifest).ok()? == std::fs::canonicalize(root_manifest).ok()?;
116    same.then(|| manifest.to_owned())
117}
118
119/// The paths one package ships, one per listed line.
120#[derive(Debug, Default)]
121pub struct Listing(BTreeSet<String>);
122
123impl Listing {
124    /// Parse `cargo package --list` output: trimmed, non-empty lines.
125    #[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    /// Whether the package ships exactly `path`. An exact line, never a
138    /// substring: `docs/SECURITY.md` and `SECURITY.md.bak` are different
139    /// files.
140    #[must_use]
141    pub fn carries(&self, path: &str) -> bool {
142        self.0.contains(path)
143    }
144
145    /// Whether any of `changed` is a path the package ships: the test
146    /// release-plz applies to attribute a commit to the package.
147    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    /// Which of `forbidden` the package ships, in order.
152    #[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/// Every path release-kit lands or owns in a target that a package must
163/// not ship: the target configuration, the landing record, and every
164/// destination the record names, less the reporting policy.
165///
166/// With no record, release-kit has landed nothing, so only its own two
167/// files are claimed: every other destination the sources could land would
168/// be the target's own file there, and claiming it would fault a file
169/// release-kit never rewrites.
170#[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/// The `[package].exclude` entries that stop a package shipping `shipped`.
185///
186/// One `/.release-kit` covers everything under release-kit's own
187/// directory, and each other path is rooted as it is. An exact path is
188/// never wrong, where a parent directory can hold the target's own files.
189#[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}