Skip to main content

changepacks_csharp/
package.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6use changepacks_core::publish::{
7    PublishOutput, resolve_dry_run_publish_command, run_publish_command,
8};
9use changepacks_core::{Config, Language, Package, UpdateType};
10use changepacks_utils::next_version;
11use tokio::fs::{read_to_string, write};
12
13use crate::dry_run::run_managed_dry_run;
14use crate::xml_utils::update_version_in_xml;
15
16#[derive(Debug)]
17pub struct CSharpPackage {
18    name: Option<String>,
19    version: Option<String>,
20    path: PathBuf,
21    relative_path: PathBuf,
22    is_changed: bool,
23    dependencies: HashSet<String>,
24}
25
26impl CSharpPackage {
27    #[must_use]
28    pub fn new(
29        name: Option<String>,
30        version: Option<String>,
31        path: PathBuf,
32        relative_path: PathBuf,
33    ) -> Self {
34        Self {
35            name,
36            version,
37            path,
38            relative_path,
39            is_changed: false,
40            dependencies: HashSet::new(),
41        }
42    }
43}
44
45#[async_trait]
46impl Package for CSharpPackage {
47    fn name(&self) -> Option<&str> {
48        self.name.as_deref()
49    }
50
51    fn version(&self) -> Option<&str> {
52        self.version.as_deref()
53    }
54
55    fn path(&self) -> &Path {
56        &self.path
57    }
58
59    fn relative_path(&self) -> &Path {
60        &self.relative_path
61    }
62
63    async fn update_version(&mut self, update_type: UpdateType) -> Result<()> {
64        let current_version = self.version.as_deref().unwrap_or("0.0.0");
65        let new_version = next_version(current_version, update_type)?;
66
67        let csproj_raw = read_to_string(&self.path).await?;
68        let has_version = self.version.is_some();
69
70        let updated_content = update_version_in_xml(&csproj_raw, &new_version, has_version)?;
71
72        write(&self.path, updated_content).await?;
73        self.version = Some(new_version);
74        Ok(())
75    }
76
77    fn language(&self) -> Language {
78        Language::CSharp
79    }
80
81    fn is_changed(&self) -> bool {
82        self.is_changed
83    }
84
85    fn set_changed(&mut self, changed: bool) {
86        self.is_changed = changed;
87    }
88
89    fn set_name(&mut self, name: String) {
90        self.name = Some(name);
91    }
92
93    fn default_publish_command(&self) -> String {
94        "dotnet pack -c Release && dotnet nuget push".to_string()
95    }
96
97    fn default_dry_run_publish_command(&self) -> Option<String> {
98        // No single shell one-liner reliably represents the C# dry-run flow
99        // (pack + push to an ephemeral local feed + guaranteed cleanup), so
100        // we return `None` here and override `dry_run_publish` below with a
101        // managed RAII implementation. Returning `None` still lets users
102        // supply a custom shell command via `publishDryRun` in config; that
103        // override is honored first inside the override.
104        None
105    }
106
107    /// Managed dry-run for C#/.NET packages.
108    ///
109    /// Honors `config.publishDryRun` overrides first (existing shell-string
110    /// behavior, matching every other language). When no override is set,
111    /// runs `dotnet pack` + `dotnet nuget push` against ephemeral
112    /// `tempfile::TempDir` directories that are cleaned up via RAII — even
113    /// on error, panic, or future cancellation.
114    #[cfg(not(tarpaulin_include))]
115    async fn dry_run_publish(&self, config: &Config) -> Result<Option<PublishOutput>> {
116        let dir = self
117            .path()
118            .parent()
119            .context("Package directory not found")?;
120
121        // 1) Per-project / per-language override wins (existing semantics).
122        if let Some(user_cmd) =
123            resolve_dry_run_publish_command(self.relative_path(), self.language(), None, config)
124        {
125            return Ok(Some(run_publish_command(&user_cmd, dir).await?));
126        }
127
128        // 2) Managed dry-run with guaranteed cleanup (see `dry_run.rs`).
129        Ok(Some(run_managed_dry_run(dir).await?))
130    }
131
132    fn dependencies(&self) -> &HashSet<String> {
133        &self.dependencies
134    }
135
136    fn add_dependency(&mut self, dependency: &str) {
137        self.dependencies.insert(dependency.to_string());
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::fs;
145    use tempfile::TempDir;
146
147    #[tokio::test]
148    async fn test_new() {
149        let temp_dir = TempDir::new().unwrap();
150        let csproj_path = temp_dir.path().join("Test.csproj");
151        fs::write(
152            &csproj_path,
153            r#"<Project Sdk="Microsoft.NET.Sdk">
154  <PropertyGroup>
155    <Version>1.0.0</Version>
156  </PropertyGroup>
157</Project>
158"#,
159        )
160        .unwrap();
161
162        let package = CSharpPackage::new(
163            Some("Test".to_string()),
164            Some("1.0.0".to_string()),
165            csproj_path.clone(),
166            PathBuf::from("Test.csproj"),
167        );
168
169        assert_eq!(package.name(), Some("Test"));
170        assert_eq!(package.version(), Some("1.0.0"));
171        assert_eq!(package.path(), csproj_path);
172        assert_eq!(package.relative_path(), PathBuf::from("Test.csproj"));
173        assert!(!package.is_changed());
174        assert_eq!(package.language(), Language::CSharp);
175        assert_eq!(
176            package.default_publish_command(),
177            "dotnet pack -c Release && dotnet nuget push"
178        );
179        // `dotnet nuget push` has no built-in dry-run mode, so the crate
180        // returns None and lets the publish loop skip with a warning.
181        assert!(package.default_dry_run_publish_command().is_none());
182
183        temp_dir.close().unwrap();
184    }
185
186    #[tokio::test]
187    async fn test_set_changed() {
188        let temp_dir = TempDir::new().unwrap();
189        let csproj_path = temp_dir.path().join("Test.csproj");
190        fs::write(
191            &csproj_path,
192            r#"<Project Sdk="Microsoft.NET.Sdk">
193  <PropertyGroup>
194    <Version>1.0.0</Version>
195  </PropertyGroup>
196</Project>
197"#,
198        )
199        .unwrap();
200
201        let mut package = CSharpPackage::new(
202            Some("Test".to_string()),
203            Some("1.0.0".to_string()),
204            csproj_path.clone(),
205            PathBuf::from("Test.csproj"),
206        );
207
208        assert!(!package.is_changed());
209        package.set_changed(true);
210        assert!(package.is_changed());
211        package.set_changed(false);
212        assert!(!package.is_changed());
213
214        temp_dir.close().unwrap();
215    }
216
217    #[tokio::test]
218    async fn test_update_version_patch() {
219        let temp_dir = TempDir::new().unwrap();
220        let csproj_path = temp_dir.path().join("Test.csproj");
221        fs::write(
222            &csproj_path,
223            r#"<Project Sdk="Microsoft.NET.Sdk">
224  <PropertyGroup>
225    <Version>1.0.0</Version>
226  </PropertyGroup>
227</Project>
228"#,
229        )
230        .unwrap();
231
232        let mut package = CSharpPackage::new(
233            Some("Test".to_string()),
234            Some("1.0.0".to_string()),
235            csproj_path.clone(),
236            PathBuf::from("Test.csproj"),
237        );
238
239        package.update_version(UpdateType::Patch).await.unwrap();
240
241        let content = fs::read_to_string(&csproj_path).unwrap();
242        assert!(content.contains("<Version>1.0.1</Version>"));
243
244        temp_dir.close().unwrap();
245    }
246
247    #[tokio::test]
248    async fn test_update_version_minor() {
249        let temp_dir = TempDir::new().unwrap();
250        let csproj_path = temp_dir.path().join("Test.csproj");
251        fs::write(
252            &csproj_path,
253            r#"<Project Sdk="Microsoft.NET.Sdk">
254  <PropertyGroup>
255    <Version>1.0.0</Version>
256  </PropertyGroup>
257</Project>
258"#,
259        )
260        .unwrap();
261
262        let mut package = CSharpPackage::new(
263            Some("Test".to_string()),
264            Some("1.0.0".to_string()),
265            csproj_path.clone(),
266            PathBuf::from("Test.csproj"),
267        );
268
269        package.update_version(UpdateType::Minor).await.unwrap();
270
271        let content = fs::read_to_string(&csproj_path).unwrap();
272        assert!(content.contains("<Version>1.1.0</Version>"));
273
274        temp_dir.close().unwrap();
275    }
276
277    #[tokio::test]
278    async fn test_update_version_major() {
279        let temp_dir = TempDir::new().unwrap();
280        let csproj_path = temp_dir.path().join("Test.csproj");
281        fs::write(
282            &csproj_path,
283            r#"<Project Sdk="Microsoft.NET.Sdk">
284  <PropertyGroup>
285    <Version>1.0.0</Version>
286  </PropertyGroup>
287</Project>
288"#,
289        )
290        .unwrap();
291
292        let mut package = CSharpPackage::new(
293            Some("Test".to_string()),
294            Some("1.0.0".to_string()),
295            csproj_path.clone(),
296            PathBuf::from("Test.csproj"),
297        );
298
299        package.update_version(UpdateType::Major).await.unwrap();
300
301        let content = fs::read_to_string(&csproj_path).unwrap();
302        assert!(content.contains("<Version>2.0.0</Version>"));
303
304        temp_dir.close().unwrap();
305    }
306
307    #[tokio::test]
308    async fn test_update_version_preserves_other_elements() {
309        let temp_dir = TempDir::new().unwrap();
310        let csproj_path = temp_dir.path().join("Test.csproj");
311        let original_content = r#"<Project Sdk="Microsoft.NET.Sdk">
312  <PropertyGroup>
313    <OutputType>Exe</OutputType>
314    <TargetFramework>net8.0</TargetFramework>
315    <Version>1.0.0</Version>
316    <PackageId>MyPackage</PackageId>
317  </PropertyGroup>
318</Project>
319"#;
320        fs::write(&csproj_path, original_content).unwrap();
321
322        let mut package = CSharpPackage::new(
323            Some("Test".to_string()),
324            Some("1.0.0".to_string()),
325            csproj_path.clone(),
326            PathBuf::from("Test.csproj"),
327        );
328
329        package.update_version(UpdateType::Patch).await.unwrap();
330
331        let content = fs::read_to_string(&csproj_path).unwrap();
332        assert!(content.contains("<Version>1.0.1</Version>"));
333        assert!(content.contains("<OutputType>Exe</OutputType>"));
334        assert!(content.contains("<TargetFramework>net8.0</TargetFramework>"));
335        assert!(content.contains("<PackageId>MyPackage</PackageId>"));
336
337        temp_dir.close().unwrap();
338    }
339
340    #[test]
341    fn test_dependencies() {
342        let mut package = CSharpPackage::new(
343            Some("Test".to_string()),
344            Some("1.0.0".to_string()),
345            PathBuf::from("/test/Test.csproj"),
346            PathBuf::from("test/Test.csproj"),
347        );
348
349        // Initially empty
350        assert!(package.dependencies().is_empty());
351
352        // Add dependencies
353        package.add_dependency("Newtonsoft.Json");
354        package.add_dependency("CoreLib");
355
356        let deps = package.dependencies();
357        assert_eq!(deps.len(), 2);
358        assert!(deps.contains("Newtonsoft.Json"));
359        assert!(deps.contains("CoreLib"));
360
361        // Adding duplicate should not increase count
362        package.add_dependency("Newtonsoft.Json");
363        assert_eq!(package.dependencies().len(), 2);
364    }
365
366    #[test]
367    fn test_set_name() {
368        let mut package = CSharpPackage::new(
369            None,
370            Some("1.0.0".to_string()),
371            PathBuf::from("/test/Test.csproj"),
372            PathBuf::from("Test.csproj"),
373        );
374        assert_eq!(package.name(), None);
375        package.set_name("my-project".to_string());
376        assert_eq!(package.name(), Some("my-project"));
377    }
378}