1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::{Project, ProjectFinder};
4use quick_xml::Reader;
5use quick_xml::events::Event;
6use std::{
7 collections::HashMap,
8 path::{Path, PathBuf},
9};
10use tokio::fs::read_to_string;
11
12use crate::{package::CSharpPackage, workspace::CSharpWorkspace};
13
14#[derive(Debug)]
15pub struct CSharpProjectFinder {
16 projects: HashMap<PathBuf, Project>,
17 project_files: Vec<&'static str>,
18}
19
20impl Default for CSharpProjectFinder {
21 fn default() -> Self {
22 Self::new()
23 }
24}
25
26impl CSharpProjectFinder {
27 #[must_use]
28 pub fn new() -> Self {
29 Self {
30 projects: HashMap::new(),
31 project_files: vec![".csproj"],
32 }
33 }
34
35 fn extract_name_from_path(path: &Path) -> Option<String> {
37 path.file_stem()
38 .and_then(|s| s.to_str())
39 .map(std::string::ToString::to_string)
40 }
41}
42
43fn extract_project_name_from_path(path_str: &str) -> Option<String> {
47 let filename = path_str.rsplit(['\\', '/']).next()?;
49
50 filename
52 .strip_suffix(".csproj")
53 .map(std::string::ToString::to_string)
54}
55
56impl CSharpProjectFinder {
57 fn extract_version(content: &str) -> Option<String> {
59 let mut reader = Reader::from_str(content);
60 let mut buf = Vec::new();
61 let mut in_property_group = false;
62 let mut in_version = false;
63
64 loop {
65 match reader.read_event_into(&mut buf) {
66 Ok(Event::Start(e)) => {
67 let name = e.local_name();
68 if name.as_ref() == b"PropertyGroup" {
69 in_property_group = true;
70 } else if in_property_group && name.as_ref() == b"Version" {
71 in_version = true;
72 }
73 }
74 Ok(Event::End(e)) => {
75 let name = e.local_name();
76 if name.as_ref() == b"PropertyGroup" {
77 in_property_group = false;
78 } else if name.as_ref() == b"Version" {
79 in_version = false;
80 }
81 }
82 Ok(Event::Text(e)) => {
83 if in_version && let Ok(text) = e.decode() {
84 let version = text.trim().to_string();
85 if !version.is_empty() {
86 return Some(version);
87 }
88 }
89 }
90 Ok(Event::Eof) | Err(_) => break,
91 _ => {}
92 }
93 buf.clear();
94 }
95 None
96 }
97
98 #[cfg(not(tarpaulin_include))]
106 #[allow(dead_code)]
107 fn extract_package_references(content: &str) -> Vec<String> {
108 let mut reader = Reader::from_str(content);
109 let mut buf = Vec::new();
110 let mut packages = Vec::new();
111
112 loop {
113 match reader.read_event_into(&mut buf) {
114 Ok(Event::Empty(e) | Event::Start(e))
115 if e.local_name().as_ref() == b"PackageReference" =>
116 {
117 for attr in e.attributes().flatten() {
118 if attr.key.as_ref() == b"Include"
119 && let Ok(value) = attr.unescape_value()
120 {
121 packages.push(value.to_string());
122 }
123 }
124 }
125 Ok(Event::Eof) | Err(_) => break,
126 _ => {}
127 }
128 buf.clear();
129 }
130 packages
131 }
132
133 fn extract_project_references(content: &str) -> Vec<String> {
136 let mut reader = Reader::from_str(content);
137 let mut buf = Vec::new();
138 let mut projects = Vec::new();
139
140 loop {
141 match reader.read_event_into(&mut buf) {
142 Ok(Event::Empty(e) | Event::Start(e))
143 if e.local_name().as_ref() == b"ProjectReference" =>
144 {
145 for attr in e.attributes().flatten() {
146 if attr.key.as_ref() == b"Include"
147 && let Ok(value) = attr.unescape_value()
148 {
149 if let Some(name) = extract_project_name_from_path(&value) {
152 projects.push(name);
153 }
154 }
155 }
156 }
157 Ok(Event::Eof) | Err(_) => break,
158 _ => {}
159 }
160 buf.clear();
161 }
162 projects
163 }
164
165 async fn is_workspace(path: &Path) -> bool {
168 if let Some(parent) = path.parent() {
169 if let Ok(mut entries) = tokio::fs::read_dir(parent).await {
171 while let Ok(Some(entry)) = entries.next_entry().await {
172 if let Some(ext) = entry.path().extension()
173 && ext == "sln"
174 {
175 return true;
176 }
177 }
178 }
179 }
180 false
181 }
182}
183
184#[async_trait]
185impl ProjectFinder for CSharpProjectFinder {
186 fn projects(&self) -> Vec<&Project> {
187 self.projects.values().collect::<Vec<_>>()
188 }
189
190 fn projects_mut(&mut self) -> Vec<&mut Project> {
191 self.projects.values_mut().collect::<Vec<_>>()
192 }
193
194 fn project_files(&self) -> &[&str] {
195 &self.project_files
196 }
197
198 async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
199 if path.is_file() {
201 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
202
203 if extension != "csproj" {
204 return Ok(());
205 }
206
207 if self.projects.contains_key(path) {
208 return Ok(());
209 }
210
211 let csproj_content = read_to_string(path).await?;
213
214 let name = Self::extract_name_from_path(path);
215 let version = Self::extract_version(&csproj_content);
216 let is_workspace = Self::is_workspace(path).await;
217
218 let (path_key, mut project) = if is_workspace {
219 (
220 path.to_path_buf(),
221 Project::Workspace(Box::new(CSharpWorkspace::new(
222 name,
223 version,
224 path.to_path_buf(),
225 relative_path.to_path_buf(),
226 ))),
227 )
228 } else {
229 (
230 path.to_path_buf(),
231 Project::Package(Box::new(CSharpPackage::new(
232 name,
233 version,
234 path.to_path_buf(),
235 relative_path.to_path_buf(),
236 ))),
237 )
238 };
239
240 for dep in Self::extract_project_references(&csproj_content) {
242 project.add_dependency(&dep);
243 }
244
245 self.projects.insert(path_key, project);
246 }
247 Ok(())
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use std::fs;
255 use tempfile::TempDir;
256
257 #[tokio::test]
258 async fn test_new() {
259 let finder = CSharpProjectFinder::new();
260 assert_eq!(finder.project_files(), &[".csproj"]);
261 assert_eq!(finder.projects().len(), 0);
262 }
263
264 #[tokio::test]
265 async fn test_default() {
266 let finder = CSharpProjectFinder::default();
267 assert_eq!(finder.project_files(), &[".csproj"]);
268 assert_eq!(finder.projects().len(), 0);
269 }
270
271 #[tokio::test]
272 async fn test_visit_package() {
273 let temp_dir = TempDir::new().unwrap();
274 let csproj_path = temp_dir.path().join("TestProject.csproj");
275 fs::write(
276 &csproj_path,
277 r#"<Project Sdk="Microsoft.NET.Sdk">
278 <PropertyGroup>
279 <Version>1.0.0</Version>
280 </PropertyGroup>
281</Project>
282"#,
283 )
284 .unwrap();
285
286 let mut finder = CSharpProjectFinder::new();
287 finder
288 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
289 .await
290 .unwrap();
291
292 assert_eq!(finder.projects().len(), 1);
293 match finder.projects()[0] {
294 Project::Package(pkg) => {
295 assert_eq!(pkg.name(), Some("TestProject"));
296 assert_eq!(pkg.version(), Some("1.0.0"));
297 }
298 _ => panic!("Expected Package"),
299 }
300
301 temp_dir.close().unwrap();
302 }
303
304 #[tokio::test]
305 async fn test_visit_workspace_with_sln() {
306 let temp_dir = TempDir::new().unwrap();
307 let csproj_path = temp_dir.path().join("TestProject.csproj");
308 let sln_path = temp_dir.path().join("TestSolution.sln");
309
310 fs::write(
311 &csproj_path,
312 r#"<Project Sdk="Microsoft.NET.Sdk">
313 <PropertyGroup>
314 <Version>1.0.0</Version>
315 </PropertyGroup>
316</Project>
317"#,
318 )
319 .unwrap();
320
321 fs::write(&sln_path, "Microsoft Visual Studio Solution File").unwrap();
322
323 let mut finder = CSharpProjectFinder::new();
324 finder
325 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
326 .await
327 .unwrap();
328
329 assert_eq!(finder.projects().len(), 1);
330 match finder.projects()[0] {
331 Project::Workspace(ws) => {
332 assert_eq!(ws.name(), Some("TestProject"));
333 assert_eq!(ws.version(), Some("1.0.0"));
334 }
335 _ => panic!("Expected Workspace"),
336 }
337
338 temp_dir.close().unwrap();
339 }
340
341 #[tokio::test]
342 async fn test_visit_package_without_version() {
343 let temp_dir = TempDir::new().unwrap();
344 let csproj_path = temp_dir.path().join("TestProject.csproj");
345 fs::write(
346 &csproj_path,
347 r#"<Project Sdk="Microsoft.NET.Sdk">
348 <PropertyGroup>
349 <OutputType>Exe</OutputType>
350 </PropertyGroup>
351</Project>
352"#,
353 )
354 .unwrap();
355
356 let mut finder = CSharpProjectFinder::new();
357 finder
358 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
359 .await
360 .unwrap();
361
362 assert_eq!(finder.projects().len(), 1);
363 match finder.projects()[0] {
364 Project::Package(pkg) => {
365 assert_eq!(pkg.name(), Some("TestProject"));
366 assert_eq!(pkg.version(), None);
367 }
368 _ => panic!("Expected Package"),
369 }
370
371 temp_dir.close().unwrap();
372 }
373
374 #[tokio::test]
375 async fn test_visit_non_csproj_file() {
376 let temp_dir = TempDir::new().unwrap();
377 let other_file = temp_dir.path().join("other.xml");
378 fs::write(&other_file, r#"<root>content</root>"#).unwrap();
379
380 let mut finder = CSharpProjectFinder::new();
381 finder
382 .visit(&other_file, &PathBuf::from("other.xml"))
383 .await
384 .unwrap();
385
386 assert_eq!(finder.projects().len(), 0);
387
388 temp_dir.close().unwrap();
389 }
390
391 #[tokio::test]
392 async fn test_visit_directory() {
393 let temp_dir = TempDir::new().unwrap();
394 let dir_path = temp_dir.path().join("some_dir");
395 fs::create_dir_all(&dir_path).unwrap();
396
397 let mut finder = CSharpProjectFinder::new();
398 finder
399 .visit(&dir_path, &PathBuf::from("some_dir"))
400 .await
401 .unwrap();
402
403 assert_eq!(finder.projects().len(), 0);
404
405 temp_dir.close().unwrap();
406 }
407
408 #[tokio::test]
409 async fn test_visit_duplicate() {
410 let temp_dir = TempDir::new().unwrap();
411 let csproj_path = temp_dir.path().join("TestProject.csproj");
412 fs::write(
413 &csproj_path,
414 r#"<Project Sdk="Microsoft.NET.Sdk">
415 <PropertyGroup>
416 <Version>1.0.0</Version>
417 </PropertyGroup>
418</Project>
419"#,
420 )
421 .unwrap();
422
423 let mut finder = CSharpProjectFinder::new();
424 finder
425 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
426 .await
427 .unwrap();
428 finder
429 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
430 .await
431 .unwrap();
432
433 assert_eq!(finder.projects().len(), 1);
434
435 temp_dir.close().unwrap();
436 }
437
438 #[tokio::test]
439 async fn test_visit_multiple_packages() {
440 let temp_dir = TempDir::new().unwrap();
441 let csproj1 = temp_dir.path().join("Project1").join("Project1.csproj");
442 let csproj2 = temp_dir.path().join("Project2").join("Project2.csproj");
443 fs::create_dir_all(csproj1.parent().unwrap()).unwrap();
444 fs::create_dir_all(csproj2.parent().unwrap()).unwrap();
445 fs::write(
446 &csproj1,
447 r#"<Project Sdk="Microsoft.NET.Sdk">
448 <PropertyGroup>
449 <Version>1.0.0</Version>
450 </PropertyGroup>
451</Project>
452"#,
453 )
454 .unwrap();
455 fs::write(
456 &csproj2,
457 r#"<Project Sdk="Microsoft.NET.Sdk">
458 <PropertyGroup>
459 <Version>2.0.0</Version>
460 </PropertyGroup>
461</Project>
462"#,
463 )
464 .unwrap();
465
466 let mut finder = CSharpProjectFinder::new();
467 finder
468 .visit(&csproj1, &PathBuf::from("Project1/Project1.csproj"))
469 .await
470 .unwrap();
471 finder
472 .visit(&csproj2, &PathBuf::from("Project2/Project2.csproj"))
473 .await
474 .unwrap();
475
476 assert_eq!(finder.projects().len(), 2);
477
478 temp_dir.close().unwrap();
479 }
480
481 #[tokio::test]
482 async fn test_projects_mut() {
483 let temp_dir = TempDir::new().unwrap();
484 let csproj_path = temp_dir.path().join("TestProject.csproj");
485 fs::write(
486 &csproj_path,
487 r#"<Project Sdk="Microsoft.NET.Sdk">
488 <PropertyGroup>
489 <Version>1.0.0</Version>
490 </PropertyGroup>
491</Project>
492"#,
493 )
494 .unwrap();
495
496 let mut finder = CSharpProjectFinder::new();
497 finder
498 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
499 .await
500 .unwrap();
501
502 let mut projects = finder.projects_mut();
503 assert_eq!(projects.len(), 1);
504 match &mut projects[0] {
505 Project::Package(pkg) => {
506 assert!(!pkg.is_changed());
507 pkg.set_changed(true);
508 assert!(pkg.is_changed());
509 }
510 _ => panic!("Expected Package"),
511 }
512
513 temp_dir.close().unwrap();
514 }
515
516 #[tokio::test]
517 async fn test_visit_package_with_project_references() {
518 let temp_dir = TempDir::new().unwrap();
519 let csproj_path = temp_dir.path().join("TestProject.csproj");
520 fs::write(
521 &csproj_path,
522 r#"<Project Sdk="Microsoft.NET.Sdk">
523 <PropertyGroup>
524 <Version>1.0.0</Version>
525 </PropertyGroup>
526 <ItemGroup>
527 <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
528 </ItemGroup>
529 <ItemGroup>
530 <ProjectReference Include="..\CoreLib\CoreLib.csproj" />
531 <ProjectReference Include="..\Utils\Utils.csproj" />
532 </ItemGroup>
533</Project>
534"#,
535 )
536 .unwrap();
537
538 let mut finder = CSharpProjectFinder::new();
539 finder
540 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
541 .await
542 .unwrap();
543
544 let projects = finder.projects();
545 assert_eq!(projects.len(), 1);
546 match projects[0] {
547 Project::Package(pkg) => {
548 assert_eq!(pkg.name(), Some("TestProject"));
549 let deps = pkg.dependencies();
550 assert_eq!(deps.len(), 2);
552 assert!(deps.contains("CoreLib"));
553 assert!(deps.contains("Utils"));
554 }
555 _ => panic!("Expected Package"),
556 }
557
558 temp_dir.close().unwrap();
559 }
560
561 #[test]
562 fn test_extract_version() {
563 let content = r#"<Project Sdk="Microsoft.NET.Sdk">
564 <PropertyGroup>
565 <Version>1.2.3</Version>
566 </PropertyGroup>
567</Project>"#;
568 assert_eq!(
569 CSharpProjectFinder::extract_version(content),
570 Some("1.2.3".to_string())
571 );
572
573 let no_version = r#"<Project Sdk="Microsoft.NET.Sdk">
574 <PropertyGroup>
575 <OutputType>Exe</OutputType>
576 </PropertyGroup>
577</Project>"#;
578 assert_eq!(CSharpProjectFinder::extract_version(no_version), None);
579 }
580
581 #[test]
582 fn test_extract_package_references() {
583 let content = r#"<Project Sdk="Microsoft.NET.Sdk">
584 <ItemGroup>
585 <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
586 <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
587 </ItemGroup>
588</Project>"#;
589 let refs = CSharpProjectFinder::extract_package_references(content);
590 assert_eq!(refs.len(), 2);
591 assert!(refs.contains(&"Newtonsoft.Json".to_string()));
592 assert!(refs.contains(&"System.CommandLine".to_string()));
593 }
594
595 #[test]
596 fn test_extract_project_references() {
597 let content = r#"<Project Sdk="Microsoft.NET.Sdk">
598 <ItemGroup>
599 <ProjectReference Include="..\CoreLib\CoreLib.csproj" />
600 <ProjectReference Include="..\Utils\Utils.csproj" />
601 </ItemGroup>
602</Project>"#;
603 let refs = CSharpProjectFinder::extract_project_references(content);
604 assert_eq!(refs.len(), 2);
605 assert!(refs.contains(&"CoreLib".to_string()));
606 assert!(refs.contains(&"Utils".to_string()));
607 }
608
609 #[test]
610 fn test_extract_project_name_from_path() {
611 assert_eq!(
613 super::extract_project_name_from_path(r"..\CoreLib\CoreLib.csproj"),
614 Some("CoreLib".to_string())
615 );
616 assert_eq!(
617 super::extract_project_name_from_path(r"..\..\Utils\Utils.csproj"),
618 Some("Utils".to_string())
619 );
620 assert_eq!(
622 super::extract_project_name_from_path("../CoreLib/CoreLib.csproj"),
623 Some("CoreLib".to_string())
624 );
625 assert_eq!(
627 super::extract_project_name_from_path("MyProject.csproj"),
628 Some("MyProject".to_string())
629 );
630 assert_eq!(super::extract_project_name_from_path("MyProject.txt"), None);
632 }
633
634 #[test]
635 fn test_extract_version_end_tag() {
636 let content = r#"<Project><PropertyGroup><Version>
637 1.2.3
638 </Version></PropertyGroup></Project>"#;
639 assert_eq!(
640 CSharpProjectFinder::extract_version(content),
641 Some("1.2.3".to_string())
642 );
643 }
644
645 #[test]
646 fn test_extract_version_malformed_xml() {
647 let content = "<Project><PropertyGroup><Version>1.0.0";
648 let _ = CSharpProjectFinder::extract_version(content);
650 }
651
652 #[test]
653 fn test_extract_version_empty_version() {
654 let content = r#"<Project><PropertyGroup><Version> </Version></PropertyGroup></Project>"#;
655 assert_eq!(CSharpProjectFinder::extract_version(content), None);
656 }
657
658 #[test]
659 fn test_extract_version_with_empty_element() {
660 let content = r#"<Project Sdk="Microsoft.NET.Sdk">
663 <PropertyGroup>
664 <IsPackable />
665 <Version>3.2.1</Version>
666 </PropertyGroup>
667</Project>"#;
668 assert_eq!(
669 CSharpProjectFinder::extract_version(content),
670 Some("3.2.1".to_string())
671 );
672 }
673
674 #[test]
675 fn test_extract_version_with_comment() {
676 let content = r#"<Project>
678 <PropertyGroup>
679 <!-- version follows -->
680 <Version>4.0.0</Version>
681 </PropertyGroup>
682</Project>"#;
683 assert_eq!(
684 CSharpProjectFinder::extract_version(content),
685 Some("4.0.0".to_string())
686 );
687 }
688}