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
11pub 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 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 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 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 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 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 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
152fn 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
174fn 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 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 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 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 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 assert!(graph
329 .edges
330 .contains(&(PackageId("src/Api".into()), PackageId("src/Core".into()),)));
331 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 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 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}