use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::Result;
use async_trait::async_trait;
use crate::{Language, Package, UpdateType, Workspace};
macro_rules! impl_test_publish_commands {
() => {
fn default_publish_command(&self) -> String {
"echo publish".to_string()
}
fn default_dry_run_publish_command(&self) -> Option<String> {
Some("echo publish --dry-run".to_string())
}
};
}
macro_rules! impl_unsupported_dry_run_accessors {
() => {
fn name(&self) -> Option<&str> {
Some("unsupported-dry-run")
}
fn version(&self) -> Option<&str> {
Some("1.0.0")
}
fn path(&self) -> &Path {
&self.path
}
fn relative_path(&self) -> &Path {
Path::new("project.csproj")
}
fn is_changed(&self) -> bool {
false
}
fn language(&self) -> Language {
Language::CSharp
}
fn set_changed(&mut self, _changed: bool) {}
fn set_name(&mut self, _name: String) {}
fn default_publish_command(&self) -> String {
"echo publish".to_string()
}
fn default_dry_run_publish_command(&self) -> Option<String> {
None
}
crate::impl_dependencies_accessors!();
};
}
macro_rules! impl_mock_project_accessors {
() => {
crate::impl_basic_accessors!();
fn language(&self) -> Language {
self.language
}
crate::impl_dependencies_accessors!();
impl_test_publish_commands!();
};
}
macro_rules! define_mock {
($type_name:ident, $default_path:expr, $default_relative:expr) => {
#[derive(Debug)]
pub struct $type_name {
pub name: Option<String>,
pub version: Option<String>,
pub path: PathBuf,
pub relative_path: PathBuf,
pub language: Language,
pub dependencies: HashSet<String>,
pub is_changed: bool,
}
impl $type_name {
pub fn new(name: Option<&str>, version: Option<&str>, language: Language) -> Self {
Self {
name: name.map(String::from),
version: version.map(String::from),
path: PathBuf::from($default_path),
relative_path: PathBuf::from($default_relative),
language,
dependencies: HashSet::new(),
is_changed: false,
}
}
pub fn with_all(
name: Option<&str>,
version: Option<&str>,
path: &str,
relative_path: &str,
language: Language,
) -> Self {
Self {
name: name.map(String::from),
version: version.map(String::from),
path: PathBuf::from(path),
relative_path: PathBuf::from(relative_path),
language,
dependencies: HashSet::new(),
is_changed: false,
}
}
pub fn with_paths(name: Option<&str>, path: &str, relative_path: &str) -> Self {
Self {
name: name.map(String::from),
version: Some("1.0.0".to_string()),
path: PathBuf::from(path),
relative_path: PathBuf::from(relative_path),
language: Language::Node,
dependencies: HashSet::new(),
is_changed: false,
}
}
pub fn same_path(name: &str, path: &str) -> Self {
Self::with_paths(Some(name), path, path)
}
#[must_use]
pub fn with_language(mut self, language: Language) -> Self {
self.language = language;
self
}
}
};
}
define_mock!(MockPackage, "/test/Cargo.toml", "Cargo.toml");
#[async_trait]
impl Package for MockPackage {
impl_mock_project_accessors!();
async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
Ok(())
}
}
define_mock!(MockWorkspace, "/test/package.json", "package.json");
#[async_trait]
impl Workspace for MockWorkspace {
impl_mock_project_accessors!();
async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
Ok(())
}
}
#[derive(Debug)]
pub struct UnsupportedDryRunProject {
pub path: PathBuf,
pub dependencies: HashSet<String>,
}
#[cfg(test)]
macro_rules! shared_project_default_tests {
(
mock: $mock:ident,
trait_name: $trait_name:ident,
kind: $kind:literal,
dir_not_found: $dir_not_found:literal,
publishable_test: $publishable_test:ident $(,)?
) => {
#[test]
fn test_check_changed_already_changed() {
let mut project =
$mock::with_paths(Some("test"), "/project/package.json", "package.json");
project.is_changed = true;
project
.check_changed(std::path::Path::new("/project/src/index.js"))
.unwrap();
assert!(project.is_changed());
}
#[rstest::rstest]
#[case("/project/src/index.js", true)]
#[case("/project/.changepacks/change.json", false)]
#[case("/other-project/src/index.js", false)]
fn test_check_changed(#[case] changed_path: &str, #[case] expected: bool) {
let mut project =
$mock::with_paths(Some("test"), "/project/package.json", "package.json");
project
.check_changed(std::path::Path::new(changed_path))
.unwrap();
assert_eq!(project.is_changed(), expected);
}
#[test]
fn $publishable_test() {
let project = $mock::with_paths(Some("test"), "/project/package.json", "package.json");
assert!(project.is_publishable_by_default());
assert_eq!(
project.is_dry_run_publishable_by_default(),
project.is_publishable_by_default()
);
}
#[test]
fn test_get_publish_command_by_path() {
let project = $mock::with_paths(
Some("test"),
"/project/package.json",
"packages/core/package.json",
);
let mut publish = std::collections::BTreeMap::new();
publish.insert(
"packages/core/package.json".to_string(),
"custom publish".to_string(),
);
let config = crate::Config {
publish,
..Default::default()
};
assert_eq!(project.get_publish_command(&config), "custom publish");
}
#[rstest::rstest]
#[case(crate::Language::Node, "node", "npm publish --access public")]
#[case(crate::Language::Python, "python", "poetry publish")]
#[case(crate::Language::Rust, "rust", "cargo publish")]
#[case(crate::Language::Dart, "dart", "dart pub publish")]
#[case(crate::Language::Java, "java", "./gradlew publish")]
#[case(crate::Language::CSharp, "csharp", "dotnet nuget push")]
fn test_get_publish_command_by_language(
#[case] language: crate::Language,
#[case] key: &str,
#[case] command: &str,
) {
let project = $mock::with_paths(Some("test"), "/project/manifest", "manifest")
.with_language(language);
let mut publish = std::collections::BTreeMap::new();
publish.insert(key.to_string(), command.to_string());
let config = crate::Config {
publish,
..Default::default()
};
assert_eq!(project.get_publish_command(&config), command);
}
#[test]
fn test_get_publish_command_default() {
let project = $mock::with_paths(Some("test"), "/project/package.json", "package.json");
let config = crate::Config::default();
assert_eq!(project.get_publish_command(&config), "echo publish");
}
#[tokio::test]
async fn test_publish_success() {
let temp_dir = std::env::temp_dir();
let path = temp_dir.join("package.json");
let project = $mock::with_paths(Some("test"), path.to_str().unwrap(), "package.json");
let config = crate::Config::default();
let output = project.publish(&config).await.unwrap();
assert!(output.success);
assert!(output.stdout.contains("publish"));
}
#[tokio::test]
async fn test_publish_uses_project_path_override() {
let path = std::env::temp_dir().join("package.json");
let project = $mock::with_paths(
Some("test"),
path.to_str().unwrap(),
"packages/core/package.json",
);
let config = crate::Config {
publish: std::collections::BTreeMap::from([(
"packages/core/package.json".to_string(),
concat!("echo ", $kind, "-path-override").to_string(),
)]),
..Default::default()
};
let output = project.publish(&config).await.unwrap();
assert!(output.success);
assert!(output.stdout.contains(concat!($kind, "-path-override")));
}
#[tokio::test]
async fn test_publish_uses_language_override() {
let path = std::env::temp_dir().join("package.json");
let project = $mock::with_paths(Some("test"), path.to_str().unwrap(), "package.json");
let config = crate::Config {
publish: std::collections::BTreeMap::from([(
"node".to_string(),
concat!("echo ", $kind, "-language-override").to_string(),
)]),
..Default::default()
};
let output = project.publish(&config).await.unwrap();
assert!(output.success);
assert!(output.stdout.contains(concat!($kind, "-language-override")));
}
#[tokio::test]
async fn test_publish_failure() {
let temp_dir = std::env::temp_dir();
let path = temp_dir.join("package.json");
let project = $mock::with_paths(Some("test"), path.to_str().unwrap(), "package.json");
let mut publish = std::collections::BTreeMap::new();
let fail_cmd = if cfg!(target_os = "windows") {
"cmd /c exit 1"
} else {
"exit 1"
};
publish.insert("node".to_string(), fail_cmd.to_string());
let config = crate::Config {
publish,
..Default::default()
};
let output = project.publish(&config).await.unwrap();
assert!(!output.success);
}
#[tokio::test]
async fn test_publish_no_parent_directory() {
let project = $mock::with_paths(Some("test"), "", "");
let config = crate::Config::default();
let result = project.publish(&config).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains($dir_not_found));
}
#[tokio::test]
async fn test_publish_reports_missing_current_directory() {
let missing_dir = std::env::temp_dir().join(format!(
"changepacks_missing_{}_dir_{}",
$kind,
std::process::id()
));
let _ = std::fs::remove_dir_all(&missing_dir);
let path = missing_dir.join("package.json");
let project = $mock::with_paths(Some("test"), path.to_str().unwrap(), "package.json");
let result = project.publish(&crate::Config::default()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_dry_run_publish_uses_project_path_override() {
let path = std::env::temp_dir().join("package.json");
let project = $mock::with_paths(
Some("test"),
path.to_str().unwrap(),
"packages/core/package.json",
);
let config = crate::Config {
publish_dry_run: std::collections::BTreeMap::from([(
"packages/core/package.json".to_string(),
concat!("echo ", $kind, "-dry-path-override").to_string(),
)]),
..Default::default()
};
let output = project.dry_run_publish(&config).await.unwrap().unwrap();
assert!(output.success);
assert!(output.stdout.contains(concat!($kind, "-dry-path-override")));
}
#[tokio::test]
async fn test_dry_run_publish_uses_language_override() {
let path = std::env::temp_dir().join("package.json");
let project = $mock::with_paths(Some("test"), path.to_str().unwrap(), "package.json");
let config = crate::Config {
publish_dry_run: std::collections::BTreeMap::from([(
"node".to_string(),
concat!("echo ", $kind, "-dry-language-override").to_string(),
)]),
..Default::default()
};
let output = project.dry_run_publish(&config).await.unwrap().unwrap();
assert!(output.success);
assert!(
output
.stdout
.contains(concat!($kind, "-dry-language-override"))
);
}
#[tokio::test]
async fn test_dry_run_publish_uses_default_command() {
let path = std::env::temp_dir().join("package.json");
let project = $mock::with_paths(Some("test"), path.to_str().unwrap(), "package.json");
let output = project
.dry_run_publish(&crate::Config::default())
.await
.unwrap()
.unwrap();
assert!(output.success);
assert!(output.stdout.contains("publish --dry-run"));
}
#[tokio::test]
async fn test_dry_run_publish_returns_none_when_unsupported() {
let project = crate::test_support::UnsupportedDryRunProject {
path: std::env::temp_dir().join("project.csproj"),
dependencies: std::collections::HashSet::new(),
};
let output = $trait_name::dry_run_publish(&project, &crate::Config::default())
.await
.unwrap();
assert!(output.is_none());
}
#[tokio::test]
async fn test_unsupported_dry_run_project_fixture_contract() {
let path = std::env::temp_dir().join("project.csproj");
let mut project = crate::test_support::UnsupportedDryRunProject {
path: path.clone(),
dependencies: std::collections::HashSet::new(),
};
assert_eq!($trait_name::name(&project), Some("unsupported-dry-run"));
assert_eq!($trait_name::version(&project), Some("1.0.0"));
assert_eq!($trait_name::path(&project), path.as_path());
assert_eq!(
$trait_name::relative_path(&project),
std::path::Path::new("project.csproj")
);
assert_eq!($trait_name::language(&project), crate::Language::CSharp);
assert!(!$trait_name::is_changed(&project));
assert_eq!(
$trait_name::default_publish_command(&project),
"echo publish"
);
assert_eq!($trait_name::default_dry_run_publish_command(&project), None);
$trait_name::set_changed(&mut project, true);
$trait_name::set_name(&mut project, "renamed".to_string());
assert!(
!$trait_name::is_changed(&project),
"set_changed must stay a no-op on the fixture"
);
assert_eq!(
$trait_name::name(&project),
Some("unsupported-dry-run"),
"set_name must stay a no-op on the fixture"
);
$trait_name::update_version(&mut project, crate::UpdateType::Patch)
.await
.unwrap();
assert_eq!($trait_name::version(&project), Some("1.0.0"));
}
#[test]
fn test_set_name_updates_via_impl_basic_accessors_macro() {
let mut project =
$mock::with_paths(Some("original"), "/project/package.json", "package.json");
project.set_name("new-name".to_string());
assert_eq!(project.name(), Some("new-name"));
}
};
}
#[cfg(test)]
pub(crate) use shared_project_default_tests;
#[async_trait]
impl Package for UnsupportedDryRunProject {
impl_unsupported_dry_run_accessors!();
async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
Ok(())
}
}
#[async_trait]
impl Workspace for UnsupportedDryRunProject {
impl_unsupported_dry_run_accessors!();
async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
Ok(())
}
}