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