Skip to main content

affected_core/resolvers/
dotnet.rs

1use anyhow::{Context, Result};
2use quick_xml::events::Event;
3use quick_xml::Reader;
4use regex::Regex;
5use std::collections::HashMap;
6use std::path::Path;
7
8use crate::resolvers::{file_to_package, Resolver};
9use crate::types::{Ecosystem, Package, PackageId, ProjectGraph};
10
11/// DotnetResolver detects .NET solutions via `*.sln` files and resolves project references.
12///
13/// Uses `glob` for solution file discovery, `regex` for parsing `.sln` project entries,
14/// and `quick-xml` for parsing `<ProjectReference>` elements from `.csproj`/`.fsproj`/`.vbproj` files.
15pub struct DotnetResolver;
16impl super::sealed::Sealed for DotnetResolver {}
17
18impl Resolver for DotnetResolver {
19    fn ecosystem(&self) -> Ecosystem {
20        Ecosystem::Dotnet
21    }
22
23    fn detect(&self, root: &Path) -> bool {
24        let pattern = root.join("*.sln").to_string_lossy().to_string();
25        glob::glob(&pattern)
26            .map(|mut paths| paths.any(|p| p.is_ok()))
27            .unwrap_or(false)
28    }
29
30    fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
31        // Find the first .sln file at root
32        let pattern = root.join("*.sln").to_string_lossy().to_string();
33        let sln_path = glob::glob(&pattern)
34            .context("Failed to glob for .sln files")?
35            .filter_map(|p| p.ok())
36            .next()
37            .context("No .sln file found")?;
38
39        let sln_content = std::fs::read_to_string(&sln_path)
40            .with_context(|| format!("Failed to read {}", sln_path.display()))?;
41
42        let sln_projects = parse_sln_projects(&sln_content)?;
43
44        tracing::debug!(
45            "Dotnet: found {} project entries in {}",
46            sln_projects.len(),
47            sln_path.display()
48        );
49
50        let mut packages = HashMap::new();
51        // Map from normalized project-file path (relative to root) to PackageId
52        let mut proj_path_to_id: HashMap<String, PackageId> = HashMap::new();
53
54        for (name, rel_proj_path) in &sln_projects {
55            let proj_file = root.join(rel_proj_path);
56            if !proj_file.exists() {
57                tracing::debug!(
58                    "Dotnet: project file '{}' does not exist, skipping",
59                    proj_file.display()
60                );
61                continue;
62            }
63
64            // The PackageId is the project's directory relative to root
65            let proj_dir = proj_file.parent().unwrap_or(root);
66            let rel_dir = proj_dir
67                .strip_prefix(root)
68                .unwrap_or(proj_dir)
69                .to_string_lossy()
70                .replace('\\', "/");
71            let pkg_id = PackageId(rel_dir.clone());
72
73            tracing::debug!(
74                "Dotnet: discovered project '{}' at '{}'",
75                name,
76                rel_proj_path
77            );
78
79            proj_path_to_id.insert(rel_proj_path.clone(), pkg_id.clone());
80            packages.insert(
81                pkg_id.clone(),
82                Package {
83                    id: pkg_id,
84                    name: name.clone(),
85                    version: None,
86                    path: proj_dir.to_path_buf(),
87                    manifest_path: proj_file,
88                },
89            );
90        }
91
92        // Build dependency edges from ProjectReference elements
93        let mut edges = Vec::new();
94
95        for (_, rel_proj_path) in &sln_projects {
96            let proj_file = root.join(rel_proj_path);
97            if !proj_file.exists() {
98                continue;
99            }
100
101            let content = std::fs::read_to_string(&proj_file)?;
102            let references = parse_project_references(&content)?;
103
104            let from_id = match proj_path_to_id.get(rel_proj_path) {
105                Some(id) => id.clone(),
106                None => continue,
107            };
108
109            let proj_dir = proj_file.parent().unwrap_or(root);
110
111            for ref_path in &references {
112                // Resolve the reference path relative to the project file's directory
113                let resolved = proj_dir.join(ref_path);
114                let resolved = resolved
115                    .canonicalize()
116                    .unwrap_or(resolved)
117                    .to_string_lossy()
118                    .replace('\\', "/");
119
120                // Try to match against known project paths
121                for (known_path, to_id) in &proj_path_to_id {
122                    let known_abs = root
123                        .join(known_path)
124                        .canonicalize()
125                        .unwrap_or_else(|_| root.join(known_path))
126                        .to_string_lossy()
127                        .replace('\\', "/");
128                    if resolved == known_abs {
129                        edges.push((from_id.clone(), to_id.clone()));
130                        break;
131                    }
132                }
133            }
134        }
135
136        Ok(ProjectGraph {
137            packages,
138            edges,
139            root: root.to_path_buf(),
140        })
141    }
142
143    fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
144        file_to_package(graph, file)
145    }
146
147    fn test_command(&self, package_id: &PackageId) -> Vec<String> {
148        vec!["dotnet".into(), "test".into(), package_id.0.clone()]
149    }
150}
151
152/// Parse a `.sln` file's content and extract project entries.
153///
154/// Returns a vec of (project_name, normalized_relative_path) for `.csproj`, `.fsproj`, and `.vbproj` files.
155fn parse_sln_projects(sln_content: &str) -> Result<Vec<(String, String)>> {
156    let re = Regex::new(r#"Project\("[^"]*"\)\s*=\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"[^"]*""#)
157        .context("Failed to compile .sln regex")?;
158
159    let mut projects = Vec::new();
160    for line in sln_content.lines() {
161        if let Some(caps) = re.captures(line) {
162            let name = caps[1].to_string();
163            let path = caps[2].replace('\\', "/");
164
165            if path.ends_with(".csproj") || path.ends_with(".fsproj") || path.ends_with(".vbproj") {
166                projects.push((name, path));
167            }
168        }
169    }
170
171    Ok(projects)
172}
173
174/// Parse a project file (`.csproj`/`.fsproj`/`.vbproj`) and extract `<ProjectReference Include="...">` paths.
175///
176/// Returns normalized paths (backslashes replaced with forward slashes).
177fn parse_project_references(xml: &str) -> Result<Vec<String>> {
178    let mut reader = Reader::from_str(xml);
179    let mut buf = Vec::new();
180    let mut references = Vec::new();
181
182    loop {
183        match reader.read_event_into(&mut buf) {
184            Ok(Event::Empty(ref e)) | Ok(Event::Start(ref e)) => {
185                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
186                if tag_name == "ProjectReference" {
187                    for attr in e.attributes().flatten() {
188                        let key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
189                        if key == "Include" {
190                            let value = String::from_utf8_lossy(&attr.value).replace('\\', "/");
191                            references.push(value);
192                        }
193                    }
194                }
195            }
196            Ok(Event::Eof) => break,
197            Err(e) => anyhow::bail!("Error parsing project file XML: {}", e),
198            _ => {}
199        }
200        buf.clear();
201    }
202
203    Ok(references)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_detect_sln() {
212        let dir = tempfile::tempdir().unwrap();
213        std::fs::write(
214            dir.path().join("MyApp.sln"),
215            "Microsoft Visual Studio Solution File\n",
216        )
217        .unwrap();
218
219        assert!(DotnetResolver.detect(dir.path()));
220    }
221
222    #[test]
223    fn test_detect_no_sln() {
224        let dir = tempfile::tempdir().unwrap();
225        assert!(!DotnetResolver.detect(dir.path()));
226    }
227
228    #[test]
229    fn test_parse_sln_projects() {
230        let sln = r#"
231Microsoft Visual Studio Solution File, Format Version 12.00
232Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "src\Core\Core.csproj", "{AAA-BBB}"
233Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "src\Api\Api.csproj", "{CCC-DDD}"
234Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SolutionFolder", "src\Folder", "{EEE-FFF}"
235"#;
236
237        let projects = parse_sln_projects(sln).unwrap();
238        assert_eq!(projects.len(), 2);
239        assert_eq!(projects[0].0, "Core");
240        assert_eq!(projects[0].1, "src/Core/Core.csproj");
241        assert_eq!(projects[1].0, "Api");
242        assert_eq!(projects[1].1, "src/Api/Api.csproj");
243    }
244
245    #[test]
246    fn test_parse_csproj_references() {
247        let xml = r#"<Project Sdk="Microsoft.NET.Sdk">
248  <PropertyGroup>
249    <TargetFramework>net8.0</TargetFramework>
250  </PropertyGroup>
251  <ItemGroup>
252    <ProjectReference Include="..\Core\Core.csproj" />
253    <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
254  </ItemGroup>
255</Project>"#;
256
257        let refs = parse_project_references(xml).unwrap();
258        assert_eq!(refs.len(), 1);
259        assert_eq!(refs[0], "../Core/Core.csproj");
260    }
261
262    #[test]
263    fn test_resolve_dotnet_solution() {
264        let dir = tempfile::tempdir().unwrap();
265
266        // .sln file with 3 projects: Core, Api, Tests
267        std::fs::write(
268            dir.path().join("MyApp.sln"),
269            r#"Microsoft Visual Studio Solution File, Format Version 12.00
270Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "src/Core/Core.csproj", "{AAA}"
271Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "src/Api/Api.csproj", "{BBB}"
272Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "tests/Tests/Tests.csproj", "{CCC}"
273"#,
274        )
275        .unwrap();
276
277        // Core project (no internal deps)
278        std::fs::create_dir_all(dir.path().join("src/Core")).unwrap();
279        std::fs::write(
280            dir.path().join("src/Core/Core.csproj"),
281            r#"<Project Sdk="Microsoft.NET.Sdk">
282  <PropertyGroup>
283    <TargetFramework>net8.0</TargetFramework>
284  </PropertyGroup>
285</Project>"#,
286        )
287        .unwrap();
288
289        // Api depends on Core
290        std::fs::create_dir_all(dir.path().join("src/Api")).unwrap();
291        std::fs::write(
292            dir.path().join("src/Api/Api.csproj"),
293            r#"<Project Sdk="Microsoft.NET.Sdk">
294  <PropertyGroup>
295    <TargetFramework>net8.0</TargetFramework>
296  </PropertyGroup>
297  <ItemGroup>
298    <ProjectReference Include="../Core/Core.csproj" />
299  </ItemGroup>
300</Project>"#,
301        )
302        .unwrap();
303
304        // Tests depends on Api
305        std::fs::create_dir_all(dir.path().join("tests/Tests")).unwrap();
306        std::fs::write(
307            dir.path().join("tests/Tests/Tests.csproj"),
308            r#"<Project Sdk="Microsoft.NET.Sdk">
309  <PropertyGroup>
310    <TargetFramework>net8.0</TargetFramework>
311  </PropertyGroup>
312  <ItemGroup>
313    <ProjectReference Include="../../src/Api/Api.csproj" />
314  </ItemGroup>
315</Project>"#,
316        )
317        .unwrap();
318
319        let graph = DotnetResolver.resolve(dir.path()).unwrap();
320        assert_eq!(graph.packages.len(), 3);
321        assert!(graph.packages.contains_key(&PackageId("src/Core".into())));
322        assert!(graph.packages.contains_key(&PackageId("src/Api".into())));
323        assert!(graph
324            .packages
325            .contains_key(&PackageId("tests/Tests".into())));
326
327        // Api depends on Core
328        assert!(graph
329            .edges
330            .contains(&(PackageId("src/Api".into()), PackageId("src/Core".into()),)));
331        // Tests depends on Api
332        assert!(graph
333            .edges
334            .contains(&(PackageId("tests/Tests".into()), PackageId("src/Api".into()),)));
335    }
336
337    #[test]
338    fn test_resolve_no_internal_deps() {
339        let dir = tempfile::tempdir().unwrap();
340
341        std::fs::write(
342            dir.path().join("MyApp.sln"),
343            r#"Microsoft Visual Studio Solution File, Format Version 12.00
344Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Alpha", "src/Alpha/Alpha.csproj", "{AAA}"
345Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Beta", "src/Beta/Beta.csproj", "{BBB}"
346"#,
347        )
348        .unwrap();
349
350        std::fs::create_dir_all(dir.path().join("src/Alpha")).unwrap();
351        std::fs::write(
352            dir.path().join("src/Alpha/Alpha.csproj"),
353            r#"<Project Sdk="Microsoft.NET.Sdk">
354  <ItemGroup>
355    <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
356  </ItemGroup>
357</Project>"#,
358        )
359        .unwrap();
360
361        std::fs::create_dir_all(dir.path().join("src/Beta")).unwrap();
362        std::fs::write(
363            dir.path().join("src/Beta/Beta.csproj"),
364            r#"<Project Sdk="Microsoft.NET.Sdk">
365  <ItemGroup>
366    <PackageReference Include="Serilog" Version="3.0.0" />
367  </ItemGroup>
368</Project>"#,
369        )
370        .unwrap();
371
372        let graph = DotnetResolver.resolve(dir.path()).unwrap();
373        assert_eq!(graph.packages.len(), 2);
374        assert!(graph.edges.is_empty());
375    }
376
377    #[test]
378    fn test_resolve_normalizes_backslashes() {
379        let dir = tempfile::tempdir().unwrap();
380
381        // .sln with Windows-style backslash paths
382        std::fs::write(
383            dir.path().join("App.sln"),
384            "Microsoft Visual Studio Solution File, Format Version 12.00\r\n\
385             Project(\"{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}\") = \"Lib\", \"src\\Lib\\Lib.csproj\", \"{AAA}\"\r\n\
386             Project(\"{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}\") = \"App\", \"src\\App\\App.csproj\", \"{BBB}\"\r\n",
387        )
388        .unwrap();
389
390        std::fs::create_dir_all(dir.path().join("src/Lib")).unwrap();
391        std::fs::write(
392            dir.path().join("src/Lib/Lib.csproj"),
393            r#"<Project Sdk="Microsoft.NET.Sdk">
394  <PropertyGroup>
395    <TargetFramework>net8.0</TargetFramework>
396  </PropertyGroup>
397</Project>"#,
398        )
399        .unwrap();
400
401        std::fs::create_dir_all(dir.path().join("src/App")).unwrap();
402        std::fs::write(
403            dir.path().join("src/App/App.csproj"),
404            r#"<Project Sdk="Microsoft.NET.Sdk">
405  <ItemGroup>
406    <ProjectReference Include="..\Lib\Lib.csproj" />
407  </ItemGroup>
408</Project>"#,
409        )
410        .unwrap();
411
412        let graph = DotnetResolver.resolve(dir.path()).unwrap();
413        assert_eq!(graph.packages.len(), 2);
414        assert!(graph.packages.contains_key(&PackageId("src/Lib".into())));
415        assert!(graph.packages.contains_key(&PackageId("src/App".into())));
416
417        // App depends on Lib
418        assert!(graph
419            .edges
420            .contains(&(PackageId("src/App".into()), PackageId("src/Lib".into()),)));
421    }
422
423    #[test]
424    fn test_test_command() {
425        let cmd = DotnetResolver.test_command(&PackageId("src/Core".into()));
426        assert_eq!(cmd, vec!["dotnet", "test", "src/Core"]);
427    }
428}