mod mutant_killers_tests;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use miette::LabeledSpan;
use monochange_core::BumpSeverity;
use monochange_core::ChangelogDefinition;
use monochange_core::ChangelogFormat;
use monochange_core::ChangelogSettings;
use monochange_core::ChangelogTarget;
use monochange_core::CliCommandDefinition;
use monochange_core::CliInputDefinition;
use monochange_core::CliInputKind;
use monochange_core::CliStepDefinition;
use monochange_core::CliStepInputValue;
use monochange_core::Ecosystem;
use monochange_core::EcosystemType;
use monochange_core::GroupChangelogInclude;
use monochange_core::GroupDefinition;
use monochange_core::MonochangeResult;
use monochange_core::PackageRecord;
use monochange_core::PublishMode;
use monochange_core::PublishRegistry;
use monochange_core::PublishState;
use monochange_core::RegistryKind;
use monochange_core::ShellConfig;
use monochange_core::SourceProvider;
use monochange_core::lint::ChangesetLintSettings;
use monochange_core::lint::ChangesetScopedLintSettings;
use monochange_core::lint::ChangesetSummaryLintSettings;
use monochange_core::lint::LintRuleConfig;
use monochange_core::lint::LintSeverity;
use monochange_test_helpers::current_test_name;
use monochange_test_helpers::snapshot_settings;
use semver::Version;
use tempfile::tempdir;
fn infer_package_bump_from_explicit_version(
package_id: &str,
packages: &[PackageRecord],
explicit_version: Option<&Version>,
) -> Option<BumpSeverity> {
let explicit_version = explicit_version?;
packages
.iter()
.find(|package| package.id == package_id)
.and_then(|package| package.current_version.as_ref())
.map(|current_version| crate::infer_bump_from_versions(current_version, explicit_version))
}
fn infer_group_bump_from_explicit_version(
group: &GroupDefinition,
workspace_root: &Path,
packages: &[PackageRecord],
explicit_version: Option<&Version>,
) -> MonochangeResult<Option<BumpSeverity>> {
let Some(explicit_version) = explicit_version else {
return Ok(None);
};
let mut max_version: Option<&Version> = None;
for member_id in &group.packages {
let package_id = resolve_package_reference(member_id, workspace_root, packages)?;
if let Some(current_version) = packages
.iter()
.find(|package| package.id == package_id)
.and_then(|package| package.current_version.as_ref())
{
max_version = Some(match max_version {
Some(current_max) if current_version > current_max => current_version,
Some(current_max) => current_max,
None => current_version,
});
}
}
Ok(max_version
.map(|current_version| crate::infer_bump_from_versions(current_version, explicit_version)))
}
use crate::apply_version_groups;
use crate::cli_input_kind_matches_step_input;
use crate::frontmatter_span_for_line_column;
use crate::line_and_column_for_offset;
use crate::line_index_for_offset;
use crate::load_change_signals;
use crate::load_changeset_file;
use crate::load_workspace_configuration;
use crate::range_to_span;
use crate::render_diagnostic_notes;
use crate::render_source_diagnostic;
use crate::render_source_snippet;
use crate::render_source_snippets;
use crate::resolve_package_reference;
use crate::sort_labels_by_location;
use crate::validate_step_input_overrides;
use crate::validate_step_override_kind;
use crate::validate_workspace;
fn validate_step() -> CliStepDefinition {
CliStepDefinition::Validate {
name: None,
when: None,
always_run: false,
inputs: BTreeMap::new(),
}
}
#[test]
fn cli_step_override_validation_covers_type_helpers_and_missing_inherited_inputs() {
let command = CliCommandDefinition {
name: "release".to_string(),
help_text: None,
inputs: vec![
cli_input("fix", CliInputKind::Boolean),
cli_input("format", CliInputKind::Choice),
],
steps: Vec::new(),
dry_run: false,
};
let validate = validate_step();
validate_step_override_kind(
&command,
&validate,
"fix",
Some(&CliStepInputValue::Boolean(true)),
true,
)
.unwrap_or_else(|error| panic!("boolean override should validate: {error}"));
validate_step_override_kind(
&command,
&validate,
"fix",
Some(&CliStepInputValue::String("{{ inputs.fix }}".to_string())),
true,
)
.unwrap_or_else(|error| panic!("boolean template override should validate: {error}"));
validate_step_override_kind(
&command,
&validate,
"format",
Some(&CliStepInputValue::String("json".to_string())),
false,
)
.unwrap_or_else(|error| panic!("string override should validate: {error}"));
validate_step_override_kind(
&command,
&validate,
"format",
Some(&CliStepInputValue::List(vec!["json".to_string()])),
false,
)
.unwrap_or_else(|error| panic!("list override should validate: {error}"));
assert!(
validate_step_override_kind(
&command,
&validate,
"fix",
Some(&CliStepInputValue::List(vec!["true".to_string()])),
true,
)
.is_err()
);
assert!(
validate_step_override_kind(
&command,
&validate,
"format",
Some(&CliStepInputValue::Boolean(true)),
false,
)
.is_err()
);
assert!(cli_input_kind_matches_step_input(
CliInputKind::String,
CliInputKind::Choice,
));
assert!(cli_input_kind_matches_step_input(
CliInputKind::Path,
CliInputKind::String,
));
assert!(cli_input_kind_matches_step_input(
CliInputKind::Choice,
CliInputKind::Path,
));
let mut inherited_inputs = BTreeMap::new();
inherited_inputs.insert("format".to_string(), CliStepInputValue::Inherited);
let step = CliStepDefinition::PrepareRelease {
name: None,
when: None,
always_run: false,
allow_empty_changesets: false,
inputs: inherited_inputs,
};
let command_without_format = CliCommandDefinition {
name: "release".to_string(),
help_text: None,
inputs: Vec::new(),
steps: Vec::new(),
dry_run: false,
};
let error = validate_step_input_overrides(&command_without_format, &step)
.err()
.unwrap_or_else(|| panic!("expected missing inherited input error"));
assert!(
error
.to_string()
.contains("inherits input `format` but the command does not declare it")
);
}
fn fixture_path(relative: &str) -> PathBuf {
monochange_test_helpers::fs::fixture_path_from(env!("CARGO_MANIFEST_DIR"), relative)
}
fn setup_fixture(relative: &str) -> tempfile::TempDir {
monochange_test_helpers::fs::setup_fixture_from(env!("CARGO_MANIFEST_DIR"), relative)
}
fn setup_scenario_workspace(relative: &str) -> tempfile::TempDir {
monochange_test_helpers::fs::setup_scenario_workspace_from(env!("CARGO_MANIFEST_DIR"), relative)
}
#[test]
fn shared_fs_test_support_helpers_cover_plain_and_case_names_and_fixture_copying() {
assert_eq!(
current_test_name(),
"shared_fs_test_support_helpers_cover_plain_and_case_names_and_fixture_copying"
);
let named = std::thread::Builder::new()
.name("case_1_config_helper_thread".to_string())
.spawn(current_test_name)
.unwrap_or_else(|error| panic!("spawn thread: {error}"))
.join()
.unwrap_or_else(|error| panic!("join thread: {error:?}"));
assert_eq!(named, "config_helper_thread");
let copied_fixture = setup_fixture("test-support/setup-fixture");
assert_eq!(
std::fs::read_to_string(copied_fixture.path().join("root.txt"))
.unwrap_or_else(|error| panic!("read copied fixture: {error}")),
"root fixture\n"
);
let scenario = setup_scenario_workspace("test-support/scenario-root");
assert_eq!(
std::fs::read_to_string(scenario.path().join("root-only.txt"))
.unwrap_or_else(|error| panic!("read copied scenario: {error}")),
"root scenario\n"
);
assert!(!scenario.path().join("expected").exists());
}
#[test]
fn load_workspace_configuration_uses_defaults_when_file_is_missing() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
assert_eq!(configuration.defaults.parent_bump, BumpSeverity::Patch);
assert!(!configuration.defaults.include_private);
assert!(configuration.defaults.warn_on_group_mismatch);
assert!(!configuration.defaults.strict_version_conflicts);
assert_eq!(configuration.defaults.package_type, None);
assert_eq!(configuration.defaults.changelog, None);
assert_eq!(
configuration.defaults.changelog_format,
ChangelogFormat::Monochange
);
assert_eq!(configuration.defaults.empty_update_message, None);
assert!(configuration.packages.is_empty());
assert!(configuration.groups.is_empty());
assert!(configuration.cli.is_empty());
assert_eq!(configuration.cargo.enabled, None);
assert_eq!(configuration.npm.enabled, None);
assert_eq!(configuration.deno.enabled, None);
assert_eq!(configuration.dart.enabled, None);
}
#[test]
fn load_workspace_configuration_supports_diagnostics_cli_command_definition() {
let root = fixture_path("config/diagnostics-cli");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let diagnostics = configuration
.cli
.iter()
.find(|command| command.name == "diagnostics")
.unwrap_or_else(|| panic!("expected diagnostics command"));
assert_eq!(diagnostics.steps.len(), 1);
match diagnostics.steps.first() {
Some(CliStepDefinition::DiagnoseChangesets { .. }) => {}
Some(_) => panic!("expected DiagnoseChangesets step"),
None => panic!("expected diagnostics step"),
}
}
#[test]
fn load_workspace_configuration_keeps_configured_cli_commands_without_implicit_defaults() {
let root = fixture_path("config/merge-default-cli-overrides");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let cli_command_names = configuration
.cli
.iter()
.map(|cli_command| cli_command.name.as_str())
.collect::<Vec<_>>();
assert_eq!(cli_command_names, vec!["release"]);
let release = configuration
.cli
.iter()
.find(|command| command.name == "release")
.unwrap_or_else(|| panic!("expected release command"));
assert_eq!(
release.help_text.as_deref(),
Some("Prepare a release and refresh the cached release manifest")
);
assert!(release.inputs.is_empty());
assert!(matches!(
release.steps.first(),
Some(CliStepDefinition::PrepareRelease { .. })
));
assert_eq!(release.steps.len(), 1);
}
#[test]
fn load_workspace_configuration_supports_commit_release_cli_command_definition() {
let root = fixture_path("config/commit-release-cli");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let commit_release = configuration
.cli
.iter()
.find(|command| command.name == "commit-release")
.unwrap_or_else(|| panic!("expected commit-release command"));
assert_eq!(commit_release.steps.len(), 2);
assert!(matches!(
commit_release.steps.first(),
Some(CliStepDefinition::PrepareRelease { .. })
));
assert!(matches!(
commit_release.steps.get(1),
Some(CliStepDefinition::CommitRelease { .. })
));
}
#[test]
fn load_workspace_configuration_supports_retarget_release_cli_command_definition() {
let root = fixture_path("config/repair-release-cli");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let repair_release = configuration
.cli
.iter()
.find(|command| command.name == "repair-release")
.unwrap_or_else(|| panic!("expected repair-release command"));
assert_eq!(repair_release.steps.len(), 1);
assert!(matches!(
repair_release.steps.first(),
Some(CliStepDefinition::RetargetRelease { .. })
));
}
#[test]
fn load_workspace_configuration_rejects_invalid_boolean_input_defaults() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
std::fs::write(
tempdir.path().join("monochange.toml"),
r#"
[cli.repair-release]
[[cli.repair-release.inputs]]
name = "force"
type = "boolean"
default = "maybe"
[[cli.repair-release.steps]]
type = "RetargetRelease"
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let error = load_workspace_configuration(tempdir.path())
.err()
.unwrap_or_else(|| panic!("expected invalid boolean default error"));
assert!(
error
.to_string()
.contains("boolean default must be `true` or `false`")
);
}
#[test]
fn load_workspace_configuration_rejects_empty_when_conditions() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
std::fs::write(
tempdir.path().join("monochange.toml"),
r#"
[cli.announce]
[[cli.announce.steps]]
type = "Command"
when = ""
command = "echo should not run"
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let error = load_workspace_configuration(tempdir.path())
.err()
.unwrap_or_else(|| panic!("expected empty-when error"));
assert!(error.to_string().contains("has an empty `when` condition"));
}
#[test]
fn load_workspace_configuration_supports_boolean_input_default_values() {
let root = fixture_path("config/accepts-boolean-input-defaults");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let command = configuration
.cli
.iter()
.find(|command| command.name == "pr-check")
.unwrap_or_else(|| panic!("expected pr-check command"));
let dry_run = command
.inputs
.iter()
.find(|input| input.name == "dry_run")
.unwrap_or_else(|| panic!("missing dry_run input"));
let sync = command
.inputs
.iter()
.find(|input| input.name == "sync")
.unwrap_or_else(|| panic!("missing sync input"));
assert_eq!(dry_run.default.as_deref(), Some("true"));
assert_eq!(sync.default.as_deref(), Some("false"));
}
#[test]
fn load_workspace_configuration_supports_numeric_input_default_values() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
std::fs::write(
tempdir.path().join("monochange.toml"),
r#"
[cli.deploy]
[[cli.deploy.inputs]]
name = "port"
type = "string"
default = 8080
[[cli.deploy.inputs]]
name = "timeout"
type = "string"
default = 3.5
[[cli.deploy.steps]]
type = "Validate"
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
let command = configuration
.cli
.iter()
.find(|command| command.name == "deploy")
.unwrap_or_else(|| panic!("expected deploy command"));
let port = command
.inputs
.iter()
.find(|input| input.name == "port")
.unwrap_or_else(|| panic!("missing port input"));
let timeout = command
.inputs
.iter()
.find(|input| input.name == "timeout")
.unwrap_or_else(|| panic!("missing timeout input"));
assert_eq!(port.default.as_deref(), Some("8080"));
assert_eq!(timeout.default.as_deref(), Some("3.5"));
}
#[test]
fn load_workspace_configuration_parses_package_group_and_cli_command_declarations() {
let root = fixture_path("config/package-group-and-cli");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
assert_eq!(configuration.defaults.parent_bump, BumpSeverity::Minor);
assert!(configuration.defaults.include_private);
assert!(!configuration.defaults.strict_version_conflicts);
assert_eq!(
configuration.defaults.package_type,
Some(monochange_core::PackageType::Cargo)
);
assert_eq!(
configuration.defaults.changelog,
Some(ChangelogDefinition::PathPattern(
"{{ path }}/CHANGELOG.md".to_string()
))
);
assert_eq!(
configuration.defaults.changelog_format,
ChangelogFormat::Monochange
);
assert_eq!(configuration.packages.len(), 2);
assert_eq!(configuration.groups.len(), 1);
assert_eq!(
configuration
.cli
.iter()
.find(|command| command.name == "release")
.unwrap_or_else(|| panic!("expected release CLI command"))
.steps
.len(),
1
);
assert_eq!(configuration.defaults.empty_update_message, None);
assert_eq!(configuration.npm.roots, vec!["packages/*"]);
let first_package = configuration
.packages
.first()
.unwrap_or_else(|| panic!("expected first package"));
assert_eq!(first_package.id, "core");
assert_eq!(
first_package.changelog,
Some(ChangelogTarget {
path: PathBuf::from("crates/core/changelog.md"),
format: ChangelogFormat::Monochange,
initial_header: None,
})
);
assert_eq!(
configuration
.groups
.first()
.unwrap_or_else(|| panic!("expected group"))
.packages,
vec!["core", "npm:web"]
);
}
#[test]
fn load_workspace_configuration_parses_github_release_settings() {
let root = fixture_path("config/github-release-settings");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let source = configuration
.source
.unwrap_or_else(|| panic!("expected source config"));
assert_eq!(source.provider, SourceProvider::GitHub);
assert_eq!(source.owner, "ifiokjr");
assert_eq!(source.repo, "monochange");
assert!(source.releases.enabled);
assert!(source.releases.draft);
assert!(source.releases.prerelease);
assert!(source.releases.generate_notes);
assert_eq!(
source.releases.source,
monochange_core::ProviderReleaseNotesSource::GitHubGenerated
);
assert!(source.pull_requests.enabled);
assert_eq!(source.pull_requests.branch_prefix, "automation/release");
assert_eq!(source.pull_requests.base, "develop");
assert_eq!(
source.pull_requests.title,
"chore(release): prepare release"
);
assert_eq!(
source.pull_requests.labels,
vec!["release", "automated", "bot"]
);
assert!(source.pull_requests.auto_merge);
}
#[test]
fn load_workspace_configuration_parses_release_branch_policy() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
std::fs::write(
tempdir.path().join("monochange.toml"),
r#"
[source]
provider = "github"
owner = "monochange"
repo = "monochange"
[source.releases]
branches = ["main", "release/*"]
enforce_for_tags = true
enforce_for_publish = true
enforce_for_commit = true
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
let source = configuration
.source
.unwrap_or_else(|| panic!("expected source config"));
assert_eq!(source.releases.branches, vec!["main", "release/*"]);
assert!(source.releases.enforce_for_tags);
assert!(source.releases.enforce_for_publish);
assert!(source.releases.enforce_for_commit);
}
#[test]
fn load_workspace_configuration_rejects_blank_release_branch_policy_values() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
std::fs::write(
tempdir.path().join("monochange.toml"),
r#"
[source]
provider = "github"
owner = "monochange"
repo = "monochange"
[source.releases]
branches = ["main", " "]
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let error = load_workspace_configuration(tempdir.path())
.err()
.unwrap_or_else(|| panic!("expected blank release branch policy value error"));
assert!(
error
.to_string()
.contains("[source.releases].branches must not include empty values")
);
}
#[test]
fn load_workspace_configuration_rejects_empty_release_branch_policy() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
std::fs::write(
tempdir.path().join("monochange.toml"),
r#"
[source]
provider = "github"
owner = "monochange"
repo = "monochange"
[source.releases]
branches = []
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let error = load_workspace_configuration(tempdir.path())
.err()
.unwrap_or_else(|| panic!("expected config error"));
assert!(
error
.to_string()
.contains("[source.releases].branches must contain at least one branch pattern")
);
}
#[test]
fn load_workspace_configuration_parses_github_affected_settings() {
let root = fixture_path("config/github-affected-settings");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let source = configuration
.source
.unwrap_or_else(|| panic!("expected source config"));
assert_eq!(source.provider, SourceProvider::GitHub);
let affected = &configuration.changesets.affected;
assert!(affected.enabled);
assert!(affected.comment_on_failure);
assert_eq!(affected.skip_labels, vec!["no-changeset-required"]);
}
#[test]
fn load_workspace_configuration_rejects_missing_package_paths() {
let root = fixture_path("config/rejects-missing-paths");
let rendered = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"))
.render();
assert!(
rendered.contains("does not exist")
|| rendered.contains("missing")
|| rendered.contains("cannot find"),
"rendered: {rendered}"
);
}
#[test]
fn load_workspace_configuration_rejects_duplicate_package_paths() {
let root = fixture_path("config/rejects-duplicate-paths");
let rendered = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"))
.render();
assert!(
rendered.contains("duplicate")
|| rendered.contains("same path")
|| rendered.contains("already used by"),
"rendered: {rendered}"
);
}
#[test]
fn load_workspace_configuration_rejects_missing_expected_manifests() {
let root = fixture_path("config/rejects-missing-manifests");
let result = load_workspace_configuration(&root);
let error = result
.err()
.unwrap_or_else(|| panic!("expected config error"));
let rendered = error.render();
assert!(
rendered.contains("is missing expected cargo manifest")
|| rendered.contains("does not exist")
|| rendered.contains("cannot find")
|| error.to_string().contains("missing")
|| error.to_string().contains("not found"),
"rendered: {rendered}\nerror: {error}"
);
}
#[test]
fn load_workspace_configuration_rejects_empty_source_owner_and_repo() {
let root_owner = fixture_path("config/rejects-empty-github-owner");
assert!(
load_workspace_configuration(&root_owner)
.err()
.unwrap_or_else(|| panic!("expected config error"))
.to_string()
.contains("[source].owner must not be empty")
);
let root_repo = fixture_path("config/rejects-empty-github-repo");
assert!(
load_workspace_configuration(&root_repo)
.err()
.unwrap_or_else(|| panic!("expected config error"))
.to_string()
.contains("[source].repo must not be empty")
);
}
#[test]
fn load_workspace_configuration_rejects_invalid_pull_request_settings() {
let root = fixture_path("config/rejects-invalid-pr-settings");
assert!(
load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"))
.to_string()
.contains("[source.pull_requests].branch_prefix must not be empty")
);
}
#[test]
fn load_workspace_configuration_rejects_empty_pull_request_base_and_title() {
let root_base = fixture_path("config/rejects-empty-pr-base");
assert!(
load_workspace_configuration(&root_base)
.err()
.unwrap_or_else(|| panic!("expected config error"))
.to_string()
.contains("[source.pull_requests].base must not be empty")
);
let root_title = fixture_path("config/rejects-empty-pr-title");
assert!(
load_workspace_configuration(&root_title)
.err()
.unwrap_or_else(|| panic!("expected config error"))
.to_string()
.contains("[source.pull_requests].title must not be empty")
);
}
#[test]
fn load_workspace_configuration_rejects_invalid_github_release_note_source_combinations() {
let root = fixture_path("config/rejects-invalid-release-source");
assert!(
load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"))
.to_string()
.contains("generate_notes cannot be true")
);
}
#[test]
fn load_workspace_configuration_rejects_empty_pull_request_labels() {
let root = fixture_path("config/rejects-empty-pr-labels");
assert!(
load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"))
.to_string()
.contains("[source.pull_requests].labels must not include empty values")
);
}
#[test]
fn load_workspace_configuration_uses_defaults_package_type_when_type_is_omitted() {
let root = fixture_path("config/defaults-package-type");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let package = configuration
.packages
.first()
.unwrap_or_else(|| panic!("expected package"));
assert_eq!(package.package_type, monochange_core::PackageType::Cargo);
}
#[test]
fn load_workspace_configuration_uses_defaults_changelog_pattern_when_package_changelog_is_omitted()
{
let root = fixture_path("config/defaults-changelog-pattern");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let package = configuration
.packages
.first()
.unwrap_or_else(|| panic!("expected package"));
assert_eq!(
configuration.defaults.changelog,
Some(ChangelogDefinition::PathPattern(
"{{ path }}/changelog.md".to_string()
))
);
assert_eq!(
package.changelog,
Some(ChangelogTarget {
path: PathBuf::from("crates/core/changelog.md"),
format: ChangelogFormat::Monochange,
initial_header: None,
})
);
}
#[test]
fn load_workspace_configuration_supports_package_changelog_true_false_and_string() {
let root = fixture_path("config/changelog-variants");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let core = configuration
.package_by_id("core")
.unwrap_or_else(|| panic!("expected core package"));
let app = configuration
.package_by_id("app")
.unwrap_or_else(|| panic!("expected app package"));
let tool = configuration
.package_by_id("tool")
.unwrap_or_else(|| panic!("expected tool package"));
assert_eq!(
configuration.defaults.changelog,
Some(ChangelogDefinition::Disabled)
);
assert_eq!(
core.changelog,
Some(ChangelogTarget {
path: PathBuf::from("crates/core/CHANGELOG.md"),
format: ChangelogFormat::Monochange,
initial_header: None,
})
);
assert_eq!(app.changelog, None);
assert_eq!(
tool.changelog,
Some(ChangelogTarget {
path: PathBuf::from("docs/tool-release-notes.md"),
format: ChangelogFormat::Monochange,
initial_header: None,
})
);
}
#[test]
fn load_workspace_configuration_supports_changelog_format_tables_and_overrides() {
let root = fixture_path("config/changelog-format-tables");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let core = configuration
.package_by_id("core")
.unwrap_or_else(|| panic!("expected core package"));
let app = configuration
.package_by_id("app")
.unwrap_or_else(|| panic!("expected app package"));
let group = configuration
.groups
.first()
.unwrap_or_else(|| panic!("expected group"));
assert_eq!(
configuration.defaults.changelog_format,
ChangelogFormat::KeepAChangelog
);
assert_eq!(
core.changelog,
Some(ChangelogTarget {
path: PathBuf::from("crates/core/CHANGELOG.md"),
format: ChangelogFormat::KeepAChangelog,
initial_header: Some("Default {{ release_owner }} changelog".to_string()),
})
);
assert_eq!(
app.changelog,
Some(ChangelogTarget {
path: PathBuf::from("docs/app-release-notes.md"),
format: ChangelogFormat::Monochange,
initial_header: Some("App {{ package_name }} changelog".to_string()),
})
);
assert_eq!(
group.changelog,
Some(ChangelogTarget {
path: PathBuf::from("docs/group-release-notes.md"),
format: ChangelogFormat::KeepAChangelog,
initial_header: Some("Default {{ release_owner }} changelog".to_string()),
})
);
}
#[test]
fn load_workspace_configuration_supports_group_changelog_include_policies() {
let root = fixture_path("config/group-changelog-include");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let sdk = configuration
.group_by_id("sdk")
.unwrap_or_else(|| panic!("expected sdk group"));
let main = configuration
.group_by_id("main")
.unwrap_or_else(|| panic!("expected main group"));
let docs = configuration
.group_by_id("docs")
.unwrap_or_else(|| panic!("expected docs group"));
assert_eq!(sdk.changelog_include, GroupChangelogInclude::All);
assert_eq!(main.changelog_include, GroupChangelogInclude::GroupOnly);
assert_eq!(
docs.changelog_include,
GroupChangelogInclude::Selected(["api".to_string(), "site".to_string()].into())
);
}
#[test]
fn load_workspace_configuration_rejects_invalid_group_changelog_include_members() {
let root = fixture_path("config/rejects-group-changelog-include-invalid-member");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains(
"group `sdk` changelog include entry `missing` must reference a package declared in that group"
));
assert!(rendered.contains("group changelog include member"));
}
#[test]
fn load_workspace_configuration_supports_empty_group_changelog_include_lists() {
let root = fixture_path("config/group-changelog-include-empty-list");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let sdk = configuration
.group_by_id("sdk")
.unwrap_or_else(|| panic!("expected sdk group"));
assert_eq!(sdk.changelog_include, GroupChangelogInclude::GroupOnly);
}
#[test]
fn load_workspace_configuration_rejects_invalid_group_changelog_include_modes() {
let root = fixture_path("config/rejects-group-changelog-include-invalid-mode");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains(
"group `sdk` changelog include must be `\"all\"`, `\"group-only\"`, or an array of member package ids"
));
assert!(rendered.contains("group changelog include"));
}
#[test]
fn load_workspace_configuration_rejects_empty_group_changelog_include_members() {
let root = fixture_path("config/rejects-group-changelog-include-empty-member");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("group `sdk` changelog include entries must not be empty"));
assert!(rendered.contains("group changelog include member"));
}
#[test]
fn load_workspace_configuration_supports_empty_update_messages() {
let root = fixture_path("config/empty-update-messages");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let core = configuration
.package_by_id("core")
.unwrap_or_else(|| panic!("expected core package"));
let group = configuration
.group_by_id("sdk")
.unwrap_or_else(|| panic!("expected sdk group"));
assert_eq!(
configuration.defaults.empty_update_message.as_deref(),
Some("No package-specific changes for {{ package }}; version is now {{ version }}.")
);
assert_eq!(
core.empty_update_message.as_deref(),
Some("Package override for {{ package }}@{{ version }}")
);
assert_eq!(
group.empty_update_message.as_deref(),
Some("Group fallback for {{ package }} from {{ group }}")
);
}
#[test]
fn load_workspace_configuration_rejects_group_changelog_tables_without_paths() {
let root = fixture_path("config/rejects-group-changelog-no-path");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("group `sdk` changelog must declare a `path`"));
assert!(rendered.contains("group changelog missing path"));
}
#[test]
fn load_workspace_configuration_requires_package_type_without_default() {
let root = fixture_path("config/rejects-missing-type");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("must declare `type` or set `[defaults].package_type`"));
assert!(rendered.contains("single-ecosystem repository"));
}
#[test]
fn load_workspace_configuration_rejects_package_group_namespace_collisions() {
let root = fixture_path("config/rejects-namespace-collision");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("collides with an existing package or group id"));
assert!(rendered.contains("package and group ids share one namespace"));
}
#[test]
fn load_workspace_configuration_rejects_unknown_group_members() {
let root = fixture_path("config/rejects-unknown-group-members");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("references unknown package `missing`"));
assert!(rendered.contains("declare the package first under [package.<id>]"));
}
#[test]
fn load_workspace_configuration_rejects_multi_group_membership() {
let root = fixture_path("config/rejects-multi-group-membership");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("error: package `core` belongs to multiple groups"));
assert!(rendered.contains("--> monochange.toml"));
assert!(rendered.contains("::: monochange.toml"));
assert!(
rendered.contains("= help: move the package into exactly one [group.<id>] declaration")
);
assert!(rendered.contains("= note: the first snippet marks the primary failure location"));
}
#[test]
fn load_workspace_configuration_rejects_duplicate_primary_version_format() {
let root = fixture_path("config/rejects-duplicate-primary-version");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("primary release identity"));
assert!(
rendered
.contains("choose a single package or group as the primary outward release identity")
);
}
#[test]
fn load_workspace_configuration_rejects_unknown_versioned_file_dependencies() {
let root = fixture_path("config/rejects-unknown-versioned-dep");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("unknown versioned file name `missing`"));
assert!(rendered.contains(
"reference a declared package id from `versioned_files` or remove the name entry"
));
}
#[test]
fn load_workspace_configuration_infers_package_versioned_file_types_from_string_entries() {
let root = fixture_path("config/infers-versioned-file-types");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let package = configuration
.packages
.first()
.unwrap_or_else(|| panic!("expected package"));
assert_eq!(package.versioned_files.len(), 2);
assert!(
package
.versioned_files
.iter()
.all(|definition| definition.ecosystem_type == Some(EcosystemType::Cargo))
);
}
#[test]
fn load_workspace_configuration_accepts_regex_versioned_files_without_explicit_type() {
let root = fixture_path("config/regex-versioned-files");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let package = configuration
.packages
.first()
.unwrap_or_else(|| panic!("expected package"));
let definition = package
.versioned_files
.first()
.unwrap_or_else(|| panic!("expected versioned file definition"));
assert_eq!(definition.path, "README.md");
assert_eq!(
definition.regex.as_deref(),
Some(r"https:\/\/example.com\/download\/v(?<version>\d+\.\d+\.\d+)\.tgz")
);
assert_eq!(definition.ecosystem_type, None);
assert_eq!(definition.fields, None);
}
#[test]
fn load_workspace_configuration_rejects_regex_versioned_files_without_version_capture() {
let root = fixture_path("config/rejects-regex-versioned-file-without-version-capture");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("must include a named `version` capture"));
}
#[test]
fn load_workspace_configuration_rejects_regex_versioned_files_with_type() {
let root = fixture_path("config/rejects-regex-versioned-file-with-type");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("regex versioned_files cannot also set `type`"));
}
#[test]
fn load_workspace_configuration_rejects_invalid_regex_versioned_file_patterns() {
let root = fixture_path("config/rejects-invalid-regex-versioned-file");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("pattern `(` is invalid"));
}
#[test]
fn load_workspace_configuration_rejects_regex_versioned_files_with_prefix() {
let root = fixture_path("config/rejects-regex-versioned-file-with-prefix");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(
rendered.contains("regex versioned_files cannot also set `prefix`, `fields`, or `name`")
);
}
#[test]
fn load_workspace_configuration_rejects_group_string_versioned_files_without_explicit_type() {
let root = fixture_path("config/rejects-group-string-versioned");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("bare-string `versioned_files`"));
assert!(rendered.contains("use `versioned_files = [{ path = \"...\", type = \"cargo\" }]`"));
}
#[test]
fn load_workspace_configuration_inherits_ecosystem_versioned_files_unless_package_opt_outs() {
let root = fixture_path("config/ecosystem-versioned-files");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let app = configuration
.packages
.iter()
.find(|package| package.id == "app")
.unwrap_or_else(|| panic!("expected app package"));
let web = configuration
.packages
.iter()
.find(|package| package.id == "web")
.unwrap_or_else(|| panic!("expected web package"));
assert_eq!(app.versioned_files.len(), 1);
assert_eq!(
app.versioned_files
.first()
.map(|definition| definition.path.as_str()),
Some("**/package.json")
);
assert!(web.ignore_ecosystem_versioned_files);
assert_eq!(web.versioned_files.len(), 1);
assert_eq!(
web.versioned_files
.first()
.map(|definition| definition.path.as_str()),
Some("package.json")
);
}
#[test]
fn load_workspace_configuration_inherits_ecosystem_versioned_files_for_cargo_deno_and_dart() {
let root = fixture_path("config/ecosystem-versioned-files-multi");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let rust = configuration
.packages
.iter()
.find(|package| package.id == "rust")
.unwrap_or_else(|| panic!("expected rust package"));
let edge = configuration
.packages
.iter()
.find(|package| package.id == "edge")
.unwrap_or_else(|| panic!("expected edge package"));
let app = configuration
.packages
.iter()
.find(|package| package.id == "app")
.unwrap_or_else(|| panic!("expected app package"));
assert_eq!(
rust.versioned_files
.first()
.map(|definition| definition.path.as_str()),
Some("Cargo.toml")
);
assert_eq!(
edge.versioned_files
.first()
.map(|definition| definition.path.as_str()),
Some("deno.json")
);
assert_eq!(
app.versioned_files
.first()
.map(|definition| definition.path.as_str()),
Some("pubspec.yaml")
);
}
#[test]
fn load_workspace_configuration_inherits_python_ecosystem_defaults() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::create_dir_all(root.join("packages/app"))
.unwrap_or_else(|error| panic!("create package dir: {error}"));
std::fs::write(
root.join("packages/app/pyproject.toml"),
"[project]\nname = \"python-app\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write pyproject: {error}"));
std::fs::write(
root.join("monochange.toml"),
r#"[ecosystems.python]
dependency_version_prefix = "~="
versioned_files = ["pyproject.toml"]
[package.app]
path = "packages/app"
type = "python"
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let configuration =
load_workspace_configuration(root).unwrap_or_else(|error| panic!("configuration: {error}"));
assert_eq!(
configuration.python.dependency_version_prefix.as_deref(),
Some("~=")
);
let package = configuration
.packages
.iter()
.find(|package| package.id == "app")
.unwrap_or_else(|| panic!("expected app package"));
assert_eq!(package.package_type, monochange_core::PackageType::Python);
assert_eq!(
package
.versioned_files
.first()
.map(|definition| definition.ecosystem_type),
Some(Some(EcosystemType::Python))
);
assert_eq!(
package
.versioned_files
.first()
.map(|definition| definition.path.as_str()),
Some("pyproject.toml")
);
assert_eq!(
package.publish.registry,
Some(PublishRegistry::Builtin(RegistryKind::Pypi))
);
}
#[test]
fn load_workspace_configuration_normalizes_go_ecosystem_settings() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::create_dir_all(root.join("packages/api"))
.unwrap_or_else(|error| panic!("create package dir: {error}"));
std::fs::write(
root.join("packages/api/go.mod"),
"module github.com/example/repo/api\n\ngo 1.22\n",
)
.unwrap_or_else(|error| panic!("write go.mod: {error}"));
std::fs::write(
root.join("monochange.toml"),
r#"[ecosystems.go]
dependency_version_prefix = "v"
versioned_files = ["go.mod"]
[ecosystems.go.publish.placeholder]
readme = "Go placeholder"
[package.api]
path = "packages/api"
type = "go"
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let configuration =
load_workspace_configuration(root).unwrap_or_else(|error| panic!("configuration: {error}"));
assert_eq!(
configuration.go.dependency_version_prefix.as_deref(),
Some("v")
);
let package = configuration
.packages
.iter()
.find(|package| package.id == "api")
.unwrap_or_else(|| panic!("expected api package"));
assert_eq!(package.package_type, monochange_core::PackageType::Go);
assert_eq!(
package
.versioned_files
.first()
.map(|definition| definition.ecosystem_type),
Some(Some(EcosystemType::Go))
);
assert_eq!(
package
.versioned_files
.first()
.map(|definition| definition.path.as_str()),
Some("go.mod")
);
assert_eq!(
package.publish.registry,
Some(PublishRegistry::Builtin(RegistryKind::GoProxy))
);
}
#[test]
fn load_workspace_configuration_reports_python_ecosystem_normalization_errors() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::write(
root.join("monochange.toml"),
r#"[ecosystems.python.publish]
registry = "https://example.com/simple"
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let error = load_workspace_configuration(root)
.expect_err("unsupported Python registry override should be rejected");
let message = error.to_string();
assert!(
message.contains("ecosystems `python` uses built-in publishing"),
"unexpected error: {message}"
);
}
#[test]
fn load_workspace_configuration_rejects_python_versioned_file_glob_unsupported_files() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::create_dir_all(root.join("packages/app"))
.unwrap_or_else(|error| panic!("create package dir: {error}"));
std::fs::write(
root.join("packages/app/pyproject.toml"),
"[project]\nname = \"python-app\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write pyproject: {error}"));
std::fs::write(root.join("packages/app/unsupported.json"), "{}")
.unwrap_or_else(|error| panic!("write unsupported: {error}"));
std::fs::write(
root.join("monochange.toml"),
r#"[package.app]
path = "packages/app"
type = "python"
versioned_files = ["packages/app/*.json"]
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let error = match load_workspace_configuration(root) {
Ok(configuration) => panic!("expected error: {configuration:?}"),
Err(error) => error,
};
let message = error.to_string();
assert!(
message.contains("ecosystem `python`"),
"unexpected error: {message}"
);
}
#[test]
fn load_workspace_configuration_parses_ecosystem_lockfile_commands() {
let root = fixture_path("config/lockfile-commands");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
assert_eq!(configuration.npm.lockfile_commands.len(), 3);
let first_command = configuration
.npm
.lockfile_commands
.first()
.unwrap_or_else(|| panic!("expected first lockfile command"));
assert_eq!(first_command.command, "npm install --package-lock-only");
assert_eq!(
first_command.cwd.as_deref(),
Some(Path::new("packages/app"))
);
let second_command = configuration
.npm
.lockfile_commands
.get(1)
.unwrap_or_else(|| panic!("expected second lockfile command"));
assert_eq!(
second_command.shell,
ShellConfig::Custom("bash".to_string())
);
let third_command = configuration
.npm
.lockfile_commands
.get(2)
.unwrap_or_else(|| panic!("expected third lockfile command"));
assert!(third_command.cwd.is_none());
}
#[test]
fn load_workspace_configuration_rejects_empty_lockfile_commands() {
let root = fixture_path("config/rejects-empty-lockfile-command");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
assert!(
error
.render()
.contains("lockfile_commands must provide a non-empty command")
);
}
#[test]
fn load_workspace_configuration_rejects_empty_lockfile_command_cwds() {
let root = fixture_path("config/rejects-empty-lockfile-command-cwd");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
assert!(
error
.render()
.contains("lockfile_commands must provide a non-empty cwd when set")
);
}
#[test]
fn load_workspace_configuration_rejects_lockfile_command_cwds_outside_the_workspace() {
let root = fixture_path("config/rejects-lockfile-command-outside-workspace");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
assert!(
error
.render()
.contains("lockfile_commands cwd `/tmp` must stay within the workspace root")
);
}
#[test]
fn load_workspace_configuration_rejects_missing_lockfile_command_cwds() {
let root = fixture_path("config/rejects-missing-lockfile-command-cwd");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
assert!(
error.render().contains(
"lockfile_commands cwd `packages/missing` does not exist or is not a directory"
)
);
}
#[test]
fn load_workspace_configuration_rejects_globs_that_match_unsupported_files_for_an_ecosystem() {
let root = fixture_path("config/rejects-bad-glob");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("matched unsupported file"));
assert!(rendered.contains("narrow the glob"));
}
#[test]
fn apply_version_groups_assigns_group_ids_and_detects_mismatched_versions() {
let root = fixture_path("config/version-groups");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let mut packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Npm,
"web",
root.join("packages/web/package.json"),
root.clone(),
Some(Version::new(2, 0, 0)),
PublishState::Public,
),
];
let (groups, warnings) = apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let group = groups
.first()
.unwrap_or_else(|| panic!("expected one version group"));
assert_eq!(group.group_id, "sdk");
assert_eq!(group.members.len(), 2);
assert!(group.mismatch_detected);
let first_package = packages
.first()
.unwrap_or_else(|| panic!("expected first package"));
let second_package = packages
.get(1)
.unwrap_or_else(|| panic!("expected second package"));
assert_eq!(first_package.version_group_id.as_deref(), Some("sdk"));
assert_eq!(second_package.version_group_id.as_deref(), Some("sdk"));
assert_eq!(
first_package.metadata.get("config_id").map(String::as_str),
Some("core")
);
assert_eq!(
second_package.metadata.get("config_id").map(String::as_str),
Some("web")
);
assert_eq!(warnings.len(), 1);
}
#[test]
fn load_change_signals_resolves_configured_package_ids() {
let root = fixture_path("config/change-signals-basic");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"cargo-core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let mut packages = packages;
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let signals = load_change_signals(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| panic!("change signals: {error}"));
let signal = signals
.first()
.unwrap_or_else(|| panic!("expected one change signal"));
let package = packages
.first()
.unwrap_or_else(|| panic!("expected discovered package"));
assert_eq!(signal.package_id, package.id);
assert_eq!(signal.requested_bump, Some(BumpSeverity::Minor));
assert_eq!(signal.explicit_version, None);
assert_eq!(signal.notes.as_deref(), Some("public API addition"));
assert_eq!(signal.source_path, root.join("change.md"));
assert!(signal.evidence_refs.is_empty());
}
#[test]
fn load_change_signals_parses_explicit_versions_and_infers_bumps() {
let root = fixture_path("config/change-signals-explicit-version");
let mut packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"cargo-core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let changeset = load_changeset_file(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| panic!("changeset file: {error}"));
let target = changeset
.targets
.first()
.unwrap_or_else(|| panic!("expected target"));
let signal = changeset
.signals
.first()
.unwrap_or_else(|| panic!("expected signal"));
assert_eq!(target.bump, Some(BumpSeverity::Minor));
assert_eq!(target.explicit_version, Some(Version::new(1, 2, 0)));
assert_eq!(signal.requested_bump, Some(BumpSeverity::Minor));
assert_eq!(signal.explicit_version, Some(Version::new(1, 2, 0)));
}
#[test]
fn load_changeset_file_lints_configured_summary_requirements() {
let root = fixture_path("config/changeset-lint-summary");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let summary_rule = configuration
.lints
.rules
.get("changesets/summary")
.unwrap_or_else(|| panic!("expected changesets/summary rule"));
assert_eq!(summary_rule.severity(), LintSeverity::Error);
assert!(summary_rule.bool_option("required", false));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let _file = load_changeset_file(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| {
panic!(
"changeset should load successfully; lint errors are reported by `mc check`: {error}"
)
});
}
#[test]
fn load_changeset_file_lints_configured_bump_and_type_rules() {
let root = fixture_path("config/changeset-lint-bump-type");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let _file = load_changeset_file(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| {
panic!(
"changeset should load successfully; lint errors are reported by `mc check`: {error}"
)
});
}
#[test]
fn load_changeset_file_lints_configured_custom_type_rule() {
let root = fixture_path("config/changeset-lint-custom-type");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
assert!(configuration.changelog.types.contains_key("unicorns"));
assert!(
configuration
.lints
.rules
.contains_key("changesets/types/unicorns")
);
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let _file = load_changeset_file(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| {
panic!(
"changeset should load successfully; lint errors are reported by `mc check`: {error}"
)
});
}
#[test]
fn load_workspace_configuration_rejects_unknown_changeset_type_lint_rule() {
let root = fixture_path("config/changeset-lint-unknown-type");
let error = load_workspace_configuration(&root)
.expect_err("unknown changeset type lint rule should be rejected");
let message = error.to_string();
assert!(
message.contains(
"[lints.rules].changesets/types/unicorns references an unknown changeset type"
),
"unexpected error: {message}"
);
}
fn detailed_lint_rule(options: &[(&str, serde_json::Value)]) -> LintRuleConfig {
LintRuleConfig::Detailed {
level: LintSeverity::Error,
options: options
.iter()
.map(|(key, value)| ((*key).to_string(), value.clone()))
.collect(),
}
}
fn changeset_lint_rules(entries: &[(&str, LintRuleConfig)]) -> BTreeMap<String, LintRuleConfig> {
entries
.iter()
.map(|(key, value)| ((*key).to_string(), value.clone()))
.collect()
}
fn raw_change(bump: Option<BumpSeverity>, change_type: Option<&str>) -> crate::RawChangeEntry {
crate::RawChangeEntry {
package: "cargo-core".to_string(),
bump,
version: None,
reason: None,
details: None,
change_type: change_type.map(str::to_string),
caused_by: Vec::new(),
}
}
fn expect_config_error(result: MonochangeResult<()>, expected: &str) {
let error = result.expect_err("expected config error").to_string();
assert!(error.contains(expected), "unexpected error: {error}");
}
#[test]
fn changeset_lint_rule_parsing_covers_enabled_disabled_and_invalid_options() {
let settings = crate::changeset_lint_settings_from_rules(&changeset_lint_rules(&[
(
"packages/name",
LintRuleConfig::Severity(LintSeverity::Error),
),
(
"changesets/duplicate",
LintRuleConfig::Severity(LintSeverity::Off),
),
(
"changesets/no_section_headings",
LintRuleConfig::Severity(LintSeverity::Error),
),
(
"changesets/summary",
detailed_lint_rule(&[
("required", serde_json::json!(true)),
("heading_level", serde_json::json!(2)),
("min_length", serde_json::json!(3)),
("max_length", serde_json::json!(80)),
("forbid_trailing_period", serde_json::json!(true)),
("forbid_conventional_commit_prefix", serde_json::json!(true)),
]),
),
(
"changesets/bump/minor",
detailed_lint_rule(&[
("required_sections", serde_json::json!(["Impact"])),
("min_body_chars", serde_json::json!(12)),
("max_body_chars", serde_json::json!(200)),
("require_code_block", serde_json::json!(true)),
("required_bump", serde_json::json!("minor")),
("forbidden_headings", serde_json::json!(["Minor"])),
]),
),
(
"changesets/types/Feature",
detailed_lint_rule(&[("required_sections", serde_json::json!(["Usage"]))]),
),
(
"changesets/unknown",
LintRuleConfig::Severity(LintSeverity::Error),
),
]))
.unwrap_or_else(|error| panic!("parse changeset lints: {error}"));
assert!(settings.no_section_headings);
assert!(settings.summary.required);
assert_eq!(settings.summary.heading_level, Some(2));
assert!(settings.bump.contains_key(&BumpSeverity::Minor));
assert!(settings.types.contains_key("Feature"));
for (rule_id, config, expected) in [
(
"changesets/bump/mega",
LintRuleConfig::Severity(LintSeverity::Error),
"uses an unknown bump severity",
),
(
"changesets/types/",
LintRuleConfig::Severity(LintSeverity::Error),
"must include a type name",
),
(
"changesets/summary",
detailed_lint_rule(&[("required", serde_json::json!("yes"))]),
"required must be a boolean",
),
(
"changesets/summary",
detailed_lint_rule(&[(
"forbid_conventional_commit_prefix",
serde_json::json!("yes"),
)]),
"forbid_conventional_commit_prefix must be a boolean",
),
(
"changesets/summary",
detailed_lint_rule(&[("heading_level", serde_json::json!("2"))]),
"heading_level must be a non-negative integer",
),
(
"changesets/bump/major",
detailed_lint_rule(&[("required_sections", serde_json::json!("Impact"))]),
"required_sections must be a string array",
),
(
"changesets/bump/major",
detailed_lint_rule(&[("required_sections", serde_json::json!([1]))]),
"required_sections must be a string array",
),
(
"changesets/bump/major",
detailed_lint_rule(&[("required_bump", serde_json::json!(1))]),
"required_bump must be a bump severity string",
),
(
"changesets/bump/major",
detailed_lint_rule(&[("required_bump", serde_json::json!("mega"))]),
"required_bump uses an unknown bump severity",
),
] {
let error =
crate::changeset_lint_settings_from_rules(&changeset_lint_rules(&[(rule_id, config)]))
.expect_err("invalid lint rule should be rejected")
.to_string();
assert!(error.contains(expected), "unexpected error: {error}");
}
}
#[test]
fn changeset_lint_validation_covers_summary_and_scoped_rule_errors() {
let changelog = ChangelogSettings::default();
let mut valid = ChangesetLintSettings::default();
valid.bump.insert(
BumpSeverity::Minor,
ChangesetScopedLintSettings {
required_sections: vec!["Impact".to_string()],
min_body_chars: Some(5),
max_body_chars: Some(200),
require_code_block: false,
required_bump: None,
forbidden_headings: Vec::new(),
},
);
valid.types.insert(
"feat".to_string(),
ChangesetScopedLintSettings {
required_sections: vec!["Usage".to_string()],
min_body_chars: None,
max_body_chars: None,
require_code_block: false,
required_bump: Some(BumpSeverity::Minor),
forbidden_headings: vec!["Feature".to_string()],
},
);
crate::validate_changeset_lint_settings(&valid, &changelog)
.unwrap_or_else(|error| panic!("valid lint settings: {error}"));
let invalid_cases = [
(
ChangesetLintSettings {
summary: ChangesetSummaryLintSettings {
heading_level: Some(7),
..ChangesetSummaryLintSettings::default()
},
..ChangesetLintSettings::default()
},
"heading_level must be between 1 and 6",
),
(
ChangesetLintSettings {
summary: ChangesetSummaryLintSettings {
min_length: Some(20),
max_length: Some(10),
..ChangesetSummaryLintSettings::default()
},
..ChangesetLintSettings::default()
},
"min_length must not exceed max_length",
),
];
for (settings, expected) in invalid_cases {
let error = crate::validate_changeset_lint_settings(&settings, &changelog)
.expect_err("invalid lint settings should be rejected")
.to_string();
assert!(error.contains(expected), "unexpected error: {error}");
}
for (scoped, expected) in [
(
ChangesetScopedLintSettings {
min_body_chars: Some(20),
max_body_chars: Some(10),
..ChangesetScopedLintSettings::default()
},
"min_body_chars must not exceed max_body_chars",
),
(
ChangesetScopedLintSettings {
required_sections: vec![" ".to_string()],
..ChangesetScopedLintSettings::default()
},
"required_sections must not include empty values",
),
(
ChangesetScopedLintSettings {
forbidden_headings: vec![String::new()],
..ChangesetScopedLintSettings::default()
},
"forbidden_headings must not include empty values",
),
] {
let mut settings = ChangesetLintSettings::default();
settings.bump.insert(BumpSeverity::Patch, scoped);
let error = crate::validate_changeset_lint_settings(&settings, &changelog)
.expect_err("invalid scoped lint settings should be rejected")
.to_string();
assert!(error.contains(expected), "unexpected error: {error}");
}
let mut invalid_type_settings = ChangesetLintSettings::default();
invalid_type_settings.types.insert(
"feat".to_string(),
ChangesetScopedLintSettings {
required_sections: vec![String::new()],
..ChangesetScopedLintSettings::default()
},
);
let error = crate::validate_changeset_lint_settings(&invalid_type_settings, &changelog)
.expect_err("invalid type scoped lint settings should be rejected")
.to_string();
assert!(
error.contains("changesets/types/feat.required_sections must not include empty values"),
"unexpected error: {error}"
);
}
#[test]
fn lint_markdown_changeset_covers_summary_type_and_scope_failures() {
let path = Path::new("change.md");
let changes = [raw_change(Some(BumpSeverity::Minor), Some("Feature"))];
let mut settings = ChangesetLintSettings::default();
assert!(crate::lint_markdown_changeset("", &changes, &settings, path).is_ok());
settings.summary.required = true;
expect_config_error(
crate::lint_markdown_changeset("", &changes, &settings, path),
"changeset body must start with a summary heading",
);
expect_config_error(
crate::lint_markdown_changeset("plain summary", &changes, &settings, path),
"changeset body must start with a summary heading",
);
settings.summary.heading_level = Some(2);
expect_config_error(
crate::lint_markdown_changeset("# Wrong level", &changes, &settings, path),
"summary heading must use level 2",
);
settings.summary.min_length = Some(20);
expect_config_error(
crate::lint_markdown_changeset("## Short", &changes, &settings, path),
"summary must be at least 20 characters",
);
settings.summary.min_length = None;
settings.summary.max_length = Some(5);
expect_config_error(
crate::lint_markdown_changeset("## Summary too long", &changes, &settings, path),
"summary must be at most 5 characters",
);
settings.summary.max_length = None;
settings.summary.forbid_trailing_period = true;
expect_config_error(
crate::lint_markdown_changeset("## Summary.", &changes, &settings, path),
"summary must not end with a period",
);
settings.summary.forbid_trailing_period = false;
settings.summary.forbid_conventional_commit_prefix = true;
expect_config_error(
crate::lint_markdown_changeset("## feat(core): add behavior", &changes, &settings, path),
"summary must not use a conventional-commit prefix",
);
assert!(!crate::has_conventional_commit_prefix("Add behavior"));
settings.summary = ChangesetSummaryLintSettings::default();
settings.no_section_headings = true;
expect_config_error(
crate::lint_markdown_changeset("# Summary\n\n## Feature", &changes, &settings, path),
"must not also be used as a heading",
);
assert!(
crate::lint_markdown_changeset("# Summary\n\n## Details", &changes, &settings, path)
.is_ok()
);
settings.no_section_headings = false;
settings.types.insert(
"feature".to_string(),
ChangesetScopedLintSettings {
required_sections: vec!["Impact".to_string()],
..ChangesetScopedLintSettings::default()
},
);
expect_config_error(
crate::lint_markdown_changeset("# Summary", &changes, &settings, path),
"changeset must include a `Impact` section",
);
assert!(
crate::lint_markdown_changeset("# Summary\n\n## Impact", &changes, &settings, path).is_ok()
);
}
#[test]
fn lint_markdown_scope_covers_each_configured_requirement() {
let path = Path::new("change.md");
let body = "# Summary\n\n## Impact\n\n```rust\nlet value = 1;\n```";
let change = raw_change(Some(BumpSeverity::Patch), Some("breaking"));
expect_config_error(
crate::lint_markdown_scope(
body,
&change,
&ChangesetScopedLintSettings {
required_bump: Some(BumpSeverity::Major),
..ChangesetScopedLintSettings::default()
},
path,
),
"requires bump `major`, found `patch`",
);
expect_config_error(
crate::lint_markdown_scope(
body,
&raw_change(None, Some("breaking")),
&ChangesetScopedLintSettings {
required_bump: Some(BumpSeverity::Major),
..ChangesetScopedLintSettings::default()
},
path,
),
"requires bump `major`, found `auto`",
);
expect_config_error(
crate::lint_markdown_scope(
"# Summary",
&change,
&ChangesetScopedLintSettings {
required_sections: vec!["Impact".to_string()],
..ChangesetScopedLintSettings::default()
},
path,
),
"changeset must include a `Impact` section",
);
expect_config_error(
crate::lint_markdown_scope(
body,
&change,
&ChangesetScopedLintSettings {
forbidden_headings: vec!["Impact".to_string()],
..ChangesetScopedLintSettings::default()
},
path,
),
"changeset must not use `Impact` as a heading",
);
assert!(
crate::lint_markdown_scope(
body,
&change,
&ChangesetScopedLintSettings {
forbidden_headings: vec!["Missing".to_string()],
..ChangesetScopedLintSettings::default()
},
path,
)
.is_ok()
);
expect_config_error(
crate::lint_markdown_scope(
"# Tiny",
&change,
&ChangesetScopedLintSettings {
min_body_chars: Some(20),
..ChangesetScopedLintSettings::default()
},
path,
),
"body must be at least 20 characters",
);
expect_config_error(
crate::lint_markdown_scope(
body,
&change,
&ChangesetScopedLintSettings {
max_body_chars: Some(10),
..ChangesetScopedLintSettings::default()
},
path,
),
"body must be at most 10 characters",
);
expect_config_error(
crate::lint_markdown_scope(
"# Summary\n\n## Impact",
&change,
&ChangesetScopedLintSettings {
require_code_block: true,
..ChangesetScopedLintSettings::default()
},
path,
),
"must include a fenced code block",
);
assert!(
crate::lint_markdown_scope(
"# Summary\n\n~~~text\ncode\n~~~",
&change,
&ChangesetScopedLintSettings {
require_code_block: true,
..ChangesetScopedLintSettings::default()
},
path,
)
.is_ok()
);
}
#[test]
fn markdown_heading_level_rejects_missing_separator_after_hashes() {
assert_eq!(crate::markdown_heading_level("#Not a heading"), None);
}
#[test]
fn markdown_change_text_normalizes_relative_heading_levels() {
let (summary, details) =
crate::markdown_change_text("# Summary\n\n## Details\n\n### Sub-details\n\n- bullet");
assert_eq!(summary.as_deref(), Some("Summary"));
assert_eq!(
details.as_deref(),
Some("##### Details\n\n###### Sub-details\n\n- bullet")
);
}
#[test]
fn markdown_change_text_normalizes_headings_after_plain_text_summary() {
let (summary, details) = crate::markdown_change_text("Summary\n\n# Details\n\n## Sub-details");
assert_eq!(summary.as_deref(), Some("Summary"));
assert_eq!(
details.as_deref(),
Some("##### Details\n\n###### Sub-details")
);
}
#[test]
fn markdown_change_text_clamps_deep_headings_and_preserves_code_fences() {
let (summary, details) = crate::markdown_change_text(
"# Summary\n\n###### Deep detail\n\n```md\n# leave code fences alone\n```",
);
assert_eq!(summary.as_deref(), Some("Summary"));
assert_eq!(
details.as_deref(),
Some("###### Deep detail\n\n```md\n# leave code fences alone\n```")
);
}
#[test]
fn load_change_signals_parses_markdown_change_types_and_details() {
let root = fixture_path("config/change-signals-types-and-details");
let mut packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"cargo-core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let signals = load_change_signals(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| panic!("change signals: {error}"));
let signal = signals.first().unwrap_or_else(|| panic!("expected signal"));
assert_eq!(signal.change_type.as_deref(), Some("security"));
assert_eq!(signal.source_path, root.join("change.md"));
assert_eq!(
signal.details.as_deref(),
Some("Roll the signing key before the release window closes.")
);
}
#[test]
fn load_change_signals_accept_group_scalar_type_shorthand_with_default_bump() {
let root = fixture_path("config/change-signals-group-type-shorthand");
let mut packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"cargo-core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Cargo,
"cargo-app",
root.join("crates/app/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
];
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let changeset = load_changeset_file(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| panic!("changeset file: {error}"));
let target = changeset
.targets
.first()
.unwrap_or_else(|| panic!("expected target"));
assert_eq!(target.id, "sdk");
assert_eq!(target.bump, Some(BumpSeverity::Minor));
assert_eq!(target.change_type.as_deref(), Some("test"));
assert!(
changeset
.signals
.iter()
.all(|signal| signal.requested_bump == Some(BumpSeverity::Minor))
);
}
#[test]
fn load_workspace_configuration_rejects_changelog_type_without_default_bump() {
let root = fixture_path("config/change-signals-type-only-no-default-bump");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
assert!(
error
.to_string()
.contains("[changelog].types.docs must declare a `bump` default")
);
}
#[test]
fn load_change_signals_treats_bump_named_scalar_as_configured_type() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::create_dir_all(root.join("crates/core"))
.unwrap_or_else(|error| panic!("create package dir: {error}"));
std::fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write manifest: {error}"));
std::fs::write(
root.join("monochange.toml"),
r#"
[package.core]
path = "crates/core"
type = "cargo"
[changelog.sections.fixes]
heading = "Fixed"
priority = 20
[changelog.types.patch]
section = "fixes"
bump = "minor"
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
std::fs::write(
root.join("change.md"),
"---\ncore: patch\n---\n\nFix a parser bug.\n",
)
.unwrap_or_else(|error| panic!("write changeset: {error}"));
let configuration =
load_workspace_configuration(root).unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let signals = load_change_signals(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| panic!("change signals: {error}"));
let signal = signals.first().unwrap_or_else(|| panic!("expected signal"));
assert_eq!(signal.requested_bump, Some(BumpSeverity::Minor));
assert_eq!(signal.change_type.as_deref(), Some("patch"));
}
#[test]
fn load_change_signals_rejects_bump_named_scalar_when_type_is_not_configured() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::create_dir_all(root.join("crates/core"))
.unwrap_or_else(|error| panic!("create package dir: {error}"));
std::fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write manifest: {error}"));
std::fs::write(
root.join("monochange.toml"),
r#"
[package.core]
path = "crates/core"
type = "cargo"
[changelog.sections.documentation]
heading = "Documentation"
priority = 30
[changelog.sections.testing]
heading = "Testing"
priority = 40
[changelog.types.docs]
section = "documentation"
bump = "none"
[changelog.types.test]
section = "testing"
bump = "patch"
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let change_path = root.join("change.md");
std::fs::write(&change_path, "---\ncore: patch\n---\n\nPatch change.\n")
.unwrap_or_else(|error| panic!("write changeset: {error}"));
let configuration =
load_workspace_configuration(root).unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let error = load_change_signals(&change_path, &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected parse error"));
let rendered = error.to_string();
assert!(rendered.contains("invalid scalar change type `patch`"));
assert!(rendered.contains("valid types: docs, test"));
}
#[test]
fn load_change_signals_allows_object_bump_without_configured_type() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::create_dir_all(root.join("crates/core"))
.unwrap_or_else(|error| panic!("create package dir: {error}"));
std::fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write manifest: {error}"));
std::fs::write(
root.join("monochange.toml"),
r#"
[package.core]
path = "crates/core"
type = "cargo"
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let change_path = root.join("change.md");
std::fs::write(
&change_path,
"---\ncore:\n bump: patch\n---\n\nPatch change.\n",
)
.unwrap_or_else(|error| panic!("write changeset: {error}"));
let configuration =
load_workspace_configuration(root).unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let signals = load_change_signals(&change_path, &configuration, &packages)
.unwrap_or_else(|error| panic!("change signals: {error}"));
let signal = signals.first().unwrap_or_else(|| panic!("expected signal"));
assert_eq!(signal.requested_bump, Some(BumpSeverity::Patch));
assert_eq!(signal.change_type, None);
}
#[test]
fn load_change_signals_applies_toml_type_defaults_and_validates_context_types() {
let tempdir = setup_fixture("changeset-target-metadata/render-workspace");
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
let mut packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"core",
tempdir.path().join("crates/core/Cargo.toml"),
tempdir.path().to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Cargo,
"app",
tempdir.path().join("crates/app/Cargo.toml"),
tempdir.path().to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
];
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let type_path = tempdir.path().join("type-only.toml");
std::fs::write(
&type_path,
r#"[[changes]]
package = "sdk"
type = "test"
reason = "Exercise a group TOML type default."
"#,
)
.unwrap_or_else(|error| panic!("write type-only.toml: {error}"));
let loaded = load_changeset_file(&type_path, &configuration, &packages)
.unwrap_or_else(|error| panic!("typed changeset: {error}"));
let target = loaded
.targets
.iter()
.find(|target| target.id == "sdk")
.unwrap_or_else(|| panic!("expected sdk target"));
assert_eq!(target.bump, Some(BumpSeverity::Minor));
assert_eq!(target.change_type.as_deref(), Some("test"));
assert_eq!(loaded.signals.len(), 2);
assert!(loaded.signals.iter().all(|signal| {
signal.requested_bump == Some(BumpSeverity::Minor)
&& signal.change_type.as_deref() == Some("test")
}));
let version_path = tempdir.path().join("version-only.toml");
std::fs::write(
&version_path,
r#"[[changes]]
package = "sdk"
version = "2.0.0"
reason = "Exercise group version inference."
"#,
)
.unwrap_or_else(|error| panic!("write version-only.toml: {error}"));
let loaded = load_changeset_file(&version_path, &configuration, &packages)
.unwrap_or_else(|error| panic!("versioned changeset: {error}"));
let target = loaded
.targets
.iter()
.find(|target| target.id == "sdk")
.unwrap_or_else(|| panic!("expected sdk target"));
assert_eq!(target.bump, Some(BumpSeverity::Major));
assert!(
loaded
.signals
.iter()
.all(|signal| signal.requested_bump == Some(BumpSeverity::Major))
);
let packages_without_app = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
tempdir.path().join("crates/core/Cargo.toml"),
tempdir.path().to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let missing_member_error =
load_change_signals(&version_path, &configuration, &packages_without_app)
.err()
.unwrap_or_else(|| panic!("expected missing group member error"));
assert!(
missing_member_error
.to_string()
.contains("change package reference `app` did not match any discovered package")
);
let invalid_group_path = tempdir.path().join("invalid-group-type.toml");
std::fs::write(
&invalid_group_path,
r#"[[changes]]
package = "sdk"
type = "nope"
"#,
)
.unwrap_or_else(|error| panic!("write invalid-group-type.toml: {error}"));
let group_error = load_change_signals(&invalid_group_path, &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected invalid group type error"));
assert!(
group_error
.to_string()
.contains("target `sdk` has invalid type `nope`")
);
let invalid_package_path = tempdir.path().join("invalid-package-type.toml");
std::fs::write(
&invalid_package_path,
r#"[[changes]]
package = "core"
type = "nope"
"#,
)
.unwrap_or_else(|error| panic!("write invalid-package-type.toml: {error}"));
let package_error = load_change_signals(&invalid_package_path, &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected invalid package type error"));
assert!(
package_error
.to_string()
.contains("target `core` has invalid type `nope`")
);
}
#[test]
fn load_change_signals_reports_no_configured_scalar_types() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::create_dir_all(root.join("crates/core"))
.unwrap_or_else(|error| panic!("create package dir: {error}"));
std::fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write manifest: {error}"));
std::fs::write(
root.join("monochange.toml"),
r#"
[package.core]
path = "crates/core"
type = "cargo"
[changelog.sections.misc]
heading = "Miscellaneous"
priority = 90
"#,
)
.unwrap_or_else(|error| panic!("write config: {error}"));
let change_path = root.join("change.md");
std::fs::write(&change_path, "---\ncore: docs\n---\n\n# docs\n")
.unwrap_or_else(|error| panic!("write changeset: {error}"));
let configuration =
load_workspace_configuration(root).unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let error = load_change_signals(&change_path, &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected scalar type error"));
let rendered = error.to_string();
assert!(rendered.contains("invalid scalar change type `docs`"));
assert!(rendered.contains("no configured types are available for this target"));
let scalar = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("docs")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let scalar_error = crate::parse_markdown_change_target(
&scalar,
Path::new("change.md"),
"core",
&configuration,
)
.err()
.unwrap_or_else(|| panic!("expected non-context scalar type error"));
let rendered = scalar_error.to_string();
assert!(rendered.contains("invalid scalar change type `docs`"));
assert!(rendered.contains("no configured types are available for this target"));
}
#[test]
fn validate_changelog_configuration_reports_invalid_toml_when_types_need_raw_fields() {
let mut sections = BTreeMap::new();
sections.insert(
"testing".to_string(),
monochange_core::ChangelogSectionDef {
heading: "Testing".to_string(),
description: None,
priority: 40,
},
);
let mut types = BTreeMap::new();
types.insert(
"test".to_string(),
monochange_core::ChangelogType {
bump: BumpSeverity::Patch,
section: "testing".to_string(),
description: None,
},
);
let error = crate::validate_changelog_configuration(
"[changelog.types.test\n",
&crate::RawChangelogSettings {
templates: Vec::new(),
sections,
section_thresholds: monochange_core::ChangelogSectionThresholds::default(),
types,
},
&[],
&[],
)
.err()
.unwrap_or_else(|| panic!("expected invalid TOML error"));
assert!(
error
.to_string()
.contains("failed to parse monochange.toml")
);
}
#[test]
fn load_change_signals_reject_unknown_scalar_type_with_valid_types_help() {
let root = fixture_path("config/rejects-change-unknown-type-configured");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let error = load_change_signals(&root.join("change.md"), &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected parse error"));
let rendered = error.to_string();
assert!(rendered.contains("invalid scalar change type `nope`"));
assert!(rendered.contains("valid types: docs, test"));
}
#[test]
fn load_change_signals_reject_unknown_object_type_with_valid_types_help() {
let root = fixture_path("config/rejects-change-unknown-object-type");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let error = load_change_signals(&root.join("change.md"), &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected parse error"));
let rendered = error.to_string();
assert!(rendered.contains("invalid type `nope`"));
assert!(rendered.contains("valid types: security"));
}
#[test]
fn load_change_signals_reject_object_type_when_unknown_type_is_used() {
let root = fixture_path("config/rejects-change-object-type-without-configured-sections");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let error = load_change_signals(&root.join("change.md"), &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected parse error"));
let rendered = error.to_string();
assert!(
rendered.contains("invalid")
|| rendered.contains("unknown")
|| rendered.contains("unsupported"),
"error message should indicate unknown/invalid type: {rendered}"
);
}
#[test]
fn load_change_signals_reject_unknown_group_object_type_with_valid_types_help() {
let root = fixture_path("config/rejects-group-change-unknown-object-type");
let mut packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"cargo-core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Cargo,
"cargo-app",
root.join("crates/app/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
];
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let error = load_change_signals(&root.join("change.md"), &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected parse error"));
let rendered = error.to_string();
assert!(rendered.contains("target `sdk` has invalid type `docs`"));
assert!(rendered.contains("valid types: test"));
}
#[test]
fn load_change_signals_reject_none_bump_without_type_or_version() {
let root = fixture_path("config/rejects-change-none-without-type-or-version");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let error = load_change_signals(&root.join("change.md"), &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected parse error"));
assert!(error.to_string().contains(
"must not use `bump = \"none\"` without also declaring `type`, `version`, or `caused_by`"
));
}
#[test]
fn load_change_signals_reject_invalid_object_bumps() {
let root = fixture_path("config/rejects-change-invalid-object-bump");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let error = load_change_signals(&root.join("change.md"), &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected parse error"));
assert!(
error
.to_string()
.contains("has invalid bump `nope`; expected `none`, `patch`, `minor`, or `major`")
);
}
#[test]
fn validate_configured_change_type_accepts_known_type() {
let root = fixture_path("changeset-target-metadata/render-workspace");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
crate::validate_configured_change_type(
&configuration,
Path::new("change.md"),
"core",
"security",
)
.unwrap_or_else(|error| panic!("validate type: {error}"));
}
#[test]
fn validate_configured_change_type_rejects_package_excluded_type() {
let root = fixture_path("changelog-formats/excluded-changelog-types");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let error = crate::validate_configured_change_type(
&configuration,
Path::new("change.md"),
"core",
"test",
)
.err()
.unwrap_or_else(|| panic!("expected invalid type error"));
assert!(error.to_string().contains("invalid type `test`"));
assert!(error.to_string().contains("valid types: feat, fix"));
let scalar = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("test")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let scalar_error = crate::parse_markdown_change_target(
&scalar,
Path::new("change.md"),
"core",
&configuration,
)
.err()
.unwrap_or_else(|| panic!("expected scalar type error"));
assert!(
scalar_error
.to_string()
.contains("invalid scalar change type `test`")
);
assert!(scalar_error.to_string().contains("valid types: feat, fix"));
}
#[test]
fn parse_markdown_change_target_accepts_unconfigured_object_type_literal() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
let value = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("type: docs")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let parsed = crate::parse_markdown_change_target(
&value,
Path::new("change.md"),
"unknown-target",
&configuration,
)
.unwrap_or_else(|error| panic!("parse target: {error}"));
assert_eq!(parsed, (None, None, Some("docs".to_string()), Vec::new()));
}
#[test]
fn parse_markdown_change_target_accepts_unconfigured_scalar_type_literal() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
let value = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("docs")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let parsed = crate::parse_markdown_change_target(
&value,
Path::new("change.md"),
"unknown-target",
&configuration,
)
.unwrap_or_else(|error| panic!("parse target: {error}"));
assert_eq!(
parsed,
(
Some(BumpSeverity::None),
None,
Some("docs".to_string()),
Vec::new()
)
);
}
#[test]
fn parse_markdown_change_target_rejects_non_scalar_non_mapping_values() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
let value = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("[docs]")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let error =
crate::parse_markdown_change_target(&value, Path::new("change.md"), "core", &configuration)
.expect_err("sequence values should fail");
assert!(
error
.to_string()
.contains("must map to a configured change type or to a table")
);
}
#[test]
fn parse_markdown_change_target_rejects_empty_mapping() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
let value = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("{}")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let error =
crate::parse_markdown_change_target(&value, Path::new("change.md"), "core", &configuration)
.expect_err("empty mapping should fail");
assert!(
error
.to_string()
.contains("must declare `bump`, `version`, `type`, or a valid scalar shorthand")
);
}
#[test]
fn configured_change_sections_fall_back_to_empty_for_unknown_targets() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
assert!(
!crate::configured_change_sections(&configuration, "unknown")
.sections
.is_empty()
);
assert!(
!crate::configured_change_sections(&configuration, "unknown")
.types
.is_empty()
);
}
#[test]
fn load_change_signals_rejects_markdown_without_frontmatter() {
let root = fixture_path("config/rejects-change-no-frontmatter");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let error = load_change_signals(&root.join("change.md"), &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected parse error"));
assert!(error.to_string().contains("missing markdown frontmatter"));
}
#[test]
fn load_change_signals_rejects_unterminated_markdown_frontmatter() {
let root = fixture_path("config/rejects-change-unterminated");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let error = load_change_signals(&root.join("change.md"), &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected parse error"));
assert!(
error
.to_string()
.contains("unterminated markdown frontmatter")
);
}
#[test]
fn load_change_signals_rejects_invalid_markdown_bumps() {
let root = fixture_path("config/rejects-change-invalid-bump");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let error = load_change_signals(&root.join("change.md"), &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected parse error"));
assert!(
error
.to_string()
.contains("invalid scalar change type `note`")
);
}
#[test]
fn load_change_signals_rejects_duplicate_package_entries() {
let root = fixture_path("config/rejects-change-duplicate-entries");
let mut packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let rendered = load_change_signals(&root.join("change.toml"), &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected duplicate entry error"))
.render();
assert!(rendered.contains("duplicate change entry"));
}
#[test]
fn load_change_signals_expands_group_targets_into_member_packages() {
let root = fixture_path("config/change-signals-group-expand");
let mut packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Npm,
"web",
root.join("packages/web/package.json"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
];
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let signals = load_change_signals(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| panic!("change signals: {error}"));
assert_eq!(signals.len(), 2);
assert!(
signals
.iter()
.all(|signal| signal.requested_bump == Some(BumpSeverity::Minor))
);
}
#[test]
fn load_change_signals_handles_mixed_group_and_member_targets() {
let root = fixture_path("config/change-signals-group-mixed");
let mut packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Npm,
"web",
root.join("packages/web/package.json"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
];
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let signals = load_change_signals(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| panic!("change signals: {error}"));
assert_eq!(
signals.len(),
2,
"expected exactly 2 signals (no duplicates)"
);
let core_signal = signals
.iter()
.find(|signal| signal.package_id.contains("core"))
.unwrap_or_else(|| panic!("expected core signal"));
let web_signal = signals
.iter()
.find(|signal| signal.package_id.contains("web"))
.unwrap_or_else(|| panic!("expected web signal"));
assert_eq!(
core_signal.requested_bump,
Some(BumpSeverity::Patch),
"explicitly-listed member should get its own bump"
);
assert_eq!(
web_signal.requested_bump,
Some(BumpSeverity::Minor),
"unlisted member should get the group bump"
);
}
#[test]
fn load_change_signals_rejects_invalid_explicit_versions() {
let root = fixture_path("config/rejects-change-invalid-version");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let error = load_change_signals(&root.join("change.md"), &configuration, &packages)
.err()
.unwrap_or_else(|| panic!("expected invalid version error"));
assert!(error.to_string().contains("invalid version `nope`"));
}
#[test]
fn load_changeset_file_preserves_group_targets_and_source_paths() {
let root = fixture_path("config/changeset-file-group-targets");
let mut packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Npm,
"web",
root.join("packages/web/package.json"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
];
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let changeset = load_changeset_file(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| panic!("changeset file: {error}"));
assert_eq!(changeset.path, root.join("change.md"));
assert_eq!(changeset.summary.as_deref(), Some("grouped release"));
assert_eq!(changeset.targets.len(), 1);
let target = changeset
.targets
.first()
.unwrap_or_else(|| panic!("expected one changeset target"));
assert_eq!(target.id, "sdk");
assert_eq!(target.kind.as_str(), "group");
assert_eq!(target.origin, "direct-change");
assert_eq!(target.explicit_version, None);
assert_eq!(changeset.signals.len(), 2);
assert!(
changeset
.signals
.iter()
.all(|signal| signal.source_path == root.join("change.md"))
);
}
#[test]
fn resolve_package_reference_rejects_ambiguous_package_names() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"shared",
tempdir.path().join("crates/core/Cargo.toml"),
tempdir.path().to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Npm,
"shared",
tempdir.path().join("packages/shared/package.json"),
tempdir.path().to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
];
let error = resolve_package_reference("shared", tempdir.path(), &packages)
.err()
.unwrap_or_else(|| panic!("expected ambiguous package error"));
assert!(error.to_string().contains("matched multiple packages"));
}
#[test]
fn validate_workspace_accepts_changesets_that_mix_group_and_member_references() {
let root = fixture_path("config/accepts-mixed-changeset");
validate_workspace(&root)
.unwrap_or_else(|error| panic!("should accept mixed group+member references: {error}"));
}
#[test]
fn load_workspace_configuration_rejects_publish_release_without_source_config() {
let root = fixture_path("config/rejects-publish-no-github");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected github CLI command config error"));
assert!(
error
.to_string()
.contains("uses `PublishRelease` but `[source]` is not configured")
);
}
#[test]
fn load_workspace_configuration_assigns_default_publish_registries_per_ecosystem() {
let root = fixture_path("config/publish-default-registries");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let packages = configuration
.packages
.iter()
.map(|package| (package.id.as_str(), &package.publish))
.collect::<BTreeMap<_, _>>();
assert_eq!(
packages
.get("core")
.and_then(|publish| publish.registry.as_ref()),
Some(&PublishRegistry::Builtin(RegistryKind::CratesIo))
);
assert_eq!(
packages
.get("web")
.and_then(|publish| publish.registry.as_ref()),
Some(&PublishRegistry::Builtin(RegistryKind::Npm))
);
assert_eq!(
packages
.get("jsr_pkg")
.and_then(|publish| publish.registry.as_ref()),
Some(&PublishRegistry::Builtin(RegistryKind::Jsr))
);
assert_eq!(
packages
.get("dart_pkg")
.and_then(|publish| publish.registry.as_ref()),
Some(&PublishRegistry::Builtin(RegistryKind::PubDev))
);
assert!(packages.values().all(|publish| publish.enabled));
assert!(
packages
.values()
.all(|publish| publish.mode == PublishMode::Builtin)
);
assert!(
packages
.values()
.all(|publish| publish.trusted_publishing.enabled)
);
}
#[test]
fn default_publish_registry_for_ecosystem_covers_all_supported_types() {
assert_eq!(
crate::default_publish_registry_for_ecosystem(EcosystemType::Cargo),
Some(PublishRegistry::Builtin(RegistryKind::CratesIo))
);
assert_eq!(
crate::default_publish_registry_for_ecosystem(EcosystemType::Npm),
Some(PublishRegistry::Builtin(RegistryKind::Npm))
);
assert_eq!(
crate::default_publish_registry_for_ecosystem(EcosystemType::Deno),
Some(PublishRegistry::Builtin(RegistryKind::Jsr))
);
assert_eq!(
crate::default_publish_registry_for_ecosystem(EcosystemType::Dart),
Some(PublishRegistry::Builtin(RegistryKind::PubDev))
);
assert_eq!(
crate::default_publish_registry_for_ecosystem(EcosystemType::Python),
Some(PublishRegistry::Builtin(RegistryKind::Pypi))
);
assert_eq!(
crate::default_publish_registry_for_ecosystem(EcosystemType::Go),
Some(PublishRegistry::Builtin(RegistryKind::GoProxy))
);
}
#[test]
fn normalize_trusted_publishing_settings_supports_boolean_shorthand() {
let settings = crate::normalize_trusted_publishing_settings(
None,
Some(crate::RawTrustedPublishingSettings::Enabled(false)),
);
assert!(!settings.enabled);
assert_eq!(settings.repository, None);
assert_eq!(settings.workflow, None);
assert_eq!(settings.environment, None);
}
#[test]
fn normalize_publish_settings_rejects_conflicting_placeholder_sources() {
let error = crate::normalize_publish_settings(
r"
[package.core.publish.placeholder]
",
Some(&monochange_core::PublishSettings {
registry: Some(PublishRegistry::Builtin(RegistryKind::CratesIo)),
placeholder: monochange_core::PlaceholderSettings {
readme: Some("inline".to_string()),
readme_file: Some(PathBuf::from("docs/placeholder.md")),
},
..monochange_core::PublishSettings::default()
}),
crate::RawPublishSettings::default(),
"package",
"core",
EcosystemType::Cargo,
)
.err()
.unwrap_or_else(|| panic!("expected placeholder conflict error"));
assert!(
error.to_string().contains(
"package `core` publish.placeholder cannot set both `readme` and `readme_file`"
)
);
}
#[test]
fn load_workspace_configuration_allows_package_publish_placeholder_to_override_ecosystem_default() {
let root = fixture_path("config/publish-placeholder-package-override");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let package = configuration
.package_by_id("web")
.unwrap_or_else(|| panic!("expected web package"));
assert_eq!(package.publish.placeholder.readme, None);
assert_eq!(
package.publish.placeholder.readme_file.as_deref(),
Some(Path::new("docs/web-placeholder.md"))
);
}
#[test]
fn normalize_publish_settings_merges_rate_limit_enforcement_overrides() {
let disabled = crate::normalize_publish_settings(
r"[package.core.publish.rate_limits]
enforce = false
",
Some(&monochange_core::PublishSettings {
registry: Some(PublishRegistry::Builtin(RegistryKind::CratesIo)),
rate_limits: monochange_core::PublishRateLimitSettings { enforce: true },
..monochange_core::PublishSettings::default()
}),
crate::RawPublishSettings {
rate_limits: crate::RawPublishRateLimitSettings {
enforce: Some(false),
},
..crate::RawPublishSettings::default()
},
"package",
"core",
EcosystemType::Cargo,
)
.unwrap_or_else(|error| panic!("publish settings: {error}"));
assert!(!disabled.rate_limits.enforce);
let enabled = crate::normalize_publish_settings(
r"[package.core.publish.rate_limits]
enforce = true
",
Some(&monochange_core::PublishSettings {
registry: Some(PublishRegistry::Builtin(RegistryKind::CratesIo)),
..monochange_core::PublishSettings::default()
}),
crate::RawPublishSettings {
rate_limits: crate::RawPublishRateLimitSettings {
enforce: Some(true),
},
..crate::RawPublishSettings::default()
},
"package",
"core",
EcosystemType::Cargo,
)
.unwrap_or_else(|error| panic!("publish settings: {error}"));
assert!(enabled.rate_limits.enforce);
}
#[test]
fn load_workspace_configuration_inherits_ecosystem_publish_trusted_publishing_defaults() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::create_dir_all(root.join("packages/web"))
.unwrap_or_else(|error| panic!("create web package: {error}"));
std::fs::create_dir_all(root.join("packages/legacy"))
.unwrap_or_else(|error| panic!("create legacy package: {error}"));
std::fs::write(
root.join("packages/web/package.json"),
r#"{ "name": "web", "version": "1.0.0" }"#,
)
.unwrap_or_else(|error| panic!("write web manifest: {error}"));
std::fs::write(
root.join("packages/legacy/package.json"),
r#"{ "name": "legacy", "version": "1.0.0" }"#,
)
.unwrap_or_else(|error| panic!("write legacy manifest: {error}"));
std::fs::write(
root.join("monochange.toml"),
r#"
[ecosystems.npm.publish.trusted_publishing]
enabled = true
repository = "monochange/monochange"
workflow = "publish.yml"
environment = "publisher"
[package.web]
path = "packages/web"
type = "npm"
[package.legacy]
path = "packages/legacy"
type = "npm"
[package.legacy.publish]
trusted_publishing = false
"#,
)
.unwrap_or_else(|error| panic!("write monochange.toml: {error}"));
let configuration =
load_workspace_configuration(root).unwrap_or_else(|error| panic!("configuration: {error}"));
let web = configuration
.package_by_id("web")
.unwrap_or_else(|| panic!("expected web package"));
let legacy = configuration
.package_by_id("legacy")
.unwrap_or_else(|| panic!("expected legacy package"));
assert!(web.publish.trusted_publishing.enabled);
assert_eq!(
web.publish.trusted_publishing.repository.as_deref(),
Some("monochange/monochange")
);
assert_eq!(
web.publish.trusted_publishing.workflow.as_deref(),
Some("publish.yml")
);
assert_eq!(
web.publish.trusted_publishing.environment.as_deref(),
Some("publisher")
);
assert!(!legacy.publish.trusted_publishing.enabled);
assert_eq!(
legacy.publish.trusted_publishing.workflow.as_deref(),
Some("publish.yml")
);
}
#[test]
fn load_workspace_configuration_inherits_publish_attestation_policy() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::create_dir_all(root.join("packages/web"))
.unwrap_or_else(|error| panic!("create web package: {error}"));
std::fs::create_dir_all(root.join("packages/legacy"))
.unwrap_or_else(|error| panic!("create legacy package: {error}"));
std::fs::write(
root.join("packages/web/package.json"),
r#"{ "name": "web", "version": "1.0.0" }"#,
)
.unwrap_or_else(|error| panic!("write web manifest: {error}"));
std::fs::write(
root.join("packages/legacy/package.json"),
r#"{ "name": "legacy", "version": "1.0.0" }"#,
)
.unwrap_or_else(|error| panic!("write legacy manifest: {error}"));
std::fs::write(
root.join("monochange.toml"),
r#"
[ecosystems.npm.publish.attestations]
require_registry_provenance = true
[package.web]
path = "packages/web"
type = "npm"
[package.legacy]
path = "packages/legacy"
type = "npm"
[package.legacy.publish.attestations]
require_registry_provenance = false
"#,
)
.unwrap_or_else(|error| panic!("write monochange.toml: {error}"));
let configuration =
load_workspace_configuration(root).unwrap_or_else(|error| panic!("configuration: {error}"));
let web = configuration
.package_by_id("web")
.unwrap_or_else(|| panic!("expected web package"));
let legacy = configuration
.package_by_id("legacy")
.unwrap_or_else(|| panic!("expected legacy package"));
assert!(web.publish.attestations.require_registry_provenance);
assert!(!legacy.publish.attestations.require_registry_provenance);
assert!(web.publish.enabled);
}
#[test]
fn load_workspace_configuration_rejects_github_release_attestations_for_other_sources() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::write(
root.join("monochange.toml"),
r#"
[source]
provider = "gitlab"
owner = "monochange"
repo = "monochange"
[source.releases.attestations]
require_github_artifact_attestations = true
"#,
)
.unwrap_or_else(|error| panic!("write monochange.toml: {error}"));
let error = load_workspace_configuration(root)
.expect_err("GitHub release attestations should reject GitLab source");
assert!(error.to_string().contains(
"[source.releases.attestations].require_github_artifact_attestations requires [source].provider = \"github\""
));
}
#[test]
fn load_workspace_configuration_parses_release_attestation_policy() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::write(
root.join("monochange.toml"),
r#"
[source]
provider = "github"
owner = "monochange"
repo = "monochange"
[source.releases.attestations]
require_github_artifact_attestations = true
"#,
)
.unwrap_or_else(|error| panic!("write monochange.toml: {error}"));
let configuration =
load_workspace_configuration(root).unwrap_or_else(|error| panic!("configuration: {error}"));
let source = configuration
.source
.unwrap_or_else(|| panic!("expected source config"));
assert!(
source
.releases
.attestations
.require_github_artifact_attestations
);
}
#[test]
fn load_workspace_configuration_merges_trusted_publishing_details() {
let root = fixture_path("config/publish-trusted-publishing-overrides");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let package = configuration
.package_by_id("web")
.unwrap_or_else(|| panic!("expected web package"));
assert!(package.publish.trusted_publishing.enabled);
assert_eq!(
package.publish.trusted_publishing.repository.as_deref(),
Some("ifiokjr/monochange")
);
assert_eq!(
package.publish.trusted_publishing.workflow.as_deref(),
Some("publish.yml")
);
assert_eq!(
package.publish.trusted_publishing.environment.as_deref(),
Some("publisher")
);
}
#[test]
fn load_workspace_configuration_rejects_builtin_publish_registry_override() {
let root = fixture_path("config/rejects-publish-builtin-registry-override");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected builtin publish registry error"));
assert!(
error.to_string().contains(
"package `core` uses built-in publishing with an unsupported registry override"
)
);
assert!(error.to_string().contains("mode = \"external\""));
}
#[test]
fn load_workspace_configuration_rejects_open_release_pull_request_without_source_config() {
let root = fixture_path("config/rejects-pr-no-github");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected github CLI command config error"));
assert!(
error
.to_string()
.contains("uses `OpenReleaseRequest` but `[source]` is not configured")
);
}
#[test]
fn load_workspace_configuration_rejects_comment_released_issues_for_unsupported_provider() {
assert!(!crate::source_capabilities(SourceProvider::Forgejo).released_issue_comments);
let root_gitlab = fixture_path("config/rejects-comment-unsupported");
let error = load_workspace_configuration(&root_gitlab)
.err()
.unwrap_or_else(|| panic!("expected provider capability error"));
assert!(error.to_string().contains(
"uses `CommentReleasedIssues` but `[source].provider = \"gitlab\"` does not support released-issue comments"
));
let root_gitea = fixture_path("config/rejects-comment-unsupported-gitea");
let error = load_workspace_configuration(&root_gitea)
.err()
.unwrap_or_else(|| panic!("expected provider capability error"));
assert!(error.to_string().contains(
"uses `CommentReleasedIssues` but `[source].provider = \"gitea\"` does not support released-issue comments"
));
}
#[test]
fn load_workspace_configuration_accepts_comment_released_issues_for_github() {
let root = fixture_path("config/accepts-comment-github");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
assert!(
configuration
.cli
.iter()
.any(|command| command.name == "comment")
);
}
#[test]
fn load_workspace_configuration_rejects_affected_packages_when_changeset_verification_disabled() {
let root = fixture_path("config/rejects-enforce-no-affected");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected verification CLI command config error"));
assert!(
error
.to_string()
.contains("uses `AffectedPackages` but `[changesets.affected].enabled` is false")
);
}
#[test]
fn load_workspace_configuration_rejects_affected_packages_without_path_inputs() {
let root = fixture_path("config/rejects-affected-no-path");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected verification CLI command config error"));
assert!(
error
.to_string()
.contains("declares neither a `changed_paths` nor a `from` input")
);
}
#[test]
fn load_workspace_configuration_accepts_affected_packages_step_input_overrides() {
let root = fixture_path("config/affected-step-overrides");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
assert!(
configuration
.cli
.iter()
.any(|command| command.name == "pr-check")
);
}
#[test]
fn load_workspace_configuration_accepts_affected_packages_with_from_in_step_override() {
let root = fixture_path("config/affected-step-from");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
assert!(
configuration
.cli
.iter()
.any(|command| command.name == "pr-check")
);
}
#[test]
fn load_workspace_configuration_rejects_affected_packages_when_step_override_provides_no_path_source()
{
let root = fixture_path("config/rejects-affected-no-source");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"));
assert!(
error
.to_string()
.contains("declares neither a `changed_paths` nor a `from` input")
);
}
#[test]
fn load_workspace_configuration_rejects_step_override_with_boolean_for_non_boolean_input() {
let root = fixture_path("config/rejects-bool-for-non-bool");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"));
assert!(
error
.to_string()
.contains("override `changed_paths` must use a"),
"error was: {error}"
);
}
#[test]
fn load_workspace_configuration_rejects_step_override_with_list_for_boolean_input() {
let root = fixture_path("config/rejects-list-for-bool");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"));
assert!(
error.to_string().contains("override `verify` must use a"),
"error was: {error}"
);
}
#[test]
fn load_workspace_configuration_rejects_unknown_step_input_override() {
let root = fixture_path("validate-step-inputs/unknown-input-on-discover");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"));
assert!(
error
.to_string()
.contains("unknown input override `nonexistent`"),
"error was: {error}"
);
assert!(
error.to_string().contains("valid inputs: format"),
"error was: {error}"
);
}
#[test]
fn load_workspace_configuration_rejects_unknown_input_on_validate_step() {
let root = fixture_path("validate-step-inputs/unknown-input-on-validate");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"));
assert!(
error
.to_string()
.contains("unknown input override `format`"),
"error was: {error}"
);
assert!(
error.to_string().contains("valid inputs: fix"),
"error was: {error}"
);
}
#[test]
fn load_workspace_configuration_allows_any_input_on_command_step() {
let root = fixture_path("validate-step-inputs/any-input-on-command");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let build_cmd = configuration
.cli
.iter()
.find(|c| c.name == "build")
.unwrap_or_else(|| panic!("expected build command"));
assert_eq!(build_cmd.steps.len(), 1);
}
#[test]
fn load_workspace_configuration_rejects_wrong_type_format_override_on_discover() {
let root = fixture_path("validate-step-inputs/wrong-type-format-on-discover");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"));
assert!(
error
.to_string()
.contains("override `format` must use a string value"),
"error was: {error}"
);
}
#[test]
fn load_workspace_configuration_rejects_list_for_string_input_on_change_step() {
let root = fixture_path("validate-step-inputs/list-for-string-on-change");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"));
assert!(
error
.to_string()
.contains("override `reason` must use a string value"),
"error was: {error}"
);
}
#[test]
fn load_workspace_configuration_accepts_valid_diagnose_changesets_step_inputs() {
let root = fixture_path("validate-step-inputs/valid-diagnose-inputs");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let diag_cmd = configuration
.cli
.iter()
.find(|c| c.name == "diag")
.unwrap_or_else(|| panic!("expected diag command"));
assert_eq!(diag_cmd.steps.len(), 1);
}
#[test]
fn load_workspace_configuration_rejects_unknown_input_on_diagnose_changesets() {
let root = fixture_path("validate-step-inputs/unknown-input-on-diagnose");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"));
assert!(
error
.to_string()
.contains("unknown input override `verbose`"),
"error was: {error}"
);
assert!(
error
.to_string()
.contains("valid inputs: format, changeset"),
"error was: {error}"
);
}
#[test]
fn load_workspace_configuration_parses_release_note_customization() {
let root = fixture_path("config/release-note-customization");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let package = configuration
.package_by_id("core")
.unwrap_or_else(|| panic!("expected package"));
assert_eq!(configuration.changelog.templates.len(), 3);
assert_eq!(package.excluded_changelog_types.len(), 0); let security_section = configuration
.changelog
.sections
.get("security")
.unwrap_or_else(|| panic!("expected security section"));
assert_eq!(security_section.heading, "Security");
assert_eq!(security_section.priority, 40);
}
#[test]
fn load_workspace_configuration_parses_extra_changelog_section_with_description() {
let root = fixture_path("config/release-note-customization-with-description");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let package = configuration
.package_by_id("core")
.unwrap_or_else(|| panic!("expected package"));
assert_eq!(package.excluded_changelog_types.len(), 0); let testing_section = configuration
.changelog
.sections
.get("testing")
.unwrap_or_else(|| panic!("expected testing section"));
assert_eq!(testing_section.heading, "Testing");
assert_eq!(testing_section.priority, 40);
assert_eq!(
testing_section.description,
Some("Changes that only modify tests".to_string())
);
}
#[test]
fn section_patterns_support_root_sections_without_ids() {
assert_eq!(
crate::section_patterns("defaults", ""),
["[defaults]".to_string(), "[defaults]".to_string()]
);
}
#[test]
fn load_workspace_configuration_inherits_default_changelog_sections() {
let root = fixture_path("config/default-extra-changelog-sections");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let package = configuration
.package_by_id("core")
.unwrap_or_else(|| panic!("expected package"));
assert_eq!(0 , 0);
assert_eq!(package.excluded_changelog_types.len(), 0); let security_section = configuration
.changelog
.sections
.get("security")
.unwrap_or_else(|| panic!("expected security section"));
assert_eq!(security_section.heading, "Security");
}
#[test]
fn load_workspace_configuration_rejects_empty_extra_changelog_section_names() {
let root = fixture_path("config/rejects-empty-section-names");
let rendered = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"))
.render();
assert!(rendered.contains("sections key must not be empty"));
}
#[test]
fn load_workspace_configuration_rejects_empty_extra_changelog_section_types() {
let root = fixture_path("config/rejects-empty-section-types");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"));
let rendered = error.render();
assert!(rendered.contains("types key must not be empty"));
}
#[test]
fn load_workspace_configuration_rejects_empty_extra_changelog_section_type_values() {
let root = fixture_path("config/rejects-empty-section-type-values");
let rendered = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"))
.render();
assert!(rendered.contains("types key must not be empty"));
}
#[test]
fn load_workspace_configuration_rejects_duplicate_changelog_section_types() {
let root = fixture_path("config/rejects-duplicate-section-types");
let rendered = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"))
.render();
assert!(
rendered.contains("references section"),
"expected duplicate type error, got: {rendered}"
);
}
#[test]
fn load_workspace_configuration_rejects_unknown_change_template_variables() {
let root = fixture_path("config/rejects-unknown-template-vars");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"));
assert!(
error
.render()
.contains("unsupported variables: commit_hash")
);
}
#[test]
fn load_workspace_configuration_rejects_reserved_cli_command_names() {
let root = fixture_path("config/rejects-reserved-cli-names");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
assert!(error.to_string().contains("reserved built-in command"));
}
#[test]
fn load_workspace_configuration_rejects_duplicate_cli_command_tables() {
let root = fixture_path("config/rejects-duplicate-cli");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
assert!(error.to_string().contains("failed to parse"));
}
#[test]
fn load_workspace_configuration_rejects_unsupported_workflows_namespace() {
let root = fixture_path("config/rejects-legacy-workflows");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
assert!(error.to_string().contains("unknown field `workflows`"));
}
#[test]
fn load_change_signals_rejects_unknown_package_references_with_diagnostic_help() {
let root = fixture_path("config/rejects-change-unknown-pkg");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let error = load_change_signals(&root.join("change.md"), &configuration, &[])
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("error: changeset `"));
assert!(rendered.contains("unknown package or group `missing-package`"));
assert!(rendered.contains("help: declare the package or group id in monochange.toml"));
}
#[test]
fn load_change_signals_reports_pretty_frontmatter_parse_errors_with_fix_hint() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
std::fs::write(
tempdir.path().join("monochange.toml"),
"[package.\"@monochange/skill\"]\npath = \"crates/core\"\ntype = \"cargo\"\n",
)
.unwrap_or_else(|error| panic!("write config: {error}"));
std::fs::create_dir_all(tempdir.path().join("crates/core"))
.unwrap_or_else(|error| panic!("mkdir crate: {error}"));
std::fs::write(
tempdir.path().join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
)
.unwrap_or_else(|error| panic!("write cargo manifest: {error}"));
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
let change_path = tempdir.path().join("change.md");
std::fs::write(
&change_path,
"---\n@monochange/skill: patch\n---\n\n# broken\n",
)
.unwrap_or_else(|error| panic!("write changeset: {error}"));
let error = load_change_signals(&change_path, &configuration, &[])
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(
rendered.contains("error: failed to parse"),
"rendered: {rendered}"
);
assert!(
rendered.contains(&format!("--> {}:2:1", change_path.display())),
"rendered: {rendered}"
);
assert!(
rendered.contains("2 | @monochange/skill: patch"),
"rendered: {rendered}"
);
assert!(
rendered.contains("wrap package or group ids that contain characters like `@`, `/`, `:`, or spaces in double quotes"),
"rendered: {rendered}"
);
}
#[test]
fn source_diagnostic_helpers_cover_empty_labels_sorting_and_fallback_spans() {
let source = "alpha\nbeta\ngamma";
let empty = render_source_diagnostic("demo.md", source, "plain failure", &[], None);
assert!(empty.contains("error: plain failure"), "{empty}");
assert!(empty.contains(" --> demo.md:1:1"), "{empty}");
assert!(!empty.contains(" = help:"), "{empty}");
assert!(!empty.contains(" = note:"), "{empty}");
let labels = vec![
LabeledSpan::new_with_span(Some("primary".to_string()), range_to_span(6..10)),
LabeledSpan::new_with_span(Some("later".to_string()), range_to_span(11..16)),
LabeledSpan::new_with_span(Some("earlier".to_string()), range_to_span(0..0)),
];
let sorted = sort_labels_by_location(&labels);
assert_eq!(sorted.first().and_then(LabeledSpan::label), Some("primary"));
assert_eq!(sorted.get(1).and_then(LabeledSpan::label), Some("earlier"));
assert_eq!(sorted.get(2).and_then(LabeledSpan::label), Some("later"));
let rendered = render_source_diagnostic(
"demo.md",
source,
"annotated failure",
&labels,
Some("try fixing it"),
);
assert!(rendered.contains(" --> demo.md:2:1"), "{rendered}");
assert!(rendered.contains(" ::: demo.md:1:1"), "{rendered}");
assert!(rendered.contains(" ::: demo.md:3:1"), "{rendered}");
assert!(rendered.contains("^ primary"), "{rendered}");
assert!(rendered.contains("^ earlier"), "{rendered}");
assert!(rendered.contains("^ later"), "{rendered}");
assert!(rendered.contains(" = help: try fixing it"), "{rendered}");
assert!(
rendered.contains(" = note: the first snippet marks the primary failure location"),
"{rendered}"
);
let secondary = render_source_snippet(
"demo.md",
source,
&LabeledSpan::new_with_span(None, range_to_span(0..0)),
false,
);
assert_eq!(secondary.first(), Some(&" ::: demo.md:1:1".to_string()));
assert!(
secondary.iter().any(|line| line.contains("^ here")),
"{secondary:?}"
);
assert!(render_source_snippets("demo.md", source, &[]).is_empty());
assert!(sort_labels_by_location(&[]).is_empty());
assert!(render_diagnostic_notes(&[]).is_empty());
let single_label = labels
.first()
.cloned()
.map(|label| vec![label])
.unwrap_or_default();
assert!(render_diagnostic_notes(&single_label).is_empty());
assert_eq!(line_index_for_offset(source, usize::MAX), 2);
assert_eq!(line_and_column_for_offset(source, usize::MAX), (3, 6));
assert_eq!(frontmatter_span_for_line_column(source, 2, 99), 10..11);
assert_eq!(
frontmatter_span_for_line_column(source, 99, 1),
source.len()..source.len()
);
}
#[test]
fn load_workspace_configuration_rejects_unsupported_github_namespace() {
let root = fixture_path("config/rejects-source-and-github");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected config error"));
assert!(error.to_string().contains("unknown field `github`"));
}
#[test]
fn load_change_signals_infers_group_bump_from_member_explicit_version() {
let root = fixture_path("config/change-signals-group-explicit");
let mut packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Cargo,
"app",
root.join("crates/app/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
];
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let changeset = load_changeset_file(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| panic!("changeset file: {error}"));
let target = changeset
.targets
.first()
.unwrap_or_else(|| panic!("expected target"));
assert_eq!(target.bump, Some(BumpSeverity::Major));
assert_eq!(target.explicit_version, Some(Version::new(2, 0, 0)));
}
#[test]
fn load_change_signals_uses_highest_group_member_version_for_explicit_group_bump() {
let root = fixture_path("config/change-signals-group-explicit-max");
let mut packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Cargo,
"app",
root.join("crates/app/Cargo.toml"),
root.clone(),
Some(Version::new(2, 0, 0)),
PublishState::Public,
),
];
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let changeset = load_changeset_file(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| panic!("changeset file: {error}"));
let target = changeset
.targets
.first()
.unwrap_or_else(|| panic!("expected target"));
assert_eq!(target.bump, Some(BumpSeverity::Minor));
assert_eq!(target.explicit_version, Some(Version::new(2, 1, 0)));
}
#[test]
fn load_change_signals_keeps_highest_group_member_version_when_members_descend() {
let root = fixture_path("config/change-signals-group-explicit-desc");
let mut packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Cargo,
"app",
root.join("crates/app/Cargo.toml"),
root.clone(),
Some(Version::new(2, 0, 0)),
PublishState::Public,
),
];
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let changeset = load_changeset_file(&root.join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| panic!("changeset file: {error}"));
let target = changeset
.targets
.first()
.unwrap_or_else(|| panic!("expected target"));
assert_eq!(target.bump, Some(BumpSeverity::Minor));
assert_eq!(target.explicit_version, Some(Version::new(2, 1, 0)));
}
#[test]
fn load_workspace_configuration_accepts_detailed_and_enabled_true_changelog_in_defaults() {
let root = fixture_path("config/defaults-changelog-enabled-true");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
assert!(!configuration.packages.is_empty());
assert_eq!(
configuration.defaults.changelog,
Some(ChangelogDefinition::PackageDefault)
);
}
#[test]
fn load_workspace_configuration_accepts_detailed_changelog_disabled_in_defaults() {
let root = fixture_path("config/defaults-changelog-disabled");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
assert!(!configuration.packages.is_empty());
assert_eq!(
configuration.defaults.changelog,
Some(ChangelogDefinition::Disabled)
);
}
#[test]
fn load_workspace_configuration_accepts_detailed_changelog_enabled_with_no_path_in_defaults() {
let root = fixture_path("config/defaults-changelog-no-path");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
assert!(!configuration.packages.is_empty());
assert_eq!(
configuration.defaults.changelog,
Some(ChangelogDefinition::PackageDefault)
);
}
fn package_definition(id: &str, path: &str) -> monochange_core::PackageDefinition {
monochange_core::PackageDefinition {
id: id.to_string(),
path: PathBuf::from(path),
package_type: monochange_core::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,
version_format: monochange_core::VersionFormat::Namespaced,
publish: monochange_core::PublishSettings::default(),
}
}
fn cli_input(name: &str, kind: CliInputKind) -> CliInputDefinition {
CliInputDefinition {
name: name.to_string(),
kind,
help_text: None,
required: false,
default: None,
choices: Vec::new(),
short: None,
}
}
fn cli_command(name: &str, steps: Vec<CliStepDefinition>) -> CliCommandDefinition {
CliCommandDefinition {
name: name.to_string(),
help_text: None,
inputs: Vec::new(),
steps,
dry_run: false,
}
}
fn sample_source_configuration(provider: SourceProvider) -> monochange_core::SourceConfiguration {
monochange_core::SourceConfiguration {
provider,
host: None,
api_url: None,
owner: "ifiokjr".to_string(),
repo: "monochange".to_string(),
releases: monochange_core::ProviderReleaseSettings {
enabled: true,
..Default::default()
},
pull_requests: monochange_core::ProviderMergeRequestSettings {
enabled: true,
..Default::default()
},
}
}
#[test]
fn load_workspace_configuration_rejects_duplicate_command_step_ids() {
let root = fixture_path("config/rejects-duplicate-step-ids");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected error for duplicate step ids"));
assert!(
error.to_string().contains("duplicate step id"),
"error: {error}"
);
}
#[test]
fn load_workspace_configuration_rejects_empty_command_step_id() {
let root = fixture_path("config/rejects-empty-step-id");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected error for empty step id"));
assert!(error.to_string().contains("empty id"), "error: {error}");
}
#[test]
fn load_changeset_file_reports_io_and_toml_parse_errors() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
let missing = load_changeset_file(&tempdir.path().join("missing.toml"), &configuration, &[])
.err()
.unwrap_or_else(|| panic!("expected missing file error"));
assert!(missing.to_string().contains("failed to read"));
let invalid = tempdir.path().join("invalid.toml");
std::fs::write(&invalid, "changes = [")
.unwrap_or_else(|error| panic!("write invalid: {error}"));
let parse_error = load_changeset_file(&invalid, &configuration, &[])
.err()
.unwrap_or_else(|| panic!("expected parse error"));
assert!(parse_error.to_string().contains("failed to parse"));
}
#[test]
fn resolve_package_reference_reports_missing_and_ambiguous_matches() {
let root = PathBuf::from("/workspace");
let package_a = PackageRecord::new(
Ecosystem::Cargo,
"shared",
root.join("crates/shared/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
);
let package_b = PackageRecord::new(
Ecosystem::Cargo,
"shared",
root.join("packages/shared/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
);
let missing =
resolve_package_reference("missing", &root, &[package_a.clone(), package_b.clone()])
.err()
.unwrap_or_else(|| panic!("expected missing package error"));
assert!(
missing
.to_string()
.contains("did not match any discovered package")
);
let ambiguous = resolve_package_reference("shared", &root, &[package_a, package_b])
.err()
.unwrap_or_else(|| panic!("expected ambiguous package error"));
assert!(ambiguous.to_string().contains("matched multiple packages"));
}
#[test]
fn load_workspace_configuration_rejects_empty_step_name() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::create_dir_all(root.join("crates/core"))
.unwrap_or_else(|error| panic!("mkdir core: {error}"));
std::fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write cargo: {error}"));
std::fs::write(
root.join("monochange.toml"),
r#"[defaults]
package_type = "cargo"
[package.core]
path = "crates/core"
[cli.release]
[[cli.release.steps]]
type = "PrepareRelease"
name = " "
"#,
)
.unwrap_or_else(|error| panic!("write monochange: {error}"));
let error = load_workspace_configuration(root)
.err()
.unwrap_or_else(|| panic!("expected error for empty step name"));
assert!(error.to_string().contains("empty `name`"), "error: {error}");
}
#[test]
fn load_workspace_configuration_rejects_duplicate_step_names() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::create_dir_all(root.join("crates/core"))
.unwrap_or_else(|error| panic!("mkdir core: {error}"));
std::fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write cargo: {error}"));
std::fs::write(
root.join("monochange.toml"),
r#"[defaults]
package_type = "cargo"
[package.core]
path = "crates/core"
[cli.release]
[[cli.release.steps]]
type = "PrepareRelease"
name = "plan release"
[[cli.release.steps]]
type = "Command"
name = "plan release"
command = "echo hi"
"#,
)
.unwrap_or_else(|error| panic!("write monochange: {error}"));
let error = load_workspace_configuration(root)
.err()
.unwrap_or_else(|| panic!("expected error for duplicate step names"));
assert!(
error.to_string().contains("duplicate step name"),
"error: {error}"
);
}
#[test]
fn load_workspace_configuration_accepts_command_step_with_shell_string() {
let root = fixture_path("config/accepts-shell-string");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let test_cmd = configuration
.cli
.iter()
.find(|c| c.name == "test")
.unwrap_or_else(|| panic!("expected test command"));
match test_cmd.steps.first() {
Some(CliStepDefinition::Command { shell, id, .. }) => {
assert_eq!(*shell, ShellConfig::Custom("bash".to_string()));
assert_eq!(id.as_deref(), Some("greet"));
}
_ => panic!("expected Command step"),
}
}
#[test]
fn raw_changelog_config_resolves_package_and_group_paths() {
let package_path = Path::new("packages/core");
let legacy_disabled =
crate::RawChangelogConfig::Legacy(crate::RawChangelogDefinition::Enabled(false));
assert!(legacy_disabled.is_disabled());
assert_eq!(
legacy_disabled.resolve_for_package(package_path, true),
None
);
assert_eq!(legacy_disabled.resolve_for_group(), None);
let legacy_pattern = crate::RawChangelogConfig::Legacy(crate::RawChangelogDefinition::Path(
"{{ path }}/notes.md".to_string(),
));
assert_eq!(
legacy_pattern.resolve_for_package(package_path, true),
Some(PathBuf::from("packages/core/notes.md"))
);
assert_eq!(
legacy_pattern.resolve_for_package(package_path, false),
Some(PathBuf::from("{{ path }}/notes.md"))
);
assert_eq!(
legacy_pattern.resolve_for_group(),
Some(PathBuf::from("{{ path }}/notes.md"))
);
let detailed_disabled = crate::RawChangelogConfig::Detailed(crate::RawChangelogTable {
enabled: Some(false),
path: Some("group/CHANGELOG.md".to_string()),
format: None,
initial_header: None,
include: None,
});
assert!(detailed_disabled.is_disabled());
assert_eq!(
detailed_disabled.resolve_for_package(package_path, true),
None
);
assert_eq!(detailed_disabled.resolve_for_group(), None);
let detailed_default = crate::RawChangelogConfig::Detailed(crate::RawChangelogTable {
enabled: Some(true),
path: None,
format: None,
initial_header: Some("Header".to_string()),
include: Some(crate::RawGroupChangelogInclude::Mode(
"group-only".to_string(),
)),
});
assert_eq!(
detailed_default.resolve_for_package(package_path, true),
Some(PathBuf::from("packages/core/CHANGELOG.md"))
);
assert_eq!(detailed_default.resolve_for_group(), None);
assert!(matches!(
detailed_default.include(),
Some(crate::RawGroupChangelogInclude::Mode(mode)) if mode == "group-only"
));
}
#[test]
fn validate_ecosystem_version_readable_reports_missing_json_and_yaml_string_fields() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
let package_json = root.join("package.json");
let pubspec = root.join("pubspec.yaml");
let npm_fields = vec!["releaseVersion".to_string()];
std::fs::write(&package_json, "{\"releaseVersion\":1}")
.unwrap_or_else(|error| panic!("write package.json {}: {error}", package_json.display()));
let npm_error = crate::validate_ecosystem_version_readable(
&package_json,
"package.json",
EcosystemType::Npm,
Some(&npm_fields),
"package",
"web",
)
.err()
.unwrap_or_else(|| panic!("expected missing json version field error"));
assert!(
npm_error
.to_string()
.contains("does not contain a `releaseVersion` string field")
);
std::fs::write(&pubspec, "name: app\nversion: 1\n")
.unwrap_or_else(|error| panic!("write pubspec {}: {error}", pubspec.display()));
let dart_error = crate::validate_ecosystem_version_readable(
&pubspec,
"pubspec.yaml",
EcosystemType::Dart,
None,
"package",
"app",
)
.err()
.unwrap_or_else(|| panic!("expected missing yaml version field error"));
assert!(
dart_error
.to_string()
.contains("does not contain a `version` string field")
);
let deno_json = root.join("deno.json");
std::fs::write(&deno_json, "{\"name\":\"app\"}")
.unwrap_or_else(|error| panic!("write deno.json {}: {error}", deno_json.display()));
let deno_error = crate::validate_ecosystem_version_readable(
&deno_json,
"deno.json",
EcosystemType::Deno,
None,
"package",
"deno-app",
)
.err()
.unwrap_or_else(|| panic!("expected missing deno version field error"));
assert!(deno_error.to_string().contains(
"package `deno-app` versioned file `deno.json` does not contain a `version` string field"
));
let pyproject = root.join("pyproject.toml");
std::fs::write(&pyproject, "[project]\nname = \"app\"\n")
.unwrap_or_else(|error| panic!("write pyproject {}: {error}", pyproject.display()));
crate::validate_ecosystem_version_readable(
&pyproject,
"pyproject.toml",
EcosystemType::Python,
None,
"package",
"python-app",
)
.unwrap_or_else(|error| panic!("expected python validation to be a no-op: {error}"));
let go_mod = root.join("go.mod");
std::fs::write(&go_mod, "module example.com/app\n")
.unwrap_or_else(|error| panic!("write go.mod {}: {error}", go_mod.display()));
crate::validate_ecosystem_version_readable(
&go_mod,
"go.mod",
EcosystemType::Go,
None,
"package",
"go-app",
)
.unwrap_or_else(|error| panic!("expected go validation to be a no-op: {error}"));
}
#[test]
fn infer_bump_helpers_cover_major_minor_patch_and_none() {
assert_eq!(
crate::infer_bump_from_versions(&Version::new(1, 2, 3), &Version::new(2, 0, 0)),
BumpSeverity::Major
);
assert_eq!(
crate::infer_bump_from_versions(&Version::new(1, 2, 3), &Version::new(1, 3, 0)),
BumpSeverity::Minor
);
assert_eq!(
crate::infer_bump_from_versions(&Version::new(1, 2, 3), &Version::new(1, 2, 4)),
BumpSeverity::Patch
);
assert_eq!(
crate::infer_bump_from_versions(
&Version::new(1, 2, 3),
&Version::parse("1.2.3-beta.1").unwrap_or_else(|error| panic!("version: {error}"))
),
BumpSeverity::Patch
);
assert_eq!(
crate::infer_bump_from_versions(&Version::new(1, 2, 3), &Version::new(1, 2, 3)),
BumpSeverity::None
);
let workspace_root = PathBuf::from("/workspace");
let core = PackageRecord::new(
Ecosystem::Cargo,
"core",
workspace_root.join("crates/core/Cargo.toml"),
workspace_root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
);
let app = PackageRecord::new(
Ecosystem::Cargo,
"app",
workspace_root.join("crates/app/Cargo.toml"),
workspace_root.clone(),
Some(Version::new(2, 0, 0)),
PublishState::Public,
);
let group = GroupDefinition {
id: "sdk".to_string(),
packages: vec![core.id.clone(), app.id.clone()],
changelog: None,
changelog_include: GroupChangelogInclude::All,
excluded_changelog_types: Vec::new(),
empty_update_message: None,
release_title: None,
changelog_version_title: None,
versioned_files: Vec::new(),
tag: true,
release: true,
version_format: monochange_core::VersionFormat::Primary,
};
assert_eq!(
infer_group_bump_from_explicit_version(
&group,
&workspace_root,
&[core.clone(), app.clone()],
Some(&Version::new(2, 1, 0))
)
.unwrap_or_else(|error| panic!("expected Ok, got: {error}")),
Some(BumpSeverity::Minor)
);
let group_with_missing = GroupDefinition {
id: "sdk-missing".to_string(),
packages: vec![core.id.clone(), "missing".to_string(), app.id.clone()],
changelog: None,
changelog_include: GroupChangelogInclude::All,
excluded_changelog_types: Vec::new(),
empty_update_message: None,
release_title: None,
changelog_version_title: None,
versioned_files: Vec::new(),
tag: true,
release: true,
version_format: monochange_core::VersionFormat::Primary,
};
let error = infer_group_bump_from_explicit_version(
&group_with_missing,
&workspace_root,
&[core.clone(), app.clone()],
Some(&Version::new(2, 1, 0)),
)
.err()
.unwrap_or_else(|| panic!("expected error for unresolvable group member"));
assert!(error.to_string().contains("missing"));
let core_id = core.id.clone();
assert_eq!(
infer_package_bump_from_explicit_version(
&core_id,
&[core, app],
Some(&Version::new(1, 0, 1))
),
Some(BumpSeverity::Patch)
);
assert_eq!(
infer_group_bump_from_explicit_version(&group, &workspace_root, &[], None)
.unwrap_or_else(|error| panic!("expected Ok, got: {error}")),
None
);
}
#[test]
fn validate_source_and_changeset_settings_reject_empty_values() {
let source_error =
crate::validate_source_configuration(Some(&monochange_core::SourceConfiguration {
provider: SourceProvider::GitHub,
host: None,
api_url: None,
owner: " ".to_string(),
repo: "monochange".to_string(),
releases: monochange_core::ProviderReleaseSettings::default(),
pull_requests: monochange_core::ProviderMergeRequestSettings::default(),
}))
.err()
.unwrap_or_else(|| panic!("expected source validation error"));
assert!(
source_error
.to_string()
.contains("[source].owner must not be empty")
);
let changeset_error = crate::validate_changesets_configuration(
&monochange_core::ChangesetSettings {
affected: monochange_core::ChangesetAffectedSettings {
skip_labels: vec![String::new()],
..Default::default()
},
},
&[],
)
.err()
.unwrap_or_else(|| panic!("expected changeset validation error"));
assert!(
changeset_error
.to_string()
.contains("[changesets.affected].skip_labels must not include empty values")
);
let affected_empty_path_error = crate::validate_changesets_configuration(
&monochange_core::ChangesetSettings {
affected: monochange_core::ChangesetAffectedSettings {
changed_paths: vec![" ".to_string()],
..Default::default()
},
},
&[],
)
.err()
.unwrap_or_else(|| panic!("expected affected path validation error"));
assert!(
affected_empty_path_error
.to_string()
.contains("[changesets.affected].changed_paths must not include empty values")
);
let source_repo_error =
crate::validate_source_configuration(Some(&monochange_core::SourceConfiguration {
provider: SourceProvider::GitHub,
host: None,
api_url: None,
owner: "ifiokjr".to_string(),
repo: " ".to_string(),
releases: monochange_core::ProviderReleaseSettings::default(),
pull_requests: monochange_core::ProviderMergeRequestSettings::default(),
}))
.err()
.unwrap_or_else(|| panic!("expected source repo validation error"));
assert!(
source_repo_error
.to_string()
.contains("[source].repo must not be empty")
);
}
#[test]
fn validate_changesets_configuration_rejects_invalid_additional_path_globs() {
let error = crate::validate_changesets_configuration(
&monochange_core::ChangesetSettings::default(),
&[monochange_core::PackageDefinition {
additional_paths: vec!["[".to_string()],
..package_definition("core", "crates/core")
}],
)
.err()
.unwrap_or_else(|| panic!("expected invalid additional path glob"));
assert!(
error
.to_string()
.contains("[package.core].additional_paths contains invalid glob pattern")
);
let empty_value = crate::validate_changesets_configuration(
&monochange_core::ChangesetSettings::default(),
&[monochange_core::PackageDefinition {
additional_paths: vec![" ".to_string()],
..package_definition("core", "crates/core")
}],
)
.err()
.unwrap_or_else(|| panic!("expected empty additional path error"));
assert!(
empty_value
.to_string()
.contains("[package.core].additional_paths must not include empty values")
);
}
#[test]
fn validate_cli_rejects_invalid_command_shapes() {
let duplicate = crate::validate_cli(&[
cli_command(
"release",
vec![CliStepDefinition::Validate {
name: None,
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
),
cli_command(
"release",
vec![CliStepDefinition::Validate {
name: None,
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
),
])
.err()
.unwrap_or_else(|| panic!("expected duplicate command error"));
assert!(
duplicate
.to_string()
.contains("duplicate CLI command `release`")
);
let reserved = crate::validate_cli(&[cli_command(
"help",
vec![CliStepDefinition::Validate {
name: None,
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
)])
.err()
.unwrap_or_else(|| panic!("expected reserved name error"));
assert!(reserved.to_string().contains("reserved built-in command"));
let reserved_step_prefix = crate::validate_cli(&[cli_command(
"step:discover",
vec![CliStepDefinition::Validate {
name: None,
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
)])
.err()
.unwrap_or_else(|| panic!("expected reserved step prefix error"));
assert!(
reserved_step_prefix
.to_string()
.contains("uses reserved `step:` prefix")
);
let no_steps = crate::validate_cli(&[cli_command("release", Vec::new())])
.err()
.unwrap_or_else(|| panic!("expected missing steps error"));
assert!(
no_steps
.to_string()
.contains("must define at least one step")
);
}
#[test]
fn validate_cli_rejects_invalid_inputs_and_step_metadata() {
let mut duplicate_inputs = cli_command(
"release",
vec![CliStepDefinition::Validate {
name: Some("Validate workspace".to_string()),
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
);
duplicate_inputs.inputs = vec![
cli_input("token", CliInputKind::String),
cli_input("token", CliInputKind::String),
];
let duplicate_input_error = crate::validate_cli(&[duplicate_inputs])
.err()
.unwrap_or_else(|| panic!("expected duplicate input error"));
assert!(
duplicate_input_error
.to_string()
.contains("defines duplicate input `token`")
);
let mut invalid_choice_default = cli_command(
"release",
vec![CliStepDefinition::Validate {
name: Some("Validate workspace".to_string()),
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
);
let mut channel = cli_input("channel", CliInputKind::Choice);
channel.choices = vec!["stable".to_string()];
channel.default = Some("beta".to_string());
invalid_choice_default.inputs = vec![channel];
let choice_error = crate::validate_cli(&[invalid_choice_default])
.err()
.unwrap_or_else(|| panic!("expected invalid choice default"));
assert!(
choice_error
.to_string()
.contains("default `beta` is not one of the configured choices")
);
let mut invalid_boolean_default = cli_command(
"release",
vec![CliStepDefinition::Validate {
name: Some("Validate workspace".to_string()),
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
);
let mut confirm = cli_input("confirm", CliInputKind::Boolean);
confirm.default = Some("yes".to_string());
invalid_boolean_default.inputs = vec![confirm];
let boolean_error = crate::validate_cli(&[invalid_boolean_default])
.err()
.unwrap_or_else(|| panic!("expected invalid boolean default"));
assert!(
boolean_error
.to_string()
.contains("boolean default must be `true` or `false`")
);
let empty_step_name = crate::validate_cli(&[cli_command(
"release",
vec![CliStepDefinition::Validate {
name: Some(" ".to_string()),
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
)])
.err()
.unwrap_or_else(|| panic!("expected empty step name error"));
assert!(empty_step_name.to_string().contains("has an empty `name`"));
let duplicate_step_names = crate::validate_cli(&[cli_command(
"release",
vec![
CliStepDefinition::Validate {
name: Some("Shared".to_string()),
when: None,
always_run: false,
inputs: BTreeMap::new(),
},
CliStepDefinition::Discover {
name: Some("Shared".to_string()),
when: None,
always_run: false,
inputs: BTreeMap::new(),
},
],
)])
.err()
.unwrap_or_else(|| panic!("expected duplicate step name error"));
assert!(
duplicate_step_names
.to_string()
.contains("duplicate step name `Shared`")
);
let invalid_command = crate::validate_cli(&[cli_command(
"release",
vec![CliStepDefinition::Command {
name: Some("Run command".to_string()),
when: Some(" ".to_string()),
always_run: false,
show_progress: None,
command: String::new(),
dry_run_command: Some(" ".to_string()),
shell: ShellConfig::Default,
id: Some(" ".to_string()),
variables: None,
inputs: BTreeMap::from([(
String::new(),
CliStepInputValue::String("value".to_string()),
)]),
}],
)])
.err()
.unwrap_or_else(|| panic!("expected invalid command step"));
assert!(
invalid_command
.to_string()
.contains("has an empty `when` condition")
);
let empty_command = crate::validate_cli(&[cli_command(
"release",
vec![CliStepDefinition::Command {
name: Some("Run command".to_string()),
when: None,
always_run: false,
show_progress: None,
command: " ".to_string(),
dry_run_command: None,
shell: ShellConfig::Default,
id: Some("run-command".to_string()),
variables: None,
inputs: BTreeMap::new(),
}],
)])
.err()
.unwrap_or_else(|| panic!("expected empty command error"));
assert!(
empty_command
.to_string()
.contains("command steps must provide a non-empty command")
);
let empty_dry_run_command = crate::validate_cli(&[cli_command(
"release",
vec![CliStepDefinition::Command {
name: Some("Run command".to_string()),
when: None,
always_run: false,
show_progress: None,
command: "echo release".to_string(),
dry_run_command: Some(" ".to_string()),
shell: ShellConfig::Default,
id: Some("run-command".to_string()),
variables: None,
inputs: BTreeMap::new(),
}],
)])
.err()
.unwrap_or_else(|| panic!("expected empty dry-run command error"));
assert!(
empty_dry_run_command
.to_string()
.contains("`dry_run_command` must provide a non-empty command")
);
let empty_input_name = crate::validate_cli(&[CliCommandDefinition {
name: "release".to_string(),
help_text: None,
inputs: vec![cli_input(" ", CliInputKind::String)],
steps: vec![CliStepDefinition::Validate {
name: Some("Validate workspace".to_string()),
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
dry_run: false,
}])
.err()
.unwrap_or_else(|| panic!("expected empty input name error"));
assert!(
empty_input_name
.to_string()
.contains("has an input with an empty name")
);
let reserved_input_name = crate::validate_cli(&[CliCommandDefinition {
name: "release".to_string(),
help_text: None,
inputs: vec![cli_input("help", CliInputKind::String)],
steps: vec![CliStepDefinition::Validate {
name: Some("Validate workspace".to_string()),
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
dry_run: false,
}])
.err()
.unwrap_or_else(|| panic!("expected reserved input name error"));
assert!(
reserved_input_name
.to_string()
.contains("collides with an implicit command flag")
);
let empty_choices = crate::validate_cli(&[CliCommandDefinition {
name: "release".to_string(),
help_text: None,
inputs: vec![cli_input("channel", CliInputKind::Choice)],
steps: vec![CliStepDefinition::Validate {
name: Some("Validate workspace".to_string()),
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
dry_run: false,
}])
.err()
.unwrap_or_else(|| panic!("expected empty choices error"));
assert!(
empty_choices
.to_string()
.contains("must define at least one choice")
);
}
#[test]
fn validate_cli_runtime_requirements_enforce_source_features() {
let publish_without_source = crate::validate_cli_runtime_requirements(
&[cli_command(
"release",
vec![CliStepDefinition::PublishRelease {
name: None,
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
)],
&monochange_core::ChangesetSettings::default(),
None,
)
.err()
.unwrap_or_else(|| panic!("expected missing source error"));
assert!(
publish_without_source
.to_string()
.contains("PublishRelease")
);
assert!(
publish_without_source
.to_string()
.contains("not configured")
);
let mut source = sample_source_configuration(SourceProvider::GitHub);
source.releases.enabled = false;
let publish_disabled = crate::validate_cli_runtime_requirements(
&[cli_command(
"release",
vec![CliStepDefinition::PublishRelease {
name: None,
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
)],
&monochange_core::ChangesetSettings::default(),
Some(&source),
)
.err()
.unwrap_or_else(|| panic!("expected disabled release error"));
assert!(publish_disabled.to_string().contains("PublishRelease"));
assert!(publish_disabled.to_string().contains("enabled` is false"));
let mut pull_requests_disabled = sample_source_configuration(SourceProvider::GitHub);
pull_requests_disabled.pull_requests.enabled = false;
let open_request_error = crate::validate_cli_runtime_requirements(
&[cli_command(
"release",
vec![CliStepDefinition::OpenReleaseRequest {
name: None,
when: None,
always_run: false,
no_verify: false,
inputs: BTreeMap::new(),
}],
)],
&monochange_core::ChangesetSettings::default(),
Some(&pull_requests_disabled),
)
.err()
.unwrap_or_else(|| panic!("expected disabled pull request error"));
assert!(
open_request_error
.to_string()
.contains("OpenReleaseRequest")
);
assert!(open_request_error.to_string().contains("enabled` is false"));
let comment_provider_error = crate::validate_cli_runtime_requirements(
&[cli_command(
"release",
vec![CliStepDefinition::CommentReleasedIssues {
name: None,
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
)],
&monochange_core::ChangesetSettings::default(),
Some(&sample_source_configuration(SourceProvider::GitLab)),
)
.err()
.unwrap_or_else(|| panic!("expected unsupported provider error"));
assert!(
comment_provider_error
.to_string()
.contains("does not support released-issue comments")
);
}
#[test]
fn validate_cli_runtime_requirements_enforce_affected_package_inputs() {
let verify_disabled = crate::validate_cli_runtime_requirements(
&[cli_command(
"affected",
vec![CliStepDefinition::AffectedPackages {
name: None,
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
)],
&monochange_core::ChangesetSettings {
affected: monochange_core::ChangesetAffectedSettings {
enabled: false,
..Default::default()
},
},
Some(&sample_source_configuration(SourceProvider::GitHub)),
)
.err()
.unwrap_or_else(|| panic!("expected verify disabled error"));
assert!(verify_disabled.to_string().contains("AffectedPackages"));
assert!(verify_disabled.to_string().contains("enabled` is false"));
let missing_selector = crate::validate_cli_runtime_requirements(
&[cli_command(
"affected",
vec![CliStepDefinition::AffectedPackages {
name: None,
when: None,
always_run: false,
inputs: BTreeMap::new(),
}],
)],
&monochange_core::ChangesetSettings::default(),
Some(&sample_source_configuration(SourceProvider::GitHub)),
)
.err()
.unwrap_or_else(|| panic!("expected selector error"));
assert!(
missing_selector
.to_string()
.contains("declares neither a `changed_paths` nor a `from` input")
);
let mut command = cli_command(
"affected",
vec![CliStepDefinition::AffectedPackages {
name: None,
when: None,
always_run: false,
inputs: BTreeMap::from([
("changed_paths".to_string(), CliStepInputValue::Inherited),
("label".to_string(), CliStepInputValue::Inherited),
]),
}],
);
command.inputs = vec![
cli_input("changed_paths", CliInputKind::StringList),
cli_input("label", CliInputKind::String),
];
let invalid_label = crate::validate_cli_runtime_requirements(
&[command],
&monochange_core::ChangesetSettings::default(),
Some(&sample_source_configuration(SourceProvider::GitHub)),
)
.err()
.unwrap_or_else(|| panic!("expected invalid label kind error"));
assert!(
invalid_label
.to_string()
.contains("inherits input `label` with incompatible type")
);
}
#[test]
fn validate_package_and_source_settings_cover_duplicate_and_pattern_errors() {
let root = fixture_path("config/validation-helper-branches");
let duplicate_path_error = crate::validate_package_and_group_definitions(
&root,
"[package.core]\npath = 'crates/core'\n\n[package.util]\npath = 'crates/core'\n",
&[
package_definition("core", "crates/core"),
package_definition("util", "crates/core"),
],
&[],
)
.err()
.unwrap_or_else(|| panic!("expected duplicate path error"));
assert!(
duplicate_path_error
.to_string()
.contains("package path `crates/core` is already used by `core`")
);
let mut primary_core = package_definition("core", "crates/core");
primary_core.version_format = monochange_core::VersionFormat::Primary;
let mut primary_util = package_definition("util", "crates/util");
primary_util.version_format = monochange_core::VersionFormat::Primary;
let duplicate_primary_error = crate::validate_package_and_group_definitions(
&root,
"[package.core]\nversion_format = 'primary'\n\n[package.util]\nversion_format = 'primary'\n",
&[primary_core, primary_util],
&[],
)
.err()
.unwrap_or_else(|| panic!("expected duplicate primary error"));
assert!(
duplicate_primary_error
.to_string()
.contains("`version_format = \"primary\"` is already used by `core`")
);
let source_error = crate::validate_changesets_configuration(
&monochange_core::ChangesetSettings {
affected: monochange_core::ChangesetAffectedSettings {
changed_paths: vec!["[".to_string()],
..Default::default()
},
},
&[],
)
.err()
.unwrap_or_else(|| panic!("expected invalid source glob error"));
assert!(
source_error
.to_string()
.contains("[changesets.affected].changed_paths contains invalid glob pattern")
);
let package_pattern_error = crate::validate_changesets_configuration(
&monochange_core::ChangesetSettings::default(),
&[monochange_core::PackageDefinition {
ignored_paths: vec![String::new()],
..package_definition("core", "crates/core")
}],
)
.err()
.unwrap_or_else(|| panic!("expected invalid package path pattern error"));
assert!(
package_pattern_error
.to_string()
.contains("[package.core].ignored_paths must not include empty values")
);
let duplicate_id_error = crate::validate_package_and_group_definitions(
&root,
"[package.core]\npath = 'crates/core'\n",
&[
package_definition("core", "crates/core"),
package_definition("core", "crates/util"),
],
&[],
)
.err()
.unwrap_or_else(|| panic!("expected duplicate id error"));
assert!(
duplicate_id_error
.to_string()
.contains("duplicate package id `core`")
);
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
std::fs::create_dir_all(tempdir.path().join("crates/core"))
.unwrap_or_else(|error| panic!("mkdir core dir: {error}"));
let missing_manifest_error = crate::validate_package_and_group_definitions(
tempdir.path(),
"[package.core]\npath = 'crates/core'\ntype = 'cargo'\n",
&[package_definition("core", "crates/core")],
&[],
)
.err()
.unwrap_or_else(|| panic!("expected missing manifest error"));
assert!(
missing_manifest_error
.to_string()
.contains("missing expected cargo manifest")
);
}
#[test]
fn parse_markdown_change_target_and_validation_helpers_cover_remaining_error_paths() {
let root = fixture_path("changeset-target-metadata/render-workspace");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let invalid_scalar = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("docs")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let scalar_error = crate::parse_markdown_change_target(
&invalid_scalar,
Path::new("change.md"),
"core",
&configuration,
)
.err()
.unwrap_or_else(|| panic!("expected invalid scalar error"));
assert!(
scalar_error
.to_string()
.contains("invalid scalar change type `docs`")
);
assert!(scalar_error.to_string().contains("valid types"));
let unknown_keys = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("extra: true")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let unknown_keys_error = crate::parse_markdown_change_target(
&unknown_keys,
Path::new("change.md"),
"core",
&configuration,
)
.err()
.unwrap_or_else(|| panic!("expected unsupported field error"));
assert!(
unknown_keys_error
.to_string()
.contains("uses unsupported field(s): extra")
);
let invalid_bump = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("bump: nope")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let invalid_bump_error = crate::parse_markdown_change_target(
&invalid_bump,
Path::new("change.md"),
"core",
&configuration,
)
.err()
.unwrap_or_else(|| panic!("expected invalid bump error"));
assert!(
invalid_bump_error
.to_string()
.contains("has invalid bump `nope`")
);
let invalid_version = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("version: nope")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let invalid_version_error = crate::parse_markdown_change_target(
&invalid_version,
Path::new("change.md"),
"core",
&configuration,
)
.err()
.unwrap_or_else(|| panic!("expected invalid version error"));
assert!(
invalid_version_error
.to_string()
.contains("has invalid version `nope`")
);
let none_only = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("bump: none")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let none_only_error = crate::parse_markdown_change_target(
&none_only,
Path::new("change.md"),
"core",
&configuration,
)
.err()
.unwrap_or_else(|| panic!("expected none-only error"));
assert!(
none_only_error
.to_string()
.contains("must not use `bump = \"none\"`")
);
let none_with_caused_by =
serde_yaml_ng::from_str::<serde_yaml_ng::Value>("bump: none\ncaused_by: [\"sdk\"]")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let parsed_none_with_caused_by = crate::parse_markdown_change_target(
&none_with_caused_by,
Path::new("change.md"),
"core",
&configuration,
)
.unwrap_or_else(|error| panic!("parse none caused_by: {error}"));
assert_eq!(
parsed_none_with_caused_by,
(
Some(BumpSeverity::None),
None,
None,
vec!["sdk".to_string()]
)
);
let unknown_caused_by =
serde_yaml_ng::from_str::<serde_yaml_ng::Value>("bump: patch\ncaused_by: [\"missing\"]")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let unknown_caused_by_error = crate::parse_markdown_change_target(
&unknown_caused_by,
Path::new("change.md"),
"core",
&configuration,
)
.err()
.unwrap_or_else(|| panic!("expected unknown caused_by error"));
insta::assert_snapshot!(
"parse_markdown_change_target_unknown_caused_by_error",
unknown_caused_by_error.to_string()
);
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
std::fs::create_dir_all(tempdir.path().join("crates/core"))
.unwrap_or_else(|error| panic!("mkdir core: {error}"));
std::fs::write(
tempdir.path().join("monochange.toml"),
"[package.core]\npath = \"crates/core\"\ntype = \"cargo\"\n",
)
.unwrap_or_else(|error| panic!("write monochange.toml: {error}"));
std::fs::write(
tempdir.path().join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write Cargo.toml: {error}"));
let no_types_configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("workspace configuration: {error}"));
let no_types_error = crate::validate_configured_change_type(
&no_types_configuration,
Path::new("change.md"),
"core",
"custom_unknown_type",
)
.err()
.unwrap_or_else(|| panic!("expected invalid type error"));
insta::assert_snapshot!(
"parse_markdown_change_target_no_configured_types_error",
no_types_error.to_string()
);
}
#[test]
fn parse_markdown_change_target_covers_caused_by_scalar_and_error_paths() {
let _guard = snapshot_settings().bind_to_scope();
let root = fixture_path("changeset-target-metadata/render-workspace");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let scalar_type = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("security")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let parsed_scalar_type = crate::parse_markdown_change_target(
&scalar_type,
Path::new("change.md"),
"core",
&configuration,
)
.unwrap_or_else(|error| panic!("parse scalar type: {error}"));
assert_eq!(
parsed_scalar_type,
(
Some(BumpSeverity::Patch),
None,
Some("security".to_string()),
Vec::new(),
)
);
let invalid_compound = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("- security")
.unwrap_or_else(|error| panic!("yaml parse: {error}"));
let invalid_compound_error = crate::parse_markdown_change_target(
&invalid_compound,
Path::new("change.md"),
"core",
&configuration,
)
.err()
.unwrap_or_else(|| panic!("expected invalid compound error"));
insta::assert_snapshot!(
"parse_markdown_change_target_invalid_compound_error",
invalid_compound_error.to_string()
);
for (label, snapshot_name, yaml) in [
(
"empty string",
"parse_markdown_change_target_empty_caused_by_string_error",
"bump: patch\ncaused_by: \" \"",
),
(
"empty sequence",
"parse_markdown_change_target_empty_caused_by_sequence_error",
"bump: patch\ncaused_by: []",
),
(
"non-string entry",
"parse_markdown_change_target_non_string_caused_by_entry_error",
"bump: patch\ncaused_by: [123]",
),
(
"non-sequence type",
"parse_markdown_change_target_invalid_caused_by_type_error",
"bump: patch\ncaused_by: 123",
),
] {
let value = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(yaml)
.unwrap_or_else(|error| panic!("yaml parse for {label}: {error}"));
let error = crate::parse_markdown_change_target(
&value,
Path::new("change.md"),
"core",
&configuration,
)
.err()
.unwrap_or_else(|| panic!("expected caused_by error for {label}"));
insta::assert_snapshot!(snapshot_name, error.to_string());
}
}
#[test]
fn load_change_signals_covers_configured_type_and_caused_by_context_paths() {
let _guard = snapshot_settings().bind_to_scope();
let tempdir = setup_fixture("changeset-target-metadata/render-workspace");
std::fs::write(
tempdir.path().join("change.md"),
"---\ncore: security\napp:\n bump: none\n caused_by: sdk\n---\n\n# follow-up\n",
)
.unwrap_or_else(|error| panic!("write change.md: {error}"));
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
let mut packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"core",
tempdir.path().join("crates/core/Cargo.toml"),
tempdir.path().to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Cargo,
"app",
tempdir.path().join("crates/app/Cargo.toml"),
tempdir.path().to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
];
for (package, id) in packages.iter_mut().zip(["core", "app"]) {
package.id = id.to_string();
}
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let signals = load_change_signals(&tempdir.path().join("change.md"), &configuration, &packages)
.unwrap_or_else(|error| panic!("change signals: {error}"));
assert_eq!(signals.len(), 2);
let core_signal = signals
.iter()
.find(|signal| signal.package_id == "core")
.unwrap_or_else(|| panic!("expected core signal"));
assert_eq!(core_signal.requested_bump, Some(BumpSeverity::Patch));
assert_eq!(core_signal.change_type.as_deref(), Some("security"));
let app_signal = signals
.iter()
.find(|signal| signal.package_id == "app")
.unwrap_or_else(|| panic!("expected app signal"));
assert_eq!(app_signal.requested_bump, Some(BumpSeverity::None));
assert_eq!(app_signal.caused_by, vec!["sdk".to_string()]);
std::fs::write(
tempdir.path().join("invalid-change.md"),
"---\ncore:\n - invalid\n---\n\n# invalid\n",
)
.unwrap_or_else(|error| panic!("write invalid-change.md: {error}"));
let error = load_change_signals(
&tempdir.path().join("invalid-change.md"),
&configuration,
&packages,
)
.err()
.unwrap_or_else(|| panic!("expected invalid compound target error"));
insta::assert_snapshot!(
"load_change_signals_invalid_compound_target_error",
error.to_string()
);
std::fs::write(
tempdir.path().join("unknown-target.md"),
"---\nunknown: note\n---\n\n# unknown\n",
)
.unwrap_or_else(|error| panic!("write unknown-target.md: {error}"));
let unknown_target_error = load_change_signals(
&tempdir.path().join("unknown-target.md"),
&configuration,
&packages,
)
.err()
.unwrap_or_else(|| panic!("expected unknown target error"));
insta::assert_snapshot!(
"load_change_signals_unknown_target_error",
unknown_target_error.to_string()
);
}
#[test]
fn load_change_signals_applies_default_bump_for_object_type_with_context() {
let tempdir = setup_fixture("changeset-target-metadata/render-workspace");
std::fs::write(
tempdir.path().join("object-type.md"),
"---\nsdk:\n type: test\n---\n\n# grouped object type\n",
)
.unwrap_or_else(|error| panic!("write object-type.md: {error}"));
let configuration = load_workspace_configuration(tempdir.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
let mut packages = vec![
PackageRecord::new(
Ecosystem::Cargo,
"core",
tempdir.path().join("crates/core/Cargo.toml"),
tempdir.path().to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
PackageRecord::new(
Ecosystem::Cargo,
"app",
tempdir.path().join("crates/app/Cargo.toml"),
tempdir.path().to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
),
];
for (package, id) in packages.iter_mut().zip(["core", "app"]) {
package.id = id.to_string();
}
apply_version_groups(&mut packages, &configuration)
.unwrap_or_else(|error| panic!("version groups: {error}"));
let signals = load_change_signals(
&tempdir.path().join("object-type.md"),
&configuration,
&packages,
)
.unwrap_or_else(|error| panic!("change signals: {error}"));
assert_eq!(signals.len(), 2);
for signal in &signals {
assert!(signal.package_id == "core" || signal.package_id == "app");
assert_eq!(signal.requested_bump, Some(BumpSeverity::Minor));
assert_eq!(signal.change_type.as_deref(), Some("test"));
}
}
#[test]
fn validate_versioned_files_and_release_notes_cover_remaining_validation_paths() {
let root = fixture_path("config/validation-helper-branches");
let config_contents = "[package.core]\npath = 'crates/core'\n";
let declared_packages = std::collections::BTreeSet::from(["core"]);
let missing_type = crate::validate_versioned_files(
&root,
config_contents,
&[monochange_core::VersionedFileDefinition {
path: "README.md".to_string(),
ecosystem_type: None,
name: None,
fields: None,
prefix: None,
regex: None,
}],
&declared_packages,
"package",
"core",
)
.err()
.unwrap_or_else(|| panic!("expected missing type error"));
assert!(
missing_type
.to_string()
.contains("versioned_files must set `type`")
);
let invalid_glob = crate::validate_versioned_files(
&root,
config_contents,
&[monochange_core::VersionedFileDefinition {
path: "[".to_string(),
ecosystem_type: Some(EcosystemType::Cargo),
name: None,
fields: None,
prefix: None,
regex: None,
}],
&declared_packages,
"package",
"core",
)
.err()
.unwrap_or_else(|| panic!("expected invalid glob error"));
assert!(
invalid_glob
.to_string()
.contains("invalid glob pattern `[`")
);
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
std::fs::create_dir_all(tempdir.path().join("packages/web"))
.unwrap_or_else(|error| panic!("mkdir web package: {error}"));
std::fs::write(
tempdir.path().join("packages/web/package.json"),
"{\"name\":\"web\",\"version\":\"1.0.0\"}\n",
)
.unwrap_or_else(|error| panic!("write package.json: {error}"));
let unsupported_match = crate::validate_versioned_files(
tempdir.path(),
config_contents,
&[monochange_core::VersionedFileDefinition {
path: "packages/*/package.json".to_string(),
ecosystem_type: Some(EcosystemType::Cargo),
name: None,
fields: None,
prefix: None,
regex: None,
}],
&declared_packages,
"package",
"core",
)
.err()
.unwrap_or_else(|| panic!("expected unsupported match error"));
assert!(
unsupported_match
.to_string()
.contains("matched unsupported file")
);
assert!(
unsupported_match
.to_string()
.contains("for ecosystem `cargo`")
);
assert!(crate::path_is_supported_for_ecosystem(
Path::new("pubspec.yaml"),
EcosystemType::Dart
));
let dart_unsupported_match = crate::validate_versioned_files(
tempdir.path(),
config_contents,
&[monochange_core::VersionedFileDefinition {
path: "packages/*/package.json".to_string(),
ecosystem_type: Some(EcosystemType::Dart),
name: None,
fields: None,
prefix: None,
regex: None,
}],
&declared_packages,
"package",
"core",
)
.err()
.unwrap_or_else(|| panic!("expected unsupported dart match error"));
assert!(
dart_unsupported_match
.to_string()
.contains("for ecosystem `dart`")
);
assert!(crate::path_is_supported_for_ecosystem(
Path::new("go.mod"),
EcosystemType::Go
));
let go_unsupported_match = crate::validate_versioned_files(
tempdir.path(),
config_contents,
&[monochange_core::VersionedFileDefinition {
path: "packages/*/package.json".to_string(),
ecosystem_type: Some(EcosystemType::Go),
name: None,
fields: None,
prefix: None,
regex: None,
}],
&declared_packages,
"package",
"core",
)
.err()
.unwrap_or_else(|| panic!("expected unsupported go match error"));
assert!(
go_unsupported_match
.to_string()
.contains("for ecosystem `go`")
);
let empty_template = crate::validate_changelog_configuration(
"",
&crate::RawChangelogSettings {
templates: vec![" ".to_string()],
sections: BTreeMap::new(),
section_thresholds: monochange_core::ChangelogSectionThresholds::default(),
types: BTreeMap::new(),
},
&[],
&[],
)
.err()
.unwrap_or_else(|| panic!("expected empty template error"));
assert!(
empty_template
.to_string()
.contains("must not include empty templates")
);
assert_eq!(
crate::change_template_variables("{{ summary }} {{ details | default('') }} {{"),
vec!["details".to_string(), "summary".to_string()]
);
let invalid_thresholds = crate::validate_changelog_configuration(
"",
&crate::RawChangelogSettings {
templates: Vec::new(),
sections: BTreeMap::new(),
section_thresholds: monochange_core::ChangelogSectionThresholds {
collapse: 80,
ignored: 50,
},
types: BTreeMap::new(),
},
&[],
&[],
)
.err()
.unwrap_or_else(|| panic!("expected invalid threshold error"));
assert!(
invalid_thresholds
.to_string()
.contains("section_thresholds.ignored")
);
}
#[test]
fn validate_github_source_and_api_configuration_cover_remaining_paths() {
let github_error =
crate::validate_source_configuration(Some(&monochange_core::SourceConfiguration {
provider: SourceProvider::GitHub,
host: None,
api_url: None,
owner: "ifiokjr".to_string(),
repo: "monochange".to_string(),
releases: monochange_core::ProviderReleaseSettings::default(),
pull_requests: monochange_core::ProviderMergeRequestSettings {
labels: vec![" ".to_string()],
..Default::default()
},
}))
.err()
.unwrap_or_else(|| panic!("expected invalid github labels error"));
assert!(
github_error
.to_string()
.contains("[source.pull_requests].labels must not include empty values")
);
let source_api_error =
crate::validate_source_configuration(Some(&monochange_core::SourceConfiguration {
provider: SourceProvider::GitHub,
host: Some("https://example.invalid".to_string()),
api_url: Some("http://api.example.invalid".to_string()),
owner: "ifiokjr".to_string(),
repo: "monochange".to_string(),
releases: monochange_core::ProviderReleaseSettings::default(),
pull_requests: monochange_core::ProviderMergeRequestSettings::default(),
}))
.err()
.unwrap_or_else(|| panic!("expected insecure api_url error"));
assert!(source_api_error.to_string().contains("insecure scheme"));
crate::validate_api_url_host("https://github.example.com/api/v3", SourceProvider::GitHub)
.unwrap_or_else(|error| panic!("custom GitHub host should warn but succeed: {error}"));
}
#[test]
fn matching_package_helpers_cover_references_and_definitions() {
let root = PathBuf::from("/workspace");
let core = PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
);
let web = PackageRecord::new(
Ecosystem::Npm,
"web",
root.join("packages/web/package.json"),
root.clone(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
);
let packages = vec![core.clone(), web.clone()];
assert_eq!(
crate::find_matching_package_indices(&packages, &root, "core"),
vec![0]
);
assert_eq!(
crate::find_matching_package_indices(&packages, &root, "packages/web"),
vec![1]
);
let definition = monochange_core::PackageDefinition {
id: "web".to_string(),
path: PathBuf::from("packages/web"),
package_type: monochange_core::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,
version_format: monochange_core::VersionFormat::Primary,
publish: monochange_core::PublishSettings::default(),
};
assert_eq!(
crate::find_matching_package_indices_for_definition(&packages, &root, &definition),
vec![1]
);
assert!(crate::package_matches_definition(&web, &root, &definition));
assert!(!crate::package_matches_definition(
&core,
&root,
&definition
));
assert!(crate::ecosystem_matches_package_type(
Ecosystem::Flutter,
monochange_core::PackageType::Flutter
));
}
#[test]
fn load_workspace_configuration_rejects_versioned_file_without_type_or_regex() {
let root = fixture_path("config/rejects-versioned-file-without-type-or-regex");
let error = load_workspace_configuration(&root)
.err()
.unwrap_or_else(|| panic!("expected configuration error"));
let rendered = error.render();
assert!(rendered.contains("versioned_files must set `type`"));
assert!(rendered.contains("versioned_files entry is missing `type`"));
}
#[test]
fn validate_versioned_files_content_rejects_missing_file() {
let root = fixture_path("config/versioned-file-missing");
let error = crate::validate_versioned_files_content(&root)
.err()
.unwrap_or_else(|| panic!("expected error for missing versioned file"));
assert!(error.to_string().contains("does-not-exist.toml"));
assert!(error.to_string().contains("does not exist"));
}
#[test]
fn validate_versioned_files_content_rejects_regex_without_match() {
let root = fixture_path("config/versioned-file-regex-no-match");
let error = crate::validate_versioned_files_content(&root)
.err()
.unwrap_or_else(|| panic!("expected error for regex without match"));
assert!(error.to_string().contains("does not match any content"));
}
#[test]
fn validate_versioned_files_content_rejects_unparseable_version() {
let root = fixture_path("config/versioned-file-unparseable-version");
let error = crate::validate_versioned_files_content(&root)
.err()
.unwrap_or_else(|| panic!("expected error for missing version field"));
assert!(
error
.to_string()
.contains("does not contain a readable version field")
);
}
#[test]
fn validate_versioned_files_content_warns_on_empty_glob() {
let root = fixture_path("config/versioned-file-empty-glob");
let warnings = crate::validate_versioned_files_content(&root)
.unwrap_or_else(|error| panic!("expected Ok with warnings, got error: {error}"));
assert_eq!(warnings.len(), 1);
assert!(warnings.first().unwrap().contains("matches no files"));
}
#[test]
fn validate_changeset_targets_reports_missing_file_read_errors() {
let root = fixture_path("changeset-target-metadata/render-workspace");
let configuration = load_workspace_configuration(&root)
.unwrap_or_else(|error| panic!("configuration: {error}"));
let missing_changeset = root.join(".changeset/missing.md");
let error = crate::validate_changeset_targets(&configuration, &missing_changeset)
.err()
.unwrap_or_else(|| panic!("expected missing changeset read error"));
assert!(error.to_string().contains("failed to read"));
}
#[test]
fn validate_api_url_host_rejects_insecure_http_scheme() {
let error = crate::validate_api_url_host("http://attacker.com/api/v3", SourceProvider::GitHub)
.err()
.unwrap_or_else(|| panic!("expected error for http://"));
assert!(error.to_string().contains("insecure scheme"));
}
#[test]
fn validate_api_url_host_accepts_https_scheme() {
crate::validate_api_url_host("https://api.github.com", SourceProvider::GitHub)
.unwrap_or_else(|error| panic!("expected Ok for https://: {error}"));
}
#[test]
fn changeset_files_with_crlf_line_endings_parse_correctly() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::write(
root.join("monochange.toml"),
"[defaults]\npackage_type = \"cargo\"\n\n[package.core]\npath = \"crates/core\"\n",
)
.unwrap_or_else(|error| panic!("write toml: {error}"));
std::fs::create_dir_all(root.join("crates/core"))
.unwrap_or_else(|error| panic!("mkdir: {error}"));
std::fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write cargo: {error}"));
let crlf_changeset = "---\r\ncore: patch\r\n---\r\n\r\nFix a bug with CRLF endings.\r\n";
std::fs::create_dir_all(root.join(".changeset"))
.unwrap_or_else(|error| panic!("mkdir changeset: {error}"));
std::fs::write(root.join(".changeset/crlf-test.md"), crlf_changeset)
.unwrap_or_else(|error| panic!("write changeset: {error}"));
let configuration =
load_workspace_configuration(root).unwrap_or_else(|error| panic!("config: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let changeset = load_changeset_file(
&root.join(".changeset/crlf-test.md"),
&configuration,
&packages,
)
.unwrap_or_else(|error| panic!("expected CRLF changeset to parse, got: {error}"));
assert!(!changeset.targets.is_empty());
let summary = changeset
.summary
.unwrap_or_else(|| panic!("expected summary"));
assert!(summary.contains("CRLF endings"));
}
#[test]
fn changeset_files_with_bare_cr_line_endings_parse_correctly() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
std::fs::write(
root.join("monochange.toml"),
"[defaults]\npackage_type = \"cargo\"\n\n[package.core]\npath = \"crates/core\"\n",
)
.unwrap_or_else(|error| panic!("write toml: {error}"));
std::fs::create_dir_all(root.join("crates/core"))
.unwrap_or_else(|error| panic!("mkdir: {error}"));
std::fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write cargo: {error}"));
let bare_cr = "---\rcore: patch\r---\r\rFix with bare CR.\r";
std::fs::create_dir_all(root.join(".changeset"))
.unwrap_or_else(|error| panic!("mkdir: {error}"));
std::fs::write(root.join(".changeset/bare-cr.md"), bare_cr)
.unwrap_or_else(|error| panic!("write: {error}"));
let configuration =
load_workspace_configuration(root).unwrap_or_else(|error| panic!("config: {error}"));
let packages = vec![PackageRecord::new(
Ecosystem::Cargo,
"core",
root.join("crates/core/Cargo.toml"),
root.to_path_buf(),
Some(Version::new(1, 0, 0)),
PublishState::Public,
)];
let changeset = load_changeset_file(
&root.join(".changeset/bare-cr.md"),
&configuration,
&packages,
)
.unwrap_or_else(|error| panic!("expected bare CR changeset to parse, got: {error}"));
assert!(!changeset.targets.is_empty());
let summary = changeset
.summary
.unwrap_or_else(|| panic!("expected summary"));
assert!(summary.contains("bare CR"));
}
#[test]
fn load_workspace_configuration_parses_top_level_lints_and_scopes() {
let root = fixture_path("config/lint-settings");
let configuration =
load_workspace_configuration(&root).unwrap_or_else(|error| panic!("config: {error}"));
assert_eq!(
configuration.lints.presets,
vec![
"cargo/recommended".to_string(),
"npm/recommended".to_string()
]
);
assert_eq!(
configuration
.lints
.rules
.get("cargo/internal-dependency-workspace")
.map(LintRuleConfig::severity),
Some(LintSeverity::Error)
);
assert_eq!(
configuration.lints.include,
vec!["crates/**".to_string(), "packages/**".to_string()]
);
assert_eq!(configuration.lints.exclude, vec!["examples/**".to_string()]);
assert!(configuration.lints.disable_gitignore);
assert_eq!(configuration.lints.scopes.len(), 1);
let scope = configuration
.lints
.scopes
.first()
.expect("expected lint scope");
assert_eq!(scope.name.as_deref(), Some("published cargo packages"));
assert_eq!(scope.selector.ecosystems, vec!["cargo".to_string()]);
assert_eq!(scope.selector.managed, Some(true));
assert_eq!(scope.selector.publishable, Some(true));
}
use proptest::prelude::*;
use crate::package_type_to_ecosystem_type;
use crate::render_changelog_path_template;
#[test]
fn package_type_cargo_maps_to_ecosystem_type_cargo() {
assert_eq!(
package_type_to_ecosystem_type(monochange_core::PackageType::Cargo),
EcosystemType::Cargo
);
}
#[test]
fn package_type_npm_maps_to_ecosystem_type_npm() {
assert_eq!(
package_type_to_ecosystem_type(monochange_core::PackageType::Npm),
EcosystemType::Npm
);
}
#[test]
fn package_type_deno_maps_to_ecosystem_type_deno() {
assert_eq!(
package_type_to_ecosystem_type(monochange_core::PackageType::Deno),
EcosystemType::Deno
);
}
#[test]
fn package_type_dart_maps_to_ecosystem_type_dart() {
assert_eq!(
package_type_to_ecosystem_type(monochange_core::PackageType::Dart),
EcosystemType::Dart
);
}
#[test]
fn package_type_flutter_maps_to_ecosystem_type_dart() {
assert_eq!(
package_type_to_ecosystem_type(monochange_core::PackageType::Flutter),
EcosystemType::Dart
);
}
proptest! {
#[test]
fn package_dir_always_replaced_with_directory_name(
prefix in any::<String>(),
suffix in any::<String>(),
path_str in any::<String>()
) {
prop_assume!(!prefix.contains("{{") && !prefix.contains("}}"));
prop_assume!(!suffix.contains("{{") && !suffix.contains("}}"));
prop_assume!(!path_str.is_empty());
let template = format!("{prefix}{{{{package_dir}}}}{suffix}");
let path = Path::new(&path_str);
let result = render_changelog_path_template(&template, path);
let expected_dir = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
prop_assert!(!result.contains("{{package_dir}}"));
prop_assert_eq!(result, format!("{prefix}{expected_dir}{suffix}"));
}
#[test]
fn package_name_always_replaced_with_file_stem(
prefix in any::<String>(),
suffix in any::<String>(),
path_str in any::<String>()
) {
prop_assume!(!prefix.contains("{{") && !prefix.contains("}}"));
prop_assume!(!suffix.contains("{{") && !suffix.contains("}}"));
prop_assume!(!path_str.is_empty());
let template = format!("{prefix}{{{{package_name}}}}{suffix}");
let path = Path::new(&path_str);
let result = render_changelog_path_template(&template, path);
let expected_stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
prop_assert!(!result.contains("{{package_name}}"));
prop_assert_eq!(result, format!("{prefix}{expected_stem}{suffix}"));
}
#[test]
fn unknown_placeholders_are_left_unchanged(
prefix in any::<String>(),
suffix in any::<String>(),
path_str in any::<String>()
) {
prop_assume!(!prefix.contains("{{") && !prefix.contains("}}"));
prop_assume!(!suffix.contains("{{") && !suffix.contains("}}"));
let template = format!("{prefix}{{{{unknown}}}}{suffix}");
let path = Path::new(&path_str);
let result = render_changelog_path_template(&template, path);
prop_assert_eq!(result, template);
}
#[test]
fn idempotent_when_no_placeholders_exist(
template in any::<String>(),
path_str in any::<String>()
) {
prop_assume!(!template.contains("{{"));
let path = Path::new(&path_str);
let once = render_changelog_path_template(&template, path);
let twice = render_changelog_path_template(&once, path);
prop_assert_eq!(once, twice);
}
#[test]
fn infer_bump_from_versions_returns_major_when_major_increases(
current_major in 0u64..20,
current_minor in 0u64..20,
current_patch in 0u64..20,
next_major_delta in 1u64..5,
) {
let current = Version::new(current_major, current_minor, current_patch);
let explicit = Version::new(
current_major + next_major_delta,
current_minor,
current_patch,
);
prop_assert_eq!(crate::infer_bump_from_versions(¤t, &explicit), BumpSeverity::Major);
}
#[test]
fn infer_bump_from_versions_returns_minor_when_minor_increases(
current_major in 0u64..20,
current_minor in 0u64..20,
current_patch in 0u64..20,
next_minor_delta in 1u64..5,
) {
let current = Version::new(current_major, current_minor, current_patch);
let explicit = Version::new(
current_major,
current_minor + next_minor_delta,
current_patch,
);
prop_assert_eq!(crate::infer_bump_from_versions(¤t, &explicit), BumpSeverity::Minor);
}
#[test]
fn infer_bump_from_versions_returns_patch_when_only_patch_increases(
current_major in 0u64..20,
current_minor in 0u64..20,
current_patch in 0u64..20,
next_patch_delta in 1u64..5,
) {
let current = Version::new(current_major, current_minor, current_patch);
let explicit = Version::new(
current_major,
current_minor,
current_patch + next_patch_delta,
);
prop_assert_eq!(crate::infer_bump_from_versions(¤t, &explicit), BumpSeverity::Patch);
}
}
#[test]
fn load_workspace_configuration_parses_dry_run_on_cli_command() {
let root = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
std::fs::write(
root.path().join("monochange.toml"),
br#"
[defaults]
package_type = "cargo"
[package.core]
path = "crates/core"
[cli.publish-check]
help_text = "Validate the release and preview package publishing in dry-run mode"
dry_run = true
steps = [
{ type = "PrepareRelease", name = "plan release" },
{ type = "PublishPackages", name = "publish packages dry run" },
]
"#,
)
.unwrap_or_else(|error| panic!("write monochange.toml: {error}"));
std::fs::create_dir_all(root.path().join("crates/core"))
.unwrap_or_else(|error| panic!("create crates/core: {error}"));
std::fs::write(
root.path().join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write Cargo.toml: {error}"));
let configuration = load_workspace_configuration(root.path())
.unwrap_or_else(|error| panic!("configuration: {error}"));
let publish_check = configuration
.cli
.iter()
.find(|command| command.name == "publish-check")
.unwrap_or_else(|| panic!("expected publish-check command"));
assert!(publish_check.dry_run);
assert_eq!(
publish_check.help_text.as_deref(),
Some("Validate the release and preview package publishing in dry-run mode")
);
assert_eq!(publish_check.steps.len(), 2);
}
fn source_config_for_provider(provider: SourceProvider) -> monochange_core::SourceConfiguration {
monochange_core::SourceConfiguration {
provider,
owner: "owner".to_string(),
repo: "repo".to_string(),
host: None,
api_url: None,
releases: monochange_core::ProviderReleaseSettings::default(),
pull_requests: monochange_core::ProviderMergeRequestSettings::default(),
}
}
#[test]
fn source_provider_capabilities_reject_unsupported_settings() {
let gitea_without_host = source_config_for_provider(SourceProvider::Gitea);
let error = crate::validate_source_provider_capabilities(&gitea_without_host).unwrap_err();
assert!(error.to_string().contains("[source].host must be set"));
let mut gitlab_with_draft = source_config_for_provider(SourceProvider::GitLab);
gitlab_with_draft.releases.draft = true;
let error = crate::validate_source_provider_capabilities(&gitlab_with_draft).unwrap_err();
assert!(
error
.to_string()
.contains("[source.releases].draft is not supported")
);
let mut gitlab_with_prerelease = source_config_for_provider(SourceProvider::GitLab);
gitlab_with_prerelease.releases.prerelease = true;
let error = crate::validate_source_provider_capabilities(&gitlab_with_prerelease).unwrap_err();
assert!(
error
.to_string()
.contains("[source.releases].prerelease is not supported")
);
let mut gitea_with_notes = source_config_for_provider(SourceProvider::Gitea);
gitea_with_notes.host = Some("https://git.example.com".to_string());
gitea_with_notes.releases.generate_notes = true;
let error = crate::validate_source_provider_capabilities(&gitea_with_notes).unwrap_err();
assert!(
error
.to_string()
.contains("provider-generated release notes are not supported")
);
let mut gitea_with_auto_merge = source_config_for_provider(SourceProvider::Gitea);
gitea_with_auto_merge.host = Some("https://git.example.com".to_string());
gitea_with_auto_merge.pull_requests.auto_merge = true;
let error = crate::validate_source_provider_capabilities(&gitea_with_auto_merge).unwrap_err();
assert!(
error
.to_string()
.contains("[source.pull_requests].auto_merge is not supported")
);
}
#[test]
fn validate_ecosystem_version_readable_reports_parse_and_field_errors() {
let root = tempdir().unwrap();
let unsupported = root.path().join("package.txt");
std::fs::write(&unsupported, "version = \"1.0.0\"").unwrap();
let error = crate::validate_ecosystem_version_readable(
&unsupported,
"package.txt",
EcosystemType::Npm,
None,
"package",
"pkg",
)
.unwrap_err();
assert!(error.to_string().contains("is not supported for ecosystem"));
let missing = root.path().join("package.json");
let error = crate::validate_ecosystem_version_readable(
&missing,
"package.json",
EcosystemType::Npm,
None,
"package",
"pkg",
)
.unwrap_err();
assert!(error.to_string().contains("failed to read"));
let bad_json = root.path().join("package.json");
std::fs::write(&bad_json, "{").unwrap();
let error = crate::validate_ecosystem_version_readable(
&bad_json,
"package.json",
EcosystemType::Npm,
None,
"package",
"pkg",
)
.unwrap_err();
assert!(error.to_string().contains("is not valid JSON"));
std::fs::write(&bad_json, "{\"version\":\"1.0.0\"}").unwrap();
crate::validate_ecosystem_version_readable(
&bad_json,
"package.json",
EcosystemType::Npm,
None,
"package",
"pkg",
)
.unwrap();
std::fs::write(&bad_json, "{\"name\":\"pkg\"}").unwrap();
let error = crate::validate_ecosystem_version_readable(
&bad_json,
"package.json",
EcosystemType::Npm,
None,
"package",
"pkg",
)
.unwrap_err();
assert!(
error
.to_string()
.contains("does not contain a `version` string field")
);
let bad_yaml = root.path().join("pubspec.yaml");
std::fs::write(&bad_yaml, ": bad").unwrap();
let error = crate::validate_ecosystem_version_readable(
&bad_yaml,
"pubspec.yaml",
EcosystemType::Dart,
None,
"package",
"pkg",
)
.unwrap_err();
assert!(error.to_string().contains("is not valid YAML"));
std::fs::write(&bad_yaml, "version: 1.0.0\n").unwrap();
crate::validate_ecosystem_version_readable(
&bad_yaml,
"pubspec.yaml",
EcosystemType::Dart,
None,
"package",
"pkg",
)
.unwrap();
std::fs::write(&bad_yaml, "name: pkg\n").unwrap();
let error = crate::validate_ecosystem_version_readable(
&bad_yaml,
"pubspec.yaml",
EcosystemType::Dart,
None,
"package",
"pkg",
)
.unwrap_err();
assert!(
error
.to_string()
.contains("does not contain a `version` string field")
);
let go_mod = root.path().join("go.mod");
std::fs::write(&go_mod, "module example.com/pkg\n").unwrap();
crate::validate_ecosystem_version_readable(
&go_mod,
"go.mod",
EcosystemType::Go,
None,
"package",
"pkg",
)
.unwrap();
let bad_toml = root.path().join("Cargo.toml");
std::fs::write(&bad_toml, "[").unwrap();
let error = crate::validate_ecosystem_version_readable(
&bad_toml,
"Cargo.toml",
EcosystemType::Cargo,
None,
"package",
"pkg",
)
.unwrap_err();
assert!(error.to_string().contains("is not valid TOML"));
std::fs::write(
&bad_toml,
"[package]\nname = \"pkg\"\nversion = \"1.0.0\"\n",
)
.unwrap();
let fields = vec!["package.version".to_string()];
crate::validate_ecosystem_version_readable(
&bad_toml,
"Cargo.toml",
EcosystemType::Cargo,
Some(&fields),
"package",
"pkg",
)
.unwrap();
std::fs::write(&bad_toml, "[package]\nname = \"pkg\"\n").unwrap();
let error = crate::validate_ecosystem_version_readable(
&bad_toml,
"Cargo.toml",
EcosystemType::Cargo,
Some(&fields),
"package",
"pkg",
)
.unwrap_err();
assert!(
error
.to_string()
.contains("does not contain a `package.version` string field")
);
}