Skip to main content

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}