use crate::{Config, Language};
use anyhow::{Context, Result};
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::{
ffi::{OsStr, OsString},
path::{Path, PathBuf},
};
pub const PACKAGE_DIR_NOT_FOUND: &str = "Package directory not found";
pub const WORKSPACE_DIR_NOT_FOUND: &str = "Workspace directory not found";
#[derive(Debug)]
pub struct PublishOutput {
pub success: bool,
pub stdout: String,
pub stderr: String,
}
#[must_use]
pub fn normalize_path_separators(s: &str) -> Cow<'_, str> {
if s.contains('\\') {
Cow::Owned(s.replace('\\', "/"))
} else {
Cow::Borrowed(s)
}
}
#[must_use]
pub fn normalize_path_separators_of(path: &Path) -> Cow<'_, str> {
match path.to_string_lossy() {
Cow::Borrowed(s) => normalize_path_separators(s),
Cow::Owned(s) => Cow::Owned(if s.contains('\\') {
s.replace('\\', "/")
} else {
s
}),
}
}
#[must_use]
pub fn lookup_by_path_or_language<'a>(
map: &'a BTreeMap<String, String>,
relative_path: &Path,
language: Language,
) -> Option<&'a String> {
if map.is_empty() {
return None;
}
let lossy = relative_path.to_string_lossy();
if let Some(cmd) = map.get(lossy.as_ref()) {
return Some(cmd);
}
if lossy.contains('\\')
&& let Some(cmd) = map.get(lossy.replace('\\', "/").as_str())
{
return Some(cmd);
}
map.get(language.publish_key())
}
#[must_use]
pub fn resolve_publish_command<F: FnOnce() -> String>(
relative_path: &Path,
language: Language,
default_command_fn: F,
config: &Config,
) -> String {
lookup_by_path_or_language(&config.publish, relative_path, language)
.cloned()
.unwrap_or_else(default_command_fn)
}
#[must_use]
pub fn resolve_dry_run_publish_command<F: FnOnce() -> Option<String>>(
relative_path: &Path,
language: Language,
default_dry_run_command_fn: F,
config: &Config,
) -> Option<String> {
lookup_by_path_or_language(&config.publish_dry_run, relative_path, language)
.cloned()
.or_else(default_dry_run_command_fn)
}
fn utf8_or_lossy(bytes: Vec<u8>) -> String {
String::from_utf8(bytes)
.unwrap_or_else(|e| String::from_utf8_lossy(&e.into_bytes()).into_owned())
}
impl From<std::process::Output> for PublishOutput {
fn from(output: std::process::Output) -> Self {
Self {
success: output.status.success(),
stdout: utf8_or_lossy(output.stdout),
stderr: utf8_or_lossy(output.stderr),
}
}
}
#[cfg(target_os = "windows")]
fn build_shell_command(command: &str) -> tokio::process::Command {
let mut c = tokio::process::Command::new("cmd");
c.arg("/C").arg(command);
c
}
#[cfg(not(target_os = "windows"))]
fn build_shell_command(command: &str) -> tokio::process::Command {
let mut c = tokio::process::Command::new("sh");
c.arg("-c").arg(command);
c
}
fn prepend_path_dirs(extra_path_dirs: &[PathBuf]) -> Result<Option<OsString>> {
join_path_dirs(extra_path_dirs, std::env::var_os("PATH").as_deref())
}
fn join_path_dirs(
extra_path_dirs: &[PathBuf],
existing: Option<&OsStr>,
) -> Result<Option<OsString>> {
if extra_path_dirs.is_empty() {
return Ok(None);
}
let path = std::env::join_paths(
extra_path_dirs
.iter()
.map(|dir| Cow::Borrowed(dir.as_os_str()))
.chain(
existing
.map(std::env::split_paths)
.into_iter()
.flatten()
.map(|dir| Cow::<OsStr>::Owned(dir.into_os_string())),
),
)
.context("failed to construct PATH from injected and existing directories")?;
Ok(Some(path))
}
pub async fn run_publish_command(command: &str, working_dir: &Path) -> Result<PublishOutput> {
run_publish_command_with_path_dirs(command, working_dir, &[]).await
}
pub async fn run_publish_command_with_path_dirs(
command: &str,
working_dir: &Path,
extra_path_dirs: &[PathBuf],
) -> Result<PublishOutput> {
let mut cmd = build_shell_command(command);
cmd.current_dir(working_dir);
if let Some(path) = prepend_path_dirs(extra_path_dirs)? {
cmd.env("PATH", path);
}
cmd.kill_on_drop(true);
let output = cmd.output().await?;
Ok(output.into())
}
pub async fn run_publish_flow(
command: &str,
manifest_path: &Path,
extra_path_dirs: &[PathBuf],
missing_dir_ctx: &'static str,
) -> Result<PublishOutput> {
let dir = manifest_path.parent().context(missing_dir_ctx)?;
run_publish_command_with_path_dirs(command, dir, extra_path_dirs).await
}
pub async fn run_dry_run_publish_flow(
command: Option<&str>,
manifest_path: &Path,
extra_path_dirs: &[PathBuf],
missing_dir_ctx: &'static str,
) -> Result<Option<PublishOutput>> {
let Some(cmd) = command else {
return Ok(None);
};
let dir = manifest_path.parent().context(missing_dir_ctx)?;
Ok(Some(
run_publish_command_with_path_dirs(cmd, dir, extra_path_dirs).await?,
))
}
pub async fn run_publish_command_os_args<I, S>(
program: &str,
args: I,
working_dir: &Path,
kill_on_drop: bool,
) -> Result<PublishOutput>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut cmd = tokio::process::Command::new(program);
cmd.args(args).current_dir(working_dir);
cmd.kill_on_drop(kill_on_drop);
let output = cmd.output().await?;
Ok(output.into())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
async fn wait_for_file(path: &Path, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if tokio::fs::metadata(path).await.is_ok() {
return true;
}
tokio::task::yield_now().await;
}
false
}
#[test]
fn test_lookup_by_path_or_language_empty_map_returns_none() {
let path = Path::new("packages/core/package.json");
let populated = BTreeMap::from([(
"packages/core/package.json".to_string(),
"custom publish".to_string(),
)]);
assert_eq!(
lookup_by_path_or_language(&populated, path, Language::Node).map(String::as_str),
Some("custom publish")
);
let empty = BTreeMap::new();
assert!(lookup_by_path_or_language(&empty, path, Language::Node).is_none());
assert!(
lookup_by_path_or_language(&empty, Path::new("package.json"), Language::Node).is_none()
);
}
#[test]
fn test_lookup_by_path_or_language_path_key_wins_over_language_key() {
let map = BTreeMap::from([
(
"packages/core/package.json".to_string(),
"path publish".to_string(),
),
("node".to_string(), "language publish".to_string()),
]);
let result = lookup_by_path_or_language(
&map,
Path::new("packages/core/package.json"),
Language::Node,
);
assert_eq!(result.map(String::as_str), Some("path publish"));
}
#[test]
fn test_lookup_by_path_or_language_backslash_path_matches_forward_slash_key() {
let map = BTreeMap::from([
(
"packages/core/package.json".to_string(),
"path publish".to_string(),
),
("node".to_string(), "language publish".to_string()),
]);
let result = lookup_by_path_or_language(
&map,
Path::new("packages\\core\\package.json"),
Language::Node,
);
assert_eq!(result.map(String::as_str), Some("path publish"));
}
#[test]
fn test_lookup_by_path_or_language_falls_back_to_language_key() {
let map = BTreeMap::from([
(
"packages/other/package.json".to_string(),
"other publish".to_string(),
),
("node".to_string(), "language publish".to_string()),
]);
let result = lookup_by_path_or_language(
&map,
Path::new("packages/core/package.json"),
Language::Node,
);
assert_eq!(result.map(String::as_str), Some("language publish"));
}
#[test]
fn test_lookup_by_path_or_language_returns_none_when_neither_matches() {
let map = BTreeMap::from([
(
"packages/other/package.json".to_string(),
"other publish".to_string(),
),
("rust".to_string(), "cargo publish".to_string()),
]);
let result = lookup_by_path_or_language(
&map,
Path::new("packages/core/package.json"),
Language::Node,
);
assert!(result.is_none());
}
#[test]
fn test_resolve_publish_command_by_path() {
let mut publish = BTreeMap::new();
publish.insert(
"packages/core/package.json".to_string(),
"custom publish".to_string(),
);
let config = Config {
publish,
..Default::default()
};
let result = resolve_publish_command(
Path::new("packages/core/package.json"),
Language::Node,
|| "npm publish".to_string(),
&config,
);
assert_eq!(result, "custom publish");
}
#[test]
fn test_resolve_publish_command_by_path_backslash_separators() {
let mut publish = BTreeMap::new();
publish.insert(
"packages/core/package.json".to_string(),
"custom publish".to_string(),
);
let config = Config {
publish,
..Default::default()
};
let result = resolve_publish_command(
Path::new("packages\\core\\package.json"),
Language::Node,
|| "npm publish".to_string(),
&config,
);
assert_eq!(result, "custom publish");
}
#[test]
fn test_resolve_publish_command_by_language() {
let mut publish = BTreeMap::new();
publish.insert(
"node".to_string(),
"npm publish --access public".to_string(),
);
let config = Config {
publish,
..Default::default()
};
let result = resolve_publish_command(
Path::new("package.json"),
Language::Node,
|| "npm publish".to_string(),
&config,
);
assert_eq!(result, "npm publish --access public");
}
#[test]
fn test_resolve_publish_command_default_fallback() {
let config = Config::default();
let result = resolve_publish_command(
Path::new("package.json"),
Language::Node,
|| "npm publish".to_string(),
&config,
);
assert_eq!(result, "npm publish");
}
#[test]
fn test_resolve_dry_run_publish_command_by_path() {
let mut publish_dry_run = BTreeMap::new();
publish_dry_run.insert(
"packages/core/package.json".to_string(),
"custom dry".to_string(),
);
let config = Config {
publish_dry_run,
..Default::default()
};
let result = resolve_dry_run_publish_command(
Path::new("packages/core/package.json"),
Language::Node,
|| Some("npm publish --dry-run".to_string()),
&config,
);
assert_eq!(result.as_deref(), Some("custom dry"));
}
#[test]
fn test_resolve_dry_run_publish_command_by_language() {
let mut publish_dry_run = BTreeMap::new();
publish_dry_run.insert("node".to_string(), "npm publish --dry-run -tag".to_string());
let config = Config {
publish_dry_run,
..Default::default()
};
let result = resolve_dry_run_publish_command(
Path::new("package.json"),
Language::Node,
|| Some("npm publish --dry-run".to_string()),
&config,
);
assert_eq!(result.as_deref(), Some("npm publish --dry-run -tag"));
}
#[test]
fn test_resolve_dry_run_publish_command_falls_back_to_language_default() {
let config = Config::default();
let result = resolve_dry_run_publish_command(
Path::new("package.json"),
Language::Node,
|| Some("npm publish --dry-run".to_string()),
&config,
);
assert_eq!(result.as_deref(), Some("npm publish --dry-run"));
}
#[test]
fn test_resolve_dry_run_publish_command_unsupported_returns_none() {
let config = Config::default();
let result = resolve_dry_run_publish_command(
Path::new("project.csproj"),
Language::CSharp,
|| None,
&config,
);
assert!(result.is_none());
}
#[test]
fn test_resolve_dry_run_publish_command_unsupported_with_path_override() {
let mut publish_dry_run = BTreeMap::new();
publish_dry_run.insert(
"project.csproj".to_string(),
"dotnet pack -c Release".to_string(),
);
let config = Config {
publish_dry_run,
..Default::default()
};
let result = resolve_dry_run_publish_command(
Path::new("project.csproj"),
Language::CSharp,
|| None,
&config,
);
assert_eq!(result.as_deref(), Some("dotnet pack -c Release"));
}
#[test]
fn test_resolve_dry_run_publish_command_unsupported_with_language_override() {
let mut publish_dry_run = BTreeMap::new();
publish_dry_run.insert("csharp".to_string(), "dotnet pack -c Release".to_string());
let config = Config {
publish_dry_run,
..Default::default()
};
let result = resolve_dry_run_publish_command(
Path::new("project.csproj"),
Language::CSharp,
|| None,
&config,
);
assert_eq!(result.as_deref(), Some("dotnet pack -c Release"));
}
#[tokio::test]
async fn test_run_publish_command_success() {
let temp_dir = std::env::temp_dir();
let command = if cfg!(target_os = "windows") {
"cmd /c echo publish"
} else {
"echo publish"
};
let output = run_publish_command(command, &temp_dir).await.unwrap();
assert!(output.success);
assert!(output.stdout.contains("publish"));
}
#[tokio::test]
async fn test_run_publish_command_failure() {
let temp_dir = std::env::temp_dir();
let command = if cfg!(target_os = "windows") {
"cmd /c exit 1"
} else {
"exit 1"
};
let output = run_publish_command(command, &temp_dir).await.unwrap();
assert!(!output.success);
}
#[tokio::test]
async fn test_run_publish_command_cancellation_kills_shell_child() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!(
"changepacks_shell_cancel_{}_{}",
std::process::id(),
unique
));
std::fs::create_dir_all(&base).unwrap();
let started = base.join("started");
let completed = base.join("completed");
#[cfg(target_os = "windows")]
let command = "echo started>started & ping -n 3 127.0.0.1 >nul & echo completed>completed"
.to_string();
#[cfg(not(target_os = "windows"))]
let command =
"printf '%s' \"$$\" > pid; printf started > started; sleep 2; printf completed > completed"
.to_string();
let task_dir = base.clone();
let mut task = tokio::spawn(async move { run_publish_command(&command, &task_dir).await });
tokio::task::yield_now().await;
let started_written = wait_for_file(&started, Duration::from_secs(5)).await;
assert!(
started_written || !task.is_finished(),
"shell command finished before readiness: {:?}",
(&mut task).await
);
assert!(started_written, "shell child never wrote its start marker");
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
#[cfg(unix)]
{
let pid = std::fs::read_to_string(base.join("pid")).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut exited = false;
while Instant::now() < deadline {
let running = tokio::process::Command::new("kill")
.args(["-0", pid.trim()])
.status()
.await
.is_ok_and(|status| status.success());
if !running {
exited = true;
break;
}
tokio::task::yield_now().await;
}
assert!(exited, "cancelled shell child {pid} did not exit");
}
assert!(
!wait_for_file(&completed, Duration::from_secs(4)).await,
"cancelled shell child wrote its delayed completion marker"
);
let _ = std::fs::remove_dir_all(base);
}
#[tokio::test]
async fn test_run_publish_command_with_path_dirs_empty_is_noop() {
let temp_dir = std::env::temp_dir();
let command = if cfg!(target_os = "windows") {
"cmd /c echo publish"
} else {
"echo publish"
};
let output = run_publish_command_with_path_dirs(command, &temp_dir, &[])
.await
.unwrap();
assert!(output.success);
assert!(output.stdout.contains("publish"));
}
#[tokio::test]
async fn test_run_publish_command_with_path_dirs_resolves_bare_binary() {
let base = std::env::temp_dir().join(format!("changepacks_pathinj_{}", std::process::id()));
let bin = base.join("bin");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&bin).unwrap();
let hook = bin.join(if cfg!(target_os = "windows") {
"cphook.cmd"
} else {
"cphook"
});
if cfg!(target_os = "windows") {
std::fs::write(&hook, "@echo hook-ran\r\n").unwrap();
} else {
std::fs::write(&hook, "#!/bin/sh\necho hook-ran\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
let output =
run_publish_command_with_path_dirs("cphook", &base, std::slice::from_ref(&bin))
.await
.unwrap();
let _ = std::fs::remove_dir_all(&base);
assert!(output.success, "stderr: {}", output.stderr);
assert!(output.stdout.contains("hook-ran"));
}
#[tokio::test]
async fn test_run_dry_run_publish_flow_without_command_returns_none() {
let output = run_dry_run_publish_flow(
None,
Path::new("manifest-without-a-parent"),
&[],
PACKAGE_DIR_NOT_FOUND,
)
.await
.unwrap();
assert!(output.is_none());
}
#[tokio::test]
async fn test_run_publish_flow_reports_missing_manifest_parent() {
let manifest_path = if cfg!(target_os = "windows") {
Path::new(r"C:\")
} else {
Path::new("/")
};
let error = run_publish_flow(
"echo unreachable",
manifest_path,
&[],
WORKSPACE_DIR_NOT_FOUND,
)
.await
.unwrap_err();
assert_eq!(error.to_string(), WORKSPACE_DIR_NOT_FOUND);
}
#[tokio::test]
async fn test_run_dry_run_publish_flow_forwards_manifest_parent_and_extra_path() {
let base =
std::env::temp_dir().join(format!("changepacks_dry_flow_{}", std::process::id()));
let package_dir = base.join("package");
let bin_dir = base.join("bin");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&package_dir).unwrap();
std::fs::create_dir_all(&bin_dir).unwrap();
let hook = bin_dir.join(if cfg!(target_os = "windows") {
"cpflowhook.cmd"
} else {
"cpflowhook"
});
if cfg!(target_os = "windows") {
std::fs::write(&hook, "@echo %CD%\r\n").unwrap();
} else {
std::fs::write(&hook, "#!/bin/sh\npwd\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
let output = run_dry_run_publish_flow(
Some("cpflowhook"),
&package_dir.join("package.json"),
std::slice::from_ref(&bin_dir),
PACKAGE_DIR_NOT_FOUND,
)
.await
.unwrap()
.unwrap();
assert!(output.success, "stderr: {}", output.stderr);
let reported = std::fs::canonicalize(output.stdout.trim())
.expect("the hook's reported working directory must exist");
let expected =
std::fs::canonicalize(&package_dir).expect("the package directory must exist");
let _ = std::fs::remove_dir_all(&base);
assert_eq!(
normalize_path_separators(reported.to_string_lossy().as_ref()),
normalize_path_separators(expected.to_string_lossy().as_ref())
);
}
#[tokio::test]
async fn test_run_publish_flow_returns_successful_output() {
let command = if cfg!(target_os = "windows") {
"echo flow-success"
} else {
"printf flow-success"
};
let output = run_publish_flow(
command,
&std::env::temp_dir().join("package.json"),
&[],
PACKAGE_DIR_NOT_FOUND,
)
.await
.unwrap();
assert!(output.success);
assert!(output.stdout.contains("flow-success"));
}
#[tokio::test]
async fn test_run_publish_flow_returns_non_zero_exit_status() {
let command = if cfg!(target_os = "windows") {
"exit /b 7"
} else {
"exit 7"
};
let output = run_publish_flow(
command,
&std::env::temp_dir().join("package.json"),
&[],
PACKAGE_DIR_NOT_FOUND,
)
.await
.unwrap();
assert!(!output.success);
}
#[test]
fn test_prepend_path_dirs_empty_returns_none() {
assert!(prepend_path_dirs(&[]).unwrap().is_none());
}
#[test]
fn test_prepend_path_dirs_prepends_first_and_preserves_existing() {
let dir = PathBuf::from(if cfg!(target_os = "windows") {
"C:\\changepacks-path-test-bin"
} else {
"/changepacks-path-test-bin"
});
let joined = prepend_path_dirs(std::slice::from_ref(&dir))
.expect("valid PATH construction")
.expect("some PATH");
let parsed: Vec<PathBuf> = std::env::split_paths(&joined).collect();
assert_eq!(parsed.first(), Some(&dir));
}
#[test]
fn test_prepend_path_dirs_preserves_multi_dir_order_before_existing_path() {
let (first, second) = if cfg!(target_os = "windows") {
(
PathBuf::from("C:\\changepacks-path-test-bin-a"),
PathBuf::from("C:\\changepacks-path-test-bin-b"),
)
} else {
(
PathBuf::from("/changepacks-path-test-bin-a"),
PathBuf::from("/changepacks-path-test-bin-b"),
)
};
let joined = prepend_path_dirs(&[first.clone(), second.clone()])
.expect("valid PATH construction")
.expect("some PATH");
let parsed: Vec<PathBuf> = std::env::split_paths(&joined).collect();
assert_eq!(parsed.first(), Some(&first));
assert_eq!(parsed.get(1), Some(&second));
let existing: Vec<PathBuf> = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect())
.unwrap_or_default();
assert_eq!(
&parsed[2..],
existing.as_slice(),
"pre-existing PATH entries must follow the injected directories unchanged"
);
}
#[test]
fn test_join_path_dirs_without_existing_path_yields_only_injected_dirs() {
let (first, second) = if cfg!(target_os = "windows") {
(
PathBuf::from("C:\\changepacks-path-test-none-a"),
PathBuf::from("C:\\changepacks-path-test-none-b"),
)
} else {
(
PathBuf::from("/changepacks-path-test-none-a"),
PathBuf::from("/changepacks-path-test-none-b"),
)
};
let joined = join_path_dirs(&[first.clone(), second.clone()], None)
.expect("valid PATH construction")
.expect("some PATH");
let parsed: Vec<PathBuf> = std::env::split_paths(&joined).collect();
assert_eq!(parsed, vec![first, second]);
}
#[test]
fn test_join_path_dirs_with_empty_existing_path_keeps_its_single_entry() {
let dir = PathBuf::from(if cfg!(target_os = "windows") {
"C:\\changepacks-path-test-empty"
} else {
"/changepacks-path-test-empty"
});
let joined = join_path_dirs(std::slice::from_ref(&dir), Some(OsStr::new("")))
.expect("valid PATH construction")
.expect("some PATH");
let parsed: Vec<PathBuf> = std::env::split_paths(&joined).collect();
assert_eq!(parsed, vec![dir, PathBuf::new()]);
}
#[test]
fn test_prepend_path_dirs_reports_invalid_platform_separator() {
#[cfg(target_os = "windows")]
let invalid_dir = PathBuf::from("C:\\changepacks\"invalid");
#[cfg(not(target_os = "windows"))]
let invalid_dir = PathBuf::from("/changepacks:invalid");
let error = prepend_path_dirs(&[invalid_dir]).unwrap_err();
assert!(
error
.to_string()
.contains("failed to construct PATH from injected and existing directories"),
"unexpected error: {error:#}"
);
}
#[tokio::test]
async fn test_run_publish_command_with_path_dirs_propagates_invalid_path_entry() {
#[cfg(target_os = "windows")]
let invalid_dir = PathBuf::from("C:\\changepacks\"invalid");
#[cfg(not(target_os = "windows"))]
let invalid_dir = PathBuf::from("/changepacks:invalid");
let temp_dir = tempfile::TempDir::new().unwrap();
let marker = temp_dir.path().join("marker.txt");
let result = run_publish_command_with_path_dirs(
"echo spawned > marker.txt",
temp_dir.path(),
std::slice::from_ref(&invalid_dir),
)
.await;
let error = result.expect_err("invalid PATH entry must fail the publish run");
assert!(
format!("{error:#}")
.contains("failed to construct PATH from injected and existing directories"),
"unexpected error: {error:#}"
);
assert!(
!marker.exists(),
"child process must not spawn when PATH construction fails"
);
temp_dir.close().unwrap();
}
#[test]
fn test_build_shell_command() {
let cmd = build_shell_command("echo hello");
let program = cmd.as_std().get_program().to_string_lossy();
#[cfg(target_os = "windows")]
assert_eq!(program, "cmd");
#[cfg(not(target_os = "windows"))]
assert_eq!(program, "sh");
}
#[tokio::test]
async fn test_run_publish_command_os_args_success() {
let temp_dir = std::env::temp_dir();
let output = run_publish_command_os_args("git", ["--version"], &temp_dir, false)
.await
.unwrap();
assert!(output.success, "stderr: {}", output.stderr);
assert!(
output.stdout.contains("git version"),
"unexpected stdout: {}",
output.stdout
);
}
#[tokio::test]
async fn test_run_publish_command_os_args_spawn_error() {
let temp_dir = std::env::temp_dir();
let result = run_publish_command_os_args(
"changepacks-no-such-binary-xyz",
[] as [&str; 0],
&temp_dir,
false,
)
.await;
assert!(
result.is_err(),
"expected spawn error for nonexistent binary"
);
}
#[tokio::test]
async fn test_run_publish_command_os_args_missing_working_dir_is_error() {
let missing_dir =
std::env::temp_dir().join(format!("changepacks_osargs_missing_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&missing_dir);
let result = run_publish_command_os_args("git", ["--version"], &missing_dir, false).await;
assert!(
result.is_err(),
"expected a spawn error for a missing working directory, got {result:?}"
);
}
#[tokio::test]
async fn test_run_publish_command_os_args_non_zero_exit() {
let temp_dir = std::env::temp_dir();
let (program, args) = if cfg!(target_os = "windows") {
("cmd", ["/C", "exit 1"])
} else {
("sh", ["-c", "exit 1"])
};
let output = run_publish_command_os_args(program, args, &temp_dir, false)
.await
.expect("non-zero exit must be Ok, not Err");
assert!(
!output.success,
"expected success=false for a non-zero exit, stdout: {}, stderr: {}",
output.stdout, output.stderr
);
}
#[tokio::test]
async fn test_run_publish_command_os_args_kill_on_drop_kills_cancelled_child() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!(
"changepacks_osargs_cancel_{}_{}",
std::process::id(),
unique
));
std::fs::create_dir_all(&base).unwrap();
let started = base.join("started");
let completed = base.join("completed");
#[cfg(target_os = "windows")]
let (program, args) = (
"cmd",
[
"/C",
"echo started>started & ping -n 3 127.0.0.1 >nul & echo completed>completed",
],
);
#[cfg(not(target_os = "windows"))]
let (program, args) = (
"sh",
[
"-c",
"printf started > started; sleep 2; printf completed > completed",
],
);
let task_dir = base.clone();
let mut task = tokio::spawn(async move {
run_publish_command_os_args(program, args, &task_dir, true).await
});
tokio::task::yield_now().await;
let started_written = wait_for_file(&started, Duration::from_secs(5)).await;
assert!(
started_written || !task.is_finished(),
"argv command finished before readiness: {:?}",
(&mut task).await
);
assert!(started_written, "argv child never wrote its start marker");
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
assert!(
!wait_for_file(&completed, Duration::from_secs(4)).await,
"cancelled argv child wrote its delayed completion marker"
);
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn test_utf8_or_lossy_valid_utf8_passthrough() {
assert_eq!(utf8_or_lossy("héllo".as_bytes().to_vec()), "héllo");
}
#[test]
fn test_utf8_or_lossy_invalid_utf8_lossy_fallback() {
assert_eq!(utf8_or_lossy(vec![0x66, 0x6f, 0x80, 0x6f]), "fo\u{FFFD}o");
}
#[test]
fn test_normalize_path_separators_backslash_to_forward_slash() {
assert_eq!(
normalize_path_separators("packages\\core\\package.json"),
"packages/core/package.json"
);
}
#[test]
fn test_normalize_path_separators_no_backslash_unchanged() {
assert_eq!(
normalize_path_separators("packages/core/package.json"),
"packages/core/package.json"
);
}
#[test]
fn test_normalize_path_separators_borrows_when_no_backslash() {
let input = "packages/core/package.json";
let normalized = normalize_path_separators(input);
assert!(matches!(normalized, Cow::Borrowed(_)));
assert!(std::ptr::eq(normalized.as_ref(), input));
}
#[test]
fn test_normalize_path_separators_owns_when_backslash_present() {
let normalized = normalize_path_separators("packages\\core\\package.json");
assert!(matches!(normalized, Cow::Owned(_)));
assert_eq!(normalized, "packages/core/package.json");
}
#[test]
fn test_normalize_path_separators_mixed_separators_fully_normalized() {
let normalized = normalize_path_separators("packages/core\\src\\lib\\package.json");
assert!(matches!(normalized, Cow::Owned(_)));
assert_eq!(normalized, "packages/core/src/lib/package.json");
}
#[test]
fn test_normalize_path_separators_empty_input_borrows_empty() {
let normalized = normalize_path_separators("");
assert!(matches!(normalized, Cow::Borrowed(_)));
assert_eq!(normalized, "");
}
#[test]
fn test_normalize_path_separators_of_borrows_utf8_path_without_backslash() {
let path = Path::new("packages/core/package.json");
let normalized = normalize_path_separators_of(path);
assert!(matches!(normalized, Cow::Borrowed(_)));
assert_eq!(normalized, "packages/core/package.json");
}
#[test]
fn test_normalize_path_separators_of_rewrites_utf8_path_with_backslash() {
let path = Path::new("packages\\core\\src\\package.json");
let normalized = normalize_path_separators_of(path);
assert!(matches!(normalized, Cow::Owned(_)));
assert_eq!(normalized, "packages/core/src/package.json");
}
#[cfg(any(unix, windows))]
fn non_utf8_path(with_backslash: bool) -> OsString {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
let separator = if with_backslash { b'\\' } else { b'/' };
OsString::from_vec(vec![b'p', 0xFF, separator, b'q'])
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStringExt;
let separator = if with_backslash { 0x005C } else { 0x002F };
OsString::from_wide(&[u16::from(b'p'), 0xD800, separator, u16::from(b'q')])
}
}
#[test]
#[cfg(any(unix, windows))]
fn test_normalize_path_separators_of_moves_owned_lossy_string_untouched() {
let os_string = non_utf8_path(false);
let path = Path::new(&os_string);
let expected = path.to_string_lossy().into_owned();
let normalized = normalize_path_separators_of(path);
assert!(matches!(normalized, Cow::Owned(_)));
assert_eq!(normalized, expected);
assert!(!normalized.contains('\\'));
assert!(normalized.contains('\u{FFFD}'));
}
#[test]
#[cfg(any(unix, windows))]
fn test_normalize_path_separators_of_rewrites_owned_lossy_string() {
let os_string = non_utf8_path(true);
let path = Path::new(&os_string);
let normalized = normalize_path_separators_of(path);
assert!(matches!(normalized, Cow::Owned(_)));
assert!(!normalized.contains('\\'));
assert_eq!(normalized, format!("p{}/q", '\u{FFFD}'));
}
}