Skip to main content

changepacks_csharp/
package.rs

1use std::{ffi::OsString, future::Future, path::PathBuf};
2
3use anyhow::Result;
4use async_trait::async_trait;
5use changepacks_core::publish::PublishOutput;
6use changepacks_core::{Config, Language, Package, UpdateType};
7
8use crate::dry_run::{
9    resolve_and_run_dry_run_with_command_runner, resolve_and_run_publish_with_command_runner,
10    run_dotnet_command,
11};
12
13// Seven-field discovered-project declaration plus `new` / `new_discovered`,
14// shared verbatim with the other four identical language types. The
15// command-runner helpers below stay in their own inherent impl block.
16changepacks_core::declare_discovered_project!(pub struct CSharpPackage);
17
18impl CSharpPackage {
19    /// Real publish with an injected command boundary, so tests can drive the
20    /// managed `dotnet pack` + `dotnet nuget push` flow without spawning
21    /// processes.
22    async fn publish_with_command_runner<F, Fut>(
23        &self,
24        config: &Config,
25        runner: F,
26    ) -> Result<PublishOutput>
27    where
28        F: FnMut(&'static str, Vec<OsString>, PathBuf) -> Fut,
29        Fut: Future<Output = Result<PublishOutput>>,
30    {
31        resolve_and_run_publish_with_command_runner(
32            self.path(),
33            self.relative_path(),
34            config,
35            changepacks_core::publish::PACKAGE_DIR_NOT_FOUND,
36            runner,
37        )
38        .await
39    }
40
41    /// Dry-run publish with an injected command boundary, mirroring
42    /// [`Self::publish_with_command_runner`] against the temporary local feed.
43    async fn dry_run_publish_with_command_runner<F, Fut>(
44        &self,
45        config: &Config,
46        runner: F,
47    ) -> Result<Option<PublishOutput>>
48    where
49        F: FnMut(&'static str, Vec<OsString>, PathBuf) -> Fut,
50        Fut: Future<Output = Result<PublishOutput>>,
51    {
52        resolve_and_run_dry_run_with_command_runner(
53            self.path(),
54            self.relative_path(),
55            config,
56            changepacks_core::publish::PACKAGE_DIR_NOT_FOUND,
57            runner,
58        )
59        .await
60    }
61}
62
63#[async_trait]
64impl Package for CSharpPackage {
65    // Standard package/workspace accessors.
66    changepacks_core::impl_basic_accessors!();
67
68    // Publishability flag accessor.
69    changepacks_core::impl_publishable_by_default!();
70
71    async fn update_version(&mut self, update_type: UpdateType) -> Result<()> {
72        let path = &self.path;
73        changepacks_utils::bump_version_with(&mut self.version, path, update_type, async |new| {
74            crate::write_csproj_version(path, new).await
75        })
76        .await
77    }
78
79    // Fixed language accessor.
80    changepacks_core::impl_language!(Language::CSharp);
81
82    // The legacy accessor value remains stable, but `publish` below never
83    // executes it: real and dry-run publishing use managed argv flows after
84    // resolving path/language overrides. Dry-run's default command remains
85    // `None` because no single shell command can safely model its local feed.
86    changepacks_core::impl_const_publish_commands!(crate::PUBLISH_COMMAND);
87
88    async fn publish(&self, config: &Config) -> Result<PublishOutput> {
89        self.publish_with_command_runner(config, run_dotnet_command)
90            .await
91    }
92
93    /// Managed dry-run for C#/.NET packages.
94    ///
95    /// Honors `config.publishDryRun` overrides first (existing shell-string
96    /// behavior, matching every other language). When no override is set,
97    /// runs `dotnet pack` + `dotnet nuget push` against ephemeral
98    /// `tempfile::TempDir` directories that are cleaned up via RAII — even
99    /// on error, panic, or future cancellation.
100    async fn dry_run_publish(&self, config: &Config) -> Result<Option<PublishOutput>> {
101        self.dry_run_publish_with_command_runner(config, run_dotnet_command)
102            .await
103    }
104
105    // Dependency set accessors.
106    changepacks_core::impl_dependencies_accessors!();
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use rstest::rstest;
113    use std::{
114        ffi::OsString,
115        fs,
116        sync::{Arc, Mutex},
117    };
118    use tempfile::TempDir;
119
120    #[test]
121    fn test_new() {
122        let temp_dir = TempDir::new().unwrap();
123        let csproj_path = temp_dir.path().join("Test.csproj");
124        fs::write(
125            &csproj_path,
126            r#"<Project Sdk="Microsoft.NET.Sdk">
127  <PropertyGroup>
128    <Version>1.0.0</Version>
129  </PropertyGroup>
130</Project>
131"#,
132        )
133        .unwrap();
134
135        let package = CSharpPackage::new(
136            Some("Test".to_string()),
137            Some("1.0.0".to_string()),
138            csproj_path.clone(),
139            PathBuf::from("Test.csproj"),
140        );
141
142        assert_eq!(package.name(), Some("Test"));
143        assert_eq!(package.version(), Some("1.0.0"));
144        assert_eq!(package.path(), csproj_path);
145        assert_eq!(package.relative_path(), PathBuf::from("Test.csproj"));
146        assert!(!package.is_changed());
147        assert_eq!(package.language(), Language::CSharp);
148        assert!(package.is_publishable_by_default());
149        assert_eq!(
150            package.default_publish_command(),
151            "dotnet pack -c Release && dotnet nuget push"
152        );
153        // The legacy command accessor remains `None`; the overridden
154        // `dry_run_publish` method supplies the managed temporary-feed flow.
155        assert!(package.default_dry_run_publish_command().is_none());
156
157        temp_dir.close().unwrap();
158    }
159
160    #[rstest]
161    #[case(true)]
162    #[case(false)]
163    fn test_csharp_package_discovered_publishability(#[case] expected: bool) {
164        let package = CSharpPackage::new_discovered(
165            Some("Test".to_string()),
166            Some("1.0.0".to_string()),
167            PathBuf::from("/test/Test.csproj"),
168            PathBuf::from("Test.csproj"),
169            expected,
170        );
171
172        assert_eq!(package.is_publishable_by_default(), expected);
173    }
174
175    #[tokio::test]
176    async fn test_dry_run_publish_forwards_path_override_without_dotnet() {
177        let temp_dir = TempDir::new().unwrap();
178        let csproj_path = temp_dir.path().join("Test.csproj");
179        let relative_path = PathBuf::from("packages/Test.csproj");
180        let package = CSharpPackage::new(None, None, csproj_path, relative_path.clone());
181        let mut config = Config::default();
182        config.publish_dry_run.insert(
183            relative_path.to_string_lossy().into_owned(),
184            "echo package-forwarded".to_string(),
185        );
186
187        let output = package.dry_run_publish(&config).await.unwrap().unwrap();
188
189        assert!(output.success, "stderr: {}", output.stderr);
190        assert!(output.stdout.contains("package-forwarded"));
191    }
192
193    #[tokio::test]
194    async fn test_publish_forwards_path_override_without_managed_dotnet_flow() {
195        let temp_dir = TempDir::new().unwrap();
196        let csproj_path = temp_dir.path().join("Test.csproj");
197        let relative_path = PathBuf::from("packages/Test.csproj");
198        let package = CSharpPackage::new(None, None, csproj_path, relative_path.clone());
199        let mut config = Config::default();
200        config.publish.insert(
201            relative_path.to_string_lossy().into_owned(),
202            "echo package-publish-forwarded".to_string(),
203        );
204
205        let output = package.publish(&config).await.unwrap();
206
207        assert!(output.success, "stderr: {}", output.stderr);
208        assert!(output.stdout.contains("package-publish-forwarded"));
209    }
210
211    #[tokio::test]
212    async fn test_publish_and_dry_run_preserve_package_directory_error_message() {
213        let root = if cfg!(target_os = "windows") {
214            PathBuf::from(r"C:\")
215        } else {
216            PathBuf::from("/")
217        };
218        let package = CSharpPackage::new(None, None, root, PathBuf::from("Test.csproj"));
219
220        let publish_error = package.publish(&Config::default()).await.unwrap_err();
221        let dry_run_error = package
222            .dry_run_publish(&Config::default())
223            .await
224            .unwrap_err();
225
226        assert_eq!(
227            publish_error.to_string(),
228            changepacks_core::publish::PACKAGE_DIR_NOT_FOUND
229        );
230        assert_eq!(
231            dry_run_error.to_string(),
232            changepacks_core::publish::PACKAGE_DIR_NOT_FOUND
233        );
234    }
235
236    #[tokio::test]
237    async fn test_managed_publish_default_through_package_surfaces_cleanup_message() {
238        let temp_dir = TempDir::new().unwrap();
239        let package = CSharpPackage::new(
240            None,
241            None,
242            temp_dir.path().join("Test.csproj"),
243            PathBuf::from("Test.csproj"),
244        );
245        let pack_path = Arc::new(Mutex::new(None::<PathBuf>));
246        let recorded_pack_path = Arc::clone(&pack_path);
247
248        let output = package
249            .publish_with_command_runner(&Config::default(), move |_program, args, _working_dir| {
250                let recorded_pack_path = Arc::clone(&recorded_pack_path);
251                async move {
252                    let is_pack = args.first().and_then(|arg| arg.to_str()) == Some("pack");
253                    if is_pack {
254                        let path = PathBuf::from(&args[5]);
255                        assert_eq!(
256                            args,
257                            vec![
258                                OsString::from("pack"),
259                                OsString::from("Test.csproj"),
260                                OsString::from("-c"),
261                                OsString::from("Release"),
262                                OsString::from("-o"),
263                                path.clone().into_os_string(),
264                            ]
265                        );
266                        fs::write(path.join("only.nupkg"), b"").unwrap();
267                        *recorded_pack_path.lock().unwrap() = Some(path);
268                    } else {
269                        assert_eq!(
270                            args,
271                            vec![
272                                OsString::from("nuget"),
273                                OsString::from("push"),
274                                PathBuf::from(&args[2]).into_os_string(),
275                                OsString::from("--skip-duplicate"),
276                            ]
277                        );
278                        let path = PathBuf::from(&args[2]).parent().unwrap().to_path_buf();
279                        fs::remove_dir_all(&path).unwrap();
280                        fs::write(&path, b"force cleanup error").unwrap();
281                    }
282                    Ok(PublishOutput {
283                        success: true,
284                        stdout: String::new(),
285                        stderr: String::new(),
286                    })
287                }
288            })
289            .await
290            .unwrap();
291
292        assert!(output.success, "stderr: {}", output.stderr);
293        assert!(
294            output
295                .stderr
296                .contains("[changepacks publish] pack tempdir cleanup error:")
297        );
298        let pack_path = pack_path.lock().unwrap().take().unwrap();
299        assert!(pack_path.is_file());
300        fs::remove_file(pack_path).unwrap();
301    }
302
303    #[tokio::test]
304    async fn test_managed_dry_run_default_through_package_uses_temporary_feed() {
305        let temp_dir = TempDir::new().unwrap();
306        let package = CSharpPackage::new(
307            None,
308            None,
309            temp_dir.path().join("Test.csproj"),
310            PathBuf::from("Test.csproj"),
311        );
312        let calls = Arc::new(Mutex::new(Vec::<Vec<OsString>>::new()));
313        let recorded_calls = Arc::clone(&calls);
314
315        let output = package
316            .dry_run_publish_with_command_runner(
317                &Config::default(),
318                move |_program, args, _working_dir| {
319                    let recorded_calls = Arc::clone(&recorded_calls);
320                    async move {
321                        if args.first().and_then(|arg| arg.to_str()) == Some("pack") {
322                            let pack_dir = PathBuf::from(&args[5]);
323                            fs::write(pack_dir.join("only.nupkg"), b"").unwrap();
324                        }
325                        recorded_calls.lock().unwrap().push(args);
326                        Ok(PublishOutput {
327                            success: true,
328                            stdout: String::new(),
329                            stderr: String::new(),
330                        })
331                    }
332                },
333            )
334            .await
335            .unwrap()
336            .unwrap();
337
338        assert!(output.success, "stderr: {}", output.stderr);
339        let calls = calls.lock().unwrap();
340        assert_eq!(calls.len(), 2, "calls: {calls:?}");
341        assert_eq!(calls[1][3], "-s");
342        let pack_dir = PathBuf::from(&calls[0][5]);
343        assert_eq!(
344            calls[0],
345            vec![
346                OsString::from("pack"),
347                OsString::from("Test.csproj"),
348                OsString::from("-c"),
349                OsString::from("Release"),
350                OsString::from("-o"),
351                pack_dir.clone().into_os_string(),
352            ]
353        );
354        let feed_dir = PathBuf::from(&calls[1][4]);
355        assert!(!pack_dir.exists());
356        assert!(!feed_dir.exists());
357    }
358
359    #[test]
360    fn test_set_changed() {
361        changepacks_core::assert_set_changed_roundtrip!(CSharpPackage::new(
362            Some("Test".to_string()),
363            Some("1.0.0".to_string()),
364            PathBuf::from("/test/Test.csproj"),
365            PathBuf::from("Test.csproj"),
366        ));
367    }
368
369    // Patch, Minor, and Major all share the same setup (write a csproj with
370    // `<Version>1.0.0</Version>`, construct the package, call
371    // `update_version`, read back); only the bump kind and the expected
372    // resulting version string differ.
373    #[rstest]
374    #[case(UpdateType::Patch, "1.0.1")]
375    #[case(UpdateType::Minor, "1.1.0")]
376    #[case(UpdateType::Major, "2.0.0")]
377    #[tokio::test]
378    async fn test_update_version(#[case] update_type: UpdateType, #[case] expected_version: &str) {
379        let temp_dir = TempDir::new().unwrap();
380        let csproj_path = temp_dir.path().join("Test.csproj");
381        fs::write(
382            &csproj_path,
383            r#"<Project Sdk="Microsoft.NET.Sdk">
384  <PropertyGroup>
385    <Version>1.0.0</Version>
386  </PropertyGroup>
387</Project>
388"#,
389        )
390        .unwrap();
391
392        let mut package = CSharpPackage::new(
393            Some("Test".to_string()),
394            Some("1.0.0".to_string()),
395            csproj_path.clone(),
396            PathBuf::from("Test.csproj"),
397        );
398
399        package.update_version(update_type).await.unwrap();
400
401        let content = fs::read_to_string(&csproj_path).unwrap();
402        assert!(content.contains(&format!("<Version>{expected_version}</Version>")));
403
404        temp_dir.close().unwrap();
405    }
406
407    #[tokio::test]
408    async fn test_update_version_preserves_other_elements() {
409        let temp_dir = TempDir::new().unwrap();
410        let csproj_path = temp_dir.path().join("Test.csproj");
411        let original_content = r#"<Project Sdk="Microsoft.NET.Sdk">
412  <PropertyGroup>
413    <OutputType>Exe</OutputType>
414    <TargetFramework>net8.0</TargetFramework>
415    <Version>1.0.0</Version>
416    <PackageId>MyPackage</PackageId>
417  </PropertyGroup>
418</Project>
419"#;
420        fs::write(&csproj_path, original_content).unwrap();
421
422        let mut package = CSharpPackage::new(
423            Some("Test".to_string()),
424            Some("1.0.0".to_string()),
425            csproj_path.clone(),
426            PathBuf::from("Test.csproj"),
427        );
428
429        package.update_version(UpdateType::Patch).await.unwrap();
430
431        let content = fs::read_to_string(&csproj_path).unwrap();
432        assert!(content.contains("<Version>1.0.1</Version>"));
433        assert!(content.contains("<OutputType>Exe</OutputType>"));
434        assert!(content.contains("<TargetFramework>net8.0</TargetFramework>"));
435        assert!(content.contains("<PackageId>MyPackage</PackageId>"));
436
437        temp_dir.close().unwrap();
438    }
439
440    /// The `Package` trait entry point — not just the `write_csproj_version`
441    /// helper — must reject a malformed `.csproj` without partially writing it,
442    /// matching the Node/Python/Dart siblings. C# cannot use the shared
443    /// `changepacks_utils::assert_malformed_manifest_rejected!` macro because
444    /// its context is `Failed to update version in C# project {path}` rather
445    /// than the `Failed to parse {label}` template, so the assertions are
446    /// written out here. Pinning that exact context (rather than just the path)
447    /// also proves the failure comes from the XML update leg and not the read
448    /// leg, which would name the same path under a different message.
449    #[tokio::test]
450    async fn test_csharp_package_update_version_malformed_manifest_leaves_file_untouched() {
451        let temp_dir = TempDir::new().unwrap();
452        let csproj_path = temp_dir.path().join("Broken.csproj");
453        // Unclosed `</PropertyGroup` and a missing `</Project>` make the
454        // manifest unparseable, so the version bump must fail before any
455        // write reaches disk.
456        let original_bytes =
457            b"<Project Sdk=\"Microsoft.NET.Sdk\">\n  <PropertyGroup>\n    <Version>1.0.0</Version>\n  </PropertyGroup\n";
458        fs::write(&csproj_path, original_bytes).unwrap();
459
460        let mut package = CSharpPackage::new(
461            Some("Test".to_string()),
462            Some("1.0.0".to_string()),
463            csproj_path.clone(),
464            PathBuf::from("Broken.csproj"),
465        );
466
467        let error = package
468            .update_version(UpdateType::Patch)
469            .await
470            .expect_err("a malformed .csproj must fail the version bump");
471        let chain = format!("{error:#}");
472
473        assert!(
474            chain.contains(&format!(
475                "Failed to update version in C# project {}",
476                csproj_path.display()
477            )),
478            "error chain should carry the update context naming the manifest path, got: {chain}"
479        );
480        assert_eq!(
481            fs::read(&csproj_path).unwrap(),
482            original_bytes,
483            "a failed bump must leave the manifest bytes untouched"
484        );
485        assert_eq!(
486            package.version(),
487            Some("1.0.0"),
488            "a failed bump must leave the in-memory version untouched"
489        );
490
491        temp_dir.close().unwrap();
492    }
493
494    #[tokio::test]
495    async fn test_update_version_without_property_group_creates_global_version() {
496        let temp_dir = TempDir::new().unwrap();
497        let csproj_path = temp_dir.path().join("NoPropertyGroup.csproj");
498        let original_content = b"<Project Sdk=\"Microsoft.NET.Sdk\">\r\n</Project>\r\n";
499        fs::write(&csproj_path, original_content).unwrap();
500        let mut package = CSharpPackage::new(
501            Some("Test".to_string()),
502            None,
503            csproj_path.clone(),
504            PathBuf::from("NoPropertyGroup.csproj"),
505        );
506
507        package.update_version(UpdateType::Patch).await.unwrap();
508
509        assert_eq!(
510            fs::read_to_string(&csproj_path).unwrap(),
511            "<Project Sdk=\"Microsoft.NET.Sdk\">\r\n<PropertyGroup>\r\n    <Version>0.0.1</Version>\r\n</PropertyGroup>\r\n</Project>\r\n"
512        );
513        assert_eq!(package.version(), Some("0.0.1"));
514        temp_dir.close().unwrap();
515    }
516
517    #[tokio::test]
518    async fn test_update_version_with_stale_metadata_ignores_conditional_version() {
519        let temp_dir = TempDir::new().unwrap();
520        let csproj_path = temp_dir.path().join("StaleVersion.csproj");
521        let original_content = b"<Project Sdk=\"Microsoft.NET.Sdk\">\n  <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n    <Version>1.2.3</Version>\n  </PropertyGroup>\n  <PropertyGroup>\n    <OutputType>Exe</OutputType>\n  </PropertyGroup>\n</Project>\n";
522        fs::write(&csproj_path, original_content).unwrap();
523        let mut package = CSharpPackage::new(
524            Some("Test".to_string()),
525            Some("1.2.3".to_string()),
526            csproj_path.clone(),
527            PathBuf::from("StaleVersion.csproj"),
528        );
529
530        package.update_version(UpdateType::Patch).await.unwrap();
531
532        assert_eq!(
533            fs::read_to_string(&csproj_path).unwrap(),
534            "<Project Sdk=\"Microsoft.NET.Sdk\">\n  <PropertyGroup Condition=\"'$(Configuration)' == 'Release'\">\n    <Version>1.2.3</Version>\n  </PropertyGroup>\n  <PropertyGroup>\n    <OutputType>Exe</OutputType>\n    <Version>1.2.4</Version>\n  </PropertyGroup>\n</Project>\n"
535        );
536        assert_eq!(package.version(), Some("1.2.4"));
537        temp_dir.close().unwrap();
538    }
539
540    #[test]
541    fn test_dependencies() {
542        changepacks_core::assert_dependencies_roundtrip!(
543            CSharpPackage::new(
544                Some("Test".to_string()),
545                Some("1.0.0".to_string()),
546                PathBuf::from("/test/Test.csproj"),
547                PathBuf::from("test/Test.csproj"),
548            ),
549            "Newtonsoft.Json",
550            "CoreLib"
551        );
552    }
553
554    #[test]
555    fn test_set_name() {
556        changepacks_core::assert_set_name_roundtrip!(CSharpPackage::new(
557            None,
558            Some("1.0.0".to_string()),
559            PathBuf::from("/test/Test.csproj"),
560            PathBuf::from("Test.csproj"),
561        ));
562    }
563}