1use serde::Deserialize;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Package {
17 pub name: String,
18 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
35pub 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
61pub 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
77pub 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}