use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use monochange_core::ChangeSignal;
use monochange_core::CompatibilityAssessment;
use monochange_core::Ecosystem;
use monochange_core::EcosystemAdapter;
use monochange_core::PackageRecord;
use monochange_core::PublishState;
use monochange_core::materialize_dependency_edges;
use monochange_semver::CompatibilityProvider;
use monochange_test_helpers::copy_directory;
use tempfile::tempdir;
use toml::Value;
use toml_edit::DocumentMut;
use toml_edit::Item;
use crate::CargoVersionedFileKind;
use crate::RustSemverProvider;
use crate::adapter;
use crate::default_lockfile_commands;
use crate::dependency_constraint;
use crate::discover_cargo_packages;
use crate::discover_lockfiles;
use crate::discover_workspace_packages;
use crate::has_workspace_section;
use crate::lockfile_requires_command_refresh;
use crate::parse_package_manifest;
use crate::parse_package_version;
use crate::supported_versioned_file_kind;
use crate::update_versioned_file_text;
use crate::validate_workspace_version_groups;
use crate::workspace_package_version;
#[test]
fn discovers_cargo_workspace_members() {
let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/cargo/workspace");
let discovery = discover_cargo_packages(&fixture_root)
.unwrap_or_else(|error| panic!("cargo discovery: {error}"));
assert_eq!(discovery.packages.len(), 2);
assert!(
discovery
.packages
.iter()
.any(|package| package.name == "cargo-core")
);
assert!(
discovery
.packages
.iter()
.any(|package| package.name == "cargo-app")
);
let dependency_edges = materialize_dependency_edges(&discovery.packages);
assert_eq!(dependency_edges.len(), 1);
assert!(
dependency_edges
.iter()
.any(|edge| edge.to_package_id.contains("crates/core/Cargo.toml"))
);
}
#[test]
fn cargo_workspace_members_inherit_workspace_package_versions() {
let fixture_root =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/cargo/workspace-versioned");
let discovery = discover_cargo_packages(&fixture_root)
.unwrap_or_else(|error| panic!("cargo discovery: {error}"));
let package = discovery
.packages
.iter()
.find(|package| package.name == "workspace-core")
.unwrap_or_else(|| panic!("expected workspace-core package"));
assert_eq!(
package
.current_version
.as_ref()
.map(ToString::to_string)
.as_deref(),
Some("2.3.4")
);
}
#[test]
fn cargo_workspace_members_mark_uses_workspace_version_metadata() {
let fixture_root =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/cargo/workspace-versioned");
let discovery = discover_cargo_packages(&fixture_root)
.unwrap_or_else(|error| panic!("cargo discovery: {error}"));
let core_package = discovery
.packages
.iter()
.find(|package| package.name == "workspace-core")
.unwrap_or_else(|| panic!("expected workspace-core package"));
assert_eq!(
core_package
.metadata
.get("uses_workspace_version")
.map(String::as_str),
Some("true")
);
let app_package = discovery
.packages
.iter()
.find(|package| package.name == "workspace-app")
.unwrap_or_else(|| panic!("expected workspace-app package"));
assert_eq!(
app_package
.metadata
.get("uses_workspace_version")
.map(String::as_str),
None
);
}
#[test]
fn discover_cargo_packages_ignores_gitignored_nested_worktrees() {
let fixture = setup_discovery_fixture("ignore-gitignored-nested-worktree");
let discovery = discover_cargo_packages(fixture.path())
.unwrap_or_else(|error| panic!("cargo discovery: {error}"));
let package_names = discovery
.packages
.iter()
.map(|package| package.name.as_str())
.collect::<Vec<_>>();
assert_eq!(package_names, vec!["root-package"]);
}
#[test]
fn discover_cargo_packages_ignores_nested_worktrees_even_when_not_gitignored() {
let fixture = setup_discovery_fixture("ignore-automatic-nested-worktree");
let discovery = discover_cargo_packages(fixture.path())
.unwrap_or_else(|error| panic!("cargo discovery: {error}"));
let package_names = discovery
.packages
.iter()
.map(|package| package.name.as_str())
.collect::<Vec<_>>();
assert_eq!(package_names, vec!["root-package"]);
}
fn setup_discovery_fixture(name: &str) -> tempfile::TempDir {
let source = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/tests/cargo")
.join(name);
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
copy_directory(&source, tempdir.path());
materialize_nested_worktree_gitdir(tempdir.path());
tempdir
}
fn materialize_nested_worktree_gitdir(root: &Path) {
for (placeholder, git_path) in [
(
root.join("sandbox/feature/gitdir.txt"),
root.join("sandbox/feature/.git"),
),
(
root.join("feature.gitdir"),
root.join(".claude/worktrees/feature/.git"),
),
] {
if placeholder.is_file() {
let gitdir = fs::read_to_string(&placeholder)
.unwrap_or_else(|error| panic!("read {}: {error}", placeholder.display()));
if let Some(parent) = git_path.parent() {
fs::create_dir_all(parent)
.unwrap_or_else(|error| panic!("create parent {}: {error}", parent.display()));
}
fs::write(&git_path, gitdir)
.unwrap_or_else(|error| panic!("write {}: {error}", git_path.display()));
}
}
}
#[test]
fn validate_workspace_version_groups_rejects_mismatched_workspace_version_groups() {
let workspace_root = PathBuf::from("/tmp/workspace");
let mut core = PackageRecord::new(
Ecosystem::Cargo,
"workspace-core",
workspace_root.join("crates/core/Cargo.toml"),
workspace_root.clone(),
None,
PublishState::Public,
);
core.metadata
.insert("config_id".to_string(), "core".to_string());
core.metadata
.insert("uses_workspace_version".to_string(), "true".to_string());
core.version_group_id = Some("sdk".to_string());
let mut app = PackageRecord::new(
Ecosystem::Cargo,
"workspace-app",
workspace_root.join("crates/app/Cargo.toml"),
workspace_root,
None,
PublishState::Public,
);
app.metadata
.insert("config_id".to_string(), "app".to_string());
app.metadata
.insert("uses_workspace_version".to_string(), "true".to_string());
let error = validate_workspace_version_groups(&[core, app])
.err()
.unwrap_or_else(|| panic!("expected validation error"));
assert!(error.to_string().contains(
"cargo packages using `version.workspace = true` must belong to the same version group"
));
}
#[test]
fn validate_workspace_version_groups_accepts_matching_workspace_version_groups() {
let workspace_root = PathBuf::from("/tmp/workspace");
let mut core = PackageRecord::new(
Ecosystem::Cargo,
"workspace-core",
workspace_root.join("crates/core/Cargo.toml"),
workspace_root.clone(),
None,
PublishState::Public,
);
core.metadata
.insert("config_id".to_string(), "core".to_string());
core.metadata
.insert("uses_workspace_version".to_string(), "true".to_string());
core.version_group_id = Some("sdk".to_string());
let mut app = PackageRecord::new(
Ecosystem::Cargo,
"workspace-app",
workspace_root.join("crates/app/Cargo.toml"),
workspace_root,
None,
PublishState::Public,
);
app.metadata
.insert("config_id".to_string(), "app".to_string());
app.metadata
.insert("uses_workspace_version".to_string(), "true".to_string());
app.version_group_id = Some("sdk".to_string());
validate_workspace_version_groups(&[core, app])
.unwrap_or_else(|error| panic!("validation: {error}"));
}
#[test]
fn rust_semver_provider_parses_compatibility_evidence() {
let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/cargo/workspace");
let discovery = discover_cargo_packages(&fixture_root)
.unwrap_or_else(|error| panic!("cargo discovery: {error}"));
let package = discovery
.packages
.iter()
.find(|package| package.name == "cargo-core")
.unwrap_or_else(|| panic!("expected cargo-core package"));
let signal = ChangeSignal {
package_id: package.id.clone(),
requested_bump: None,
explicit_version: None,
change_origin: "direct-change".to_string(),
evidence_refs: vec!["rust-semver:major:public API break detected".to_string()],
notes: Some("breaking change".to_string()),
details: None,
change_type: None,
caused_by: Vec::new(),
source_path: PathBuf::from(".changeset/feature.md"),
};
let provider = RustSemverProvider;
let assessment = provider
.assess(package, &signal)
.unwrap_or_else(|| panic!("expected semver assessment"));
assert_eq!(provider.provider_id(), "rust-semver");
assert_eq!(assessment.severity.to_string(), "major");
assert_eq!(assessment.summary, "public API break detected");
}
#[test]
fn adapter_reports_cargo_ecosystem() {
assert_eq!(adapter().ecosystem(), Ecosystem::Cargo);
}
#[test]
fn supported_versioned_file_kind_recognizes_manifest_and_lockfiles() {
assert_eq!(
supported_versioned_file_kind(Path::new("Cargo.toml")),
Some(CargoVersionedFileKind::Manifest)
);
assert_eq!(
supported_versioned_file_kind(Path::new("Cargo.lock")),
Some(CargoVersionedFileKind::Lock)
);
assert_eq!(supported_versioned_file_kind(Path::new("README.md")), None);
}
#[test]
fn discover_lockfiles_prefers_workspace_root_then_manifest_directory() {
let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/tests/monochange/cargo-lock-release");
let package = PackageRecord::new(
Ecosystem::Cargo,
"workflow-core",
fixture_root.join("crates/core/Cargo.toml"),
fixture_root.clone(),
None,
PublishState::Public,
);
let lockfiles = discover_lockfiles(&package);
assert_eq!(lockfiles.len(), 1);
assert_eq!(
lockfiles.first(),
Some(&monochange_core::normalize_path(
&fixture_root.join("Cargo.lock")
))
);
}
#[test]
fn discover_lockfiles_falls_back_to_manifest_directory() {
let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/tests/cargo/manifest-lockfile-workspace");
let package = PackageRecord::new(
Ecosystem::Cargo,
"lockfile-core",
fixture_root.join("crates/core/Cargo.toml"),
fixture_root.clone(),
None,
PublishState::Public,
);
let lockfiles = discover_lockfiles(&package);
assert_eq!(lockfiles.len(), 1);
assert_eq!(
lockfiles.first(),
Some(&monochange_core::normalize_path(
&fixture_root.join("crates/core/Cargo.lock")
))
);
}
#[test]
fn default_lockfile_commands_use_cargo_generate_lockfile_in_lockfile_directory() {
let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/tests/cargo/manifest-lockfile-workspace");
let package = PackageRecord::new(
Ecosystem::Cargo,
"lockfile-core",
fixture_root.join("crates/core/Cargo.toml"),
fixture_root.clone(),
None,
PublishState::Public,
);
assert_eq!(
default_lockfile_commands(&package),
vec![monochange_core::LockfileCommandExecution {
command: "cargo generate-lockfile".to_string(),
cwd: monochange_core::normalize_path(&fixture_root.join("crates/core")),
shell: monochange_core::ShellConfig::None,
}]
);
}
#[test]
fn lockfile_requires_command_refresh_for_incomplete_lockfiles() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
fs::create_dir_all(root.join("crates/core"))
.unwrap_or_else(|error| panic!("create core dir: {error}"));
fs::create_dir_all(root.join("crates/app"))
.unwrap_or_else(|error| panic!("create app dir: {error}"));
fs::write(
root.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"workflow-core\"\nversion = \"1.0.0\"\n\n[[package]]\nname = \"workflow-app\"\nversion = \"1.0.0\"\ndependencies = [\"workflow-core\"]\n",
)
.unwrap_or_else(|error| panic!("write cargo lock: {error}"));
let mut core = PackageRecord::new(
Ecosystem::Cargo,
"workflow-core",
root.join("crates/core/Cargo.toml"),
root.to_path_buf(),
None,
PublishState::Public,
);
core.declared_dependencies
.push(monochange_core::PackageDependency {
name: "serde".to_string(),
kind: monochange_core::DependencyKind::Runtime,
version_constraint: Some("1.0".to_string()),
optional: false,
source_field: None,
});
let app = PackageRecord::new(
Ecosystem::Cargo,
"workflow-app",
root.join("crates/app/Cargo.toml"),
root.to_path_buf(),
None,
PublishState::Public,
);
assert!(lockfile_requires_command_refresh(
&root.join("Cargo.lock"),
&[&core, &app],
));
}
#[test]
fn lockfile_requires_command_refresh_for_missing_invalid_and_empty_lockfiles() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
let package = PackageRecord::new(
Ecosystem::Cargo,
"workflow-core",
root.join("crates/core/Cargo.toml"),
root.to_path_buf(),
None,
PublishState::Public,
);
let package_refs = [&package];
let lockfile_path = root.join("Cargo.lock");
assert!(lockfile_requires_command_refresh(
&lockfile_path,
&package_refs
));
fs::write(&lockfile_path, "not valid toml = [")
.unwrap_or_else(|error| panic!("write invalid lockfile: {error}"));
assert!(lockfile_requires_command_refresh(
&lockfile_path,
&package_refs
));
fs::write(&lockfile_path, "version = 4\n")
.unwrap_or_else(|error| panic!("write empty lockfile: {error}"));
assert!(lockfile_requires_command_refresh(
&lockfile_path,
&package_refs
));
}
#[test]
fn update_versioned_file_updates_arbitrary_manifest_field_paths() {
let contents = r#"[workspace.package]
version = "1.0.0"
[workspace.metadata.bin.monochange]
version = "1.0.0"
"#;
let updated = update_versioned_file_text(
contents,
CargoVersionedFileKind::Manifest,
&["workspace.metadata.bin.monochange.version"],
Some("2.0.0"),
None,
&BTreeMap::new(),
&BTreeMap::new(),
)
.unwrap_or_else(|error| panic!("update cargo manifest: {error}"));
let document = updated
.parse::<DocumentMut>()
.unwrap_or_else(|error| panic!("parse updated manifest: {error}"));
assert_eq!(
document
.get("workspace")
.and_then(Item::as_table_like)
.and_then(|workspace| workspace.get("metadata"))
.and_then(Item::as_table_like)
.and_then(|metadata| metadata.get("bin"))
.and_then(Item::as_table_like)
.and_then(|bin| bin.get("monochange"))
.and_then(Item::as_table_like)
.and_then(|monochange| monochange.get("version"))
.and_then(Item::as_str),
Some("2.0.0")
);
assert_eq!(
document
.get("workspace")
.and_then(Item::as_table_like)
.and_then(|workspace| workspace.get("package"))
.and_then(Item::as_table_like)
.and_then(|package| package.get("version"))
.and_then(Item::as_str),
Some("1.0.0")
);
}
#[test]
fn update_versioned_file_updates_manifest_and_workspace_dependencies() {
let manifest = r#"
[package]
name = "app"
version = "1.0.0"
[dependencies]
core = "1.0.0"
shared = { workspace = true }
[workspace.package]
version = "1.0.0"
[workspace.dependencies]
core = { version = "1.0.0" }
"#;
let versioned_deps = BTreeMap::from([("core".to_string(), "2.0.0".to_string())]);
let raw_versions = BTreeMap::from([("core".to_string(), "2.0.0".to_string())]);
let updated = update_versioned_file_text(
manifest,
CargoVersionedFileKind::Manifest,
&["dependencies"],
Some("2.0.0"),
Some("3.0.0"),
&versioned_deps,
&raw_versions,
)
.unwrap_or_else(|error| panic!("update manifest: {error}"));
let manifest: Value =
toml::from_str(&updated).unwrap_or_else(|error| panic!("manifest toml: {error}"));
assert_eq!(
manifest
.get("package")
.and_then(Value::as_table)
.and_then(|table| table.get("version"))
.and_then(Value::as_str),
Some("2.0.0")
);
assert_eq!(
manifest
.get("dependencies")
.and_then(Value::as_table)
.and_then(|table| table.get("core"))
.and_then(Value::as_str),
Some("2.0.0")
);
assert_eq!(
manifest
.get("workspace")
.and_then(Value::as_table)
.and_then(|table| table.get("package"))
.and_then(Value::as_table)
.and_then(|table| table.get("version"))
.and_then(Value::as_str),
Some("3.0.0")
);
assert_eq!(
manifest
.get("workspace")
.and_then(Value::as_table)
.and_then(|table| table.get("dependencies"))
.and_then(Value::as_table)
.and_then(|table| table.get("core"))
.and_then(Value::as_table)
.and_then(|table| table.get("version"))
.and_then(Value::as_str),
Some("2.0.0")
);
assert_eq!(
manifest
.get("dependencies")
.and_then(Value::as_table)
.and_then(|table| table.get("shared"))
.and_then(Value::as_table)
.and_then(|table| table.get("workspace"))
.and_then(Value::as_bool),
Some(true)
);
}
#[test]
fn update_versioned_file_updates_lock_packages() {
let lock = r#"
[[package]]
name = "core"
version = "1.0.0"
[[package]]
name = "app"
version = "1.0.0"
"#;
let raw_versions = BTreeMap::from([
("core".to_string(), "2.0.0".to_string()),
("app".to_string(), "1.1.0".to_string()),
]);
let updated = update_versioned_file_text(
lock,
CargoVersionedFileKind::Lock,
&[],
None,
None,
&BTreeMap::new(),
&raw_versions,
)
.unwrap_or_else(|error| panic!("update lock: {error}"));
let lock: Value = toml::from_str(&updated).unwrap_or_else(|error| panic!("lock toml: {error}"));
let packages = lock
.get("package")
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("expected package array"));
assert!(packages.iter().any(|package| {
package["name"].as_str() == Some("core") && package["version"].as_str() == Some("2.0.0")
}));
assert!(packages.iter().any(|package| {
package["name"].as_str() == Some("app") && package["version"].as_str() == Some("1.1.0")
}));
}
#[test]
fn update_versioned_file_preserves_lock_formatting() {
let lock = r#"# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "core"
version = "1.0.0"
source = "registry+https://example.test"
[[package]]
name = "app"
version = "1.0.0"
dependencies = ["core"]
"#;
let updated = update_versioned_file_text(
lock,
CargoVersionedFileKind::Lock,
&[],
None,
None,
&BTreeMap::new(),
&BTreeMap::from([
("core".to_string(), "2.0.0".to_string()),
("app".to_string(), "2.0.0".to_string()),
]),
)
.unwrap_or_else(|error| panic!("update lock: {error}"));
assert!(updated.starts_with("# This file is automatically @generated by Cargo."));
assert!(updated.contains("source = \"registry+https://example.test\""));
assert!(updated.contains("dependencies = [\"core\"]"));
assert!(updated.contains("name = \"core\"\nversion = \"2.0.0\""));
assert!(updated.contains("name = \"app\"\nversion = \"2.0.0\""));
}
#[test]
fn update_versioned_file_covers_workspace_owned_and_unstructured_entries() {
let manifest = r#"
[package]
name = "app"
version = { workspace = true }
[dependencies]
core = { workspace = true }
serde = { version = "1.0.0", optional = true }
weird = true
[workspace.package]
version = "1.0.0"
[workspace.dependencies]
core = "1.0.0"
serde = { version = "1.0.0" }
"#;
let versioned_deps = BTreeMap::from([
("core".to_string(), "2.0.0".to_string()),
("serde".to_string(), "1.1.0".to_string()),
]);
let updated = update_versioned_file_text(
manifest,
CargoVersionedFileKind::Manifest,
&["dependencies", "dev-dependencies"],
Some("9.9.9"),
None,
&versioned_deps,
&BTreeMap::new(),
)
.unwrap_or_else(|error| panic!("update manifest: {error}"));
let manifest: Value =
toml::from_str(&updated).unwrap_or_else(|error| panic!("manifest toml: {error}"));
assert_eq!(
manifest
.get("package")
.and_then(Value::as_table)
.and_then(|table| table.get("version"))
.and_then(Value::as_table)
.and_then(|table| table.get("workspace"))
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
manifest
.get("dependencies")
.and_then(Value::as_table)
.and_then(|table| table.get("core"))
.and_then(Value::as_table)
.and_then(|table| table.get("workspace"))
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
manifest
.get("dependencies")
.and_then(Value::as_table)
.and_then(|table| table.get("serde"))
.and_then(Value::as_table)
.and_then(|table| table.get("version"))
.and_then(Value::as_str),
Some("1.1.0")
);
assert_eq!(
manifest
.get("dependencies")
.and_then(Value::as_table)
.and_then(|table| table.get("weird"))
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
manifest
.get("workspace")
.and_then(Value::as_table)
.and_then(|table| table.get("package"))
.and_then(Value::as_table)
.and_then(|table| table.get("version"))
.and_then(Value::as_str),
Some("1.0.0")
);
assert_eq!(
manifest
.get("workspace")
.and_then(Value::as_table)
.and_then(|table| table.get("dependencies"))
.and_then(Value::as_table)
.and_then(|table| table.get("core"))
.and_then(Value::as_str),
Some("2.0.0")
);
assert_eq!(
manifest
.get("workspace")
.and_then(Value::as_table)
.and_then(|table| table.get("dependencies"))
.and_then(Value::as_table)
.and_then(|table| table.get("serde"))
.and_then(Value::as_table)
.and_then(|table| table.get("version"))
.and_then(Value::as_str),
Some("1.1.0")
);
let lock = r#"
[[package]]
version = "1.0.0"
[[package]]
name = "core"
version = "1.0.0"
[[package]]
name = "ignored"
version = "0.1.0"
"#;
let updated = update_versioned_file_text(
lock,
CargoVersionedFileKind::Lock,
&[],
None,
None,
&BTreeMap::new(),
&BTreeMap::from([("core".to_string(), "2.0.0".to_string())]),
)
.unwrap_or_else(|error| panic!("update lock: {error}"));
let lock: Value = toml::from_str(&updated).unwrap_or_else(|error| panic!("lock toml: {error}"));
let packages = lock
.get("package")
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("expected package array"));
assert!(packages.iter().any(|package| {
package.get("name").and_then(Value::as_str) == Some("core")
&& package.get("version").and_then(Value::as_str) == Some("2.0.0")
}));
assert!(packages.iter().any(|package| {
package.get("name").and_then(Value::as_str) == Some("ignored")
&& package.get("version").and_then(Value::as_str) == Some("0.1.0")
}));
}
#[test]
fn cargo_versioned_file_helpers_cover_non_table_and_insertion_paths() {
let mut dependency_document = "[dependencies]\nweird = true\n"
.parse::<DocumentMut>()
.unwrap_or_else(|error| panic!("parse dependency document: {error}"));
let dependency_table = dependency_document
.get_mut("dependencies")
.and_then(Item::as_table_like_mut)
.unwrap_or_else(|| panic!("expected dependencies table"));
let weird_entry = dependency_table
.get_mut("weird")
.unwrap_or_else(|| panic!("expected weird entry"));
crate::update_dependency_entry(weird_entry, "2.0.0");
assert_eq!(weird_entry.as_bool(), Some(true));
let mut insert_document = "[dependencies]\n"
.parse::<DocumentMut>()
.unwrap_or_else(|error| panic!("parse insert document: {error}"));
let insert_table = insert_document
.get_mut("dependencies")
.and_then(Item::as_table_like_mut)
.unwrap_or_else(|| panic!("expected insert dependencies table"));
crate::set_table_value(insert_table, "core", "2.0.0");
assert_eq!(
insert_document
.get("dependencies")
.and_then(Item::as_table_like)
.and_then(|table| table.get("core"))
.and_then(Item::as_str),
Some("2.0.0")
);
let mut nested_document = r#"
[workspace]
version = "1.0.0"
[workspace.metadata]
value = true
"#
.parse::<DocumentMut>()
.unwrap_or_else(|error| panic!("parse nested document: {error}"));
let workspace_table = nested_document
.get_mut("workspace")
.and_then(Item::as_table_like_mut)
.unwrap_or_else(|| panic!("expected workspace table"));
crate::set_table_value_by_path(workspace_table, &[], "2.0.0");
crate::set_table_value_by_path(workspace_table, &["missing", "version"], "2.0.0");
crate::set_table_value_by_path(workspace_table, &["version", "nested"], "2.0.0");
crate::set_table_value_by_path(workspace_table, &["metadata", "version"], "2.0.0");
assert_eq!(
nested_document
.get("workspace")
.and_then(Item::as_table_like)
.and_then(|table| table.get("version"))
.and_then(Item::as_str),
Some("1.0.0")
);
assert_eq!(
nested_document
.get("workspace")
.and_then(Item::as_table_like)
.and_then(|table| table.get("metadata"))
.and_then(Item::as_table_like)
.and_then(|table| table.get("value"))
.and_then(Item::as_bool),
Some(true)
);
let mut empty_item = Item::None;
crate::set_item_string(&mut empty_item, "3.0.0");
assert_eq!(empty_item.as_str(), Some("3.0.0"));
}
#[test]
fn update_versioned_file_supports_nested_manifest_field_paths() {
let manifest = r#"
[package]
name = "app"
version = "1.0.0"
[dependencies]
core = { version = "1.0.0", path = "../core" }
serde = "1.0.0"
[dev-dependencies]
core = { version = "1.0.0" }
[workspace.package]
version = "1.0.0"
[workspace.dependencies]
core = { path = "crates/core", version = "1.0.0" }
"#;
let updated = update_versioned_file_text(
manifest,
CargoVersionedFileKind::Manifest,
&[
"workspace.version",
"workspace.dependencies.core.version",
"dependencies.core.version",
"dev_dependencies.core.version",
],
Some("2.0.0"),
Some("3.0.0"),
&BTreeMap::from([("core".to_string(), "2.0.0".to_string())]),
&BTreeMap::new(),
)
.unwrap_or_else(|error| panic!("update manifest: {error}"));
let manifest: Value =
toml::from_str(&updated).unwrap_or_else(|error| panic!("manifest toml: {error}"));
assert_eq!(
manifest
.get("package")
.and_then(Value::as_table)
.and_then(|table| table.get("version"))
.and_then(Value::as_str),
Some("2.0.0")
);
assert_eq!(
manifest
.get("workspace")
.and_then(Value::as_table)
.and_then(|table| table.get("package"))
.and_then(Value::as_table)
.and_then(|table| table.get("version"))
.and_then(Value::as_str),
Some("3.0.0")
);
assert_eq!(
manifest
.get("workspace")
.and_then(Value::as_table)
.and_then(|table| table.get("dependencies"))
.and_then(Value::as_table)
.and_then(|table| table.get("core"))
.and_then(Value::as_table)
.and_then(|table| table.get("version"))
.and_then(Value::as_str),
Some("2.0.0")
);
assert_eq!(
manifest
.get("dependencies")
.and_then(Value::as_table)
.and_then(|table| table.get("core"))
.and_then(Value::as_table)
.and_then(|table| table.get("version"))
.and_then(Value::as_str),
Some("2.0.0")
);
assert_eq!(
manifest
.get("dev-dependencies")
.and_then(Value::as_table)
.and_then(|table| table.get("core"))
.and_then(Value::as_table)
.and_then(|table| table.get("version"))
.and_then(Value::as_str),
Some("2.0.0")
);
assert_eq!(
manifest
.get("dependencies")
.and_then(Value::as_table)
.and_then(|table| table.get("serde"))
.and_then(Value::as_str),
Some("1.0.0")
);
}
#[test]
fn update_versioned_file_version_field_paths_leave_string_entries_unchanged() {
let manifest = r#"
[dependencies]
core = "1.0.0"
[workspace.dependencies]
core = "1.0.0"
"#;
let updated = update_versioned_file_text(
manifest,
CargoVersionedFileKind::Manifest,
&[
"dependencies.core.version",
"workspace.dependencies.core.version",
],
None,
None,
&BTreeMap::from([("core".to_string(), "2.0.0".to_string())]),
&BTreeMap::new(),
)
.unwrap_or_else(|error| panic!("update manifest: {error}"));
let manifest: Value =
toml::from_str(&updated).unwrap_or_else(|error| panic!("manifest toml: {error}"));
assert_eq!(
manifest
.get("dependencies")
.and_then(Value::as_table)
.and_then(|table| table.get("core"))
.and_then(Value::as_str),
Some("1.0.0")
);
assert_eq!(
manifest
.get("workspace")
.and_then(Value::as_table)
.and_then(|table| table.get("dependencies"))
.and_then(Value::as_table)
.and_then(|table| table.get("core"))
.and_then(Value::as_str),
Some("1.0.0")
);
}
#[test]
fn cargo_manifest_field_helpers_cover_direct_path_updates() {
let mut document = r#"
[package]
name = "app"
version = "1.0.0"
[dependencies]
core = { version = "1.0.0" }
[workspace.dependencies]
core = { version = "1.0.0" }
"#
.parse::<DocumentMut>()
.unwrap_or_else(|error| panic!("parse field helper document: {error}"));
let versions = BTreeMap::from([("core".to_string(), "2.0.0".to_string())]);
crate::update_manifest_field(
&mut document,
"package.version",
Some("2.0.0"),
None,
&versions,
);
crate::update_manifest_field(
&mut document,
"workspace.dependencies",
None,
None,
&versions,
);
crate::update_manifest_field(&mut document, "dependencies.core", None, None, &versions);
crate::update_manifest_field(
&mut document,
"dependencies.core.version",
None,
None,
&versions,
);
crate::update_manifest_field(
&mut document,
"workspace.dependencies.core",
None,
None,
&versions,
);
crate::update_manifest_field(
&mut document,
"workspace.dependencies.core.version",
None,
None,
&versions,
);
crate::update_manifest_field(&mut document, "unknown.field", None, None, &versions);
assert_eq!(
document
.get("package")
.and_then(Item::as_table_like)
.and_then(|table| table.get("version"))
.and_then(Item::as_str),
Some("2.0.0")
);
assert_eq!(
document
.get("dependencies")
.and_then(Item::as_table_like)
.and_then(|table| table.get("core"))
.and_then(Item::as_table_like)
.and_then(|table| table.get("version"))
.and_then(Item::as_str),
Some("2.0.0")
);
assert_eq!(
document
.get("workspace")
.and_then(Item::as_table_like)
.and_then(|table| table.get("dependencies"))
.and_then(Item::as_table_like)
.and_then(|table| table.get("core"))
.and_then(Item::as_table_like)
.and_then(|table| table.get("version"))
.and_then(Item::as_str),
Some("2.0.0")
);
let mut empty_document = ""
.parse::<DocumentMut>()
.unwrap_or_else(|error| panic!("parse empty document: {error}"));
crate::update_manifest_field(
&mut empty_document,
"dependencies.missing",
None,
None,
&BTreeMap::new(),
);
crate::update_manifest_field(
&mut empty_document,
"dependencies.missing.version",
None,
None,
&BTreeMap::new(),
);
crate::update_manifest_field(
&mut empty_document,
"workspace.dependencies.missing",
None,
None,
&BTreeMap::new(),
);
crate::update_manifest_field(
&mut empty_document,
"workspace.dependencies.missing.version",
None,
None,
&BTreeMap::new(),
);
crate::update_manifest_field(
&mut empty_document,
"dependencies.core",
None,
None,
&versions,
);
crate::update_manifest_field(
&mut empty_document,
"dependencies.core.version",
None,
None,
&versions,
);
crate::update_manifest_field(
&mut empty_document,
"workspace.dependencies.core",
None,
None,
&versions,
);
crate::update_manifest_field(
&mut empty_document,
"workspace.dependencies.core.version",
None,
None,
&versions,
);
assert!(crate::fields_target_workspace_dependencies(&[
"workspace.dependencies"
]));
assert!(!crate::fields_target_workspace_dependencies(&[
"dependencies"
]));
let mut helpers_document = "[dependencies]\n"
.parse::<DocumentMut>()
.unwrap_or_else(|error| panic!("parse helpers document: {error}"));
let helpers_table = helpers_document
.get_mut("dependencies")
.and_then(Item::as_table_like_mut)
.unwrap_or_else(|| panic!("expected helpers dependency table"));
crate::update_dependency_by_name(helpers_table, "missing", "2.0.0");
crate::update_dependency_version_by_name(helpers_table, "missing", "2.0.0");
}
#[test]
fn adapter_discover_matches_direct_cargo_discovery() {
let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/cargo/workspace");
let from_adapter = adapter()
.discover(&fixture_root)
.unwrap_or_else(|error| panic!("adapter discovery: {error}"));
let direct = discover_cargo_packages(&fixture_root)
.unwrap_or_else(|error| panic!("direct discovery: {error}"));
assert_eq!(from_adapter.packages, direct.packages);
assert_eq!(from_adapter.warnings, direct.warnings);
}
#[test]
fn discover_cargo_packages_reports_workspace_warnings_and_private_packages() {
let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/tests/cargo/workspace-pattern-warnings");
let discovery = discover_cargo_packages(&fixture_root)
.unwrap_or_else(|error| panic!("cargo discovery: {error}"));
assert_eq!(discovery.packages.len(), 3);
assert!(discovery.warnings.iter().any(|warning| {
warning.contains("missing/*") && warning.contains("matched no packages")
}));
let excluded_package = discovery
.packages
.iter()
.find(|package| package.name == "warning-excluded")
.unwrap_or_else(|| panic!("expected excluded package to be discovered standalone"));
assert_eq!(
excluded_package.workspace_root,
monochange_core::normalize_path(&fixture_root.join("crates/excluded"))
);
let private_package = discovery
.packages
.iter()
.find(|package| package.name == "warning-private")
.unwrap_or_else(|| panic!("expected private package"));
assert_eq!(private_package.publish_state, PublishState::Private);
let workspace_versioned = discovery
.packages
.iter()
.find(|package| package.name == "warning-core")
.unwrap_or_else(|| panic!("expected warning-core package"));
assert_eq!(
workspace_versioned
.current_version
.as_ref()
.map(ToString::to_string)
.as_deref(),
Some("3.2.1")
);
assert_eq!(
workspace_versioned
.metadata
.get("uses_workspace_version")
.map(String::as_str),
Some("true")
);
}
#[test]
fn parse_package_version_prefers_workspace_version_when_requested() {
let workspace_version = semver::Version::new(2, 3, 4);
let version = parse_package_version(
&toml::from_str::<Value>("workspace = true")
.unwrap_or_else(|error| panic!("workspace version toml: {error}")),
Some(&workspace_version),
);
assert_eq!(version, Some(workspace_version));
}
#[test]
fn dependency_constraint_supports_strings_and_tables() {
assert_eq!(
dependency_constraint(&Value::String("^1.2.3".to_string())),
Some("^1.2.3".to_string())
);
let table_value = toml::from_str::<Value>("version = \"~2.0\"")
.unwrap_or_else(|error| panic!("dependency table toml: {error}"));
assert_eq!(
dependency_constraint(&table_value),
Some("~2.0".to_string())
);
assert_eq!(dependency_constraint(&Value::Boolean(true)), None);
}
#[test]
fn rust_semver_provider_defaults_unknown_severity_to_none() {
let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/cargo/workspace");
let discovery = discover_cargo_packages(&fixture_root)
.unwrap_or_else(|error| panic!("cargo discovery: {error}"));
let package = discovery
.packages
.iter()
.find(|package| package.name == "cargo-core")
.unwrap_or_else(|| panic!("expected cargo-core package"));
let signal = ChangeSignal {
package_id: package.id.clone(),
requested_bump: None,
explicit_version: None,
change_origin: "direct-change".to_string(),
evidence_refs: vec!["cargo-semver:unexpected:manual review needed".to_string()],
notes: None,
details: None,
change_type: None,
caused_by: Vec::new(),
source_path: PathBuf::from(".changeset/feature.md"),
};
let assessment = RustSemverProvider
.assess(package, &signal)
.unwrap_or_else(|| panic!("expected assessment"));
assert_eq!(assessment.severity, monochange_core::BumpSeverity::None);
assert_eq!(assessment.summary, "manual review needed");
}
#[test]
fn cargo_manifest_helpers_cover_workspace_and_error_paths() {
let versioned_root =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/cargo/workspace-versioned");
let root_manifest = versioned_root.join("Cargo.toml");
assert!(has_workspace_section(&root_manifest).unwrap());
let parsed = toml::from_str::<Value>(
&fs::read_to_string(&root_manifest)
.unwrap_or_else(|error| panic!("read workspace manifest: {error}")),
)
.unwrap_or_else(|error| panic!("parse workspace manifest: {error}"));
assert_eq!(
workspace_package_version(&parsed)
.as_ref()
.map(ToString::to_string)
.as_deref(),
Some("2.3.4")
);
let virtual_root =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/tests/cargo/virtual-manifest");
let virtual_manifest = virtual_root.join("Cargo.toml");
assert_eq!(
parse_package_manifest(&virtual_manifest, &virtual_root, None)
.unwrap_or_else(|error| panic!("parse virtual manifest: {error}")),
None
);
let invalid_workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/tests/cargo/invalid-workspace/invalid-workspace.toml");
let invalid_workspace_error = has_workspace_section(&invalid_workspace)
.err()
.unwrap_or_else(|| panic!("expected invalid workspace error"));
assert!(
invalid_workspace_error
.to_string()
.contains("failed to parse")
);
let invalid_package_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/tests/cargo/invalid-package-name");
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
fs::create_dir_all(tempdir.path().join("crates/core"))
.unwrap_or_else(|error| panic!("create temp package dir: {error}"));
fs::copy(
invalid_package_root.join("invalid-workspace.toml"),
tempdir.path().join("invalid-workspace.toml"),
)
.unwrap_or_else(|error| panic!("copy workspace manifest: {error}"));
fs::copy(
invalid_package_root.join("crates/core/invalid-package.toml"),
tempdir.path().join("crates/core/Cargo.toml"),
)
.unwrap_or_else(|error| panic!("copy package manifest: {error}"));
let discovery_error =
discover_workspace_packages(&tempdir.path().join("invalid-workspace.toml"))
.err()
.unwrap_or_else(|| panic!("expected invalid package discovery error"));
assert!(discovery_error.to_string().contains("missing package.name"));
}
#[test]
fn rust_semver_provider_returns_none_for_non_cargo_packages() {
let package = PackageRecord::new(
Ecosystem::Npm,
"web",
PathBuf::from("packages/web/package.json"),
PathBuf::from("."),
None,
PublishState::Public,
);
let signal = ChangeSignal {
package_id: package.id.clone(),
requested_bump: None,
explicit_version: None,
change_origin: "direct-change".to_string(),
evidence_refs: vec!["rust-semver:minor:new API".to_string()],
notes: None,
details: None,
change_type: None,
caused_by: Vec::new(),
source_path: PathBuf::from(".changeset/feature.md"),
};
assert_eq!(
RustSemverProvider.assess(&package, &signal),
None::<CompatibilityAssessment>
);
}
#[test]
fn default_dependency_version_prefix_is_correct() {
assert_eq!(super::default_dependency_version_prefix(), "");
}
#[test]
fn default_dependency_fields_are_non_empty() {
assert!(!super::default_dependency_fields().is_empty());
}
#[test]
fn validate_versioned_file_accepts_valid_cargo_toml() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let path = tempdir.path().join("Cargo.toml");
fs::write(
&path,
r#"[package]
name = "test"
version = "1.0.0"
"#,
)
.unwrap_or_else(|error| panic!("write: {error}"));
assert!(super::validate_versioned_file(&path, "Cargo.toml", None).is_ok());
}
#[test]
fn validate_versioned_file_accepts_workspace_package_version() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let path = tempdir.path().join("Cargo.toml");
fs::write(
&path,
r#"[workspace.package]
version = "1.0.0"
"#,
)
.unwrap_or_else(|error| panic!("write: {error}"));
assert!(super::validate_versioned_file(&path, "Cargo.toml", None).is_ok());
}
#[test]
fn validate_versioned_file_accepts_custom_fields() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let path = tempdir.path().join("Cargo.toml");
fs::write(
&path,
r#"[workspace.metadata.bin.monochange]
version = "1.0.0"
"#,
)
.unwrap_or_else(|error| panic!("write: {error}"));
let custom_fields = vec!["workspace.metadata.bin.monochange.version".to_string()];
assert!(super::validate_versioned_file(&path, "Cargo.toml", Some(&custom_fields)).is_ok());
}
#[test]
fn validate_versioned_file_rejects_invalid_toml() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let path = tempdir.path().join("Cargo.toml");
fs::write(&path, "not valid toml = [").unwrap_or_else(|error| panic!("write: {error}"));
let result = super::validate_versioned_file(&path, "Cargo.toml", None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not valid TOML"));
}
#[test]
fn validate_versioned_file_rejects_missing_version() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let path = tempdir.path().join("Cargo.toml");
fs::write(&path, "[package]\nname = \"test\"\n")
.unwrap_or_else(|error| panic!("write: {error}"));
let result = super::validate_versioned_file(&path, "Cargo.toml", None);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("does not contain a readable version field")
);
}
#[test]
fn validate_versioned_file_rejects_missing_file() {
let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let path = tempdir.path().join("missing.toml");
let result = super::validate_versioned_file(&path, "missing.toml", None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not readable"));
}