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