monochange 0.6.1

Manage versions and releases for your multiplatform, multilanguage monorepo
Documentation
#![allow(clippy::disallowed_methods)]
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;

use monochange_core::Ecosystem;
use monochange_core::EcosystemType;

use super::CachedDocument;
use super::VersionedFileUpdateContext;
use super::apply_versioned_file_definition;
use super::build_versioned_file_updates;
use super::inferred_lockfile_ecosystem_type;
use super::inferred_lockfile_paths;
use super::read_cached_document;
use super::versioned_file_kind;

fn fixture_path(relative: &str) -> PathBuf {
	PathBuf::from(env!("CARGO_MANIFEST_DIR"))
		.join("../../fixtures/tests")
		.join(relative)
}

#[test]
fn go_versioned_file_kind_and_lockfile_inference_are_supported() {
	let configuration =
		monochange_config::load_workspace_configuration(&fixture_path("monochange/release-base"))
			.unwrap_or_else(|error| panic!("configuration: {error}"));
	let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let module_dir = tempdir.path().join("api");
	std::fs::create_dir(&module_dir)
		.unwrap_or_else(|error| panic!("create api module dir: {error}"));
	std::fs::write(module_dir.join("go.sum"), "")
		.unwrap_or_else(|error| panic!("write go.sum: {error}"));
	let package = monochange_core::PackageRecord {
		ecosystem: Ecosystem::Go,
		manifest_path: module_dir.join("go.mod"),
		..monochange_core::PackageRecord::new(
			Ecosystem::Go,
			"github.com/example/repo/api",
			module_dir.join("go.mod"),
			tempdir.path().to_path_buf(),
			None,
			monochange_core::PublishState::Public,
		)
	};

	assert!(versioned_file_kind(EcosystemType::Go, Path::new("go.mod")).is_some());
	assert_eq!(
		inferred_lockfile_ecosystem_type(&configuration, Ecosystem::Go),
		Some(EcosystemType::Go)
	);
	assert_eq!(
		inferred_lockfile_paths(&package),
		vec![module_dir.join("go.sum")]
	);
}

#[test]
fn read_cached_document_handles_go_text_and_invalid_utf8() {
	let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let go_mod = tempdir.path().join("go.mod");
	std::fs::write(&go_mod, "module github.com/example/repo\n")
		.unwrap_or_else(|error| panic!("write go.mod: {error}"));
	let mut updates = BTreeMap::new();

	let document = read_cached_document(&mut updates, &go_mod, EcosystemType::Go)
		.unwrap_or_else(|error| panic!("go text document: {error}"));
	assert!(matches!(document, CachedDocument::Text(_)));

	std::fs::write(&go_mod, [0xff, 0xfe])
		.unwrap_or_else(|error| panic!("write invalid go.mod: {error}"));
	let error = read_cached_document(&mut updates, &go_mod, EcosystemType::Go)
		.expect_err("invalid go.mod should fail");
	assert!(error.to_string().contains("failed to parse"));
}

#[test]
#[cfg(feature = "npm")]
fn read_cached_document_preserves_bun_lock_binary_without_utf8_parse() {
	let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let bun_lock = tempdir.path().join("bun.lockb");
	let binary_contents = vec![0xff, 0xfe, 0x00, 0x01];
	std::fs::write(&bun_lock, &binary_contents)
		.unwrap_or_else(|error| panic!("write bun.lockb: {error}"));
	let mut updates = BTreeMap::new();

	let document = read_cached_document(&mut updates, &bun_lock, EcosystemType::Npm)
		.unwrap_or_else(|error| panic!("bun lock binary document: {error}"));

	assert!(matches!(document, CachedDocument::Bytes(contents) if contents == binary_contents));
}

#[test]
fn read_cached_document_reports_deno_lock_invalid_utf8_as_text_parse_error() {
	let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let deno_lock = tempdir.path().join("deno.lock");
	std::fs::write(&deno_lock, [0xff, 0xfe])
		.unwrap_or_else(|error| panic!("write invalid deno.lock: {error}"));
	let mut updates = BTreeMap::new();

	let error = read_cached_document(&mut updates, &deno_lock, EcosystemType::Deno)
		.expect_err("invalid deno.lock should fail");
	let message = error.to_string();
	assert!(message.contains("failed to parse"));
	assert!(message.contains("as text"));
}

#[test]
fn read_cached_document_reports_go_for_unsupported_go_versioned_file() {
	let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let notes = tempdir.path().join("notes.txt");
	std::fs::write(&notes, "version = 1.0.0\n")
		.unwrap_or_else(|error| panic!("write notes: {error}"));
	let mut updates = BTreeMap::new();

	let error = read_cached_document(&mut updates, &notes, EcosystemType::Go)
		.expect_err("unsupported go versioned file");

	assert!(error.to_string().contains("ecosystem `go`"));
}

#[test]
fn apply_versioned_file_definition_reports_go_for_unsupported_glob_match() {
	let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	std::fs::write(tempdir.path().join("notes.txt"), "version = 1.0.0\n")
		.unwrap_or_else(|error| panic!("write notes: {error}"));
	let configuration =
		monochange_config::load_workspace_configuration(&fixture_path("monochange/release-base"))
			.unwrap_or_else(|error| panic!("configuration: {error}"));
	let mut released_versions = BTreeMap::new();
	released_versions.insert("lib".to_string(), "1.2.3".to_string());
	let context = VersionedFileUpdateContext {
		package_by_config_id: BTreeMap::new(),
		package_by_native_name: BTreeMap::new(),
		current_versions_by_native_name: BTreeMap::new(),
		released_versions_by_native_name: released_versions,
		configuration: &configuration,
	};
	let definition = monochange_core::VersionedFileDefinition {
		path: "*.txt".to_string(),
		ecosystem_type: Some(EcosystemType::Go),
		prefix: None,
		fields: None,
		name: None,
		regex: None,
	};
	let mut updates = BTreeMap::new();

	let error = apply_versioned_file_definition(
		tempdir.path(),
		&mut updates,
		&definition,
		"1.2.3",
		None,
		&["lib".to_string()],
		&context,
	)
	.expect_err("unsupported go glob match");

	assert!(error.to_string().contains("ecosystem `go`"));
}

