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::PrereleaseBase;
use monochange_core::PrereleaseNumbering;
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_parses_prerelease_settings() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	std::fs::write(
		tempdir.path().join("monochange.toml"),
		"[prerelease]\nenabled = true\nchannel = \"rc\"\nnumbering = \"datetime\"\nbranches = [\"next\", \"prerelease/*\"]\nbase = \"fixed\"\nbase_version = \"0.0.0\"\nkeep_changesets = false\nchangelog = true\nrelease_notes = false\npublish_packages = true\nwrite_manifests = false\n",
	)
	.unwrap_or_else(|error| panic!("write config: {error}"));

	let configuration = load_workspace_configuration(tempdir.path())
		.unwrap_or_else(|error| panic!("configuration: {error}"));
	let prerelease = configuration.prerelease;

	assert!(prerelease.enabled);
	assert_eq!(prerelease.channel, "rc");
	assert_eq!(prerelease.numbering, PrereleaseNumbering::Datetime);
	assert_eq!(prerelease.base, PrereleaseBase::Fixed);
	assert_eq!(prerelease.base_version, Some(Version::new(0, 0, 0)));
	assert_eq!(prerelease.branches, vec!["next", "prerelease/*"]);
	assert!(!prerelease.keep_changesets);
	assert!(prerelease.changelog);
	assert!(!prerelease.release_notes);
	assert!(prerelease.publish_packages);
	assert!(!prerelease.write_manifests);

	insta::assert_json_snapshot!(serde_json::json!({
		"enabled": prerelease.enabled,
		"channel": prerelease.channel,
		"numbering": format!("{:?}", prerelease.numbering),
		"base": format!("{:?}", prerelease.base),
		"baseVersion": prerelease.base_version.map(|version| version.to_string()),
		"branches": prerelease.branches,
		"keepChangesets": prerelease.keep_changesets,
		"changelog": prerelease.changelog,
		"releaseNotes": prerelease.release_notes,
		"publishPackages": prerelease.publish_packages,
		"writeManifests": prerelease.write_manifests,
	}));
}

#[test]
fn load_workspace_configuration_applies_prerelease_defaults() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	std::fs::write(
		tempdir.path().join("monochange.toml"),
		"[prerelease]\nenabled = true\n",
	)
	.unwrap_or_else(|error| panic!("write config: {error}"));

	let configuration = load_workspace_configuration(tempdir.path())
		.unwrap_or_else(|error| panic!("configuration: {error}"));
	let prerelease = configuration.prerelease;

	assert!(prerelease.enabled);
	assert_eq!(prerelease.channel, "alpha");
	assert_eq!(prerelease.numbering, PrereleaseNumbering::Increment);
	assert_eq!(prerelease.base, PrereleaseBase::Planned);
	assert_eq!(prerelease.base_version, None);
	assert!(prerelease.keep_changesets);
	assert!(!prerelease.changelog);
	assert!(prerelease.release_notes);
	assert!(!prerelease.publish_packages);
	assert!(prerelease.write_manifests);
}

#[test]
fn validate_workspace_rejects_stale_prerelease_state_when_disabled() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let state_dir = tempdir.path().join(".monochange");
	std::fs::create_dir_all(&state_dir).unwrap_or_else(|error| panic!("state dir: {error}"));
	std::fs::write(state_dir.join("prerelease-state.json"), b"{}")
		.unwrap_or_else(|error| panic!("state file: {error}"));

	let error = validate_workspace(tempdir.path())
		.expect_err("disabled prerelease mode should reject stale state");

	assert!(error.to_string().contains("prerelease state exists"));
	assert!(error.to_string().contains("prerelease-state.json"));
}

#[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.changeset_context_timeout_seconds, 120);
	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
changeset_context_timeout_seconds = 9
"#,
	)
	.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);
	assert_eq!(source.releases.changeset_context_timeout_seconds, 9);
}

#[test]
fn load_workspace_configuration_rejects_zero_changeset_context_timeout() {
	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]
changeset_context_timeout_seconds = 0
"#,
	)
	.unwrap_or_else(|error| panic!("write config: {error}"));

	let error = load_workspace_configuration(tempdir.path())
		.err()
		.unwrap_or_else(|| panic!("expected timeout validation error"));

	assert!(
		error
			.to_string()
			.contains("[source.releases].changeset_context_timeout_seconds must be greater than 0")
	);
}

