Skip to main content

changepacks_csharp/
finder.rs

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    /// Extract the project name from the .csproj file path (filename without extension)
36    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
43/// Extract project name from a path string, handling both Windows and Unix separators
44/// Input: `"..\CoreLib\CoreLib.csproj"` or `"../CoreLib/CoreLib.csproj"`
45/// Output: `"CoreLib"`
46fn extract_project_name_from_path(path_str: &str) -> Option<String> {
47    // Split by both Windows (\) and Unix (/) separators
48    let filename = path_str.rsplit(['\\', '/']).next()?;
49
50    // Remove .csproj extension
51    filename
52        .strip_suffix(".csproj")
53        .map(std::string::ToString::to_string)
54}
55
56impl CSharpProjectFinder {
57    /// Extract version from .csproj XML content using quick-xml
58    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    /// Extract `PackageReference` dependencies from .csproj XML content using quick-xml
99    ///
100    /// Excluded from coverage: marked `#[allow(dead_code)]` because the
101    /// active extraction path runs through `extract_project_references`.
102    /// Kept around for future NuGet dependency support; its single-tag
103    /// branches (`Event::Empty` vs `Event::Start` with attributes) are
104    /// not all exercised by current test fixtures.
105    #[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    /// Extract `ProjectReference` dependencies from .csproj XML content using quick-xml
134    /// Returns the project names (extracted from paths)
135    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                            // Extract project name from path like "..\CoreLib\CoreLib.csproj"
150                            // Handle both Windows (\) and Unix (/) path separators
151                            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    /// Check if this project is part of a solution (workspace)
166    /// A project is considered a workspace if there's a .sln file in the same directory
167    async fn is_workspace(path: &Path) -> bool {
168        if let Some(parent) = path.parent() {
169            // Check if there's a .sln file in the parent directory
170            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        // Check if this is a .csproj file
200        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            // Read .csproj content
212            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            // Add ProjectReference dependencies (local project references)
241            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                // Only ProjectReferences are tracked (not PackageReferences)
551                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        // Windows-style paths
612        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        // Unix-style paths
621        assert_eq!(
622            super::extract_project_name_from_path("../CoreLib/CoreLib.csproj"),
623            Some("CoreLib".to_string())
624        );
625        // Just filename
626        assert_eq!(
627            super::extract_project_name_from_path("MyProject.csproj"),
628            Some("MyProject".to_string())
629        );
630        // Invalid - no .csproj extension
631        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        // Should not panic - either returns Some or None
649        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        // Self-closing tags like <IsPackable /> generate Event::Empty,
661        // which exercises the wildcard `_ => {}` arm in extract_version
662        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        // XML comments generate Event::Comment, exercising the wildcard arm
677        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}