affected_core/resolvers/
cargo.rs1use anyhow::{Context, Result};
2use serde::Deserialize;
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use crate::resolvers::{file_to_package, Resolver};
8use crate::types::{Ecosystem, Package, PackageId, ProjectGraph};
9
10pub struct CargoResolver;
11impl super::sealed::Sealed for CargoResolver {}
12
13#[derive(Deserialize)]
14struct CargoMetadata {
15 packages: Vec<CargoPackage>,
16 workspace_members: Vec<String>,
17 resolve: Option<CargoResolve>,
18 workspace_root: String,
19}
20
21#[derive(Deserialize)]
22struct CargoPackage {
23 id: String,
24 name: String,
25 version: String,
26 manifest_path: String,
27}
28
29#[derive(Deserialize)]
30struct CargoResolve {
31 nodes: Vec<CargoNode>,
32}
33
34#[derive(Deserialize)]
35struct CargoNode {
36 id: String,
37 dependencies: Vec<String>,
38}
39
40impl Resolver for CargoResolver {
41 fn ecosystem(&self) -> Ecosystem {
42 Ecosystem::Cargo
43 }
44
45 fn detect(&self, root: &Path) -> bool {
46 let cargo_toml = root.join("Cargo.toml");
47 if !cargo_toml.exists() {
48 return false;
49 }
50 std::fs::read_to_string(&cargo_toml)
51 .map(|c| c.contains("[workspace]"))
52 .unwrap_or(false)
53 }
54
55 fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
56 let output = Command::new("cargo")
57 .args(["metadata", "--format-version", "1"])
58 .arg("--manifest-path")
59 .arg(root.join("Cargo.toml"))
60 .output()
61 .context("Failed to run 'cargo metadata'. Is cargo installed?")?;
62
63 if !output.status.success() {
64 let stderr = String::from_utf8_lossy(&output.stderr);
65 anyhow::bail!("cargo metadata failed: {stderr}");
66 }
67
68 let metadata: CargoMetadata = serde_json::from_slice(&output.stdout)
69 .context("Failed to parse cargo metadata JSON")?;
70
71 let workspace_root = PathBuf::from(&metadata.workspace_root);
72
73 let member_ids: HashSet<&str> = metadata
75 .workspace_members
76 .iter()
77 .map(|s| s.as_str())
78 .collect();
79
80 let mut packages = HashMap::new();
82 let mut id_to_name = HashMap::new();
83
84 for pkg in &metadata.packages {
85 if member_ids.contains(pkg.id.as_str()) {
86 let manifest = PathBuf::from(&pkg.manifest_path);
87 let pkg_path = manifest.parent().unwrap_or(&workspace_root).to_path_buf();
88 let pkg_id = PackageId(pkg.name.clone());
89
90 id_to_name.insert(pkg.id.clone(), pkg.name.clone());
91 packages.insert(
92 pkg_id.clone(),
93 Package {
94 id: pkg_id,
95 name: pkg.name.clone(),
96 version: Some(pkg.version.clone()),
97 path: pkg_path,
98 manifest_path: manifest,
99 },
100 );
101 }
102 }
103
104 let mut edges = Vec::new();
106 if let Some(resolve) = &metadata.resolve {
107 for node in &resolve.nodes {
108 let from_name = match id_to_name.get(&node.id) {
109 Some(n) => n,
110 None => continue,
111 };
112
113 for dep_id in &node.dependencies {
114 if let Some(to_name) = id_to_name.get(dep_id) {
115 edges.push((PackageId(from_name.clone()), PackageId(to_name.clone())));
116 }
117 }
118 }
119 }
120
121 Ok(ProjectGraph {
122 packages,
123 edges,
124 root: workspace_root,
125 })
126 }
127
128 fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
129 file_to_package(graph, file)
130 }
131
132 fn test_command(&self, package_id: &PackageId) -> Vec<String> {
133 vec![
134 "cargo".into(),
135 "test".into(),
136 "-p".into(),
137 package_id.0.clone(),
138 ]
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn test_detect_cargo_workspace() {
148 let dir = tempfile::tempdir().unwrap();
149 std::fs::write(
150 dir.path().join("Cargo.toml"),
151 "[workspace]\nmembers = [\"crates/*\"]\n",
152 )
153 .unwrap();
154
155 assert!(CargoResolver.detect(dir.path()));
156 }
157
158 #[test]
159 fn test_detect_no_workspace() {
160 let dir = tempfile::tempdir().unwrap();
161 std::fs::write(
162 dir.path().join("Cargo.toml"),
163 "[package]\nname = \"solo\"\n",
164 )
165 .unwrap();
166
167 assert!(!CargoResolver.detect(dir.path()));
168 }
169
170 #[test]
171 fn test_detect_no_cargo_toml() {
172 let dir = tempfile::tempdir().unwrap();
173 assert!(!CargoResolver.detect(dir.path()));
174 }
175
176 #[test]
177 fn test_test_command_format() {
178 let cmd = CargoResolver.test_command(&PackageId("my-crate".into()));
179 assert_eq!(cmd, vec!["cargo", "test", "-p", "my-crate"]);
180 }
181
182 #[test]
183 fn test_resolve_on_self() {
184 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
186 .parent()
187 .unwrap()
188 .parent()
189 .unwrap();
190
191 if !root.join("Cargo.toml").exists() {
192 return; }
194
195 let graph = CargoResolver.resolve(root).unwrap();
196 assert!(graph.packages.len() >= 2);
197 assert!(graph
198 .packages
199 .contains_key(&PackageId("affected-core".into())));
200 assert!(graph
201 .packages
202 .contains_key(&PackageId("affected-cli".into())));
203 assert!(graph.edges.contains(&(
205 PackageId("affected-cli".into()),
206 PackageId("affected-core".into()),
207 )));
208 }
209
210 #[test]
211 fn test_package_for_file_in_workspace() {
212 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
213 .parent()
214 .unwrap()
215 .parent()
216 .unwrap();
217
218 if !root.join("Cargo.toml").exists() {
219 return;
220 }
221
222 let graph = CargoResolver.resolve(root).unwrap();
223 let result = CargoResolver
224 .package_for_file(&graph, &PathBuf::from("crates/affected-core/src/lib.rs"));
225 assert_eq!(result, Some(PackageId("affected-core".into())));
226 }
227}