#[test]
fn apply_versioned_file_definition_updates_go_mod_dependencies() {
	let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let go_mod = tempdir.path().join("go.mod");
	std::fs::write(
		&go_mod,
		"module github.com/example/app\n\ngo 1.22\n\nrequire github.com/example/lib v1.0.0\n",
	)
	.unwrap_or_else(|error| panic!("write go.mod: {error}"));
	let configuration =
		monochange_config::load_workspace_configuration(&fixture_path("monochange/release-base"))
			.unwrap_or_else(|error| panic!("configuration: {error}"));
	let mut released_versions = BTreeMap::new();
	released_versions.insert("lib".to_string(), "1.2.3".to_string());
	let context = VersionedFileUpdateContext {
		package_by_config_id: BTreeMap::new(),
		package_by_native_name: BTreeMap::new(),
		current_versions_by_native_name: BTreeMap::new(),
		released_versions_by_native_name: released_versions,
		configuration: &configuration,
	};
	let definition = monochange_core::VersionedFileDefinition {
		path: "go.mod".to_string(),
		ecosystem_type: Some(EcosystemType::Go),
		prefix: None,
		fields: None,
		name: None,
		regex: None,
	};
	let mut updates = BTreeMap::new();

	apply_versioned_file_definition(
		tempdir.path(),
		&mut updates,
		&definition,
		"1.2.3",
		None,
		&["lib".to_string()],
		&context,
	)
	.unwrap_or_else(|error| panic!("apply go update: {error}"));
	let updated_document = updates
		.into_values()
		.next()
		.unwrap_or_else(|| panic!("updated go.mod"));
	assert!(matches!(
		updated_document,
		CachedDocument::Text(contents) if contents.contains("github.com/example/lib v1.2.3")
	));
}

#[test]
fn build_versioned_file_updates_uses_package_versioned_file_name_override() {
	let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let root = tempdir.path();
	let package_dir = root.join("packages/lib");
	std::fs::create_dir_all(&package_dir)
		.unwrap_or_else(|error| panic!("create package directory: {error}"));
	let manifest_path = package_dir.join("package.json");
	std::fs::write(
		&manifest_path,
		r#"{
  "name": "consumer",
  "version": "0.0.0",
  "dependencies": {
    "lib": "^1.0.0",
    "actual": "^1.0.0"
  }
}
"#,
	)
	.unwrap_or_else(|error| panic!("write package manifest: {error}"));
	std::fs::write(
		root.join("monochange.toml"),
		r#"
[defaults]
package_type = "npm"

[package.lib]
path = "packages/lib"
versioned_files = [
  { path = "packages/lib/package.json", type = "npm", name = "lib", fields = ["dependencies.{{ name }}"] }
]

[ecosystems.npm]
enabled = true
"#,
	)
	.unwrap_or_else(|error| panic!("write monochange config: {error}"));
	let configuration = monochange_config::load_workspace_configuration(root)
		.unwrap_or_else(|error| panic!("configuration: {error}"));
	let mut package = monochange_core::PackageRecord::new(
		Ecosystem::Npm,
		"lib",
		manifest_path.clone(),
		root.to_path_buf(),
		Some(semver::Version::new(1, 0, 0)),
		monochange_core::PublishState::Public,
	);
	package
		.metadata
		.insert("config_id".to_string(), "lib".to_string());
	let plan = monochange_core::ReleasePlan {
		workspace_root: root.to_path_buf(),
		decisions: vec![monochange_core::ReleaseDecision {
			package_id: package.id.clone(),
			trigger_type: "changeset".to_string(),
			recommended_bump: monochange_core::BumpSeverity::Minor,
			planned_version: Some(semver::Version::new(1, 2, 0)),
			group_id: None,
			reasons: Vec::new(),
			upstream_sources: Vec::new(),
			warnings: Vec::new(),
		}],
		groups: Vec::new(),
		warnings: Vec::new(),
		unresolved_items: Vec::new(),
		compatibility_evidence: Vec::new(),
	};

	let updates =
		build_versioned_file_updates(root, &configuration, std::slice::from_ref(&package), &plan)
			.unwrap_or_else(|error| panic!("build versioned updates: {error}"));

	assert_eq!(updates.len(), 1);
	assert_eq!(updates[0].path, manifest_path);
	let content = String::from_utf8(updates[0].content.clone())
		.unwrap_or_else(|error| panic!("updated manifest should be utf-8: {error}"));
	assert!(content.contains(r#""lib": "1.2.0""#));
	assert!(content.contains(r#""actual": "^1.0.0""#));

	std::fs::write(&manifest_path, "{")
		.unwrap_or_else(|error| panic!("write invalid package manifest: {error}"));
	let error = build_versioned_file_updates(root, &configuration, &[package], &plan)
		.expect_err("invalid overridden versioned file should fail");
	assert!(error.to_string().contains("failed to parse"));
}

#[test]
fn inferred_lockfile_ecosystem_type_maps_python_when_commands_are_not_configured() {
	let configuration =
		monochange_config::load_workspace_configuration(&fixture_path("monochange/release-base"))
			.unwrap_or_else(|error| panic!("configuration: {error}"));

	assert_eq!(
		inferred_lockfile_ecosystem_type(&configuration, Ecosystem::Python),
		Some(EcosystemType::Python)
	);
}