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