1use std::path::{Path, PathBuf};
10use std::process::Command;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Package {
16 pub name: String,
17 pub root: PathBuf,
20}
21
22pub 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
65pub 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
84pub 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}