changepacks_csharp/lib.rs
1//! # changepacks-csharp
2//!
3//! C#/.NET project support for changepacks.
4//!
5//! Implements project discovery and version management for .csproj XML files. Uses quick-xml
6//! for parsing with format preservation. Supports `MSBuild` project files with version elements
7//! and handles both single projects and multi-project solutions.
8
9mod dry_run;
10pub mod finder;
11pub mod package;
12mod xml_utils;
13
14use std::path::Path;
15
16use anyhow::{Context, Result};
17use tokio::fs::{read_to_string, write};
18
19pub use finder::CSharpProjectFinder;
20
21/// Legacy command description required by the core `Package` / `Workspace`
22/// trait API. C# publish execution does not run this incomplete shell string:
23/// both implementations override `publish` and use the managed argv pipeline
24/// in [`dry_run`] after resolving configuration overrides. Keeping the string
25/// preserves the existing public accessor value for callers that display it.
26pub(crate) const PUBLISH_COMMAND: &str = "dotnet pack -c Release && dotnet nuget push";
27
28/// Read the `.csproj` file at `path` into a `String`, attaching the shared
29/// read-failure context naming that path on failure.
30///
31/// Single source of the read + error-context pair used by both `.csproj`
32/// entry points: [`write_csproj_version`] here and
33/// [`CSharpProjectFinder::visit`](finder::CSharpProjectFinder) during
34/// discovery. Keeping one copy keeps the two call sites' error messages
35/// from drifting apart.
36///
37/// # Errors
38/// Returns an error if the file cannot be read (missing, unreadable, or not
39/// valid UTF-8).
40pub(crate) async fn read_csproj(path: &Path) -> Result<String> {
41 read_to_string(path)
42 .await
43 .with_context(|| format!("Failed to read C# project {}", path.display()))
44}
45
46/// Update the `<Version>` element of the `.csproj` XML at `path` to
47/// `new_version`, delegating to [`xml_utils::update_version_in_xml`] to
48/// preserve the file's original formatting (indentation, comments, sibling
49/// elements). The XML is re-scanned at write time so missing global versions
50/// are added under the first unconditional top-level `<PropertyGroup>`, or
51/// under a newly created group when none is eligible (see `update_version_in_xml`).
52///
53/// Used by `CSharpPackage::update_version`, the only project kind
54/// [`CSharpProjectFinder`] discovers — matching the Node/Python/Dart
55/// convention documented in `crates/AGENTS.md`.
56///
57/// # Errors
58/// Returns error if the file cannot be read, the XML cannot be parsed, or
59/// no supported version node can be mutated, or the write fails.
60pub(crate) async fn write_csproj_version(path: &Path, new_version: &str) -> Result<()> {
61 let csproj_raw = read_csproj(path).await?;
62 let updated = xml_utils::update_version_in_xml(&csproj_raw, new_version)
63 .with_context(|| format!("Failed to update version in C# project {}", path.display()))?;
64 if updated != csproj_raw {
65 write(path, updated)
66 .await
67 .with_context(|| format!("Failed to write C# project {}", path.display()))?;
68 }
69 Ok(())
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use changepacks_utils::test_support;
76 use tempfile::TempDir;
77
78 /// A realistic `.csproj` shared by the two round-trip tests below: CRLF
79 /// line endings, an XML declaration, a comment, two-space indentation, an
80 /// existing `<Version>1.0.0</Version>` with sibling properties on both
81 /// sides, and a trailing blank line. Every one of those is formatting
82 /// `write_csproj_version` must carry through untouched.
83 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";
84
85 /// `write_csproj_version` is the only manifest writer that does not route
86 /// through `finalize_content`; its format preservation rests entirely on
87 /// `xml_utils::update_version_in_xml` round-tripping everything but the
88 /// version text. Lock that end to end at the file boundary: the bytes on
89 /// disk after the bump must equal the input with ONLY `1.0.0` -> `1.0.1`.
90 #[tokio::test]
91 async fn test_write_csproj_version_preserves_surrounding_formatting() {
92 let temp_dir = TempDir::new().unwrap();
93 let csproj_path = temp_dir.path().join("Formatted.csproj");
94 tokio::fs::write(&csproj_path, REALISTIC_CSPROJ_CRLF)
95 .await
96 .unwrap();
97
98 write_csproj_version(&csproj_path, "1.0.1").await.unwrap();
99
100 let expected =
101 REALISTIC_CSPROJ_CRLF.replace("<Version>1.0.0</Version>", "<Version>1.0.1</Version>");
102 assert_eq!(
103 tokio::fs::read_to_string(&csproj_path).await.unwrap(),
104 expected,
105 "only the version text may change; every other byte must survive",
106 );
107 temp_dir.close().unwrap();
108 }
109
110 /// The `if updated != csproj_raw` guard in `write_csproj_version` must
111 /// skip the write entirely when the requested version already matches, so
112 /// a no-op `changepacks update` never rewrites (and never risks
113 /// reformatting or touching the mtime of) an unchanged `.csproj`.
114 #[tokio::test]
115 async fn test_write_csproj_version_skips_write_when_version_unchanged() {
116 let temp_dir = TempDir::new().unwrap();
117 let csproj_path = temp_dir.path().join("Unchanged.csproj");
118 tokio::fs::write(&csproj_path, REALISTIC_CSPROJ_CRLF)
119 .await
120 .unwrap();
121 let modified_before = tokio::fs::metadata(&csproj_path)
122 .await
123 .unwrap()
124 .modified()
125 .unwrap();
126
127 write_csproj_version(&csproj_path, "1.0.0").await.unwrap();
128
129 assert_eq!(
130 tokio::fs::read(&csproj_path).await.unwrap(),
131 REALISTIC_CSPROJ_CRLF.as_bytes(),
132 "an unchanged version must leave the file byte-identical",
133 );
134 // A skipped write cannot move the mtime. (The converse is weaker --
135 // a coarse filesystem clock could hide a real write -- so this only
136 // ever strengthens the byte assertion above, never contradicts it.)
137 assert_eq!(
138 tokio::fs::metadata(&csproj_path)
139 .await
140 .unwrap()
141 .modified()
142 .unwrap(),
143 modified_before,
144 "the write-skip guard must not touch the file at all",
145 );
146 temp_dir.close().unwrap();
147 }
148
149 #[tokio::test]
150 async fn test_write_csproj_version_creates_property_group_when_missing() {
151 let temp_dir = TempDir::new().unwrap();
152 let csproj_path = temp_dir.path().join("NoPropertyGroup.csproj");
153 let content = b"<Project Sdk=\"Microsoft.NET.Sdk\">\r\n</Project>\r\n";
154 tokio::fs::write(&csproj_path, content).await.unwrap();
155
156 write_csproj_version(&csproj_path, "1.2.3").await.unwrap();
157
158 assert_eq!(
159 tokio::fs::read_to_string(&csproj_path).await.unwrap(),
160 "<Project Sdk=\"Microsoft.NET.Sdk\">\r\n<PropertyGroup>\r\n <Version>1.2.3</Version>\r\n</PropertyGroup>\r\n</Project>\r\n"
161 );
162 temp_dir.close().unwrap();
163 }
164
165 /// The read leg of `write_csproj_version` (via [`read_csproj`]) must name
166 /// the manifest path in its error chain, so a missing/unreadable `.csproj`
167 /// is diagnosable from the CLI output alone. Pins the exact
168 /// `Failed to read C# project {path}` context added at the `read_csproj`
169 /// call site — same message-pinning style as the Node/utils siblings.
170 #[tokio::test]
171 async fn test_write_csproj_version_read_error_includes_path() {
172 let temp_dir = TempDir::new().unwrap();
173 let csproj_path = temp_dir.path().join("Missing.csproj");
174
175 let err = write_csproj_version(&csproj_path, "1.0.1")
176 .await
177 .expect_err("a missing .csproj must fail the read");
178
179 let chain = format!("{err:#}");
180 assert!(
181 chain.contains(&format!(
182 "Failed to read C# project {}",
183 csproj_path.display()
184 )),
185 "error chain should carry the read context naming the manifest path, got: {chain}"
186 );
187 temp_dir.close().unwrap();
188 }
189
190 /// The update leg of `write_csproj_version` must name the manifest path in
191 /// its error chain: `update_version_in_xml` only reports the XML-level
192 /// cause (`XML parsing error: ...`), so without the `.with_context(...)`
193 /// wrapper a user with many `.csproj` files could not tell WHICH file is
194 /// malformed. Uses an unbalanced `<Project>`/`<PropertyGroup>` document so
195 /// the XML parse — not the read — is what fails.
196 #[tokio::test]
197 async fn test_write_csproj_version_update_error_includes_path() {
198 let temp_dir = TempDir::new().unwrap();
199 let csproj_path = temp_dir.path().join("Malformed.csproj");
200 tokio::fs::write(
201 &csproj_path,
202 "<Project><PropertyGroup><Version>1.0.0</Version></PropertyGroup",
203 )
204 .await
205 .unwrap();
206
207 let err = write_csproj_version(&csproj_path, "1.0.1")
208 .await
209 .expect_err("a malformed .csproj must fail the version update");
210
211 let chain = format!("{err:#}");
212 assert!(
213 chain.contains(&format!(
214 "Failed to update version in C# project {}",
215 csproj_path.display()
216 )),
217 "error chain should carry the update context naming the manifest path, got: {chain}"
218 );
219 temp_dir.close().unwrap();
220 }
221
222 /// The write leg is the last of the three `write_csproj_version` error
223 /// contexts and the only one left unpinned: `tokio::fs::write` reports a
224 /// bare `os error` (permission denied) with no filename, so without the
225 /// `.with_context(...)` wrapper a failed bump would be unattributable to a
226 /// particular `.csproj`. Mirrors the Java sibling
227 /// (`version_updater.rs::test_write_gradle_version_build_file_write_error_names_context_and_path`):
228 /// seed a valid manifest, flip the readonly bit AFTER seeding so the read
229 /// and the XML update both still succeed, and request a DIFFERENT version
230 /// so the `updated != csproj_raw` guard actually attempts the write.
231 #[tokio::test]
232 async fn test_write_csproj_version_write_error_includes_path() {
233 let temp_dir = TempDir::new().unwrap();
234 let csproj_path = temp_dir.path().join("Readonly.csproj");
235 tokio::fs::write(&csproj_path, REALISTIC_CSPROJ_CRLF)
236 .await
237 .unwrap();
238
239 test_support::set_readonly(&csproj_path, true);
240
241 let result = write_csproj_version(&csproj_path, "1.0.1").await;
242
243 // Restore write permission BEFORE asserting so `TempDir` cleanup
244 // succeeds even if an assertion below panics.
245 test_support::set_readonly(&csproj_path, false);
246
247 let err = result.expect_err("a write to a readonly .csproj must fail");
248 let chain = format!("{err:#}");
249 assert!(
250 chain.contains(&format!(
251 "Failed to write C# project {}",
252 csproj_path.display()
253 )),
254 "error chain should carry the write context naming the manifest path, got: {chain}"
255 );
256 temp_dir.close().unwrap();
257 }
258}