Skip to main content

affected_core/resolvers/
cargo.rs

1use 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        // Collect workspace member IDs for filtering
74        let member_ids: HashSet<&str> = metadata
75            .workspace_members
76            .iter()
77            .map(|s| s.as_str())
78            .collect();
79
80        // Build packages (only workspace members)
81        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        // Build edges from resolve graph (only between workspace members)
105        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        // Test resolving the `affected` project itself
185        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; // Skip if not run from the workspace
193        }
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        // affected-cli depends on affected-core
204        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}