Skip to main content

changepacks_csharp/
workspace.rs

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