mod dry_run;
pub mod finder;
pub mod package;
mod xml_utils;
use std::path::Path;
use anyhow::{Context, Result};
use tokio::fs::{read_to_string, write};
pub use finder::CSharpProjectFinder;
pub(crate) const PUBLISH_COMMAND: &str = "dotnet pack -c Release && dotnet nuget push";
pub(crate) async fn read_csproj(path: &Path) -> Result<String> {
read_to_string(path)
.await
.with_context(|| format!("Failed to read C# project {}", path.display()))
}
pub(crate) async fn write_csproj_version(path: &Path, new_version: &str) -> Result<()> {
let csproj_raw = read_csproj(path).await?;
let updated = xml_utils::update_version_in_xml(&csproj_raw, new_version)
.with_context(|| format!("Failed to update version in C# project {}", path.display()))?;
if updated != csproj_raw {
write(path, updated)
.await
.with_context(|| format!("Failed to write C# project {}", path.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use changepacks_utils::test_support;
use tempfile::TempDir;
const REALISTIC_CSPROJ_CRLF: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n<Project Sdk=\"Microsoft.NET.Sdk\">\r\n <!-- Package metadata -->\r\n <PropertyGroup>\r\n <TargetFramework>net8.0</TargetFramework>\r\n <Version>1.0.0</Version>\r\n <Nullable>enable</Nullable>\r\n </PropertyGroup>\r\n</Project>\r\n\r\n";
#[tokio::test]
async fn test_write_csproj_version_preserves_surrounding_formatting() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("Formatted.csproj");
tokio::fs::write(&csproj_path, REALISTIC_CSPROJ_CRLF)
.await
.unwrap();
write_csproj_version(&csproj_path, "1.0.1").await.unwrap();
let expected =
REALISTIC_CSPROJ_CRLF.replace("<Version>1.0.0</Version>", "<Version>1.0.1</Version>");
assert_eq!(
tokio::fs::read_to_string(&csproj_path).await.unwrap(),
expected,
"only the version text may change; every other byte must survive",
);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_write_csproj_version_skips_write_when_version_unchanged() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("Unchanged.csproj");
tokio::fs::write(&csproj_path, REALISTIC_CSPROJ_CRLF)
.await
.unwrap();
let modified_before = tokio::fs::metadata(&csproj_path)
.await
.unwrap()
.modified()
.unwrap();
write_csproj_version(&csproj_path, "1.0.0").await.unwrap();
assert_eq!(
tokio::fs::read(&csproj_path).await.unwrap(),
REALISTIC_CSPROJ_CRLF.as_bytes(),
"an unchanged version must leave the file byte-identical",
);
assert_eq!(
tokio::fs::metadata(&csproj_path)
.await
.unwrap()
.modified()
.unwrap(),
modified_before,
"the write-skip guard must not touch the file at all",
);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_write_csproj_version_creates_property_group_when_missing() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("NoPropertyGroup.csproj");
let content = b"<Project Sdk=\"Microsoft.NET.Sdk\">\r\n</Project>\r\n";
tokio::fs::write(&csproj_path, content).await.unwrap();
write_csproj_version(&csproj_path, "1.2.3").await.unwrap();
assert_eq!(
tokio::fs::read_to_string(&csproj_path).await.unwrap(),
"<Project Sdk=\"Microsoft.NET.Sdk\">\r\n<PropertyGroup>\r\n <Version>1.2.3</Version>\r\n</PropertyGroup>\r\n</Project>\r\n"
);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_write_csproj_version_read_error_includes_path() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("Missing.csproj");
let err = write_csproj_version(&csproj_path, "1.0.1")
.await
.expect_err("a missing .csproj must fail the read");
let chain = format!("{err:#}");
assert!(
chain.contains(&format!(
"Failed to read C# project {}",
csproj_path.display()
)),
"error chain should carry the read context naming the manifest path, got: {chain}"
);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_write_csproj_version_update_error_includes_path() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("Malformed.csproj");
tokio::fs::write(
&csproj_path,
"<Project><PropertyGroup><Version>1.0.0</Version></PropertyGroup",
)
.await
.unwrap();
let err = write_csproj_version(&csproj_path, "1.0.1")
.await
.expect_err("a malformed .csproj must fail the version update");
let chain = format!("{err:#}");
assert!(
chain.contains(&format!(
"Failed to update version in C# project {}",
csproj_path.display()
)),
"error chain should carry the update context naming the manifest path, got: {chain}"
);
temp_dir.close().unwrap();
}
#[tokio::test]
async fn test_write_csproj_version_write_error_includes_path() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("Readonly.csproj");
tokio::fs::write(&csproj_path, REALISTIC_CSPROJ_CRLF)
.await
.unwrap();
test_support::set_readonly(&csproj_path, true);
let result = write_csproj_version(&csproj_path, "1.0.1").await;
test_support::set_readonly(&csproj_path, false);
let err = result.expect_err("a write to a readonly .csproj must fail");
let chain = format!("{err:#}");
assert!(
chain.contains(&format!(
"Failed to write C# project {}",
csproj_path.display()
)),
"error chain should carry the write context naming the manifest path, got: {chain}"
);
temp_dir.close().unwrap();
}
}