#![allow(clippy::disallowed_methods)]
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use monochange_core::ChangelogSettings;
use monochange_core::ChangesetContext;
use monochange_core::ChangesetRevision;
use monochange_core::HostedActorRef;
use monochange_core::HostedActorSourceKind;
use monochange_core::HostedCommitRef;
use monochange_core::HostingCapabilities;
use monochange_core::HostingProviderKind;
use monochange_core::PackageDefinition;
use monochange_core::PreparedChangeset;
use monochange_core::ProviderMergeRequestSettings;
use monochange_core::ProviderReleaseSettings;
use monochange_core::ShellConfig;
use monochange_core::SourceConfiguration;
use monochange_core::SourceProvider;
use monochange_core::VersionFormat;
use monochange_core::WorkspaceConfiguration;
use super::*;
#[test]
fn render_step_inputs_toml_uses_array_for_inherited_and_map_for_mixed_inputs() {
let mut inherited_inputs = BTreeMap::new();
inherited_inputs.insert(
"format".to_string(),
monochange_core::CliStepInputValue::Inherited,
);
let mut rendered = String::new();
render_step_inputs_toml(&mut rendered, &inherited_inputs);
assert_eq!(rendered, "inputs = [\"format\"]\n");
inherited_inputs.insert(
"release".to_string(),
monochange_core::CliStepInputValue::Inherited,
);
let mut rendered = String::new();
render_step_inputs_toml(&mut rendered, &inherited_inputs);
assert_eq!(rendered, "inputs = [\"format\", \"release\"]\n");
inherited_inputs.insert(
"draft".to_string(),
monochange_core::CliStepInputValue::Boolean(true),
);
let rendered = render_step_inputs_inline_table(&inherited_inputs);
assert_eq!(
rendered,
"{ draft = true, format = \"{{ inputs.format }}\", release = \"{{ inputs.release }}\" }"
);
}
#[test]
fn write_toml_array_items_streams_values_without_outer_brackets() {
let mut rendered = String::new();
write_toml_array_items(
&mut rendered,
["core".to_string(), "web app".to_string()].iter(),
);
assert_eq!(rendered, "\"core\", \"web app\"");
}
#[test]
fn render_command_variables_inline_table_streams_multiple_variables() {
let mut variables = BTreeMap::new();
variables.insert(
"changed".to_string(),
monochange_core::CommandVariable::ChangedFiles,
);
variables.insert(
"version".to_string(),
monochange_core::CommandVariable::Version,
);
assert_eq!(
render_command_variables_inline_table(&variables),
"{ changed = \"changed_files\", version = \"version\" }"
);
}
fn setup_workspace_ops_fixture() -> tempfile::TempDir {
monochange_test_helpers::fs::setup_fixture_from(
env!("CARGO_MANIFEST_DIR"),
"workspace-ops/lockfile-command-helpers",
)
}
fn setup_fixture(relative: &str) -> tempfile::TempDir {
monochange_test_helpers::fs::setup_fixture_from(env!("CARGO_MANIFEST_DIR"), relative)
}
#[cfg(unix)]
fn make_executable(path: &Path) {
let metadata = fs::metadata(path).unwrap_or_else(|error| panic!("metadata: {error}"));
let mut permissions = metadata.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions)
.unwrap_or_else(|error| panic!("set permissions: {error}"));
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) {}
fn sample_changeset_with_context() -> PreparedChangeset {
PreparedChangeset {
path: PathBuf::from(".changeset/feature.md"),
summary: Some("feature".to_string()),
details: None,
targets: Vec::new(),
context: Some(ChangesetContext {
provider: HostingProviderKind::GenericGit,
host: None,
capabilities: HostingCapabilities::default(),
introduced: Some(ChangesetRevision {
actor: Some(HostedActorRef {
provider: HostingProviderKind::GenericGit,
host: None,
id: None,
login: Some("ifiokjr".to_string()),
display_name: Some("Ifiok Jr.".to_string()),
url: None,
source: HostedActorSourceKind::CommitAuthor,
}),
commit: Some(HostedCommitRef {
provider: HostingProviderKind::GenericGit,
host: None,
sha: "abc1234567890".to_string(),
short_sha: "abc1234".to_string(),
url: None,
authored_at: None,
committed_at: None,
author_name: Some("Ifiok Jr.".to_string()),
author_email: Some("ifiok@example.com".to_string()),
}),
review_request: None,
}),
last_updated: None,
related_issues: Vec::new(),
}),
}
}
fn sample_source(provider: SourceProvider) -> SourceConfiguration {
SourceConfiguration {
provider,
host: match provider {
SourceProvider::Gitea | SourceProvider::Forgejo => {
Some("https://codeberg.org".to_string())
}
SourceProvider::GitHub | SourceProvider::GitLab => None,
},
api_url: None,
owner: match provider {
SourceProvider::GitLab => "group".to_string(),
_ => "org".to_string(),
},
repo: "monochange".to_string(),
releases: ProviderReleaseSettings::default(),
pull_requests: ProviderMergeRequestSettings::default(),
}
}
fn workspace_configuration_with_lockfile_commands() -> WorkspaceConfiguration {
WorkspaceConfiguration {
root_path: PathBuf::from("."),
defaults: monochange_core::WorkspaceDefaults::default(),
changelog: ChangelogSettings::default(),
prerelease: monochange_core::PrereleaseConfiguration::default(),
packages: Vec::new(),
groups: Vec::new(),
cli: Vec::new(),
changesets: monochange_core::ChangesetSettings::default(),
source: None,
lints: monochange_core::lint::WorkspaceLintSettings::default(),
cargo: monochange_core::EcosystemSettings {
lockfile_commands: vec![LockfileCommandDefinition {
command: "cargo metadata".to_string(),
cwd: None,
shell: ShellConfig::None,
}],
..monochange_core::EcosystemSettings::default()
},
npm: monochange_core::EcosystemSettings::default(),
deno: monochange_core::EcosystemSettings::default(),
dart: monochange_core::EcosystemSettings::default(),
python: monochange_core::EcosystemSettings::default(),
go: monochange_core::EcosystemSettings::default(),
}
}
#[test]
fn remap_workspace_path_rejects_paths_outside_the_workspace() {
let fixture = setup_workspace_ops_fixture();
let temp_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let error = remap_workspace_path(
fixture.path(),
temp_root.path(),
Path::new("/tmp/outside-workspace"),
)
.err()
.unwrap_or_else(|| panic!("expected remap error"));
assert!(error.to_string().contains("was outside workspace root"));
}
#[test]
fn remap_workspace_path_maps_workspace_paths_into_the_temp_root() {
let fixture = setup_workspace_ops_fixture();
let temp_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let remapped = remap_workspace_path(
fixture.path(),
temp_root.path(),
&fixture.path().join("root.txt"),
)
.unwrap_or_else(|error| panic!("remap workspace path: {error}"));
assert_eq!(remapped, temp_root.path().join("root.txt"));
}
#[test]
fn init_and_populate_workspace_cover_common_error_and_noop_paths() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
fs::create_dir_all(root.join("monochange.toml"))
.unwrap_or_else(|error| panic!("create blocking config dir: {error}"));
let init_error = init_workspace(root, true, None)
.err()
.unwrap_or_else(|| panic!("expected init write error"));
assert!(init_error.to_string().contains("failed to write"));
let empty_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let missing_error = populate_workspace(empty_root.path())
.err()
.unwrap_or_else(|| panic!("expected missing config error"));
assert!(missing_error.to_string().contains("run `mc init` first"));
let populated_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
fs::write(
populated_root.path().join("monochange.toml"),
render_cli_commands_toml(&default_cli_commands()),
)
.unwrap_or_else(|error| panic!("write populated config: {error}"));
let populated = populate_workspace(populated_root.path())
.unwrap_or_else(|error| panic!("populate no-op: {error}"));
assert!(populated.added_commands.is_empty());
}
#[test]
fn init_rendering_helpers_cover_duplicate_names_changelogs_and_package_types() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
fs::create_dir_all(root.join("crates/core/src")).unwrap();
fs::create_dir_all(root.join("packages/core")).unwrap();
fs::create_dir_all(root.join("apps/cli")).unwrap();
fs::create_dir_all(root.join("apps/mobile")).unwrap();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/core\"]\n",
)
.unwrap();
fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"1.0.0\"\nedition = \"2021\"\n",
)
.unwrap();
fs::write(
root.join("packages/core/package.json"),
r#"{ "name": "core", "version": "1.0.0" }"#,
)
.unwrap();
fs::write(
root.join("apps/cli/deno.json"),
r#"{ "name": "@acme/cli", "version": "1.0.0" }"#,
)
.unwrap();
fs::write(
root.join("apps/mobile/pubspec.yaml"),
"name: mobile\nversion: 1.0.0\nflutter:\n uses-material-design: true\n",
)
.unwrap();
fs::create_dir_all(root.join("packages/dart_pkg/lib")).unwrap();
fs::write(
root.join("packages/dart_pkg/pubspec.yaml"),
"name: dart_pkg\nversion: 1.0.0\n",
)
.unwrap();
fs::write(root.join("packages/core/changelog.md"), "# Changelog\n").unwrap();
let rendered = render_annotated_init_config(root, None, None)
.unwrap_or_else(|error| panic!("render annotated config: {error}"));
assert!(rendered.contains("type = \"cargo\""));
assert!(rendered.contains("type = \"npm\""));
assert!(rendered.contains("type = \"deno\""));
assert!(rendered.contains("type = \"dart\""));
assert!(rendered.contains("type = \"dart\""));
assert!(rendered.contains("changelog = \"packages/core/changelog.md\""));
assert!(rendered.contains("packages/core"));
assert_eq!(
detect_default_changelog(root, &root.join("packages/core")),
Some(PathBuf::from("packages/core/changelog.md"))
);
assert_eq!(
package_type_for_ecosystem(Ecosystem::Cargo),
PackageType::Cargo
);
assert_eq!(package_type_for_ecosystem(Ecosystem::Npm), PackageType::Npm);
assert_eq!(
package_type_for_ecosystem(Ecosystem::Deno),
PackageType::Deno
);
assert_eq!(
package_type_for_ecosystem(Ecosystem::Dart),
PackageType::Dart
);
}
#[test]
fn validate_and_discover_release_workspace_cover_fallback_and_errors() {
let empty_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
fs::write(empty_root.path().join("monochange.toml"), "").unwrap();
validate_cargo_workspace_version_groups(empty_root.path())
.unwrap_or_else(|error| panic!("validate empty workspace: {error}"));
let fixture = setup_fixture("monochange/release-base");
let configuration = load_workspace_configuration(fixture.path())
.unwrap_or_else(|error| panic!("load config: {error}"));
let report = discover_release_workspace(fixture.path(), &configuration)
.unwrap_or_else(|error| panic!("discover release workspace: {error}"));
assert_eq!(report.packages.len(), 2);
let fallback = discover_release_workspace(
fixture.path(),
&WorkspaceConfiguration {
packages: Vec::new(),
..configuration.clone()
},
)
.unwrap_or_else(|error| panic!("discover fallback workspace: {error}"));
assert!(!fallback.packages.is_empty());
let broken = WorkspaceConfiguration {
packages: vec![PackageDefinition {
id: "missing".to_string(),
path: PathBuf::from("missing"),
package_type: PackageType::Cargo,
changelog: None,
excluded_changelog_types: Vec::new(),
empty_update_message: None,
release_title: None,
changelog_version_title: None,
versioned_files: Vec::new(),
ignore_ecosystem_versioned_files: false,
ignored_paths: Vec::new(),
additional_paths: Vec::new(),
tag: true,
release: true,
publish: monochange_core::PublishSettings::default(),
version_format: VersionFormat::Primary,
}],
..configuration
};
let error = discover_release_workspace(fixture.path(), &broken)
.err()
.unwrap_or_else(|| panic!("expected configured package discovery error"));
assert!(
error.to_string().contains("failed to read")
|| error.to_string().contains("could not be discovered")
);
let undetected_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
fs::create_dir_all(undetected_root.path().join("empty-package"))
.unwrap_or_else(|error| panic!("create empty package dir: {error}"));
let undetected = WorkspaceConfiguration {
root_path: undetected_root.path().to_path_buf(),
defaults: monochange_core::WorkspaceDefaults {
package_type: Some(PackageType::Go),
..monochange_core::WorkspaceDefaults::default()
},
changelog: ChangelogSettings::default(),
prerelease: monochange_core::PrereleaseConfiguration::default(),
packages: vec![PackageDefinition {
id: "empty".to_string(),
path: PathBuf::from("empty-package"),
package_type: PackageType::Go,
changelog: None,
excluded_changelog_types: Vec::new(),
empty_update_message: None,
release_title: None,
changelog_version_title: None,
versioned_files: Vec::new(),
ignore_ecosystem_versioned_files: false,
ignored_paths: Vec::new(),
additional_paths: Vec::new(),
tag: true,
release: true,
publish: monochange_core::PublishSettings::default(),
version_format: VersionFormat::Primary,
}],
groups: Vec::new(),
cli: Vec::new(),
changesets: monochange_core::ChangesetSettings::default(),
source: None,
lints: monochange_core::lint::WorkspaceLintSettings::default(),
cargo: monochange_core::EcosystemSettings::default(),
npm: monochange_core::EcosystemSettings::default(),
deno: monochange_core::EcosystemSettings::default(),
dart: monochange_core::EcosystemSettings::default(),
python: monochange_core::EcosystemSettings::default(),
go: monochange_core::EcosystemSettings::default(),
};
let undetected_error = discover_release_workspace(undetected_root.path(), &undetected)
.err()
.unwrap_or_else(|| panic!("expected configured package none-discovered error"));
assert!(
undetected_error
.to_string()
.contains("configured package `empty` at empty-package could not be discovered")
);
let missing_manifest_root =
tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
fs::create_dir_all(missing_manifest_root.path().join("packages/pkg"))
.unwrap_or_else(|error| panic!("create package dir: {error}"));
let missing_manifest = WorkspaceConfiguration {
root_path: missing_manifest_root.path().to_path_buf(),
defaults: monochange_core::WorkspaceDefaults {
package_type: Some(PackageType::Npm),
..monochange_core::WorkspaceDefaults::default()
},
changelog: ChangelogSettings::default(),
prerelease: monochange_core::PrereleaseConfiguration::default(),
packages: vec![PackageDefinition {
id: "pkg".to_string(),
path: PathBuf::from("packages/pkg"),
package_type: PackageType::Npm,
changelog: None,
excluded_changelog_types: Vec::new(),
empty_update_message: None,
release_title: None,
changelog_version_title: None,
versioned_files: Vec::new(),
ignore_ecosystem_versioned_files: false,
ignored_paths: Vec::new(),
additional_paths: Vec::new(),
tag: true,
release: true,
publish: monochange_core::PublishSettings::default(),
version_format: VersionFormat::Primary,
}],
groups: Vec::new(),
cli: Vec::new(),
changesets: monochange_core::ChangesetSettings::default(),
source: None,
lints: monochange_core::lint::WorkspaceLintSettings::default(),
cargo: monochange_core::EcosystemSettings::default(),
npm: monochange_core::EcosystemSettings::default(),
deno: monochange_core::EcosystemSettings::default(),
dart: monochange_core::EcosystemSettings::default(),
python: monochange_core::EcosystemSettings::default(),
go: monochange_core::EcosystemSettings::default(),
};
let missing_manifest_error =
discover_release_workspace(missing_manifest_root.path(), &missing_manifest)
.err()
.unwrap_or_else(|| panic!("expected configured package discovery failure"));
assert!(
missing_manifest_error
.to_string()
.contains("could not be discovered")
|| missing_manifest_error
.to_string()
.contains("failed to read")
);
let non_cargo_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
fs::create_dir_all(non_cargo_root.path().join("packages/web"))
.unwrap_or_else(|error| panic!("create npm package dir: {error}"));
fs::write(
non_cargo_root.path().join("monochange.toml"),
r#"
[defaults]
package_type = "npm"
[package.web]
path = "packages/web"
"#,
)
.unwrap_or_else(|error| panic!("write npm-only monochange config: {error}"));
fs::write(
non_cargo_root.path().join("packages/web/package.json"),
r#"{ "name": "web", "version": "1.0.0" }"#,
)
.unwrap_or_else(|error| panic!("write package.json: {error}"));
validate_cargo_workspace_version_groups(non_cargo_root.path())
.unwrap_or_else(|error| panic!("validate npm-only workspace: {error}"));
}
#[test]
fn run_lockfile_command_reports_parse_spawn_and_exit_failures() {
let fixture = setup_workspace_ops_fixture();
for script in ["fail-no-stderr", "fail-stderr"] {
make_executable(&fixture.path().join("tools/bin").join(script));
}
let temp_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
copy_workspace_tree(fixture.path(), temp_root.path())
.unwrap_or_else(|error| panic!("copy workspace: {error}"));
for script in ["fail-no-stderr", "fail-stderr"] {
make_executable(&temp_root.path().join("tools/bin").join(script));
}
let parse_error = run_lockfile_command(
fixture.path(),
temp_root.path(),
&LockfileCommandExecution {
command: "'".to_string(),
cwd: fixture.path().to_path_buf(),
shell: ShellConfig::None,
},
)
.err()
.unwrap_or_else(|| panic!("expected parse error"));
assert!(parse_error.to_string().contains("failed to parse command"));
let empty_error = run_lockfile_command(
fixture.path(),
temp_root.path(),
&LockfileCommandExecution {
command: " ".to_string(),
cwd: fixture.path().to_path_buf(),
shell: ShellConfig::None,
},
)
.err()
.unwrap_or_else(|| panic!("expected empty command error"));
assert!(
empty_error
.to_string()
.contains("lockfile command must not be empty")
);
let spawn_error = run_lockfile_command(
fixture.path(),
temp_root.path(),
&LockfileCommandExecution {
command: "definitely-not-a-real-command".to_string(),
cwd: fixture.path().to_path_buf(),
shell: ShellConfig::None,
},
)
.err()
.unwrap_or_else(|| panic!("expected spawn error"));
assert!(
spawn_error
.to_string()
.contains("failed to run lockfile command")
);
let no_stderr_error = run_lockfile_command(
fixture.path(),
temp_root.path(),
&LockfileCommandExecution {
command: temp_root
.path()
.join("tools/bin/fail-no-stderr")
.display()
.to_string(),
cwd: fixture.path().to_path_buf(),
shell: ShellConfig::None,
},
)
.err()
.unwrap_or_else(|| panic!("expected nonzero exit error"));
assert!(no_stderr_error.to_string().contains("exit status"));
let stderr_error = run_lockfile_command(
fixture.path(),
temp_root.path(),
&LockfileCommandExecution {
command: temp_root
.path()
.join("tools/bin/fail-stderr")
.display()
.to_string(),
cwd: fixture.path().to_path_buf(),
shell: ShellConfig::None,
},
)
.err()
.unwrap_or_else(|| panic!("expected stderr error"));
assert!(stderr_error.to_string().contains("bad stderr"));
}
#[test]
fn workspace_copy_and_diff_helpers_skip_git_and_capture_new_files() {
let fixture = setup_workspace_ops_fixture();
make_executable(&fixture.path().join("tools/bin/write-new-file"));
let temp_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
copy_workspace_tree(fixture.path(), temp_root.path())
.unwrap_or_else(|error| panic!("copy workspace: {error}"));
make_executable(&temp_root.path().join("tools/bin/write-new-file"));
assert!(temp_root.path().join("root.txt").exists());
assert!(!temp_root.path().join(".git").exists());
run_lockfile_command(
fixture.path(),
temp_root.path(),
&LockfileCommandExecution {
command: temp_root
.path()
.join("tools/bin/write-new-file")
.display()
.to_string(),
cwd: fixture.path().to_path_buf(),
shell: ShellConfig::None,
},
)
.unwrap_or_else(|error| panic!("write generated file: {error}"));
let lockfile_cmd = LockfileCommandExecution {
command: String::new(),
cwd: fixture.path().to_path_buf(),
shell: ShellConfig::None,
};
let updates =
collect_workspace_file_updates(fixture.path(), temp_root.path(), &[], &[lockfile_cmd])
.unwrap_or_else(|error| panic!("collect updates: {error}"));
assert!(
updates
.iter()
.any(|update| update.path.ends_with("generated.txt"))
);
let mut paths = BTreeSet::new();
collect_workspace_files(fixture.path(), fixture.path(), &mut paths)
.unwrap_or_else(|error| panic!("collect files: {error}"));
assert!(paths.contains(Path::new("root.txt")));
assert!(!paths.iter().any(|path| path.starts_with(".git")));
}
#[test]
fn file_helpers_report_missing_and_invalid_paths() {
let fixture = setup_workspace_ops_fixture();
assert!(
read_optional_file(&fixture.path().join("missing.txt"))
.unwrap_or_else(|error| panic!("missing file lookup: {error}"))
.is_none()
);
let read_error = read_optional_file(fixture.path())
.err()
.unwrap_or_else(|| panic!("expected directory read error"));
assert!(read_error.to_string().contains("failed to read"));
let collect_error = collect_workspace_files(
fixture.path(),
&fixture.path().join("missing-dir"),
&mut BTreeSet::new(),
)
.err()
.unwrap_or_else(|| panic!("expected collect files error"));
assert!(collect_error.to_string().contains("failed to read"));
let strip_prefix_error = strip_workspace_prefix(
&fixture.path().join("root.txt"),
Path::new("/tmp/other-root"),
)
.err()
.unwrap_or_else(|| panic!("expected strip-prefix error"));
assert!(
strip_prefix_error
.to_string()
.contains("was outside workspace root")
);
let temp_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let destination_file = temp_root.path().join("not-a-directory");
fs::write(&destination_file, "file")
.unwrap_or_else(|error| panic!("write destination file: {error}"));
let copy_error = copy_workspace_tree(fixture.path(), &destination_file)
.err()
.unwrap_or_else(|| panic!("expected copy workspace error"));
assert!(copy_error.to_string().contains("failed to create"));
let missing_source_error =
copy_workspace_tree(&fixture.path().join("missing-source"), temp_root.path())
.err()
.unwrap_or_else(|| panic!("expected missing source error"));
assert!(missing_source_error.to_string().contains("failed to read"));
let destination_dir = temp_root.path().join("destination-dir");
fs::create_dir_all(&destination_dir)
.unwrap_or_else(|error| panic!("create destination dir: {error}"));
let source_root = temp_root.path().join("copy-source");
fs::create_dir_all(source_root.join("nested"))
.unwrap_or_else(|error| panic!("create source dir: {error}"));
fs::write(source_root.join("nested/file.txt"), "file")
.unwrap_or_else(|error| panic!("write source file: {error}"));
fs::write(destination_dir.join("nested"), "blocking file")
.unwrap_or_else(|error| panic!("write blocking file: {error}"));
let parent_create_error = ensure_parent_directory(&destination_dir.join("nested/file.txt"))
.err()
.unwrap_or_else(|| panic!("expected parent create error"));
assert!(parent_create_error.to_string().contains("failed to create"));
let copy_target_root = temp_root.path().join("copy-target-root");
fs::create_dir_all(©_target_root)
.unwrap_or_else(|error| panic!("create copy target root: {error}"));
fs::create_dir_all(copy_target_root.join("root.txt"))
.unwrap_or_else(|error| panic!("create copy target dir collision: {error}"));
let copy_file_error = copy_workspace_file(
&fixture.path().join("root.txt"),
©_target_root.join("root.txt"),
)
.err()
.unwrap_or_else(|| panic!("expected copy file error"));
assert!(copy_file_error.to_string().contains("failed to copy"));
}
#[test]
fn snapshot_directory_files_captures_file_contents() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
fs::create_dir_all(root.join("pkg")).unwrap();
fs::write(root.join("pkg/lock.json"), b"lockfile-content").unwrap();
fs::write(root.join("pkg/other.txt"), b"other").unwrap();
let mut snapshots = BTreeMap::new();
snapshot_directory_files(root, &root.join("pkg"), &mut snapshots)
.unwrap_or_else(|error| panic!("snapshot: {error}"));
assert_eq!(snapshots.len(), 2);
assert_eq!(
snapshots.get(Path::new("pkg/lock.json")).map(Vec::as_slice),
Some(b"lockfile-content".as_slice())
);
}
#[test]
fn snapshot_directory_files_skips_subdirectories() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
fs::create_dir_all(root.join("pkg/subdir")).unwrap();
fs::write(root.join("pkg/top.txt"), b"top").unwrap();
fs::write(root.join("pkg/subdir/nested.txt"), b"nested").unwrap();
let mut snapshots = BTreeMap::new();
snapshot_directory_files(root, &root.join("pkg"), &mut snapshots)
.unwrap_or_else(|error| panic!("snapshot: {error}"));
assert_eq!(snapshots.len(), 1);
assert!(snapshots.contains_key(Path::new("pkg/top.txt")));
}
#[test]
fn snapshot_and_change_file_helpers_cover_error_paths() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
let snapshot_error =
snapshot_directory_files(root, &root.join("missing-dir"), &mut BTreeMap::new())
.err()
.unwrap_or_else(|| panic!("expected snapshot error"));
assert!(snapshot_error.to_string().contains("failed to read"));
let fixture = setup_fixture("monochange/release-base");
let invalid_version_error = add_change_file(
fixture.path(),
AddChangeFileRequest::builder()
.package_refs(&["core".to_string()])
.bump(BumpSeverity::Patch)
.reason("reason")
.version(Some("not-a-version"))
.build(),
)
.err()
.unwrap_or_else(|| panic!("expected invalid version error"));
assert!(
invalid_version_error
.to_string()
.contains("invalid explicit version")
);
let blocking_parent = fixture.path().join("blocked");
fs::write(&blocking_parent, "file").unwrap();
let write_error = add_interactive_change_file(
fixture.path(),
&interactive::InteractiveChangeResult {
targets: vec![interactive::InteractiveTarget {
id: "core".to_string(),
bump: BumpSeverity::Patch,
version: None,
change_type: None,
}],
caused_by: Vec::new(),
reason: "reason".to_string(),
details: None,
},
Some(&blocking_parent.join("feature.md")),
)
.err()
.unwrap_or_else(|| panic!("expected interactive write error"));
assert!(write_error.to_string().contains("failed to create"));
let blocking_output = fixture.path().join(".changeset");
let add_change_write_error = add_change_file(
fixture.path(),
AddChangeFileRequest::builder()
.package_refs(&["core".to_string()])
.bump(BumpSeverity::Patch)
.reason("reason")
.output(Some(blocking_output.as_path()))
.build(),
)
.err()
.unwrap_or_else(|| panic!("expected add_change_file write error"));
assert!(
add_change_write_error
.to_string()
.contains("failed to write")
);
let blocked_parent = fixture.path().join("parent-file");
fs::write(&blocked_parent, "file").unwrap();
let add_change_parent_error = add_change_file(
fixture.path(),
AddChangeFileRequest::builder()
.package_refs(&["core".to_string()])
.bump(BumpSeverity::Patch)
.reason("reason")
.output(Some(blocked_parent.join("feature.md").as_path()))
.build(),
)
.err()
.unwrap_or_else(|| panic!("expected add_change_file parent creation error"));
assert!(
add_change_parent_error
.to_string()
.contains("failed to create")
);
let interactive_write_error = add_interactive_change_file(
fixture.path(),
&interactive::InteractiveChangeResult {
targets: vec![interactive::InteractiveTarget {
id: "core".to_string(),
bump: BumpSeverity::Patch,
version: None,
change_type: None,
}],
caused_by: Vec::new(),
reason: "reason".to_string(),
details: Some("extra details".to_string()),
},
Some(blocking_output.as_path()),
)
.err()
.unwrap_or_else(|| panic!("expected add_interactive_change_file write error"));
assert!(
interactive_write_error
.to_string()
.contains("failed to write")
);
let interactive_default_output = add_interactive_change_file(
fixture.path(),
&interactive::InteractiveChangeResult {
targets: vec![interactive::InteractiveTarget {
id: "core".to_string(),
bump: BumpSeverity::Patch,
version: None,
change_type: None,
}],
caused_by: Vec::new(),
reason: "reason".to_string(),
details: None,
},
None,
)
.unwrap_or_else(|error| panic!("write interactive default changeset: {error}"));
assert!(interactive_default_output.starts_with(fixture.path().join(".changeset")));
assert!(interactive_default_output.exists());
}
#[test]
fn materialize_lockfile_updates_captures_in_place_changes() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
fs::create_dir_all(root.join("tools/bin")).unwrap();
let script_path = root.join("tools/bin/update-lock");
fs::write(
&script_path,
"#!/bin/sh\necho 'updated-lockfile' > \"$PWD/lock.txt\"\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
}
fs::write(root.join("lock.txt"), b"original").unwrap();
fs::write(root.join("manifest.json"), b"old-version").unwrap();
let base_updates = vec![FileUpdate {
path: root.join("manifest.json"),
content: b"new-version".to_vec(),
}];
let lockfile_commands = vec![LockfileCommandExecution {
command: script_path.display().to_string(),
cwd: root.to_path_buf(),
shell: ShellConfig::None,
}];
let updates = materialize_lockfile_command_updates(root, &base_updates, &lockfile_commands)
.unwrap_or_else(|error| panic!("materialize: {error}"));
assert!(
updates.iter().any(|u| u.path.ends_with("manifest.json")),
"expected manifest update"
);
assert!(
updates.iter().any(|u| u.path.ends_with("lock.txt")),
"expected lockfile update"
);
let lock_content = fs::read_to_string(root.join("lock.txt")).unwrap();
assert!(
lock_content.contains("updated-lockfile"),
"lockfile should be updated in-place"
);
}
#[test]
fn lockfile_update_helpers_cover_missing_dirs_unreadable_files_and_stderr_paths() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
let unreadable_dir = root.join("snapshots");
fs::create_dir_all(&unreadable_dir).unwrap();
let unreadable_file = unreadable_dir.join("blocked.txt");
fs::write(&unreadable_file, "blocked").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&unreadable_file, fs::Permissions::from_mode(0o000)).unwrap();
}
let snapshot_error = snapshot_directory_files(root, &unreadable_dir, &mut BTreeMap::new())
.err()
.unwrap_or_else(|| panic!("expected unreadable snapshot file error"));
assert!(snapshot_error.to_string().contains("failed to read"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&unreadable_file, fs::Permissions::from_mode(0o644)).unwrap();
}
fs::create_dir_all(root.join("tools/bin")).unwrap();
let script_path = root.join("tools/bin/fail-with-stderr");
fs::write(&script_path, "#!/bin/sh\necho 'bad stderr' >&2\nexit 4\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
}
let stderr_error = run_lockfile_command_in_place(
root,
&LockfileCommandExecution {
command: script_path.display().to_string(),
cwd: PathBuf::from("."),
shell: ShellConfig::None,
},
)
.err()
.unwrap_or_else(|| panic!("expected stderr failure for in-place lockfile command"));
assert!(stderr_error.to_string().contains("bad stderr"));
}
#[test]
fn run_lockfile_command_in_place_reports_failures() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
let parse_error = run_lockfile_command_in_place(
root,
&LockfileCommandExecution {
command: "'".to_string(),
cwd: root.to_path_buf(),
shell: ShellConfig::None,
},
)
.err()
.unwrap_or_else(|| panic!("expected parse error"));
assert!(parse_error.to_string().contains("failed to parse command"));
let empty_error = run_lockfile_command_in_place(
root,
&LockfileCommandExecution {
command: " ".to_string(),
cwd: root.to_path_buf(),
shell: ShellConfig::None,
},
)
.err()
.unwrap_or_else(|| panic!("expected empty command error"));
assert!(
empty_error
.to_string()
.contains("lockfile command must not be empty")
);
let spawn_error = run_lockfile_command_in_place(
root,
&LockfileCommandExecution {
command: "definitely-not-a-real-command".to_string(),
cwd: root.to_path_buf(),
shell: ShellConfig::None,
},
)
.err()
.unwrap_or_else(|| panic!("expected spawn error"));
assert!(
spawn_error
.to_string()
.contains("failed to run lockfile command")
);
let error = run_lockfile_command_in_place(
root,
&LockfileCommandExecution {
command: "false".to_string(),
cwd: root.to_path_buf(),
shell: ShellConfig::Default,
},
)
.err()
.unwrap_or_else(|| panic!("expected error from failing command"));
assert!(error.to_string().contains("failed"));
}
#[test]
fn run_lockfile_command_variants_cover_success_and_shell_paths() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
fs::create_dir_all(root.join("tools/bin"))
.unwrap_or_else(|error| panic!("create tools dir: {error}"));
let success_script = root.join("tools/bin/succeed");
fs::write(
&success_script,
"#!/bin/sh\necho shell-success > \"$PWD/shell-output.txt\"\n",
)
.unwrap_or_else(|error| panic!("write success script: {error}"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&success_script, fs::Permissions::from_mode(0o755))
.unwrap_or_else(|error| panic!("chmod success script: {error}"));
}
run_lockfile_command_in_place(
root,
&LockfileCommandExecution {
command: format!("sh {}", success_script.display()),
cwd: root.to_path_buf(),
shell: ShellConfig::None,
},
)
.unwrap_or_else(|error| panic!("run in-place direct command: {error}"));
assert!(root.join("shell-output.txt").exists());
let temp_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
copy_workspace_tree(root, temp_root.path())
.unwrap_or_else(|error| panic!("copy workspace for test command: {error}"));
let remapped_script = temp_root.path().join("tools/bin/succeed");
run_lockfile_command(
root,
temp_root.path(),
&LockfileCommandExecution {
command: format!("sh {}", remapped_script.display()),
cwd: root.to_path_buf(),
shell: ShellConfig::Default,
},
)
.unwrap_or_else(|error| panic!("run workspace lockfile command through shell: {error}"));
assert!(temp_root.path().join("shell-output.txt").exists());
}
#[test]
fn collect_workspace_file_updates_ignores_parentless_base_updates() {
let root = tempfile::tempdir().unwrap_or_else(|error| panic!("root tempdir: {error}"));
let temp_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let base_update = FileUpdate {
path: PathBuf::new(),
content: Vec::new(),
};
let updates =
collect_workspace_file_updates(root.path(), temp_root.path(), &[base_update], &[])
.unwrap_or_else(|error| panic!("collect updates: {error}"));
assert!(updates.is_empty());
}
#[test]
fn collect_workspace_file_updates_handles_outside_command_cwds() {
let fixture = setup_workspace_ops_fixture();
let temp_root = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
copy_workspace_tree(fixture.path(), temp_root.path())
.unwrap_or_else(|error| panic!("copy workspace: {error}"));
let updates = collect_workspace_file_updates(
fixture.path(),
temp_root.path(),
&[FileUpdate {
path: fixture.path().join("Cargo.toml"),
content: b"[workspace]\n".to_vec(),
}],
&[LockfileCommandExecution {
command: "echo ignored".to_string(),
cwd: PathBuf::from("/tmp/outside-workspace"),
shell: ShellConfig::None,
}],
)
.unwrap_or_else(|error| panic!("collect workspace file updates: {error}"));
assert!(
updates
.iter()
.all(|update| update.path.starts_with(fixture.path()))
);
}
#[test]
fn read_and_delete_changeset_helpers_report_missing_paths() {
let error = read_changeset_source(Path::new("missing/changeset.md"))
.err()
.unwrap_or_else(|| panic!("expected read error"));
assert!(
error
.to_string()
.contains("failed to read missing/changeset.md")
);
let error = delete_changeset_file(Path::new("missing/changeset.md"))
.err()
.unwrap_or_else(|| panic!("expected delete error"));
assert!(
error
.to_string()
.contains("failed to delete missing/changeset.md")
);
}
#[test]
fn changeset_context_phase_label_covers_annotate_and_enrich_modes() {
let gitlab = sample_source(SourceProvider::GitLab);
let github = sample_source(SourceProvider::GitHub);
assert_eq!(github.host, None);
assert_eq!(
changeset_context_phase_label(&gitlab, true),
"annotate changeset context via gitlab"
);
assert_eq!(
changeset_context_phase_label(&gitlab, false),
"enrich changeset context via gitlab"
);
}
#[test]
fn changeset_context_timeout_uses_configured_source_release_timeout() {
let mut source = sample_source(SourceProvider::GitHub);
assert_eq!(changeset_context_timeout(&source), Duration::from_secs(120));
source.releases.changeset_context_timeout_seconds = 7;
assert_eq!(changeset_context_timeout(&source), Duration::from_secs(7));
}
#[test]
fn warn_about_incomplete_cargo_lockfiles_returns_early_when_commands_are_configured() {
let configuration = workspace_configuration_with_lockfile_commands();
warn_about_incomplete_cargo_lockfiles(Path::new("."), &configuration, &[], &BTreeMap::new());
}
#[tokio::test(flavor = "multi_thread")]
async fn apply_source_changeset_context_dispatches_non_dry_gitlab_and_gitea_enrichment() {
let mut gitlab_changesets = vec![sample_changeset_with_context()];
apply_source_changeset_context(
&sample_source(SourceProvider::GitLab),
false,
&mut gitlab_changesets,
)
.await;
let gitlab_context = gitlab_changesets
.first()
.and_then(|changeset| changeset.context.as_ref())
.unwrap_or_else(|| panic!("expected GitLab context"));
assert_eq!(gitlab_context.provider, HostingProviderKind::GitLab);
assert_eq!(gitlab_context.host.as_deref(), Some("gitlab.com"));
assert_eq!(
gitlab_context
.introduced
.as_ref()
.and_then(|revision| revision.commit.as_ref())
.and_then(|commit| commit.url.as_deref()),
Some("https://gitlab.com/group/monochange/-/commit/abc1234567890")
);
let mut gitea_changesets = vec![sample_changeset_with_context()];
apply_source_changeset_context(
&sample_source(SourceProvider::Gitea),
false,
&mut gitea_changesets,
)
.await;
let gitea_context = gitea_changesets
.first()
.and_then(|changeset| changeset.context.as_ref())
.unwrap_or_else(|| panic!("expected Gitea context"));
assert_eq!(gitea_context.provider, HostingProviderKind::Gitea);
assert_eq!(gitea_context.host.as_deref(), Some("codeberg.org"));
assert_eq!(
gitea_context
.introduced
.as_ref()
.and_then(|revision| revision.commit.as_ref())
.and_then(|commit| commit.url.as_deref()),
Some("https://codeberg.org/org/monochange/commit/abc1234567890")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn prepare_release_execution_materializes_configured_lockfile_commands() {
let fixture = monochange_test_helpers::fs::setup_fixture_from(
env!("CARGO_MANIFEST_DIR"),
"monochange/cargo-lock-release",
);
make_executable(&fixture.path().join("tools/bin/cargo"));
let config_path = fixture.path().join("monochange.toml");
let mut config = fs::read_to_string(&config_path)
.unwrap_or_else(|error| panic!("read monochange.toml: {error}"));
config.push_str(
r#"
[[ecosystems.cargo.lockfile_commands]]
command = "tools/bin/cargo"
cwd = "."
"#,
);
fs::write(&config_path, config)
.unwrap_or_else(|error| panic!("write monochange.toml: {error}"));
let prepared = prepare_release_execution_with_file_diffs(fixture.path(), false, false, false)
.await
.unwrap_or_else(|error| panic!("prepare release: {error}"));
assert!(
prepared
.phase_timings
.iter()
.any(|phase| phase.label == "materialize lockfile command updates")
);
assert_eq!(
fs::read_to_string(fixture.path().join("Cargo.lock"))
.unwrap_or_else(|error| panic!("read Cargo.lock: {error}"))
.trim(),
"[[package]]\nname = \"workflow-core\"\nversion = \"1.1.0\""
);
}
#[tokio::test(flavor = "multi_thread")]
async fn prepare_release_execution_tracks_gitlab_context_phase_timing() {
let fixture = monochange_test_helpers::fs::setup_fixture_from(
env!("CARGO_MANIFEST_DIR"),
"monochange/release-base",
);
let config_path = fixture.path().join("monochange.toml");
let mut config = fs::read_to_string(&config_path)
.unwrap_or_else(|error| panic!("read monochange.toml: {error}"));
config.push_str(
r#"
[source]
provider = "gitlab"
owner = "group"
repo = "monochange"
"#,
);
fs::write(&config_path, config)
.unwrap_or_else(|error| panic!("write monochange.toml: {error}"));
let prepared = prepare_release_execution_with_file_diffs(fixture.path(), true, false, false)
.await
.unwrap_or_else(|error| panic!("prepare release: {error}"));
assert!(
prepared
.phase_timings
.iter()
.any(|phase| phase.label == "annotate changeset context via gitlab")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn prepare_release_execution_tracks_github_background_context_phase_timing() {
let fixture = monochange_test_helpers::fs::setup_fixture_from(
env!("CARGO_MANIFEST_DIR"),
"monochange/release-base",
);
let config_path = fixture.path().join("monochange.toml");
let mut config = fs::read_to_string(&config_path)
.unwrap_or_else(|error| panic!("read monochange.toml: {error}"));
config.push_str(
r#"
[source]
provider = "github"
owner = "ifiokjr"
repo = "monochange"
"#,
);
fs::write(&config_path, config)
.unwrap_or_else(|error| panic!("write monochange.toml: {error}"));
let prepared = prepare_release_execution_with_file_diffs(fixture.path(), false, false, false)
.await
.unwrap_or_else(|error| panic!("prepare release: {error}"));
assert!(
prepared
.phase_timings
.iter()
.any(|phase| phase.label == "enrich changeset context via github")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn run_changeset_context_enrichment_with_timeout_reports_completion() {
let source = sample_source(SourceProvider::GitHub);
let completed =
run_changeset_context_enrichment_with_timeout(&source, Duration::from_secs(1), async {})
.await;
assert!(completed);
}
#[tokio::test(flavor = "current_thread")]
async fn run_changeset_context_enrichment_with_timeout_returns_false_on_elapsed_timeout() {
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::WARN)
.with_writer(std::io::sink)
.finish();
let _default_subscriber = tracing::subscriber::set_default(subscriber);
let source = sample_source(SourceProvider::GitHub);
let completed = run_changeset_context_enrichment_with_timeout(
&source,
Duration::from_millis(1),
std::future::pending::<()>(),
)
.await;
assert!(!completed);
}
struct DropSignal(Option<tokio::sync::oneshot::Sender<()>>);
impl Drop for DropSignal {
fn drop(&mut self) {
if let Some(sender) = self.0.take() {
let _ = sender.send(());
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn source_changeset_context_task_aborts_background_work_when_dropped() {
let (started_sender, started_receiver) = tokio::sync::oneshot::channel();
let (dropped_sender, dropped_receiver) = tokio::sync::oneshot::channel();
let handle = tokio::spawn(async move {
let _ = started_sender.send(());
let _drop_signal = DropSignal(Some(dropped_sender));
std::future::pending::<(Vec<PreparedChangeset>, StepPhaseTiming)>().await
});
let task = SourceChangesetContextTask::new(handle);
started_receiver
.await
.unwrap_or_else(|error| panic!("background task did not start: {error}"));
drop(task);
let drop_result = tokio::time::timeout(Duration::from_secs(1), dropped_receiver).await;
assert!(
matches!(drop_result, Ok(Ok(()))),
"background task was not dropped: {drop_result:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn join_source_changeset_context_task_reports_background_panic() {
let mut phase_timings = Vec::new();
let handle = tokio::spawn(async {
panic!("boom");
});
let task = SourceChangesetContextTask::new(handle);
let error = join_source_changeset_context_task(&mut phase_timings, task)
.await
.err()
.unwrap_or_else(|| panic!("expected join error"));
assert!(
error
.to_string()
.contains("background changeset context enrichment panicked"),
"error: {error}"
);
assert!(phase_timings.is_empty());
}
#[test]
fn prerelease_planned_mode_reuses_original_stable_from_state() {
let root = std::path::PathBuf::from("/workspace");
let package = monochange_core::PackageRecord::new(
monochange_core::Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(semver::Version::parse("2.0.0-alpha.0").unwrap()),
monochange_core::PublishState::Public,
);
let discovery = monochange_core::DiscoveryReport {
workspace_root: root,
packages: vec![package],
dependencies: Vec::new(),
version_groups: Vec::new(),
warnings: Vec::new(),
};
let mut previous = PrereleaseState {
schema_version: PRERELEASE_STATE_SCHEMA_VERSION,
channel: "alpha".to_string(),
numbering: monochange_core::PrereleaseNumbering::Increment,
base: monochange_core::PrereleaseBase::Planned,
created_at: "2026-05-22T00:00:00Z".to_string(),
updated_at: "2026-05-22T00:00:00Z".to_string(),
packages: std::collections::BTreeMap::new(),
groups: std::collections::BTreeMap::new(),
};
previous.packages.insert(
"cargo:crates/core/Cargo.toml".to_string(),
PrereleaseStateEntry {
original_stable: semver::Version::parse("1.0.0").unwrap(),
planned_stable: semver::Version::parse("2.0.0").unwrap(),
latest_prerelease: semver::Version::parse("2.0.0-alpha.0").unwrap(),
},
);
let adjusted = discovery_with_prerelease_baselines(
&discovery,
Some(&previous),
&monochange_core::PrereleaseConfiguration::default(),
);
assert_eq!(
adjusted.packages[0].current_version,
Some(semver::Version::parse("1.0.0").unwrap())
);
}
#[test]
fn prerelease_versions_increment_within_stable_base() {
let mut plan = monochange_core::ReleasePlan {
workspace_root: std::path::PathBuf::from("/workspace"),
decisions: vec![monochange_core::ReleaseDecision {
package_id: "cargo:crates/core/Cargo.toml".to_string(),
trigger_type: "direct-change".to_string(),
recommended_bump: monochange_core::BumpSeverity::Major,
planned_version: Some(semver::Version::parse("2.0.0").unwrap()),
group_id: None,
reasons: Vec::new(),
upstream_sources: Vec::new(),
warnings: Vec::new(),
}],
groups: Vec::new(),
warnings: Vec::new(),
unresolved_items: Vec::new(),
compatibility_evidence: Vec::new(),
};
let root = std::path::PathBuf::from("/workspace");
let discovery = monochange_core::DiscoveryReport {
workspace_root: root.clone(),
packages: vec![monochange_core::PackageRecord::new(
monochange_core::Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root,
Some(semver::Version::parse("1.0.0").unwrap()),
monochange_core::PublishState::Public,
)],
dependencies: Vec::new(),
version_groups: Vec::new(),
warnings: Vec::new(),
};
let mut previous = PrereleaseState {
schema_version: PRERELEASE_STATE_SCHEMA_VERSION,
channel: "alpha".to_string(),
numbering: monochange_core::PrereleaseNumbering::Increment,
base: monochange_core::PrereleaseBase::Planned,
created_at: "2026-05-22T00:00:00Z".to_string(),
updated_at: "2026-05-22T00:00:00Z".to_string(),
packages: std::collections::BTreeMap::new(),
groups: std::collections::BTreeMap::new(),
};
previous.packages.insert(
"cargo:crates/core/Cargo.toml".to_string(),
PrereleaseStateEntry {
original_stable: semver::Version::parse("1.0.0").unwrap(),
planned_stable: semver::Version::parse("2.0.0").unwrap(),
latest_prerelease: semver::Version::parse("2.0.0-alpha.0").unwrap(),
},
);
let config = monochange_core::PrereleaseConfiguration {
enabled: true,
..Default::default()
};
apply_prerelease_versions_to_plan(&mut plan, &discovery, Some(&previous), &config).unwrap();
assert_eq!(
plan.decisions[0].planned_version,
Some(semver::Version::parse("2.0.0-alpha.1").unwrap())
);
}
#[test]
fn fixed_prerelease_base_overrides_planned_version() {
let mut plan = monochange_core::ReleasePlan {
workspace_root: std::path::PathBuf::from("/workspace"),
decisions: vec![monochange_core::ReleaseDecision {
package_id: "cargo:crates/core/Cargo.toml".to_string(),
trigger_type: "direct-change".to_string(),
recommended_bump: monochange_core::BumpSeverity::Major,
planned_version: Some(semver::Version::parse("2.0.0").unwrap()),
group_id: None,
reasons: Vec::new(),
upstream_sources: Vec::new(),
warnings: Vec::new(),
}],
groups: Vec::new(),
warnings: Vec::new(),
unresolved_items: Vec::new(),
compatibility_evidence: Vec::new(),
};
let discovery = monochange_core::DiscoveryReport {
workspace_root: std::path::PathBuf::from("/workspace"),
packages: Vec::new(),
dependencies: Vec::new(),
version_groups: Vec::new(),
warnings: Vec::new(),
};
let config = monochange_core::PrereleaseConfiguration {
base: monochange_core::PrereleaseBase::Fixed,
base_version: Some(semver::Version::parse("0.0.0").unwrap()),
..Default::default()
};
apply_prerelease_versions_to_plan(&mut plan, &discovery, None, &config).unwrap();
assert_eq!(
plan.decisions[0].planned_version,
Some(semver::Version::parse("0.0.0-alpha.0").unwrap())
);
}
#[test]
fn prerelease_state_loads_missing_valid_and_malformed_json() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
assert!(load_prerelease_state(root).unwrap().is_none());
let state_path = prerelease_state_path(root);
std::fs::create_dir_all(state_path.parent().unwrap())
.unwrap_or_else(|error| panic!("state dir: {error}"));
std::fs::write(
&state_path,
serde_json::json!({
"schema_version": 1,
"channel": "alpha",
"numbering": "increment",
"base": "planned",
"created_at": "2026-05-22T00:00:00Z",
"updated_at": "2026-05-22T00:00:00Z",
"packages": {
"cargo:crates/core/Cargo.toml": {
"original_stable_version": "1.0.0",
"planned_stable_version": "2.0.0",
"latest_prerelease_version": "2.0.0-alpha.1"
}
},
"groups": {}
})
.to_string(),
)
.unwrap_or_else(|error| panic!("write state: {error}"));
let loaded = load_prerelease_state(root)
.unwrap()
.unwrap_or_else(|| panic!("expected state"));
let package = loaded
.packages
.get("cargo:crates/core/Cargo.toml")
.unwrap_or_else(|| panic!("expected package state"));
assert_eq!(loaded.channel, "alpha");
assert_eq!(package.original_stable, semver::Version::new(1, 0, 0));
assert_eq!(package.latest_prerelease.to_string(), "2.0.0-alpha.1");
std::fs::write(&state_path, b"{").unwrap_or_else(|error| panic!("write malformed: {error}"));
let error = load_prerelease_state(root).expect_err("malformed state should fail");
let rendered = error.to_string();
assert!(rendered.contains("failed to parse"));
assert!(rendered.contains("prerelease-state.json"));
}
#[test]
fn no_changeset_prerelease_plan_synthesizes_package_decisions() {
let root = std::path::PathBuf::from("/workspace");
let package = monochange_core::PackageRecord::new(
monochange_core::Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(semver::Version::parse("1.2.3").unwrap()),
monochange_core::PublishState::Public,
);
let discovery = monochange_core::DiscoveryReport {
workspace_root: root,
packages: vec![package],
dependencies: Vec::new(),
version_groups: Vec::new(),
warnings: Vec::new(),
};
let mut plan = synthesize_no_changeset_prerelease_plan(&discovery);
let config = monochange_core::PrereleaseConfiguration {
enabled: true,
..Default::default()
};
apply_prerelease_versions_to_plan(&mut plan, &discovery, None, &config).unwrap();
assert_eq!(plan.decisions.len(), 1);
assert_eq!(plan.decisions[0].trigger_type, "prerelease");
assert_eq!(
plan.decisions[0].planned_version,
Some(semver::Version::parse("1.2.3-alpha.0").unwrap())
);
}
fn test_package(
root: &std::path::Path,
name: &str,
version: &str,
) -> monochange_core::PackageRecord {
monochange_core::PackageRecord::new(
monochange_core::Ecosystem::Cargo,
name,
root.join(format!("crates/{name}/Cargo.toml")),
root.to_path_buf(),
Some(semver::Version::parse(version).unwrap()),
monochange_core::PublishState::Public,
)
}
#[test]
fn prerelease_identifier_covers_numbering_and_mismatched_latest_base() {
let stable = semver::Version::parse("2.0.0").unwrap();
let previous_for_other_base = semver::Version::parse("1.0.0-alpha.4").unwrap();
let mut config = monochange_core::PrereleaseConfiguration {
enabled: true,
..Default::default()
};
assert_eq!(
prerelease_identifier(&config, &stable, Some(&previous_for_other_base)),
"alpha.0"
);
config.numbering = monochange_core::PrereleaseNumbering::Date;
assert!(prerelease_identifier(&config, &stable, None).starts_with("alpha.20"));
config.numbering = monochange_core::PrereleaseNumbering::Datetime;
assert!(prerelease_identifier(&config, &stable, None).starts_with("alpha.20"));
}
#[test]
fn grouped_no_changeset_prerelease_plan_updates_group_and_state() {
let root = std::path::PathBuf::from("/workspace");
let mut member = test_package(&root, "member", "1.2.3");
member.version_group_id = Some("suite".to_string());
let discovery = monochange_core::DiscoveryReport {
workspace_root: root.clone(),
packages: vec![member],
dependencies: Vec::new(),
version_groups: vec![monochange_core::VersionGroup {
group_id: "suite".to_string(),
display_name: "Suite".to_string(),
members: vec!["cargo:crates/member/Cargo.toml".to_string()],
mismatch_detected: false,
}],
warnings: Vec::new(),
};
let mut plan = synthesize_no_changeset_prerelease_plan(&discovery);
let config = monochange_core::PrereleaseConfiguration {
enabled: true,
..Default::default()
};
apply_prerelease_versions_to_plan(&mut plan, &discovery, None, &config).unwrap();
assert_eq!(plan.groups.len(), 1);
assert_eq!(
plan.groups[0].planned_version.as_ref().unwrap().to_string(),
"1.2.3-alpha.0"
);
assert_eq!(
plan.decisions[0].planned_version,
plan.groups[0].planned_version
);
let prepared = build_prerelease_state_update(&root, &plan, &discovery, None, &config).unwrap();
let state: serde_json::Value = serde_json::from_slice(&prepared.state_update.content).unwrap();
assert_eq!(
state["groups"]["suite"]["latest_prerelease_version"],
serde_json::json!("1.2.3-alpha.0")
);
}
#[test]
fn prerelease_state_rejects_unsupported_schema_version() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let path = prerelease_state_path(tempdir.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
serde_json::json!({
"schema_version": 999,
"channel": "alpha",
"numbering": "increment",
"base": "planned",
"created_at": "2026-05-22T00:00:00Z",
"updated_at": "2026-05-22T00:00:00Z",
"packages": {},
"groups": {}
})
.to_string(),
)
.unwrap();
let error = load_prerelease_state(tempdir.path()).expect_err("unsupported schema should fail");
assert!(error.to_string().contains("unsupported schema_version 999"));
}
#[test]
fn prerelease_state_reports_read_failures() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let path = prerelease_state_path(tempdir.path());
std::fs::create_dir_all(&path).unwrap_or_else(|error| panic!("state directory: {error}"));
let error =
load_prerelease_state(tempdir.path()).expect_err("directory should not load as JSON");
let rendered = error.to_string();
assert!(rendered.contains("failed to read"), "error: {rendered}");
assert!(
rendered.contains("prerelease-state.json"),
"error: {rendered}"
);
}
#[test]
fn invalid_prerelease_channel_reports_version_build_error() {
let config = monochange_core::PrereleaseConfiguration {
enabled: true,
channel: "alpha+bad".to_string(),
..Default::default()
};
let base = semver::Version::parse("1.2.3").unwrap();
let error = append_prerelease(&base, &config, None).expect_err("invalid channel should fail");
assert!(
error
.to_string()
.contains("failed to build prerelease version for `1.2.3`"),
"error: {error}"
);
}
#[test]
fn prerelease_plan_skips_unplanned_group_and_uses_current_stable_group_base() {
let root = std::path::PathBuf::from("/workspace");
let mut member = test_package(&root, "member", "1.2.3-alpha.7");
member.version_group_id = Some("suite".to_string());
let discovery = monochange_core::DiscoveryReport {
workspace_root: root,
packages: vec![member],
dependencies: Vec::new(),
version_groups: Vec::new(),
warnings: Vec::new(),
};
let mut plan = monochange_core::ReleasePlan {
workspace_root: std::path::PathBuf::from("/workspace"),
decisions: vec![monochange_core::ReleaseDecision {
package_id: "cargo:crates/member/Cargo.toml".to_string(),
trigger_type: "direct-change".to_string(),
recommended_bump: monochange_core::BumpSeverity::Patch,
planned_version: None,
group_id: None,
reasons: Vec::new(),
upstream_sources: Vec::new(),
warnings: Vec::new(),
}],
groups: vec![
monochange_core::PlannedVersionGroup {
group_id: "skipped".to_string(),
display_name: "Skipped".to_string(),
members: Vec::new(),
mismatch_detected: false,
planned_version: None,
recommended_bump: monochange_core::BumpSeverity::Patch,
},
monochange_core::PlannedVersionGroup {
group_id: "suite".to_string(),
display_name: "Suite".to_string(),
members: vec!["cargo:crates/member/Cargo.toml".to_string()],
mismatch_detected: false,
planned_version: Some(semver::Version::parse("2.0.0").unwrap()),
recommended_bump: monochange_core::BumpSeverity::Patch,
},
],
warnings: Vec::new(),
unresolved_items: Vec::new(),
compatibility_evidence: Vec::new(),
};
let config = monochange_core::PrereleaseConfiguration {
enabled: true,
base: monochange_core::PrereleaseBase::CurrentStable,
..Default::default()
};
apply_prerelease_versions_to_plan(&mut plan, &discovery, None, &config).unwrap();
assert!(plan.groups[0].planned_version.is_none());
assert_eq!(
plan.groups[1].planned_version,
Some(semver::Version::parse("1.2.3-alpha.0").unwrap())
);
}
#[test]
fn prerelease_state_skips_unplanned_and_unknown_entries() {
let root = std::path::PathBuf::from("/workspace");
let package = test_package(&root, "member", "1.2.3");
let discovery = monochange_core::DiscoveryReport {
workspace_root: root.clone(),
packages: vec![package],
dependencies: Vec::new(),
version_groups: Vec::new(),
warnings: Vec::new(),
};
let plan = monochange_core::ReleasePlan {
workspace_root: root.clone(),
decisions: vec![
monochange_core::ReleaseDecision {
package_id: "cargo:crates/member/Cargo.toml".to_string(),
trigger_type: "direct-change".to_string(),
recommended_bump: monochange_core::BumpSeverity::Patch,
planned_version: None,
group_id: None,
reasons: Vec::new(),
upstream_sources: Vec::new(),
warnings: Vec::new(),
},
monochange_core::ReleaseDecision {
package_id: "missing".to_string(),
trigger_type: "direct-change".to_string(),
recommended_bump: monochange_core::BumpSeverity::Patch,
planned_version: Some(semver::Version::parse("1.2.4-alpha.0").unwrap()),
group_id: None,
reasons: Vec::new(),
upstream_sources: Vec::new(),
warnings: Vec::new(),
},
],
groups: vec![monochange_core::PlannedVersionGroup {
group_id: "skipped".to_string(),
display_name: "Skipped".to_string(),
members: Vec::new(),
mismatch_detected: false,
planned_version: None,
recommended_bump: monochange_core::BumpSeverity::Patch,
}],
warnings: Vec::new(),
unresolved_items: Vec::new(),
compatibility_evidence: Vec::new(),
};
let config = monochange_core::PrereleaseConfiguration {
enabled: true,
..Default::default()
};
let prepared = build_prerelease_state_update(&root, &plan, &discovery, None, &config).unwrap();
let state: serde_json::Value = serde_json::from_slice(&prepared.state_update.content).unwrap();
assert_eq!(state["packages"].as_object().unwrap().len(), 0);
assert_eq!(state["groups"].as_object().unwrap().len(), 0);
}
#[test]
fn fixed_prerelease_base_overrides_group_planned_version() {
let root = std::path::PathBuf::from("/workspace");
let mut member = test_package(&root, "member", "1.2.3");
member.version_group_id = Some("suite".to_string());
let discovery = monochange_core::DiscoveryReport {
workspace_root: root,
packages: vec![member],
dependencies: Vec::new(),
version_groups: Vec::new(),
warnings: Vec::new(),
};
let mut plan = monochange_core::ReleasePlan {
workspace_root: std::path::PathBuf::from("/workspace"),
decisions: Vec::new(),
groups: vec![monochange_core::PlannedVersionGroup {
group_id: "suite".to_string(),
display_name: "Suite".to_string(),
members: vec!["cargo:crates/member/Cargo.toml".to_string()],
mismatch_detected: false,
planned_version: Some(semver::Version::parse("2.0.0").unwrap()),
recommended_bump: monochange_core::BumpSeverity::Patch,
}],
warnings: Vec::new(),
unresolved_items: Vec::new(),
compatibility_evidence: Vec::new(),
};
let config = monochange_core::PrereleaseConfiguration {
enabled: true,
base: monochange_core::PrereleaseBase::Fixed,
base_version: Some(semver::Version::parse("0.5.0").unwrap()),
..Default::default()
};
apply_prerelease_versions_to_plan(&mut plan, &discovery, None, &config).unwrap();
assert_eq!(
plan.groups[0].planned_version,
Some(semver::Version::parse("0.5.0-alpha.0").unwrap())
);
}