Skip to main content

spec_drift/
workspace.rs

1//! Workspace member discovery via `cargo metadata`.
2//!
3//! Single-crate projects just produce one entry with the crate root at the
4//! project root. Virtual workspaces (`[workspace]` at the root, sources in
5//! `crates/*`) produce one entry per member. Anything that `cargo metadata`
6//! can't answer (no Cargo.toml, cargo missing) yields an empty list and the
7//! caller falls back to "treat the whole tree as one unit".
8
9use serde::Deserialize;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13/// One member of a cargo workspace (or the single package in a non-workspace
14/// project).
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Package {
17    pub name: String,
18    /// Directory containing the package's `Cargo.toml`. Every file under this
19    /// path belongs to this package.
20    pub root: PathBuf,
21}
22
23#[derive(Deserialize)]
24struct CargoMetadata {
25    #[serde(default)]
26    packages: Vec<CargoPackage>,
27}
28
29#[derive(Deserialize)]
30struct CargoPackage {
31    name: String,
32    manifest_path: String,
33}
34
35/// Load workspace members from `cargo metadata`. Returns an empty vec when
36/// cargo is unavailable, not a Rust project, or the metadata JSON is malformed
37/// — callers treat that as "unknown, don't filter."
38pub fn load(manifest_dir: &Path) -> Vec<Package> {
39    let out = Command::new("cargo")
40        .current_dir(manifest_dir)
41        .args(["metadata", "--format-version=1", "--no-deps"])
42        .output();
43    let Ok(out) = out else {
44        return Vec::new();
45    };
46    if !out.status.success() {
47        return Vec::new();
48    }
49    let Ok(md) = serde_json::from_slice::<CargoMetadata>(&out.stdout) else {
50        return Vec::new();
51    };
52    md.packages
53        .into_iter()
54        .filter_map(|p| {
55            let root = Path::new(&p.manifest_path).parent()?.to_path_buf();
56            Some(Package { name: p.name, root })
57        })
58        .collect()
59}
60
61/// Find a package by name. Returns `Err` with a helpful message listing the
62/// known members when the name doesn't match.
63pub fn find<'a>(packages: &'a [Package], name: &str) -> Result<&'a Package, String> {
64    packages.iter().find(|p| p.name == name).ok_or_else(|| {
65        let known: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect();
66        format!(
67            "--package `{name}`: not a workspace member. Known: {}",
68            if known.is_empty() {
69                "(none — not a cargo project?)".to_string()
70            } else {
71                known.join(", ")
72            }
73        )
74    })
75}
76
77/// Retain only the files under `pkg.root`.
78pub fn narrow_paths(paths: Vec<PathBuf>, pkg: &Package) -> Vec<PathBuf> {
79    paths
80        .into_iter()
81        .filter(|p| p.starts_with(&pkg.root))
82        .collect()
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn parses_metadata_into_packages() {
91        let json = r#"{"packages": [
92            {"name": "alpha", "manifest_path": "/repo/crates/alpha/Cargo.toml"},
93            {"name": "beta",  "manifest_path": "/repo/crates/beta/Cargo.toml"}
94        ]}"#;
95        let md: CargoMetadata = serde_json::from_str(json).unwrap();
96        let packages: Vec<Package> = md
97            .packages
98            .into_iter()
99            .filter_map(|p| {
100                let root = std::path::Path::new(&p.manifest_path)
101                    .parent()?
102                    .to_path_buf();
103                Some(Package { name: p.name, root })
104            })
105            .collect();
106        assert_eq!(packages.len(), 2);
107        assert_eq!(packages[0].name, "alpha");
108        assert_eq!(packages[0].root, PathBuf::from("/repo/crates/alpha"));
109        assert_eq!(packages[1].root, PathBuf::from("/repo/crates/beta"));
110    }
111
112    #[test]
113    fn malformed_metadata_returns_empty() {
114        let json = r#"{"packages": "not-an-array"}"#;
115        assert!(serde_json::from_str::<CargoMetadata>(json).is_err());
116        let json = r#"{}"#;
117        let md: CargoMetadata = serde_json::from_str(json).unwrap();
118        assert!(md.packages.is_empty());
119    }
120
121    #[test]
122    fn find_returns_helpful_error_on_unknown_name() {
123        let packages = vec![Package {
124            name: "alpha".into(),
125            root: PathBuf::from("/r/alpha"),
126        }];
127        let err = find(&packages, "beta").unwrap_err();
128        assert!(err.contains("beta"));
129        assert!(err.contains("alpha"));
130    }
131
132    #[test]
133    fn find_returns_no_cargo_hint_when_empty() {
134        let err = find(&[], "anything").unwrap_err();
135        assert!(err.contains("not a cargo project"));
136    }
137
138    #[test]
139    fn narrow_paths_drops_non_members() {
140        let pkg = Package {
141            name: "alpha".into(),
142            root: PathBuf::from("/r/alpha"),
143        };
144        let paths = vec![
145            PathBuf::from("/r/alpha/src/lib.rs"),
146            PathBuf::from("/r/beta/src/lib.rs"),
147            PathBuf::from("/r/alpha/README.md"),
148        ];
149        let out = narrow_paths(paths, &pkg);
150        assert_eq!(out.len(), 2);
151        assert!(out.iter().all(|p| p.starts_with("/r/alpha")));
152    }
153}