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    /// Every listed path, sorted.
138    pub fn paths(&self) -> impl Iterator<Item = &str> {
139        self.0.iter().map(String::as_str)
140    }
141
142    /// Whether the package ships exactly `path`. An exact line, never a
143    /// substring: `docs/SECURITY.md` and `SECURITY.md.bak` are different
144    /// files.
145    #[must_use]
146    pub fn carries(&self, path: &str) -> bool {
147        self.0.contains(path)
148    }
149
150    /// Whether any of `changed` is a path the package ships: the test
151    /// release-plz applies to attribute a commit to the package.
152    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    /// Which of `forbidden` the package ships, in order.
157    #[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/// Every path release-kit lands or owns in a target that a package must
168/// not ship: the target configuration, the landing record, and every
169/// destination the record names, less the reporting policy.
170///
171/// With no record, release-kit has landed nothing, so only its own two
172/// files are claimed: every other destination the sources could land would
173/// be the target's own file there, and claiming it would fault a file
174/// release-kit never rewrites.
175#[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/// The `[package].exclude` entries that stop a package shipping `shipped`.
190///
191/// One `/.release-kit` covers everything under release-kit's own
192/// directory, and each other path is rooted as it is. An exact path is
193/// never wrong, where a parent directory can hold the target's own files.
194#[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
212/// The files release-plz reads its configuration from, in the order it
213/// tries them.
214pub const RELEASE_PLZ_CONFIGS: [&str; 3] = [
215    "release-plz.toml",
216    ".release-plz.toml",
217    ".config/release-plz.toml",
218];
219
220/// One workspace member release-plz releases.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct Released {
223    /// The package name.
224    pub name: String,
225    /// The package's manifest, as cargo metadata states it.
226    pub manifest_path: String,
227    /// Whether the package has a binary target, whose build ships the
228    /// lock file.
229    pub binary: bool,
230}
231
232/// The workspace members release-plz 0.4.3 releases, in the order cargo
233/// metadata lists them.
234///
235/// A member takes part where its release-plz `release` key is not `false`
236/// and Cargo's `publish` field lets it publish somewhere, or where the
237/// member is `git_only` and is not an example-only package that leaves
238/// `publish` unset. release-plz's own `publish` key only stops
239/// `cargo publish`, so it selects nothing. A `[[package]]` entry's key
240/// overrides the `[workspace]` key of the same name.
241#[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
315/// The files `cargo package --list` prints that the tree does not hold
316/// under that name.
317///
318/// They are the generated manifest copy, the VCS record, and the lock,
319/// which a workspace keeps at its root and which a caller adds only for a
320/// package with a binary target.
321pub 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    /// cargo metadata for members `(name, publish, kinds)`.
330    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    /// SATISFIES release-engine:a-rust-target-releases-one-package
358    ///
359    /// release-plz 0.4.3's selection: Cargo's `publish` field, the
360    /// `git_only` key, the `release` key, and the example-only rule.
361    #[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}