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
13changepacks_core::declare_discovered_project!(pub struct CSharpPackage);
17
18impl CSharpPackage {
19 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 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 changepacks_core::impl_basic_accessors!();
67
68 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 changepacks_core::impl_language!(Language::CSharp);
81
82 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 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 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 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 #[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 #[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 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}