1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use changepacks_core::{Project, ProjectFinder, has_extension_ignore_ascii_case, is_regular_file};
4use quick_xml::Reader;
5use quick_xml::XmlVersion;
6use quick_xml::escape::resolve_predefined_entity;
7use quick_xml::events::{BytesEnd, BytesRef, BytesStart, Event};
8use std::{
9 collections::HashMap,
10 path::{Path, PathBuf},
11};
12
13use crate::{package::CSharpPackage, xml_utils::is_unconditional_project_property_group};
14
15const PROJECT_FILES: &[&str] = &[".csproj"];
20
21#[derive(Debug, Default)]
22pub struct CSharpProjectFinder {
23 projects: HashMap<PathBuf, Project>,
24}
25
26impl CSharpProjectFinder {
27 #[must_use]
28 pub fn new() -> Self {
29 Self::default()
30 }
31
32 fn extract_name_from_path(path: &Path) -> Option<String> {
34 path.file_stem()
35 .and_then(|s| s.to_str())
36 .map(std::string::ToString::to_string)
37 }
38
39 fn parse_csproj_metadata(content: &str) -> Result<(Option<String>, Vec<String>, bool)> {
48 let mut reader = Reader::from_str(content);
49 let mut buf = Vec::with_capacity(256);
60 let mut scan = CsprojScan::new();
64
65 loop {
66 match reader.read_event_into(&mut buf) {
67 Ok(Event::Start(e)) => scan.on_start(&e)?,
68 Ok(Event::Empty(e)) if e.local_name().as_ref() == b"ProjectReference" => {
69 collect_project_reference(&e, &mut scan.projects)?;
70 }
71 Ok(Event::End(e)) => scan.on_end(&e)?,
72 Ok(Event::Text(e)) => record_decoded_csproj_text(
73 e.decode(),
74 scan.in_version,
75 scan.in_is_packable,
76 &mut scan.version_text,
77 &mut scan.publishable_by_default,
78 )?,
79 Ok(Event::CData(e)) => record_decoded_csproj_text(
80 e.decode(),
81 scan.in_version,
82 scan.in_is_packable,
83 &mut scan.version_text,
84 &mut scan.publishable_by_default,
85 )?,
86 Ok(Event::GeneralRef(e)) if scan.in_version => {
95 append_resolved_reference(&e, &mut scan.version_text)?;
96 }
97 Ok(Event::Eof) => {
98 anyhow::ensure!(scan.element_depth == 0, "unexpected end of XML document");
99 break;
100 }
101 Err(error) => return Err(error.into()),
102 _ => {}
103 }
104 buf.clear();
105 }
106 Ok(scan.finish())
107 }
108}
109
110struct CsprojScan {
120 eligible_property_group_depth: Option<usize>,
122 in_version: bool,
123 in_is_packable: bool,
124 element_depth: usize,
125 project_depth: Option<usize>,
127 version: Option<String>,
128 version_text: String,
137 publishable_by_default: bool,
138 projects: Vec<String>,
139}
140
141impl CsprojScan {
142 fn new() -> Self {
143 Self {
144 eligible_property_group_depth: None,
145 in_version: false,
146 in_is_packable: false,
147 element_depth: 0,
148 project_depth: None,
149 version: None,
150 version_text: String::new(),
151 publishable_by_default: true,
152 projects: Vec::with_capacity(4),
164 }
165 }
166
167 fn on_start(&mut self, e: &BytesStart<'_>) -> Result<()> {
170 self.element_depth += 1;
171 let name = e.local_name();
172 if name.as_ref() == b"Project" && self.project_depth.is_none() {
173 self.project_depth = Some(self.element_depth);
174 } else if name.as_ref() == b"PropertyGroup"
175 && is_unconditional_project_property_group(e, self.element_depth, self.project_depth)?
176 {
177 self.eligible_property_group_depth = Some(self.element_depth);
178 } else if name.as_ref() == b"Version" {
179 self.in_version =
180 is_eligible_property_child(self.eligible_property_group_depth, self.element_depth);
181 } else if name.as_ref() == b"IsPackable" {
182 self.in_is_packable =
183 is_eligible_property_child(self.eligible_property_group_depth, self.element_depth);
184 } else if name.as_ref() == b"ProjectReference" {
185 collect_project_reference(e, &mut self.projects)?;
186 }
187 Ok(())
188 }
189
190 fn on_end(&mut self, e: &BytesEnd<'_>) -> Result<()> {
193 let name = e.local_name();
194 if name.as_ref() == b"PropertyGroup"
195 && self.eligible_property_group_depth == Some(self.element_depth)
196 {
197 self.eligible_property_group_depth = None;
198 } else if name.as_ref() == b"Version" {
199 self.in_version = false;
200 if self.version.is_none() {
206 let candidate = self.version_text.trim();
207 if !candidate.is_empty() {
208 self.version = Some(candidate.to_string());
209 }
210 }
211 self.version_text.clear();
212 } else if name.as_ref() == b"IsPackable" {
213 self.in_is_packable = false;
214 }
215 self.element_depth = self
216 .element_depth
217 .checked_sub(1)
218 .context("unexpected XML end tag")?;
219 Ok(())
220 }
221
222 fn finish(self) -> (Option<String>, Vec<String>, bool) {
225 (self.version, self.projects, self.publishable_by_default)
226 }
227}
228
229const fn is_eligible_property_child(
240 eligible_property_group_depth: Option<usize>,
241 element_depth: usize,
242) -> bool {
243 matches!(eligible_property_group_depth, Some(depth) if element_depth == depth + 1)
244}
245
246fn record_decoded_csproj_text(
257 decoded: std::result::Result<std::borrow::Cow<'_, str>, quick_xml::encoding::EncodingError>,
258 in_version: bool,
259 in_is_packable: bool,
260 version_text: &mut String,
261 publishable_by_default: &mut bool,
262) -> Result<()> {
263 let text = decoded.context("Failed to decode .csproj text node")?;
264 let text = text.as_ref();
265 if in_version {
269 version_text.push_str(text);
270 }
271 if in_is_packable && text.trim().eq_ignore_ascii_case("false") {
272 *publishable_by_default = false;
273 }
274 Ok(())
275}
276
277fn append_resolved_reference(reference: &BytesRef<'_>, version_text: &mut String) -> Result<()> {
287 if let Some(character) = reference
288 .resolve_char_ref()
289 .context("Failed to resolve .csproj character reference")?
290 {
291 version_text.push(character);
292 return Ok(());
293 }
294
295 let name = reference
296 .decode()
297 .context("Failed to decode .csproj entity reference")?;
298 let resolved = resolve_predefined_entity(&name).with_context(|| {
299 format!("Unresolvable entity reference `&{name};` in .csproj <Version>")
300 })?;
301 version_text.push_str(resolved);
302 Ok(())
303}
304
305fn collect_project_reference(
310 e: &quick_xml::events::BytesStart<'_>,
311 projects: &mut Vec<String>,
312) -> Result<()> {
313 let mut include_name = None;
314 let mut update_name = None;
315
316 for attr in e.attributes() {
317 let attr = attr.context("Failed to parse ProjectReference attribute")?;
318 let attr_name = attr.key.as_ref();
319 if !matches!(attr_name, b"Include" | b"Update") {
320 continue;
321 }
322 let value = attr
323 .normalized_value(XmlVersion::Implicit1_0)
324 .context("Failed to normalize ProjectReference attribute value")?;
325 let Some(name) = extract_project_name_from_path(&value) else {
326 continue;
327 };
328 if attr_name == b"Include" {
329 include_name = Some(name);
330 } else {
331 update_name = Some(name);
332 }
333 }
334
335 if let Some(name) = include_name.or(update_name) {
336 projects.push(name);
337 }
338 Ok(())
339}
340
341fn extract_project_name_from_path(path_str: &str) -> Option<String> {
353 let filename = path_str
360 .rsplit_once(['\\', '/'])
361 .map_or(path_str, |(_, tail)| tail);
362
363 let (stem, ext) = filename.rsplit_once('.')?;
370 ext.eq_ignore_ascii_case("csproj").then(|| stem.to_string())
371}
372
373#[async_trait]
374impl ProjectFinder for CSharpProjectFinder {
375 changepacks_core::impl_projects_hashmap_accessors!();
376
377 fn project_files(&self) -> &[&str] {
378 PROJECT_FILES
379 }
380
381 async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
382 if !has_extension_ignore_ascii_case(path, "csproj") {
395 return Ok(());
396 }
397
398 if self.contains_project(path) {
405 return Ok(());
406 }
407
408 if !is_regular_file(path).await? {
413 return Ok(());
414 }
415
416 let csproj_content = crate::read_csproj(path).await?;
418
419 let name = Self::extract_name_from_path(path);
420 let (version, project_refs, publishable_by_default) =
426 Self::parse_csproj_metadata(&csproj_content)
427 .with_context(|| format!("Failed to parse C# project XML: {}", path.display()))?;
428 let path_key = path.to_path_buf();
429 let relative_path_key = relative_path.to_path_buf();
430 let mut project = Project::Package(Box::new(CSharpPackage::new_discovered(
431 name,
432 version,
433 path_key.clone(),
434 relative_path_key,
435 publishable_by_default,
436 )));
437
438 for dep in project_refs {
443 project.add_dependency(&dep);
444 }
445
446 self.projects.insert(path_key, project);
447 Ok(())
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use changepacks_core::UpdateType;
455 use changepacks_utils::sort_by_dependencies;
456 use rstest::rstest;
457 use std::fs;
458 use tempfile::TempDir;
459 use tokio::fs as async_fs;
460
461 struct VersionPolicyCase {
462 name: &'static str,
463 input: &'static str,
464 discovered: Option<&'static str>,
465 expected: &'static str,
466 }
467
468 #[tokio::test]
469 async fn test_new() {
470 let finder = CSharpProjectFinder::new();
471 assert_eq!(finder.project_files(), &[".csproj"]);
472 assert_eq!(finder.projects().len(), 0);
473 }
474
475 #[tokio::test]
476 async fn test_default() {
477 let finder = CSharpProjectFinder::default();
478 assert_eq!(finder.project_files(), &[".csproj"]);
479 assert_eq!(finder.projects().len(), 0);
480 }
481
482 #[tokio::test]
483 async fn test_visit_package() {
484 let temp_dir = TempDir::new().unwrap();
485 let csproj_path = temp_dir.path().join("TestProject.csproj");
486 fs::write(
487 &csproj_path,
488 r#"<Project Sdk="Microsoft.NET.Sdk">
489 <PropertyGroup>
490 <Version>1.0.0</Version>
491 </PropertyGroup>
492</Project>
493"#,
494 )
495 .unwrap();
496
497 let mut finder = CSharpProjectFinder::new();
498 finder
499 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
500 .await
501 .unwrap();
502
503 assert_eq!(finder.projects().len(), 1);
504 let pkg = finder.projects()[0].expect_package();
505 assert_eq!(pkg.name(), Some("TestProject"));
506 assert_eq!(pkg.version(), Some("1.0.0"));
507
508 temp_dir.close().unwrap();
509 }
510
511 #[tokio::test]
519 async fn test_visit_mixed_case_csproj_extension() {
520 let temp_dir = TempDir::new().unwrap();
521 let csproj_path = temp_dir.path().join("App.CSPROJ");
522 fs::write(
523 &csproj_path,
524 r#"<Project Sdk="Microsoft.NET.Sdk">
525 <PropertyGroup>
526 <Version>1.0.0</Version>
527 </PropertyGroup>
528</Project>
529"#,
530 )
531 .unwrap();
532
533 let mut finder = CSharpProjectFinder::new();
534 finder
535 .visit(&csproj_path, &PathBuf::from("App.CSPROJ"))
536 .await
537 .unwrap();
538
539 assert_eq!(finder.project_count(), 1);
540 assert_eq!(finder.projects().len(), 1);
541 let pkg = finder.projects()[0].expect_package();
542 assert_eq!(pkg.name(), Some("App"));
543 assert_eq!(pkg.version(), Some("1.0.0"));
544
545 temp_dir.close().unwrap();
546 }
547
548 #[tokio::test]
549 async fn test_root_solution_csproj_manifests_are_packages() {
550 let temp_dir = TempDir::new().unwrap();
551 let library_path = temp_dir.path().join("Library.csproj");
552 let app_path = temp_dir.path().join("App.csproj");
553 fs::write(
554 temp_dir.path().join("Product.sln"),
555 "Microsoft Visual Studio Solution File",
556 )
557 .unwrap();
558 fs::write(
559 &library_path,
560 r#"<Project Sdk="Microsoft.NET.Sdk">
561 <PropertyGroup>
562 <Version>1.2.3</Version>
563 </PropertyGroup>
564</Project>
565"#,
566 )
567 .unwrap();
568 fs::write(
569 &app_path,
570 r#"<Project Sdk="Microsoft.NET.Sdk">
571 <PropertyGroup>
572 <Version>4.5.6</Version>
573 </PropertyGroup>
574 <ItemGroup>
575 <ProjectReference Include="Library.csproj" />
576 </ItemGroup>
577</Project>
578"#,
579 )
580 .unwrap();
581
582 let mut finder = CSharpProjectFinder::new();
583 finder
584 .visit(&app_path, Path::new("App.csproj"))
585 .await
586 .unwrap();
587 finder
588 .visit(&library_path, Path::new("Library.csproj"))
589 .await
590 .unwrap();
591
592 let projects = sort_by_dependencies(finder.projects()).unwrap();
593 assert_eq!(projects.len(), 2);
594 assert!(
595 projects
596 .iter()
597 .all(|project| matches!(project, Project::Package(_))),
598 "solution-contained manifests must remain packages: {projects:?}"
599 );
600 assert_eq!(
601 projects
602 .iter()
603 .map(|project| (project.name(), project.version(), project.relative_path()))
604 .collect::<Vec<_>>(),
605 vec![
606 (Some("Library"), Some("1.2.3"), Path::new("Library.csproj"),),
607 (Some("App"), Some("4.5.6"), Path::new("App.csproj")),
608 ]
609 );
610 assert!(projects[1].dependencies().contains("Library"));
611
612 temp_dir.close().unwrap();
613 }
614
615 #[tokio::test]
616 async fn test_nested_solution_csproj_manifests_are_packages() {
617 let temp_dir = TempDir::new().unwrap();
618 let solution_dir = temp_dir.path().join("solutions").join("Product");
619 let library_path = solution_dir
620 .join("src")
621 .join("Library")
622 .join("Library.csproj");
623 let app_path = solution_dir.join("src").join("App").join("App.csproj");
624 fs::create_dir_all(library_path.parent().unwrap()).unwrap();
625 fs::create_dir_all(app_path.parent().unwrap()).unwrap();
626 fs::write(
627 solution_dir.join("Product.sln"),
628 "Microsoft Visual Studio Solution File",
629 )
630 .unwrap();
631 fs::write(
632 &library_path,
633 r#"<Project Sdk="Microsoft.NET.Sdk">
634 <PropertyGroup>
635 <Version>2.0.0</Version>
636 </PropertyGroup>
637</Project>
638"#,
639 )
640 .unwrap();
641 fs::write(
642 &app_path,
643 r#"<Project Sdk="Microsoft.NET.Sdk">
644 <PropertyGroup>
645 <Version>3.1.4</Version>
646 </PropertyGroup>
647 <ItemGroup>
648 <ProjectReference Include="..\Library\Library.csproj" />
649 </ItemGroup>
650</Project>
651"#,
652 )
653 .unwrap();
654
655 let mut finder = CSharpProjectFinder::new();
656 finder
657 .visit(&app_path, Path::new("solutions/Product/src/App/App.csproj"))
658 .await
659 .unwrap();
660 finder
661 .visit(
662 &library_path,
663 Path::new("solutions/Product/src/Library/Library.csproj"),
664 )
665 .await
666 .unwrap();
667
668 let projects = sort_by_dependencies(finder.projects()).unwrap();
669 assert_eq!(projects.len(), 2);
670 assert!(
671 projects
672 .iter()
673 .all(|project| matches!(project, Project::Package(_))),
674 "nested solution manifests must remain packages: {projects:?}"
675 );
676 assert_eq!(
677 projects
678 .iter()
679 .map(|project| (project.name(), project.version(), project.relative_path()))
680 .collect::<Vec<_>>(),
681 vec![
682 (
683 Some("Library"),
684 Some("2.0.0"),
685 Path::new("solutions/Product/src/Library/Library.csproj"),
686 ),
687 (
688 Some("App"),
689 Some("3.1.4"),
690 Path::new("solutions/Product/src/App/App.csproj"),
691 ),
692 ]
693 );
694 assert!(projects[1].dependencies().contains("Library"));
695
696 temp_dir.close().unwrap();
697 }
698
699 #[tokio::test]
700 async fn test_visit_package_reads_version_from_cdata() {
701 let temp_dir = TempDir::new().unwrap();
702 let csproj_path = temp_dir.path().join("TestProject.csproj");
703 fs::write(
704 &csproj_path,
705 r#"<Project Sdk="Microsoft.NET.Sdk">
706 <PropertyGroup>
707 <Version><![CDATA[1.2.3]]></Version>
708 </PropertyGroup>
709</Project>
710"#,
711 )
712 .unwrap();
713
714 let mut finder = CSharpProjectFinder::new();
715 finder
716 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
717 .await
718 .unwrap();
719
720 assert_eq!(
721 finder.projects()[0].expect_package().version(),
722 Some("1.2.3")
723 );
724
725 temp_dir.close().unwrap();
726 }
727
728 #[tokio::test]
729 async fn test_visit_package_ignores_sln_directory() {
730 let temp_dir = TempDir::new().unwrap();
731 let csproj_path = temp_dir.path().join("TestProject.csproj");
732 fs::create_dir(temp_dir.path().join("Fake.sln")).unwrap();
733 fs::write(
734 &csproj_path,
735 r#"<Project Sdk="Microsoft.NET.Sdk">
736 <PropertyGroup>
737 <Version>1.0.0</Version>
738 </PropertyGroup>
739</Project>
740"#,
741 )
742 .unwrap();
743
744 let mut finder = CSharpProjectFinder::new();
745 finder
746 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
747 .await
748 .unwrap();
749
750 let projects = finder.projects();
751 assert_eq!(projects.len(), 1);
752 assert!(
753 matches!(projects[0], Project::Package(_)),
754 "expected Package when only a .sln directory exists, got {:?}",
755 projects[0]
756 );
757
758 temp_dir.close().unwrap();
759 }
760
761 #[tokio::test]
762 async fn test_visit_package_without_version() {
763 let temp_dir = TempDir::new().unwrap();
764 let csproj_path = temp_dir.path().join("TestProject.csproj");
765 fs::write(
766 &csproj_path,
767 r#"<Project Sdk="Microsoft.NET.Sdk">
768 <PropertyGroup>
769 <OutputType>Exe</OutputType>
770 </PropertyGroup>
771</Project>
772"#,
773 )
774 .unwrap();
775
776 let mut finder = CSharpProjectFinder::new();
777 finder
778 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
779 .await
780 .unwrap();
781
782 assert_eq!(finder.projects().len(), 1);
783 let pkg = finder.projects()[0].expect_package();
784 assert_eq!(pkg.name(), Some("TestProject"));
785 assert_eq!(pkg.version(), None);
786
787 temp_dir.close().unwrap();
788 }
789
790 #[tokio::test]
791 async fn test_visit_non_csproj_file() {
792 let temp_dir = TempDir::new().unwrap();
793 let other_file = temp_dir.path().join("other.xml");
794 fs::write(&other_file, r"<root>content</root>").unwrap();
795
796 let mut finder = CSharpProjectFinder::new();
797 finder
798 .visit(&other_file, &PathBuf::from("other.xml"))
799 .await
800 .unwrap();
801
802 assert_eq!(finder.projects().len(), 0);
803
804 temp_dir.close().unwrap();
805 }
806
807 #[tokio::test]
808 async fn test_visit_directory() {
809 let temp_dir = TempDir::new().unwrap();
810 let dir_path = temp_dir.path().join("some_dir");
811 fs::create_dir_all(&dir_path).unwrap();
812
813 let mut finder = CSharpProjectFinder::new();
814 finder
815 .visit(&dir_path, &PathBuf::from("some_dir"))
816 .await
817 .unwrap();
818
819 assert_eq!(finder.projects().len(), 0);
820
821 temp_dir.close().unwrap();
822 }
823
824 #[tokio::test]
825 async fn test_visit_duplicate() {
826 let temp_dir = TempDir::new().unwrap();
827 let csproj_path = temp_dir.path().join("TestProject.csproj");
828 fs::write(
829 &csproj_path,
830 r#"<Project Sdk="Microsoft.NET.Sdk">
831 <PropertyGroup>
832 <Version>1.0.0</Version>
833 </PropertyGroup>
834</Project>
835"#,
836 )
837 .unwrap();
838
839 let mut finder = CSharpProjectFinder::new();
840 finder
841 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
842 .await
843 .unwrap();
844 finder
845 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
846 .await
847 .unwrap();
848
849 assert_eq!(finder.projects().len(), 1);
850
851 temp_dir.close().unwrap();
852 }
853
854 #[tokio::test]
855 async fn test_visit_multiple_packages() {
856 let temp_dir = TempDir::new().unwrap();
857 let csproj1 = temp_dir.path().join("Project1").join("Project1.csproj");
858 let csproj2 = temp_dir.path().join("Project2").join("Project2.csproj");
859 fs::create_dir_all(csproj1.parent().unwrap()).unwrap();
860 fs::create_dir_all(csproj2.parent().unwrap()).unwrap();
861 fs::write(
862 &csproj1,
863 r#"<Project Sdk="Microsoft.NET.Sdk">
864 <PropertyGroup>
865 <Version>1.0.0</Version>
866 </PropertyGroup>
867</Project>
868"#,
869 )
870 .unwrap();
871 fs::write(
872 &csproj2,
873 r#"<Project Sdk="Microsoft.NET.Sdk">
874 <PropertyGroup>
875 <Version>2.0.0</Version>
876 </PropertyGroup>
877</Project>
878"#,
879 )
880 .unwrap();
881
882 let mut finder = CSharpProjectFinder::new();
883 finder
884 .visit(&csproj1, &PathBuf::from("Project1/Project1.csproj"))
885 .await
886 .unwrap();
887 finder
888 .visit(&csproj2, &PathBuf::from("Project2/Project2.csproj"))
889 .await
890 .unwrap();
891
892 assert_eq!(finder.projects().len(), 2);
893
894 temp_dir.close().unwrap();
895 }
896
897 #[tokio::test]
898 async fn test_projects_mut() {
899 let temp_dir = TempDir::new().unwrap();
900 let csproj_path = temp_dir.path().join("TestProject.csproj");
901 fs::write(
902 &csproj_path,
903 r#"<Project Sdk="Microsoft.NET.Sdk">
904 <PropertyGroup>
905 <Version>1.0.0</Version>
906 </PropertyGroup>
907</Project>
908"#,
909 )
910 .unwrap();
911
912 let mut finder = CSharpProjectFinder::new();
913 finder
914 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
915 .await
916 .unwrap();
917
918 let mut projects = finder.projects_mut();
919 assert_eq!(projects.len(), 1);
920 let pkg = projects[0].expect_package_mut();
921 assert!(!pkg.is_changed());
922 pkg.set_changed(true);
923 assert!(pkg.is_changed());
924
925 temp_dir.close().unwrap();
926 }
927
928 #[tokio::test]
929 async fn test_visit_package_with_project_references() {
930 let temp_dir = TempDir::new().unwrap();
931 let csproj_path = temp_dir.path().join("TestProject.csproj");
932 fs::write(
933 &csproj_path,
934 r#"<Project Sdk="Microsoft.NET.Sdk">
935 <PropertyGroup>
936 <Version>1.0.0</Version>
937 </PropertyGroup>
938 <ItemGroup>
939 <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
940 </ItemGroup>
941 <ItemGroup>
942 <ProjectReference Include="..\CoreLib\CoreLib.csproj" />
943 <ProjectReference Include="..\Utils\Utils.csproj" />
944 </ItemGroup>
945</Project>
946"#,
947 )
948 .unwrap();
949
950 let mut finder = CSharpProjectFinder::new();
951 finder
952 .visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
953 .await
954 .unwrap();
955
956 let projects = finder.projects();
957 assert_eq!(projects.len(), 1);
958 let pkg = projects[0].expect_package();
959 assert_eq!(pkg.name(), Some("TestProject"));
960 let deps = pkg.dependencies();
961 assert_eq!(deps.len(), 2);
963 assert!(deps.contains("CoreLib"));
964 assert!(deps.contains("Utils"));
965
966 temp_dir.close().unwrap();
967 }
968
969 const XML_STANDARD_VERSION: &str = r#"<Project Sdk="Microsoft.NET.Sdk">
974 <PropertyGroup>
975 <Version>1.2.3</Version>
976 </PropertyGroup>
977</Project>"#;
978
979 const XML_NO_VERSION_ELEMENT: &str = r#"<Project Sdk="Microsoft.NET.Sdk">
980 <PropertyGroup>
981 <OutputType>Exe</OutputType>
982 </PropertyGroup>
983</Project>"#;
984
985 const XML_VERSION_WITH_END_TAG_WHITESPACE: &str = r"<Project><PropertyGroup><Version>
986 1.2.3
987 </Version></PropertyGroup></Project>";
988
989 const XML_EMPTY_VERSION: &str =
990 r"<Project><PropertyGroup><Version> </Version></PropertyGroup></Project>";
991
992 const XML_VERSION_AFTER_EMPTY_ELEMENT: &str = r#"<Project Sdk="Microsoft.NET.Sdk">
995 <PropertyGroup>
996 <IsPackable />
997 <Version>3.2.1</Version>
998 </PropertyGroup>
999</Project>"#;
1000
1001 const XML_VERSION_AFTER_COMMENT: &str = r"<Project>
1003 <PropertyGroup>
1004 <!-- version follows -->
1005 <Version>4.0.0</Version>
1006 </PropertyGroup>
1007</Project>";
1008
1009 const XML_VERSION_WITH_DECIMAL_CHAR_REF: &str =
1013 r"<Project><PropertyGroup><Version>1.2.3</Version></PropertyGroup></Project>";
1014
1015 const XML_VERSION_WITH_HEX_CHAR_REF: &str =
1017 r"<Project><PropertyGroup><Version>1.2.3</Version></PropertyGroup></Project>";
1018
1019 const XML_VERSION_WITH_PREDEFINED_ENTITY: &str =
1021 r"<Project><PropertyGroup><Version>1.0.0-a&b</Version></PropertyGroup></Project>";
1022
1023 const XML_VERSION_IS_ONLY_A_CHAR_REF: &str =
1026 r"<Project><PropertyGroup><Version>1.0</Version></PropertyGroup></Project>";
1027
1028 const XML_DUPLICATE_VERSION: &str = r"<Project><PropertyGroup><Version>1.0.0</Version><Version>2.0.0</Version></PropertyGroup></Project>";
1030
1031 const XML_EMPTY_THEN_POPULATED_VERSION: &str = r"<Project><PropertyGroup><Version> </Version><Version>2.0.0</Version></PropertyGroup></Project>";
1034
1035 const XML_CDATA_VERSION: &str =
1037 r"<Project><PropertyGroup><Version><![CDATA[1.2.3]]></Version></PropertyGroup></Project>";
1038
1039 const XML_CONDITIONAL_VERSION_WITH_CHAR_REF: &str = r#"<Project><PropertyGroup Condition="'$(Configuration)' == 'Release'"><Version>9.9.9</Version></PropertyGroup><PropertyGroup><Version>1.2.3</Version></PropertyGroup></Project>"#;
1042
1043 const XML_TARGET_LOCAL_VERSION: &str = r#"<Project>
1052 <Target Name="Build">
1053 <PropertyGroup>
1054 <Version>7.0.0</Version>
1055 </PropertyGroup>
1056 </Target>
1057 <PropertyGroup>
1058 <Version>1.2.3</Version>
1059 </PropertyGroup>
1060</Project>"#;
1061
1062 const XML_VERSION_NESTED_BELOW_PROPERTY: &str = r"<Project>
1069 <PropertyGroup>
1070 <PackageMetadata><Version>9.9.9</Version></PackageMetadata>
1071 <Version>1.2.3</Version>
1072 </PropertyGroup>
1073</Project>";
1074
1075 #[rstest]
1076 #[case(XML_STANDARD_VERSION, Some("1.2.3"))]
1078 #[case(XML_NO_VERSION_ELEMENT, None)]
1080 #[case(XML_VERSION_WITH_END_TAG_WHITESPACE, Some("1.2.3"))]
1082 #[case(XML_EMPTY_VERSION, None)]
1084 #[case(XML_VERSION_AFTER_EMPTY_ELEMENT, Some("3.2.1"))]
1086 #[case(XML_VERSION_AFTER_COMMENT, Some("4.0.0"))]
1088 #[case(XML_VERSION_WITH_DECIMAL_CHAR_REF, Some("1.2.3"))]
1090 #[case(XML_VERSION_WITH_HEX_CHAR_REF, Some("1.2.3"))]
1092 #[case(XML_VERSION_WITH_PREDEFINED_ENTITY, Some("1.0.0-a&b"))]
1094 #[case(XML_VERSION_IS_ONLY_A_CHAR_REF, Some("1.0"))]
1096 #[case(XML_DUPLICATE_VERSION, Some("1.0.0"))]
1098 #[case(XML_EMPTY_THEN_POPULATED_VERSION, Some("2.0.0"))]
1100 #[case(XML_CDATA_VERSION, Some("1.2.3"))]
1102 #[case(XML_CONDITIONAL_VERSION_WITH_CHAR_REF, Some("1.2.3"))]
1104 #[case(XML_TARGET_LOCAL_VERSION, Some("1.2.3"))]
1107 #[case(XML_VERSION_NESTED_BELOW_PROPERTY, Some("1.2.3"))]
1110 fn test_extract_version(#[case] content: &str, #[case] expected: Option<&str>) {
1111 assert_eq!(
1112 CSharpProjectFinder::parse_csproj_metadata(content)
1113 .unwrap()
1114 .0,
1115 expected.map(std::string::ToString::to_string)
1116 );
1117 }
1118
1119 #[test]
1123 fn test_unresolvable_version_entity_returns_contextual_error() {
1124 let content =
1125 r"<Project><PropertyGroup><Version>1.0&mystery;0</Version></PropertyGroup></Project>";
1126
1127 let error = CSharpProjectFinder::parse_csproj_metadata(content).unwrap_err();
1128
1129 assert!(
1130 format!("{error:#}").contains("Unresolvable entity reference `&mystery;`"),
1131 "unexpected error: {error:#}"
1132 );
1133 }
1134
1135 #[test]
1143 fn test_out_of_range_version_char_ref_returns_contextual_error() {
1144 let content =
1145 r"<Project><PropertyGroup><Version>1.0�0</Version></PropertyGroup></Project>";
1146
1147 let error = CSharpProjectFinder::parse_csproj_metadata(content).unwrap_err();
1148
1149 assert!(
1150 format!("{error:#}").contains("Failed to resolve .csproj character reference"),
1151 "unexpected error: {error:#}"
1152 );
1153 }
1154
1155 #[test]
1159 fn test_entity_reference_outside_version_is_ignored() {
1160 let content = r"<Project><PropertyGroup><Description>Hello &custom; World</Description><Version>1.2.3</Version><IsPackable>false</IsPackable></PropertyGroup></Project>";
1161
1162 let (version, refs, publishable_by_default) =
1163 CSharpProjectFinder::parse_csproj_metadata(content).unwrap();
1164
1165 assert_eq!(version.as_deref(), Some("1.2.3"));
1166 assert!(refs.is_empty());
1167 assert!(publishable_by_default);
1170 }
1171
1172 #[test]
1173 fn test_parse_csproj_metadata_is_packable_publishability() {
1174 let cases = [
1175 (
1176 "false",
1177 "<Project><PropertyGroup><IsPackable>false</IsPackable></PropertyGroup></Project>",
1178 false,
1179 ),
1180 (
1181 "trimmed mixed case false",
1182 "<Project><PropertyGroup><IsPackable>\n False\t </IsPackable></PropertyGroup></Project>",
1183 false,
1184 ),
1185 (
1186 "true",
1187 "<Project><PropertyGroup><IsPackable>true</IsPackable></PropertyGroup></Project>",
1188 true,
1189 ),
1190 (
1191 "missing",
1192 "<Project><PropertyGroup><Version>1.0.0</Version></PropertyGroup></Project>",
1193 true,
1194 ),
1195 (
1196 "self closing",
1197 "<Project><PropertyGroup><IsPackable /></PropertyGroup></Project>",
1198 true,
1199 ),
1200 (
1201 "conditional property group",
1202 r#"<Project><PropertyGroup Condition="'$(Configuration)' == 'Release'"><IsPackable>false</IsPackable></PropertyGroup></Project>"#,
1203 true,
1204 ),
1205 (
1206 "computed",
1207 "<Project><PropertyGroup><IsPackable>$(Packable)</IsPackable></PropertyGroup></Project>",
1208 true,
1209 ),
1210 (
1211 "nested property group",
1212 "<Project><Target><PropertyGroup><IsPackable>false</IsPackable></PropertyGroup></Target></Project>",
1213 true,
1214 ),
1215 ];
1216
1217 for (label, content, expected) in cases {
1218 let publishable_by_default = CSharpProjectFinder::parse_csproj_metadata(content)
1219 .unwrap()
1220 .2;
1221 assert_eq!(publishable_by_default, expected, "{label}");
1222 }
1223 }
1224
1225 #[test]
1226 fn test_parse_csproj_metadata_scopes_is_packable_like_version() {
1227 let cases = [
1228 (
1229 "text",
1230 "<Root><Project><PropertyGroup><Version>1.2.3</Version><IsPackable>false</IsPackable></PropertyGroup></Project></Root>",
1231 ),
1232 (
1233 "cdata",
1234 "<Root><Project><PropertyGroup><Version>1.2.3</Version><IsPackable><![CDATA[false]]></IsPackable></PropertyGroup></Project></Root>",
1235 ),
1236 ];
1237
1238 for (label, content) in cases {
1239 let (version, _, publishable_by_default) =
1240 CSharpProjectFinder::parse_csproj_metadata(content).unwrap();
1241 assert_eq!(version.as_deref(), Some("1.2.3"), "{label}");
1242 assert!(!publishable_by_default, "{label}");
1243 }
1244 }
1245
1246 #[tokio::test]
1247 async fn test_visit_package_carries_is_packable_false_metadata() {
1248 let temp_dir = TempDir::new().unwrap();
1249 let csproj_path = temp_dir.path().join("Private.csproj");
1250 fs::write(
1251 &csproj_path,
1252 "<Project><PropertyGroup><IsPackable>false</IsPackable></PropertyGroup></Project>",
1253 )
1254 .unwrap();
1255
1256 let mut finder = CSharpProjectFinder::new();
1257 finder
1258 .visit(&csproj_path, Path::new("Private.csproj"))
1259 .await
1260 .unwrap();
1261
1262 let projects = finder.projects();
1263 assert_eq!(projects.len(), 1);
1264 assert!(!projects[0].is_publishable_by_default());
1265 }
1266
1267 #[test]
1273 fn test_parse_csproj_metadata_decodes_text_and_cdata_on_happy_path() {
1274 let content = "<Project><PropertyGroup><Version>1.2.3</Version><IsPackable><![CDATA[false]]></IsPackable></PropertyGroup></Project>";
1275
1276 let (version, refs, publishable_by_default) =
1277 CSharpProjectFinder::parse_csproj_metadata(content).unwrap();
1278
1279 assert_eq!(version.as_deref(), Some("1.2.3"));
1280 assert!(refs.is_empty());
1281 assert!(!publishable_by_default);
1282 }
1283
1284 #[test]
1291 fn test_record_decoded_csproj_text_propagates_decode_error() {
1292 let truncated = &"é".as_bytes()[..1];
1295 let utf8_error = std::str::from_utf8(truncated).unwrap_err();
1296 let mut version_text = String::new();
1301 let mut publishable_by_default = true;
1302
1303 let error = super::record_decoded_csproj_text(
1304 Err(quick_xml::encoding::EncodingError::from(utf8_error)),
1305 true,
1306 true,
1307 &mut version_text,
1308 &mut publishable_by_default,
1309 )
1310 .unwrap_err();
1311
1312 assert!(
1313 format!("{error:#}").contains("Failed to decode .csproj text node"),
1314 "context missing from chain: {error:#}"
1315 );
1316 assert!(
1317 format!("{error:#}").contains("cannot decode input using UTF-8"),
1318 "root cause dropped from chain: {error:#}"
1319 );
1320 assert!(version_text.is_empty());
1321 assert!(publishable_by_default);
1322 }
1323
1324 #[test]
1325 fn test_extract_version_malformed_xml() {
1326 let content = "<Project><PropertyGroup><Version>1.0.0";
1327 assert!(CSharpProjectFinder::parse_csproj_metadata(content).is_err());
1328 }
1329
1330 #[test]
1341 fn test_parse_csproj_metadata_unmatched_end_tag_is_err() {
1342 assert!(CSharpProjectFinder::parse_csproj_metadata("</Project>").is_err());
1343 }
1344
1345 #[tokio::test]
1352 async fn test_visit_unmatched_end_tag_returns_path_context() {
1353 let temp_dir = TempDir::new().unwrap();
1354 let csproj_path = temp_dir.path().join("StrayEnd.csproj");
1355 fs::write(&csproj_path, "</Project>").unwrap();
1356
1357 let mut finder = CSharpProjectFinder::new();
1358 let error = finder
1359 .visit(&csproj_path, &PathBuf::from("StrayEnd.csproj"))
1360 .await
1361 .unwrap_err();
1362
1363 let chain = format!("{error:#}");
1364 assert!(
1365 chain.contains("Failed to parse C# project XML"),
1366 "context missing from chain: {chain}"
1367 );
1368 assert!(
1369 chain.contains(&csproj_path.display().to_string()),
1370 "offending manifest path missing from chain: {chain}"
1371 );
1372 assert_eq!(
1373 finder.projects().len(),
1374 0,
1375 "a manifest that failed to parse must not be registered"
1376 );
1377
1378 temp_dir.close().unwrap();
1379 }
1380
1381 #[tokio::test]
1391 async fn test_visit_malformed_xml_returns_path_context() {
1392 let temp_dir = TempDir::new().unwrap();
1393 let csproj_path = temp_dir.path().join("Broken.csproj");
1394 fs::write(&csproj_path, "<Project><PropertyGroup><Version>1.0.0").unwrap();
1395
1396 let mut finder = CSharpProjectFinder::new();
1397 let error = finder
1398 .visit(&csproj_path, &PathBuf::from("Broken.csproj"))
1399 .await
1400 .unwrap_err();
1401 let message = error.to_string();
1402 assert!(message.contains("Failed to parse C# project XML"));
1403 assert!(message.contains("Broken.csproj"));
1404
1405 let chain = format!("{error:#}");
1407 assert!(
1408 chain.contains("Failed to parse C# project XML"),
1409 "context missing from chain: {chain}"
1410 );
1411 assert!(
1412 chain.contains(&csproj_path.display().to_string()),
1413 "absolute manifest path missing from chain: {chain}"
1414 );
1415 assert!(
1416 chain.contains("unexpected end of XML document"),
1417 "root cause dropped from chain: {chain}"
1418 );
1419 assert_eq!(
1420 error.root_cause().to_string(),
1421 "unexpected end of XML document",
1422 "context must wrap the parser error, not replace it: {chain}"
1423 );
1424
1425 temp_dir.close().unwrap();
1426 }
1427
1428 #[test]
1429 fn test_extract_project_references() {
1430 let content = r#"<Project Sdk="Microsoft.NET.Sdk">
1431 <ItemGroup>
1432 <ProjectReference Include="..\CoreLib\CoreLib.csproj" />
1433 <ProjectReference Include="..\Utils\Utils.csproj" />
1434 <ProjectReference Update="..\Updated\Updated.csproj" />
1435 </ItemGroup>
1436</Project>"#;
1437 let refs = CSharpProjectFinder::parse_csproj_metadata(content)
1438 .unwrap()
1439 .1;
1440 assert_eq!(refs.len(), 3);
1441 assert!(refs.contains(&"CoreLib".to_string()));
1442 assert!(refs.contains(&"Utils".to_string()));
1443 assert!(refs.contains(&"Updated".to_string()));
1444 }
1445
1446 #[test]
1447 fn test_extract_project_references_prefers_include_over_update() {
1448 let content = r#"<Project Sdk="Microsoft.NET.Sdk">
1449 <ItemGroup>
1450 <ProjectReference Include="..\CoreLib\CoreLib.csproj" Update="..\Fallback\Fallback.csproj" />
1451 </ItemGroup>
1452</Project>"#;
1453 let refs = CSharpProjectFinder::parse_csproj_metadata(content)
1454 .unwrap()
1455 .1;
1456 assert_eq!(refs, vec!["CoreLib".to_string()]);
1457 }
1458
1459 #[test]
1460 fn test_extract_project_references_from_start_and_empty_elements() {
1461 let content = r#"<Project>
1462 <ItemGroup>
1463 <ProjectReference Include="..\Started\Started.csproj"></ProjectReference>
1464 <ProjectReference Include="..\Empty\Empty.csproj" />
1465 </ItemGroup>
1466</Project>"#;
1467
1468 let refs = CSharpProjectFinder::parse_csproj_metadata(content)
1469 .unwrap()
1470 .1;
1471
1472 assert_eq!(refs, vec!["Started".to_string(), "Empty".to_string()]);
1473 }
1474
1475 #[test]
1480 fn test_extract_project_references_skips_unrelated_attributes() {
1481 let content = r#"<Project Sdk="Microsoft.NET.Sdk">
1482 <ItemGroup>
1483 <ProjectReference OutputItemType="Analyzer" Include="..\CoreLib\CoreLib.csproj" PrivateAssets="all" />
1484 </ItemGroup>
1485</Project>"#;
1486
1487 let refs = CSharpProjectFinder::parse_csproj_metadata(content)
1488 .unwrap()
1489 .1;
1490
1491 assert_eq!(refs, vec!["CoreLib".to_string()]);
1492 }
1493
1494 #[test]
1500 fn test_extract_project_references_ignores_non_csproj_reference_paths() {
1501 let content = r#"<Project Sdk="Microsoft.NET.Sdk">
1502 <ItemGroup>
1503 <ProjectReference Include="..\Legacy\Legacy.vbproj" />
1504 <ProjectReference Update="..\Native\Native.vcxproj" />
1505 <ProjectReference Include="..\CoreLib\CoreLib.csproj" />
1506 </ItemGroup>
1507</Project>"#;
1508
1509 let refs = CSharpProjectFinder::parse_csproj_metadata(content)
1510 .unwrap()
1511 .1;
1512
1513 assert_eq!(refs, vec!["CoreLib".to_string()]);
1514 }
1515
1516 #[test]
1517 fn test_project_reference_malformed_attribute_returns_contextual_error() {
1518 let content = r#"<Project><ItemGroup><ProjectReference Include="Valid.csproj" Broken /></ItemGroup></Project>"#;
1519
1520 let error = CSharpProjectFinder::parse_csproj_metadata(content).unwrap_err();
1521
1522 assert!(
1523 format!("{error:#}").contains("Failed to parse ProjectReference attribute"),
1524 "unexpected error: {error:#}"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_project_reference_malformed_entity_returns_contextual_error() {
1530 let content = r#"<Project><ItemGroup><ProjectReference Include="..\Bad&unknown;\Bad.csproj" /></ItemGroup></Project>"#;
1531
1532 let error = CSharpProjectFinder::parse_csproj_metadata(content).unwrap_err();
1533
1534 assert!(
1535 format!("{error:#}").contains("Failed to normalize ProjectReference attribute value"),
1536 "unexpected error: {error:#}"
1537 );
1538 }
1539
1540 #[test]
1548 fn test_parse_csproj_metadata_returns_version_and_refs_in_one_pass() {
1549 let content = r#"<Project Sdk="Microsoft.NET.Sdk">
1550 <PropertyGroup>
1551 <Version>1.5.0</Version>
1552 </PropertyGroup>
1553 <ItemGroup>
1554 <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
1555 </ItemGroup>
1556 <ItemGroup>
1557 <ProjectReference Include="..\CoreLib\CoreLib.csproj" />
1558 <ProjectReference Include="..\Utils\Utils.csproj" />
1559 </ItemGroup>
1560</Project>"#;
1561 let (version, refs, publishable_by_default) =
1562 CSharpProjectFinder::parse_csproj_metadata(content).unwrap();
1563 assert_eq!(version, Some("1.5.0".to_string()));
1564 assert_eq!(refs.len(), 2);
1565 assert!(refs.contains(&"CoreLib".to_string()));
1566 assert!(refs.contains(&"Utils".to_string()));
1567 assert!(publishable_by_default);
1568 }
1569
1570 #[tokio::test]
1571 async fn test_discovery_and_rewrite_use_unconditional_top_level_property_groups() -> Result<()>
1572 {
1573 let cases = [
1574 VersionPolicyCase {
1575 name: "target-local",
1576 input: "<Project>\n <Target Name=\"Build\">\n <PropertyGroup>\n <Version>7.0.0</Version>\n </PropertyGroup>\n </Target>\n <PropertyGroup>\n <TargetFramework>net8.0</TargetFramework>\n </PropertyGroup>\n</Project>",
1577 discovered: None,
1578 expected: "<Project>\n <Target Name=\"Build\">\n <PropertyGroup>\n <Version>7.0.0</Version>\n </PropertyGroup>\n </Target>\n <PropertyGroup>\n <TargetFramework>net8.0</TargetFramework>\n <Version>0.0.1</Version>\n </PropertyGroup>\n</Project>",
1579 },
1580 VersionPolicyCase {
1581 name: "conditional-only",
1582 input: "<Project>\n <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n <Version>7.0.0</Version>\n </PropertyGroup>\n</Project>",
1583 discovered: None,
1584 expected: "<Project>\n <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n <Version>7.0.0</Version>\n </PropertyGroup>\n <PropertyGroup>\n <Version>0.0.1</Version>\n </PropertyGroup>\n</Project>",
1585 },
1586 VersionPolicyCase {
1587 name: "conditional-before-unconditional",
1588 input: "<Project>\n <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n <Version>7.0.0</Version>\n </PropertyGroup>\n <PropertyGroup>\n <Version>1.2.3</Version>\n </PropertyGroup>\n</Project>",
1589 discovered: Some("1.2.3"),
1590 expected: "<Project>\n <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n <Version>7.0.0</Version>\n </PropertyGroup>\n <PropertyGroup>\n <Version>1.2.4</Version>\n </PropertyGroup>\n</Project>",
1591 },
1592 VersionPolicyCase {
1593 name: "cdata",
1594 input: "<Project>\n <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n <Version><![CDATA[7.0.0]]></Version>\n </PropertyGroup>\n <PropertyGroup>\n <Version><![CDATA[1.2.3]]></Version>\n </PropertyGroup>\n</Project>",
1595 discovered: Some("1.2.3"),
1596 expected: "<Project>\n <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n <Version><![CDATA[7.0.0]]></Version>\n </PropertyGroup>\n <PropertyGroup>\n <Version><![CDATA[1.2.4]]></Version>\n </PropertyGroup>\n</Project>",
1597 },
1598 VersionPolicyCase {
1599 name: "self-closing",
1600 input: "<Project>\n <Target Name=\"Build\">\n <PropertyGroup>\n <Version>7.0.0</Version>\n </PropertyGroup>\n </Target>\n <PropertyGroup>\n <Version/>\n </PropertyGroup>\n</Project>",
1601 discovered: None,
1602 expected: "<Project>\n <Target Name=\"Build\">\n <PropertyGroup>\n <Version>7.0.0</Version>\n </PropertyGroup>\n </Target>\n <PropertyGroup>\n <Version>0.0.1</Version>\n </PropertyGroup>\n</Project>",
1603 },
1604 VersionPolicyCase {
1605 name: "namespaced",
1606 input: "<msb:Project xmlns:msb=\"urn:msbuild\">\n <msb:PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n <msb:Version>7.0.0</msb:Version>\n </msb:PropertyGroup>\n <msb:PropertyGroup>\n <msb:Version>1.2.3</msb:Version>\n </msb:PropertyGroup>\n</msb:Project>",
1607 discovered: Some("1.2.3"),
1608 expected: "<msb:Project xmlns:msb=\"urn:msbuild\">\n <msb:PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n <msb:Version>7.0.0</msb:Version>\n </msb:PropertyGroup>\n <msb:PropertyGroup>\n <msb:Version>1.2.4</msb:Version>\n </msb:PropertyGroup>\n</msb:Project>",
1609 },
1610 VersionPolicyCase {
1611 name: "crlf",
1612 input: "<Project>\r\n <Target Name=\"Build\">\r\n <PropertyGroup>\r\n <Version>7.0.0</Version>\r\n </PropertyGroup>\r\n </Target>\r\n <PropertyGroup>\r\n <TargetFramework>net8.0</TargetFramework>\r\n </PropertyGroup>\r\n</Project>\r\n",
1613 discovered: None,
1614 expected: "<Project>\r\n <Target Name=\"Build\">\r\n <PropertyGroup>\r\n <Version>7.0.0</Version>\r\n </PropertyGroup>\r\n </Target>\r\n <PropertyGroup>\r\n <TargetFramework>net8.0</TargetFramework>\r\n <Version>0.0.1</Version>\r\n </PropertyGroup>\r\n</Project>\r\n",
1615 },
1616 VersionPolicyCase {
1617 name: "tab-indented",
1618 input: "<Project>\n\t<PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n\t\t<Version>7.0.0</Version>\n\t</PropertyGroup>\n\t<PropertyGroup>\n\t\t<Version>1.2.3</Version>\n\t</PropertyGroup>\n</Project>",
1619 discovered: Some("1.2.3"),
1620 expected: "<Project>\n\t<PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n\t\t<Version>7.0.0</Version>\n\t</PropertyGroup>\n\t<PropertyGroup>\n\t\t<Version>1.2.4</Version>\n\t</PropertyGroup>\n</Project>",
1621 },
1622 ];
1623
1624 for case in cases {
1625 let temp_dir = TempDir::new()?;
1626 let manifest = temp_dir.path().join("Test.csproj");
1627 async_fs::write(&manifest, case.input).await?;
1628 let mut finder = CSharpProjectFinder::new();
1629
1630 finder.visit(&manifest, Path::new("Test.csproj")).await?;
1631 {
1632 let mut projects = finder.projects_mut();
1633 let project = projects
1634 .first_mut()
1635 .context("finder did not return the C# fixture")?;
1636 assert_eq!(
1637 project.version(),
1638 case.discovered,
1639 "{} discovery",
1640 case.name
1641 );
1642 project.update_version(UpdateType::Patch).await?;
1643 }
1644 assert_eq!(
1645 async_fs::read_to_string(&manifest).await?,
1646 case.expected,
1647 "{} rewrite",
1648 case.name
1649 );
1650 temp_dir.close()?;
1651 }
1652
1653 Ok(())
1654 }
1655
1656 #[rstest]
1657 #[case(r"..\CoreLib\CoreLib.csproj", Some("CoreLib"))]
1659 #[case(r"..\..\Utils\Utils.csproj", Some("Utils"))]
1660 #[case("../CoreLib/CoreLib.csproj", Some("CoreLib"))]
1662 #[case("MyProject.csproj", Some("MyProject"))]
1664 #[case("MyProject.txt", None)]
1666 #[case("MyProject.CSPROJ", Some("MyProject"))]
1671 #[case("MyProject.CsProj", Some("MyProject"))]
1672 #[case(r"..\CoreLib\CoreLib.CSPROJ", Some("CoreLib"))]
1673 #[case("MyProject", None)]
1676 #[case("Foo.Tests.csproj", Some("Foo.Tests"))]
1681 #[case(".csproj", Some(""))]
1684 #[case(r"..\CoreLib\", None)]
1687 fn test_extract_project_name_from_path(#[case] input: &str, #[case] expected: Option<&str>) {
1688 assert_eq!(
1689 super::extract_project_name_from_path(input),
1690 expected.map(std::string::ToString::to_string)
1691 );
1692 }
1693}