use anyhow::{Context, Result};
use async_trait::async_trait;
use changepacks_core::{Project, ProjectFinder, has_extension_ignore_ascii_case, is_regular_file};
use quick_xml::Reader;
use quick_xml::XmlVersion;
use quick_xml::escape::resolve_predefined_entity;
use quick_xml::events::{BytesEnd, BytesRef, BytesStart, Event};
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use crate::{package::CSharpPackage, xml_utils::is_unconditional_project_property_group};
const PROJECT_FILES: &[&str] = &[".csproj"];
#[derive(Debug, Default)]
pub struct CSharpProjectFinder {
projects: HashMap<PathBuf, Project>,
}
impl CSharpProjectFinder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn extract_name_from_path(path: &Path) -> Option<String> {
path.file_stem()
.and_then(|s| s.to_str())
.map(std::string::ToString::to_string)
}
fn parse_csproj_metadata(content: &str) -> Result<(Option<String>, Vec<String>, bool)> {
let mut reader = Reader::from_str(content);
let mut buf = Vec::with_capacity(256);
let mut scan = CsprojScan::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => scan.on_start(&e)?,
Ok(Event::Empty(e)) if e.local_name().as_ref() == b"ProjectReference" => {
collect_project_reference(&e, &mut scan.projects)?;
}
Ok(Event::End(e)) => scan.on_end(&e)?,
Ok(Event::Text(e)) => record_decoded_csproj_text(
e.decode(),
scan.in_version,
scan.in_is_packable,
&mut scan.version_text,
&mut scan.publishable_by_default,
)?,
Ok(Event::CData(e)) => record_decoded_csproj_text(
e.decode(),
scan.in_version,
scan.in_is_packable,
&mut scan.version_text,
&mut scan.publishable_by_default,
)?,
Ok(Event::GeneralRef(e)) if scan.in_version => {
append_resolved_reference(&e, &mut scan.version_text)?;
}
Ok(Event::Eof) => {
anyhow::ensure!(scan.element_depth == 0, "unexpected end of XML document");
break;
}
Err(error) => return Err(error.into()),
_ => {}
}
buf.clear();
}
Ok(scan.finish())
}
}
struct CsprojScan {
eligible_property_group_depth: Option<usize>,
in_version: bool,
in_is_packable: bool,
element_depth: usize,
project_depth: Option<usize>,
version: Option<String>,
version_text: String,
publishable_by_default: bool,
projects: Vec<String>,
}
impl CsprojScan {
fn new() -> Self {
Self {
eligible_property_group_depth: None,
in_version: false,
in_is_packable: false,
element_depth: 0,
project_depth: None,
version: None,
version_text: String::new(),
publishable_by_default: true,
projects: Vec::with_capacity(4),
}
}
fn on_start(&mut self, e: &BytesStart<'_>) -> Result<()> {
self.element_depth += 1;
let name = e.local_name();
if name.as_ref() == b"Project" && self.project_depth.is_none() {
self.project_depth = Some(self.element_depth);
} else if name.as_ref() == b"PropertyGroup"
&& is_unconditional_project_property_group(e, self.element_depth, self.project_depth)?
{
self.eligible_property_group_depth = Some(self.element_depth);
} else if name.as_ref() == b"Version" {
self.in_version =
is_eligible_property_child(self.eligible_property_group_depth, self.element_depth);
} else if name.as_ref() == b"IsPackable" {
self.in_is_packable =
is_eligible_property_child(self.eligible_property_group_depth, self.element_depth);
} else if name.as_ref() == b"ProjectReference" {
collect_project_reference(e, &mut self.projects)?;
}
Ok(())
}
fn on_end(&mut self, e: &BytesEnd<'_>) -> Result<()> {
let name = e.local_name();
if name.as_ref() == b"PropertyGroup"
&& self.eligible_property_group_depth == Some(self.element_depth)
{
self.eligible_property_group_depth = None;
} else if name.as_ref() == b"Version" {
self.in_version = false;
if self.version.is_none() {
let candidate = self.version_text.trim();
if !candidate.is_empty() {
self.version = Some(candidate.to_string());
}
}
self.version_text.clear();
} else if name.as_ref() == b"IsPackable" {
self.in_is_packable = false;
}
self.element_depth = self
.element_depth
.checked_sub(1)
.context("unexpected XML end tag")?;
Ok(())
}
fn finish(self) -> (Option<String>, Vec<String>, bool) {
(self.version, self.projects, self.publishable_by_default)
}
}
const fn is_eligible_property_child(
eligible_property_group_depth: Option<usize>,
element_depth: usize,
) -> bool {
matches!(eligible_property_group_depth, Some(depth) if element_depth == depth + 1)
}
fn record_decoded_csproj_text(
decoded: std::result::Result<std::borrow::Cow<'_, str>, quick_xml::encoding::EncodingError>,
in_version: bool,
in_is_packable: bool,
version_text: &mut String,
publishable_by_default: &mut bool,
) -> Result<()> {
let text = decoded.context("Failed to decode .csproj text node")?;
let text = text.as_ref();
if in_version {
version_text.push_str(text);
}
if in_is_packable && text.trim().eq_ignore_ascii_case("false") {
*publishable_by_default = false;
}
Ok(())
}
fn append_resolved_reference(reference: &BytesRef<'_>, version_text: &mut String) -> Result<()> {
if let Some(character) = reference
.resolve_char_ref()
.context("Failed to resolve .csproj character reference")?
{
version_text.push(character);
return Ok(());
}
let name = reference
.decode()
.context("Failed to decode .csproj entity reference")?;
let resolved = resolve_predefined_entity(&name).with_context(|| {
format!("Unresolvable entity reference `&{name};` in .csproj <Version>")
})?;
version_text.push_str(resolved);
Ok(())
}
fn collect_project_reference(
e: &quick_xml::events::BytesStart<'_>,
projects: &mut Vec<String>,
) -> Result<()> {
let mut include_name = None;
let mut update_name = None;
for attr in e.attributes() {
let attr = attr.context("Failed to parse ProjectReference attribute")?;
let attr_name = attr.key.as_ref();
if !matches!(attr_name, b"Include" | b"Update") {
continue;
}
let value = attr
.normalized_value(XmlVersion::Implicit1_0)
.context("Failed to normalize ProjectReference attribute value")?;
let Some(name) = extract_project_name_from_path(&value) else {
continue;
};
if attr_name == b"Include" {
include_name = Some(name);
} else {
update_name = Some(name);
}
}
if let Some(name) = include_name.or(update_name) {
projects.push(name);
}
Ok(())
}
fn extract_project_name_from_path(path_str: &str) -> Option<String> {
let filename = path_str
.rsplit_once(['\\', '/'])
.map_or(path_str, |(_, tail)| tail);
let (stem, ext) = filename.rsplit_once('.')?;
ext.eq_ignore_ascii_case("csproj").then(|| stem.to_string())
}
#[async_trait]
impl ProjectFinder for CSharpProjectFinder {
changepacks_core::impl_projects_hashmap_accessors!();
fn project_files(&self) -> &[&str] {
PROJECT_FILES
}
async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
if !has_extension_ignore_ascii_case(path, "csproj") {
return Ok(());
}
if self.contains_project(path) {
return Ok(());
}
if !is_regular_file(path).await? {
return Ok(());
}
let csproj_content = crate::read_csproj(path).await?;
let name = Self::extract_name_from_path(path);
let (version, project_refs, publishable_by_default) =
Self::parse_csproj_metadata(&csproj_content)
.with_context(|| format!("Failed to parse C# project XML: {}", path.display()))?;
let path_key = path.to_path_buf();
let relative_path_key = relative_path.to_path_buf();
let mut project = Project::Package(Box::new(CSharpPackage::new_discovered(
name,
version,
path_key.clone(),
relative_path_key,
publishable_by_default,
)));
for dep in project_refs {
project.add_dependency(&dep);
}
self.projects.insert(path_key, project);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use changepacks_core::UpdateType;
use changepacks_utils::sort_by_dependencies;
use rstest::rstest;
use std::fs;
use tempfile::TempDir;
use tokio::fs as async_fs;
struct VersionPolicyCase {
name: &'static str,
input: &'static str,
discovered: Option<&'static str>,
expected: &'static str,
}
#[tokio::test]
async fn test_new() {
let finder = CSharpProjectFinder::new();
assert_eq!(finder.project_files(), &[".csproj"]);
assert_eq!(finder.projects().len(), 0);
}
#[tokio::test]
async fn test_default() {
let finder = CSharpProjectFinder::default();
assert_eq!(finder.project_files(), &[".csproj"]);
assert_eq!(finder.projects().len(), 0);
}
#[tokio::test]
async fn test_visit_package() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("TestProject.csproj");
fs::write(
&csproj_path,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
"#,
)
.unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
.await
.unwrap();
assert_eq!(finder.projects().len(), 1);
let pkg = finder.projects()[0].expect_package();
assert_eq!(pkg.name(), Some("TestProject"));
assert_eq!(pkg.version(), Some("1.0.0"));
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_visit_mixed_case_csproj_extension() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("App.CSPROJ");
fs::write(
&csproj_path,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
"#,
)
.unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&csproj_path, &PathBuf::from("App.CSPROJ"))
.await
.unwrap();
assert_eq!(finder.project_count(), 1);
assert_eq!(finder.projects().len(), 1);
let pkg = finder.projects()[0].expect_package();
assert_eq!(pkg.name(), Some("App"));
assert_eq!(pkg.version(), Some("1.0.0"));
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_root_solution_csproj_manifests_are_packages() {
let temp_dir = TempDir::new().unwrap();
let library_path = temp_dir.path().join("Library.csproj");
let app_path = temp_dir.path().join("App.csproj");
fs::write(
temp_dir.path().join("Product.sln"),
"Microsoft Visual Studio Solution File",
)
.unwrap();
fs::write(
&library_path,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.2.3</Version>
</PropertyGroup>
</Project>
"#,
)
.unwrap();
fs::write(
&app_path,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>4.5.6</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="Library.csproj" />
</ItemGroup>
</Project>
"#,
)
.unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&app_path, Path::new("App.csproj"))
.await
.unwrap();
finder
.visit(&library_path, Path::new("Library.csproj"))
.await
.unwrap();
let projects = sort_by_dependencies(finder.projects()).unwrap();
assert_eq!(projects.len(), 2);
assert!(
projects
.iter()
.all(|project| matches!(project, Project::Package(_))),
"solution-contained manifests must remain packages: {projects:?}"
);
assert_eq!(
projects
.iter()
.map(|project| (project.name(), project.version(), project.relative_path()))
.collect::<Vec<_>>(),
vec![
(Some("Library"), Some("1.2.3"), Path::new("Library.csproj"),),
(Some("App"), Some("4.5.6"), Path::new("App.csproj")),
]
);
assert!(projects[1].dependencies().contains("Library"));
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_nested_solution_csproj_manifests_are_packages() {
let temp_dir = TempDir::new().unwrap();
let solution_dir = temp_dir.path().join("solutions").join("Product");
let library_path = solution_dir
.join("src")
.join("Library")
.join("Library.csproj");
let app_path = solution_dir.join("src").join("App").join("App.csproj");
fs::create_dir_all(library_path.parent().unwrap()).unwrap();
fs::create_dir_all(app_path.parent().unwrap()).unwrap();
fs::write(
solution_dir.join("Product.sln"),
"Microsoft Visual Studio Solution File",
)
.unwrap();
fs::write(
&library_path,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>2.0.0</Version>
</PropertyGroup>
</Project>
"#,
)
.unwrap();
fs::write(
&app_path,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>3.1.4</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Library\Library.csproj" />
</ItemGroup>
</Project>
"#,
)
.unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&app_path, Path::new("solutions/Product/src/App/App.csproj"))
.await
.unwrap();
finder
.visit(
&library_path,
Path::new("solutions/Product/src/Library/Library.csproj"),
)
.await
.unwrap();
let projects = sort_by_dependencies(finder.projects()).unwrap();
assert_eq!(projects.len(), 2);
assert!(
projects
.iter()
.all(|project| matches!(project, Project::Package(_))),
"nested solution manifests must remain packages: {projects:?}"
);
assert_eq!(
projects
.iter()
.map(|project| (project.name(), project.version(), project.relative_path()))
.collect::<Vec<_>>(),
vec![
(
Some("Library"),
Some("2.0.0"),
Path::new("solutions/Product/src/Library/Library.csproj"),
),
(
Some("App"),
Some("3.1.4"),
Path::new("solutions/Product/src/App/App.csproj"),
),
]
);
assert!(projects[1].dependencies().contains("Library"));
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_visit_package_reads_version_from_cdata() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("TestProject.csproj");
fs::write(
&csproj_path,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version><![CDATA[1.2.3]]></Version>
</PropertyGroup>
</Project>
"#,
)
.unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
.await
.unwrap();
assert_eq!(
finder.projects()[0].expect_package().version(),
Some("1.2.3")
);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_visit_package_ignores_sln_directory() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("TestProject.csproj");
fs::create_dir(temp_dir.path().join("Fake.sln")).unwrap();
fs::write(
&csproj_path,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
"#,
)
.unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
.await
.unwrap();
let projects = finder.projects();
assert_eq!(projects.len(), 1);
assert!(
matches!(projects[0], Project::Package(_)),
"expected Package when only a .sln directory exists, got {:?}",
projects[0]
);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_visit_package_without_version() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("TestProject.csproj");
fs::write(
&csproj_path,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
</Project>
"#,
)
.unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
.await
.unwrap();
assert_eq!(finder.projects().len(), 1);
let pkg = finder.projects()[0].expect_package();
assert_eq!(pkg.name(), Some("TestProject"));
assert_eq!(pkg.version(), None);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_visit_non_csproj_file() {
let temp_dir = TempDir::new().unwrap();
let other_file = temp_dir.path().join("other.xml");
fs::write(&other_file, r"<root>content</root>").unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&other_file, &PathBuf::from("other.xml"))
.await
.unwrap();
assert_eq!(finder.projects().len(), 0);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_visit_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path().join("some_dir");
fs::create_dir_all(&dir_path).unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&dir_path, &PathBuf::from("some_dir"))
.await
.unwrap();
assert_eq!(finder.projects().len(), 0);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_visit_duplicate() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("TestProject.csproj");
fs::write(
&csproj_path,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
"#,
)
.unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
.await
.unwrap();
finder
.visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
.await
.unwrap();
assert_eq!(finder.projects().len(), 1);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_visit_multiple_packages() {
let temp_dir = TempDir::new().unwrap();
let csproj1 = temp_dir.path().join("Project1").join("Project1.csproj");
let csproj2 = temp_dir.path().join("Project2").join("Project2.csproj");
fs::create_dir_all(csproj1.parent().unwrap()).unwrap();
fs::create_dir_all(csproj2.parent().unwrap()).unwrap();
fs::write(
&csproj1,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
"#,
)
.unwrap();
fs::write(
&csproj2,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>2.0.0</Version>
</PropertyGroup>
</Project>
"#,
)
.unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&csproj1, &PathBuf::from("Project1/Project1.csproj"))
.await
.unwrap();
finder
.visit(&csproj2, &PathBuf::from("Project2/Project2.csproj"))
.await
.unwrap();
assert_eq!(finder.projects().len(), 2);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_projects_mut() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("TestProject.csproj");
fs::write(
&csproj_path,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
"#,
)
.unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
.await
.unwrap();
let mut projects = finder.projects_mut();
assert_eq!(projects.len(), 1);
let pkg = projects[0].expect_package_mut();
assert!(!pkg.is_changed());
pkg.set_changed(true);
assert!(pkg.is_changed());
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_visit_package_with_project_references() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("TestProject.csproj");
fs::write(
&csproj_path,
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CoreLib\CoreLib.csproj" />
<ProjectReference Include="..\Utils\Utils.csproj" />
</ItemGroup>
</Project>
"#,
)
.unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&csproj_path, &PathBuf::from("TestProject.csproj"))
.await
.unwrap();
let projects = finder.projects();
assert_eq!(projects.len(), 1);
let pkg = projects[0].expect_package();
assert_eq!(pkg.name(), Some("TestProject"));
let deps = pkg.dependencies();
assert_eq!(deps.len(), 2);
assert!(deps.contains("CoreLib"));
assert!(deps.contains("Utils"));
temp_dir.close().unwrap();
}
const XML_STANDARD_VERSION: &str = r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.2.3</Version>
</PropertyGroup>
</Project>"#;
const XML_NO_VERSION_ELEMENT: &str = r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
</Project>"#;
const XML_VERSION_WITH_END_TAG_WHITESPACE: &str = r"<Project><PropertyGroup><Version>
1.2.3
</Version></PropertyGroup></Project>";
const XML_EMPTY_VERSION: &str =
r"<Project><PropertyGroup><Version> </Version></PropertyGroup></Project>";
const XML_VERSION_AFTER_EMPTY_ELEMENT: &str = r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable />
<Version>3.2.1</Version>
</PropertyGroup>
</Project>"#;
const XML_VERSION_AFTER_COMMENT: &str = r"<Project>
<PropertyGroup>
<!-- version follows -->
<Version>4.0.0</Version>
</PropertyGroup>
</Project>";
const XML_VERSION_WITH_DECIMAL_CHAR_REF: &str =
r"<Project><PropertyGroup><Version>1.2.3</Version></PropertyGroup></Project>";
const XML_VERSION_WITH_HEX_CHAR_REF: &str =
r"<Project><PropertyGroup><Version>1.2.3</Version></PropertyGroup></Project>";
const XML_VERSION_WITH_PREDEFINED_ENTITY: &str =
r"<Project><PropertyGroup><Version>1.0.0-a&b</Version></PropertyGroup></Project>";
const XML_VERSION_IS_ONLY_A_CHAR_REF: &str =
r"<Project><PropertyGroup><Version>1.0</Version></PropertyGroup></Project>";
const XML_DUPLICATE_VERSION: &str = r"<Project><PropertyGroup><Version>1.0.0</Version><Version>2.0.0</Version></PropertyGroup></Project>";
const XML_EMPTY_THEN_POPULATED_VERSION: &str = r"<Project><PropertyGroup><Version> </Version><Version>2.0.0</Version></PropertyGroup></Project>";
const XML_CDATA_VERSION: &str =
r"<Project><PropertyGroup><Version><![CDATA[1.2.3]]></Version></PropertyGroup></Project>";
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>"#;
const XML_TARGET_LOCAL_VERSION: &str = r#"<Project>
<Target Name="Build">
<PropertyGroup>
<Version>7.0.0</Version>
</PropertyGroup>
</Target>
<PropertyGroup>
<Version>1.2.3</Version>
</PropertyGroup>
</Project>"#;
const XML_VERSION_NESTED_BELOW_PROPERTY: &str = r"<Project>
<PropertyGroup>
<PackageMetadata><Version>9.9.9</Version></PackageMetadata>
<Version>1.2.3</Version>
</PropertyGroup>
</Project>";
#[rstest]
#[case(XML_STANDARD_VERSION, Some("1.2.3"))]
#[case(XML_NO_VERSION_ELEMENT, None)]
#[case(XML_VERSION_WITH_END_TAG_WHITESPACE, Some("1.2.3"))]
#[case(XML_EMPTY_VERSION, None)]
#[case(XML_VERSION_AFTER_EMPTY_ELEMENT, Some("3.2.1"))]
#[case(XML_VERSION_AFTER_COMMENT, Some("4.0.0"))]
#[case(XML_VERSION_WITH_DECIMAL_CHAR_REF, Some("1.2.3"))]
#[case(XML_VERSION_WITH_HEX_CHAR_REF, Some("1.2.3"))]
#[case(XML_VERSION_WITH_PREDEFINED_ENTITY, Some("1.0.0-a&b"))]
#[case(XML_VERSION_IS_ONLY_A_CHAR_REF, Some("1.0"))]
#[case(XML_DUPLICATE_VERSION, Some("1.0.0"))]
#[case(XML_EMPTY_THEN_POPULATED_VERSION, Some("2.0.0"))]
#[case(XML_CDATA_VERSION, Some("1.2.3"))]
#[case(XML_CONDITIONAL_VERSION_WITH_CHAR_REF, Some("1.2.3"))]
#[case(XML_TARGET_LOCAL_VERSION, Some("1.2.3"))]
#[case(XML_VERSION_NESTED_BELOW_PROPERTY, Some("1.2.3"))]
fn test_extract_version(#[case] content: &str, #[case] expected: Option<&str>) {
assert_eq!(
CSharpProjectFinder::parse_csproj_metadata(content)
.unwrap()
.0,
expected.map(std::string::ToString::to_string)
);
}
#[test]
fn test_unresolvable_version_entity_returns_contextual_error() {
let content =
r"<Project><PropertyGroup><Version>1.0&mystery;0</Version></PropertyGroup></Project>";
let error = CSharpProjectFinder::parse_csproj_metadata(content).unwrap_err();
assert!(
format!("{error:#}").contains("Unresolvable entity reference `&mystery;`"),
"unexpected error: {error:#}"
);
}
#[test]
fn test_out_of_range_version_char_ref_returns_contextual_error() {
let content =
r"<Project><PropertyGroup><Version>1.0�0</Version></PropertyGroup></Project>";
let error = CSharpProjectFinder::parse_csproj_metadata(content).unwrap_err();
assert!(
format!("{error:#}").contains("Failed to resolve .csproj character reference"),
"unexpected error: {error:#}"
);
}
#[test]
fn test_entity_reference_outside_version_is_ignored() {
let content = r"<Project><PropertyGroup><Description>Hello &custom; World</Description><Version>1.2.3</Version><IsPackable>false</IsPackable></PropertyGroup></Project>";
let (version, refs, publishable_by_default) =
CSharpProjectFinder::parse_csproj_metadata(content).unwrap();
assert_eq!(version.as_deref(), Some("1.2.3"));
assert!(refs.is_empty());
assert!(publishable_by_default);
}
#[test]
fn test_parse_csproj_metadata_is_packable_publishability() {
let cases = [
(
"false",
"<Project><PropertyGroup><IsPackable>false</IsPackable></PropertyGroup></Project>",
false,
),
(
"trimmed mixed case false",
"<Project><PropertyGroup><IsPackable>\n False\t </IsPackable></PropertyGroup></Project>",
false,
),
(
"true",
"<Project><PropertyGroup><IsPackable>true</IsPackable></PropertyGroup></Project>",
true,
),
(
"missing",
"<Project><PropertyGroup><Version>1.0.0</Version></PropertyGroup></Project>",
true,
),
(
"self closing",
"<Project><PropertyGroup><IsPackable /></PropertyGroup></Project>",
true,
),
(
"conditional property group",
r#"<Project><PropertyGroup Condition="'$(Configuration)' == 'Release'"><IsPackable>false</IsPackable></PropertyGroup></Project>"#,
true,
),
(
"computed",
"<Project><PropertyGroup><IsPackable>$(Packable)</IsPackable></PropertyGroup></Project>",
true,
),
(
"nested property group",
"<Project><Target><PropertyGroup><IsPackable>false</IsPackable></PropertyGroup></Target></Project>",
true,
),
];
for (label, content, expected) in cases {
let publishable_by_default = CSharpProjectFinder::parse_csproj_metadata(content)
.unwrap()
.2;
assert_eq!(publishable_by_default, expected, "{label}");
}
}
#[test]
fn test_parse_csproj_metadata_scopes_is_packable_like_version() {
let cases = [
(
"text",
"<Root><Project><PropertyGroup><Version>1.2.3</Version><IsPackable>false</IsPackable></PropertyGroup></Project></Root>",
),
(
"cdata",
"<Root><Project><PropertyGroup><Version>1.2.3</Version><IsPackable><![CDATA[false]]></IsPackable></PropertyGroup></Project></Root>",
),
];
for (label, content) in cases {
let (version, _, publishable_by_default) =
CSharpProjectFinder::parse_csproj_metadata(content).unwrap();
assert_eq!(version.as_deref(), Some("1.2.3"), "{label}");
assert!(!publishable_by_default, "{label}");
}
}
#[tokio::test]
async fn test_visit_package_carries_is_packable_false_metadata() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("Private.csproj");
fs::write(
&csproj_path,
"<Project><PropertyGroup><IsPackable>false</IsPackable></PropertyGroup></Project>",
)
.unwrap();
let mut finder = CSharpProjectFinder::new();
finder
.visit(&csproj_path, Path::new("Private.csproj"))
.await
.unwrap();
let projects = finder.projects();
assert_eq!(projects.len(), 1);
assert!(!projects[0].is_publishable_by_default());
}
#[test]
fn test_parse_csproj_metadata_decodes_text_and_cdata_on_happy_path() {
let content = "<Project><PropertyGroup><Version>1.2.3</Version><IsPackable><![CDATA[false]]></IsPackable></PropertyGroup></Project>";
let (version, refs, publishable_by_default) =
CSharpProjectFinder::parse_csproj_metadata(content).unwrap();
assert_eq!(version.as_deref(), Some("1.2.3"));
assert!(refs.is_empty());
assert!(!publishable_by_default);
}
#[test]
fn test_record_decoded_csproj_text_propagates_decode_error() {
let truncated = &"é".as_bytes()[..1];
let utf8_error = std::str::from_utf8(truncated).unwrap_err();
let mut version_text = String::new();
let mut publishable_by_default = true;
let error = super::record_decoded_csproj_text(
Err(quick_xml::encoding::EncodingError::from(utf8_error)),
true,
true,
&mut version_text,
&mut publishable_by_default,
)
.unwrap_err();
assert!(
format!("{error:#}").contains("Failed to decode .csproj text node"),
"context missing from chain: {error:#}"
);
assert!(
format!("{error:#}").contains("cannot decode input using UTF-8"),
"root cause dropped from chain: {error:#}"
);
assert!(version_text.is_empty());
assert!(publishable_by_default);
}
#[test]
fn test_extract_version_malformed_xml() {
let content = "<Project><PropertyGroup><Version>1.0.0";
assert!(CSharpProjectFinder::parse_csproj_metadata(content).is_err());
}
#[test]
fn test_parse_csproj_metadata_unmatched_end_tag_is_err() {
assert!(CSharpProjectFinder::parse_csproj_metadata("</Project>").is_err());
}
#[tokio::test]
async fn test_visit_unmatched_end_tag_returns_path_context() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("StrayEnd.csproj");
fs::write(&csproj_path, "</Project>").unwrap();
let mut finder = CSharpProjectFinder::new();
let error = finder
.visit(&csproj_path, &PathBuf::from("StrayEnd.csproj"))
.await
.unwrap_err();
let chain = format!("{error:#}");
assert!(
chain.contains("Failed to parse C# project XML"),
"context missing from chain: {chain}"
);
assert!(
chain.contains(&csproj_path.display().to_string()),
"offending manifest path missing from chain: {chain}"
);
assert_eq!(
finder.projects().len(),
0,
"a manifest that failed to parse must not be registered"
);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_visit_malformed_xml_returns_path_context() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("Broken.csproj");
fs::write(&csproj_path, "<Project><PropertyGroup><Version>1.0.0").unwrap();
let mut finder = CSharpProjectFinder::new();
let error = finder
.visit(&csproj_path, &PathBuf::from("Broken.csproj"))
.await
.unwrap_err();
let message = error.to_string();
assert!(message.contains("Failed to parse C# project XML"));
assert!(message.contains("Broken.csproj"));
let chain = format!("{error:#}");
assert!(
chain.contains("Failed to parse C# project XML"),
"context missing from chain: {chain}"
);
assert!(
chain.contains(&csproj_path.display().to_string()),
"absolute manifest path missing from chain: {chain}"
);
assert!(
chain.contains("unexpected end of XML document"),
"root cause dropped from chain: {chain}"
);
assert_eq!(
error.root_cause().to_string(),
"unexpected end of XML document",
"context must wrap the parser error, not replace it: {chain}"
);
temp_dir.close().unwrap();
}
#[test]
fn test_extract_project_references() {
let content = r#"<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\CoreLib\CoreLib.csproj" />
<ProjectReference Include="..\Utils\Utils.csproj" />
<ProjectReference Update="..\Updated\Updated.csproj" />
</ItemGroup>
</Project>"#;
let refs = CSharpProjectFinder::parse_csproj_metadata(content)
.unwrap()
.1;
assert_eq!(refs.len(), 3);
assert!(refs.contains(&"CoreLib".to_string()));
assert!(refs.contains(&"Utils".to_string()));
assert!(refs.contains(&"Updated".to_string()));
}
#[test]
fn test_extract_project_references_prefers_include_over_update() {
let content = r#"<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\CoreLib\CoreLib.csproj" Update="..\Fallback\Fallback.csproj" />
</ItemGroup>
</Project>"#;
let refs = CSharpProjectFinder::parse_csproj_metadata(content)
.unwrap()
.1;
assert_eq!(refs, vec!["CoreLib".to_string()]);
}
#[test]
fn test_extract_project_references_from_start_and_empty_elements() {
let content = r#"<Project>
<ItemGroup>
<ProjectReference Include="..\Started\Started.csproj"></ProjectReference>
<ProjectReference Include="..\Empty\Empty.csproj" />
</ItemGroup>
</Project>"#;
let refs = CSharpProjectFinder::parse_csproj_metadata(content)
.unwrap()
.1;
assert_eq!(refs, vec!["Started".to_string(), "Empty".to_string()]);
}
#[test]
fn test_extract_project_references_skips_unrelated_attributes() {
let content = r#"<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference OutputItemType="Analyzer" Include="..\CoreLib\CoreLib.csproj" PrivateAssets="all" />
</ItemGroup>
</Project>"#;
let refs = CSharpProjectFinder::parse_csproj_metadata(content)
.unwrap()
.1;
assert_eq!(refs, vec!["CoreLib".to_string()]);
}
#[test]
fn test_extract_project_references_ignores_non_csproj_reference_paths() {
let content = r#"<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Legacy\Legacy.vbproj" />
<ProjectReference Update="..\Native\Native.vcxproj" />
<ProjectReference Include="..\CoreLib\CoreLib.csproj" />
</ItemGroup>
</Project>"#;
let refs = CSharpProjectFinder::parse_csproj_metadata(content)
.unwrap()
.1;
assert_eq!(refs, vec!["CoreLib".to_string()]);
}
#[test]
fn test_project_reference_malformed_attribute_returns_contextual_error() {
let content = r#"<Project><ItemGroup><ProjectReference Include="Valid.csproj" Broken /></ItemGroup></Project>"#;
let error = CSharpProjectFinder::parse_csproj_metadata(content).unwrap_err();
assert!(
format!("{error:#}").contains("Failed to parse ProjectReference attribute"),
"unexpected error: {error:#}"
);
}
#[test]
fn test_project_reference_malformed_entity_returns_contextual_error() {
let content = r#"<Project><ItemGroup><ProjectReference Include="..\Bad&unknown;\Bad.csproj" /></ItemGroup></Project>"#;
let error = CSharpProjectFinder::parse_csproj_metadata(content).unwrap_err();
assert!(
format!("{error:#}").contains("Failed to normalize ProjectReference attribute value"),
"unexpected error: {error:#}"
);
}
#[test]
fn test_parse_csproj_metadata_returns_version_and_refs_in_one_pass() {
let content = r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.5.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CoreLib\CoreLib.csproj" />
<ProjectReference Include="..\Utils\Utils.csproj" />
</ItemGroup>
</Project>"#;
let (version, refs, publishable_by_default) =
CSharpProjectFinder::parse_csproj_metadata(content).unwrap();
assert_eq!(version, Some("1.5.0".to_string()));
assert_eq!(refs.len(), 2);
assert!(refs.contains(&"CoreLib".to_string()));
assert!(refs.contains(&"Utils".to_string()));
assert!(publishable_by_default);
}
#[tokio::test]
async fn test_discovery_and_rewrite_use_unconditional_top_level_property_groups() -> Result<()>
{
let cases = [
VersionPolicyCase {
name: "target-local",
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>",
discovered: None,
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>",
},
VersionPolicyCase {
name: "conditional-only",
input: "<Project>\n <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n <Version>7.0.0</Version>\n </PropertyGroup>\n</Project>",
discovered: None,
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>",
},
VersionPolicyCase {
name: "conditional-before-unconditional",
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>",
discovered: Some("1.2.3"),
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>",
},
VersionPolicyCase {
name: "cdata",
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>",
discovered: Some("1.2.3"),
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>",
},
VersionPolicyCase {
name: "self-closing",
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>",
discovered: None,
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>",
},
VersionPolicyCase {
name: "namespaced",
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>",
discovered: Some("1.2.3"),
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>",
},
VersionPolicyCase {
name: "crlf",
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",
discovered: None,
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",
},
VersionPolicyCase {
name: "tab-indented",
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>",
discovered: Some("1.2.3"),
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>",
},
];
for case in cases {
let temp_dir = TempDir::new()?;
let manifest = temp_dir.path().join("Test.csproj");
async_fs::write(&manifest, case.input).await?;
let mut finder = CSharpProjectFinder::new();
finder.visit(&manifest, Path::new("Test.csproj")).await?;
{
let mut projects = finder.projects_mut();
let project = projects
.first_mut()
.context("finder did not return the C# fixture")?;
assert_eq!(
project.version(),
case.discovered,
"{} discovery",
case.name
);
project.update_version(UpdateType::Patch).await?;
}
assert_eq!(
async_fs::read_to_string(&manifest).await?,
case.expected,
"{} rewrite",
case.name
);
temp_dir.close()?;
}
Ok(())
}
#[rstest]
#[case(r"..\CoreLib\CoreLib.csproj", Some("CoreLib"))]
#[case(r"..\..\Utils\Utils.csproj", Some("Utils"))]
#[case("../CoreLib/CoreLib.csproj", Some("CoreLib"))]
#[case("MyProject.csproj", Some("MyProject"))]
#[case("MyProject.txt", None)]
#[case("MyProject.CSPROJ", Some("MyProject"))]
#[case("MyProject.CsProj", Some("MyProject"))]
#[case(r"..\CoreLib\CoreLib.CSPROJ", Some("CoreLib"))]
#[case("MyProject", None)]
#[case("Foo.Tests.csproj", Some("Foo.Tests"))]
#[case(".csproj", Some(""))]
#[case(r"..\CoreLib\", None)]
fn test_extract_project_name_from_path(#[case] input: &str, #[case] expected: Option<&str>) {
assert_eq!(
super::extract_project_name_from_path(input),
expected.map(std::string::ToString::to_string)
);
}
}