#[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();
	// The error message varies: could say "missing expected cargo manifest" or "does not exist"
	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,
	)];

	// Changeset linting now runs through `mc check` rather than during config loading,
	// so `load_changeset_file` should succeed even when the changeset body violates lint rules.
	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,
	)];

	// Changeset linting now runs through `mc check` rather than during config loading.
	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,
	)];

	// Changeset linting now runs through `mc check` rather than during config loading.
	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,
			style: monochange_core::ChangelogStyle::default(),
			release_notes: monochange_core::ReleaseNotesStyleOverrides::default(),
		},
		&[],
		&[],
	)
	.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_step_without_inputs() {
	let root = fixture_path("validate-step-inputs/unknown-input-on-display-versions");
	let error = load_workspace_configuration(&root)
		.err()
		.unwrap_or_else(|| panic!("expected config error"));
	assert!(
		error.to_string().contains("unknown input override `extra`"),
		"error was: {error}"
	);
	assert!(
		error.to_string().contains("this step accepts no inputs"),
		"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 `mode`"),
		"error was: {error}"
	);
	assert!(
		error.to_string().contains("valid inputs: format"),
		"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); // packages inherit changelog defaults
	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); // packages inherit defaults
	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 /* changelog_sections removed */, 0);
	assert_eq!(package.excluded_changelog_types.len(), 0); // packages inherit defaults
	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)
	);

	// A group with an unresolvable member should now produce an error
	// instead of silently dropping the member.
	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,
				stage_all: 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(),
			style: monochange_core::ChangelogStyle::default(),
			release_notes: monochange_core::ReleaseNotesStyleOverrides::default(),
		},
		&[],
		&[],
	)
	.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(),
			style: monochange_core::ChangelogStyle::default(),
			release_notes: monochange_core::ReleaseNotesStyleOverrides::default(),
		},
		&[],
		&[],
	)
	.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::Dart,
		monochange_core::PackageType::Dart
	));
}

#[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`"));
}

// -- validate_versioned_files_content tests --

#[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();

	// Write a minimal monochange.toml.
	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}"));

	// Create the package manifest.
	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}"));

	// Write a changeset file with CRLF line endings.
	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}"));

	// Bare carriage return (old Mac style) line endings.
	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));
}

// -- proptest_config module (merged from proptest_config.rs) --

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::Dart),
		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(&current, &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(&current, &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(&current, &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")
	);
}

#[test]
fn load_workspace_configuration_rejects_invalid_prerelease_settings() {
	let cases = [
		(
			"[prerelease]\nchannel = \" \"\n",
			"[prerelease].channel must not be empty",
		),
		(
			"[prerelease]\nchannel = \"bad channel\"\n",
			"[prerelease].channel must be a valid SemVer prerelease identifier",
		),
		(
			"[prerelease]\nbase = \"fixed\"\n",
			"[prerelease].base_version is required when base is `fixed`",
		),
		(
			"[prerelease]\nbase = \"fixed\"\nbase_version = \"1.0.0-alpha.1\"\n",
			"[prerelease].base_version must be a stable SemVer version",
		),
		(
			"[prerelease]\nbase = \"planned\"\nbase_version = \"1.0.0\"\n",
			"[prerelease].base_version is only valid when base is `fixed`",
		),
		(
			"[prerelease]\nbranches = [\"next\", \" \"]\n",
			"[prerelease].branches must not contain empty branch patterns",
		),
	];

	for (config, expected) in cases {
		let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
		std::fs::write(tempdir.path().join("monochange.toml"), config)
			.unwrap_or_else(|error| panic!("write config: {error}"));

		let error = load_workspace_configuration(tempdir.path())
			.expect_err("invalid prerelease configuration should fail");
		assert!(
			error.to_string().contains(expected),
			"expected `{expected}` in `{error}`"
		);
	}
}

#[test]
fn validate_workspace_configuration_rejects_leftover_prerelease_state_when_disabled() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let root = tempdir.path();
	std::fs::write(root.join("monochange.toml"), "")
		.unwrap_or_else(|error| panic!("write config: {error}"));
	let state_path = root.join(".monochange/prerelease-state.json");
	std::fs::create_dir_all(state_path.parent().unwrap())
		.unwrap_or_else(|error| panic!("state dir: {error}"));
	std::fs::write(&state_path, b"{}").unwrap_or_else(|error| panic!("state: {error}"));

	let error = validate_workspace(root).expect_err("leftover state should fail");
	assert!(
		error.to_string().contains("prerelease state exists"),
		"error: {error}"
	);
}
