use std::path::Path;
use anyhow::{Context, Result};
use changepacks_core::PublishOutput;
use changepacks_core::publish::run_publish_command_argv;
use tempfile::TempDir;
use tokio::fs::read_dir;
#[cfg(not(tarpaulin_include))]
pub async fn run_managed_dry_run(working_dir: &Path) -> Result<PublishOutput> {
let pack_dir =
TempDir::new().context("Failed to create temporary directory for dotnet pack output")?;
let feed_dir =
TempDir::new().context("Failed to create temporary directory for local NuGet feed")?;
let pack_path = pack_dir.path().to_string_lossy().into_owned();
let feed_path = feed_dir.path().to_string_lossy().into_owned();
let pack_output = run_publish_command_argv(
"dotnet",
&["pack", "-c", "Release", "-o", pack_path.as_str()],
working_dir,
true,
)
.await
.context("Failed to spawn `dotnet pack`")?;
if !pack_output.success {
return Ok(prefixed("dotnet pack", pack_output));
}
let nupkgs = collect_nupkgs(pack_dir.path())
.await
.with_context(|| format!("Failed to enumerate .nupkg files in {pack_path}"))?;
let mut combined = prefixed("dotnet pack", pack_output);
if nupkgs.is_empty() {
combined.stderr.push_str(
"\n[changepacks dry-run] no .nupkg produced by `dotnet pack`; \
check that the project sets <IsPackable>true</IsPackable> and \
includes the required PackageId / Version metadata.\n",
);
combined.success = false;
return Ok(combined);
}
for nupkg in &nupkgs {
let push_output = run_publish_command_argv(
"dotnet",
&[
"nuget",
"push",
nupkg.as_str(),
"-s",
feed_path.as_str(),
"--skip-duplicate",
],
working_dir,
true,
)
.await
.with_context(|| format!("Failed to spawn `dotnet nuget push {nupkg}`"))?;
let label = format!("dotnet nuget push {nupkg}");
let prefixed_output = prefixed(&label, push_output);
combined.success &= prefixed_output.success;
combined.stdout.push_str(&prefixed_output.stdout);
combined.stderr.push_str(&prefixed_output.stderr);
}
if let Err(e) = pack_dir.close() {
combined.stderr.push_str(&format!(
"\n[changepacks dry-run] pack tempdir cleanup error: {e}\n"
));
}
if let Err(e) = feed_dir.close() {
combined.stderr.push_str(&format!(
"\n[changepacks dry-run] feed tempdir cleanup error: {e}\n"
));
}
Ok(combined)
}
async fn collect_nupkgs(dir: &Path) -> Result<Vec<String>> {
let mut entries = read_dir(dir).await?;
let mut out = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let is_nupkg = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("nupkg"));
let is_snupkg = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.to_ascii_lowercase().ends_with(".snupkg"));
if is_nupkg && !is_snupkg {
out.push(path.to_string_lossy().into_owned());
}
}
out.sort();
Ok(out)
}
fn prefixed(label: &str, mut output: PublishOutput) -> PublishOutput {
if !output.stdout.is_empty() {
output.stdout = format!("===== {label} (stdout) =====\n{}", output.stdout);
if !output.stdout.ends_with('\n') {
output.stdout.push('\n');
}
}
if !output.stderr.is_empty() {
output.stderr = format!("===== {label} (stderr) =====\n{}", output.stderr);
if !output.stderr.ends_with('\n') {
output.stderr.push('\n');
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_prefixed_adds_header_to_stdout_and_stderr() {
let raw = PublishOutput {
success: true,
stdout: "hello".to_string(),
stderr: "warn".to_string(),
};
let out = prefixed("dotnet pack", raw);
assert!(out.stdout.starts_with("===== dotnet pack (stdout) ====="));
assert!(out.stdout.contains("hello"));
assert!(out.stdout.ends_with('\n'));
assert!(out.stderr.starts_with("===== dotnet pack (stderr) ====="));
assert!(out.stderr.contains("warn"));
assert!(out.stderr.ends_with('\n'));
assert!(out.success);
}
#[test]
fn test_prefixed_leaves_empty_streams_alone() {
let raw = PublishOutput {
success: false,
stdout: String::new(),
stderr: String::new(),
};
let out = prefixed("dotnet nuget push foo.nupkg", raw);
assert!(out.stdout.is_empty());
assert!(out.stderr.is_empty());
assert!(!out.success);
}
#[tokio::test]
async fn test_collect_nupkgs_filters_and_sorts() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("b.nupkg"), b"").unwrap();
fs::write(dir.path().join("a.nupkg"), b"").unwrap();
fs::write(dir.path().join("ignore.txt"), b"").unwrap();
fs::write(dir.path().join("Foo.1.0.0.snupkg"), b"").unwrap();
let found = collect_nupkgs(dir.path()).await.unwrap();
assert_eq!(found.len(), 2, "found = {found:?}");
assert!(found[0].ends_with("a.nupkg"));
assert!(found[1].ends_with("b.nupkg"));
for p in &found {
assert!(!p.to_lowercase().ends_with(".snupkg"));
}
}
#[tokio::test]
async fn test_collect_nupkgs_empty_dir() {
let dir = TempDir::new().unwrap();
let found = collect_nupkgs(dir.path()).await.unwrap();
assert!(found.is_empty());
}
#[tokio::test]
async fn test_collect_nupkgs_missing_dir() {
let dir = TempDir::new().unwrap();
let missing = dir.path().join("does-not-exist");
let result = collect_nupkgs(&missing).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_managed_dry_run_errors_cleanly_when_dotnet_missing() {
let work = TempDir::new().unwrap();
let _ = run_managed_dry_run(work.path()).await;
assert!(work.path().exists());
}
}