monochange 0.5.1

Manage versions and releases for your multiplatform, multilanguage monorepo
Documentation
use std::fs;

use monochange_core::ChangesetTargetKind;
use monochange_core::HostedCommitRef;
use monochange_core::HostedIssueRef;
use monochange_core::HostedIssueRelationshipKind;
use monochange_core::HostedReviewRequestKind;
use monochange_core::HostedReviewRequestRef;

use super::super::discover_changeset_paths;
use super::super::resolve_changeset_path;
use super::super::*;

#[test]
fn resolve_changeset_path_and_discovery_cover_missing_and_empty_directories() {
	let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	fs::create_dir_all(tempdir.path().join(".changeset"))
		.unwrap_or_else(|error| panic!("create changeset dir: {error}"));
	fs::write(tempdir.path().join(".changeset/feature.md"), "---\n")
		.unwrap_or_else(|error| panic!("write changeset: {error}"));

	let resolved = resolve_changeset_path(tempdir.path(), "feature.md")
		.unwrap_or_else(|error| panic!("resolve changeset path: {error}"));
	assert_eq!(resolved, tempdir.path().join(".changeset/feature.md"));

	let invalid = resolve_changeset_path(tempdir.path(), "notes.txt")
		.err()
		.unwrap_or_else(|| panic!("expected invalid changeset path"));
	assert!(
		invalid
			.to_string()
			.contains("requested changeset `notes.txt` does not exist")
	);

	let absolute = resolve_changeset_path(
		tempdir.path(),
		tempdir
			.path()
			.join(".changeset/feature.md")
			.to_string_lossy()
			.as_ref(),
	)
	.unwrap_or_else(|error| panic!("resolve absolute changeset path: {error}"));
	assert_eq!(absolute, tempdir.path().join(".changeset/feature.md"));

	let missing_dir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let missing_error = discover_changeset_paths(missing_dir.path(), false)
		.err()
		.unwrap_or_else(|| panic!("expected missing changeset directory error"));
	assert!(
		missing_error
			.to_string()
			.contains("no markdown changesets found under .changeset")
	);

	let empty_dir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	fs::create_dir_all(empty_dir.path().join(".changeset"))
		.unwrap_or_else(|error| panic!("create empty changeset dir: {error}"));
	let empty_error = discover_changeset_paths(empty_dir.path(), false)
		.err()
		.unwrap_or_else(|| panic!("expected empty changeset directory error"));
	assert!(
		empty_error
			.to_string()
			.contains("no markdown changesets found under .changeset")
	);

	let blocked_dir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	fs::write(blocked_dir.path().join(".changeset"), "not a directory\n")
		.unwrap_or_else(|error| panic!("write blocking .changeset file: {error}"));
	let blocked_error = discover_changeset_paths(blocked_dir.path(), false)
		.err()
		.unwrap_or_else(|| panic!("expected read_dir error for file-backed .changeset path"));
	assert!(blocked_error.to_string().contains("failed to read"));
}

#[test]
fn render_changeset_diagnostics_renders_full_context() {
	let report = ChangesetDiagnosticsReport {
		requested_changesets: vec![PathBuf::from(".changeset/feature.md")],
		changesets: vec![PreparedChangeset {
			path: PathBuf::from(".changeset/feature.md"),
			summary: Some("ship feature".to_string()),
			details: Some("long details".to_string()),
			targets: vec![PreparedChangesetTarget {
				id: "core".to_string(),
				kind: ChangesetTargetKind::Package,
				bump: Some(BumpSeverity::Minor),
				origin: "manual".to_string(),
				evidence_refs: vec!["src/lib.rs".to_string()],
				change_type: Some("feature".to_string()),
				caused_by: vec!["core".to_string()],
			}],
			context: Some(ChangesetContext {
				provider: HostingProviderKind::GitHub,
				host: Some("github.com".to_string()),
				capabilities: HostingCapabilities::default(),
				introduced: Some(ChangesetRevision {
					actor: None,
					commit: Some(HostedCommitRef {
						provider: HostingProviderKind::GitHub,
						host: Some("github.com".to_string()),
						sha: "abc123456789".to_string(),
						short_sha: "abc1234".to_string(),
						url: Some(
							"https://github.com/example/repo/commit/abc123456789".to_string(),
						),
						authored_at: None,
						committed_at: None,
						author_name: None,
						author_email: None,
					}),
					review_request: Some(HostedReviewRequestRef {
						provider: HostingProviderKind::GitHub,
						host: Some("github.com".to_string()),
						kind: HostedReviewRequestKind::PullRequest,
						id: "#42".to_string(),
						title: None,
						url: Some("https://github.com/example/repo/pull/42".to_string()),
						author: None,
					}),
				}),
				last_updated: Some(ChangesetRevision {
					actor: None,
					commit: Some(HostedCommitRef {
						provider: HostingProviderKind::GitHub,
						host: Some("github.com".to_string()),
						sha: "def123456789".to_string(),
						short_sha: "def1234".to_string(),
						url: None,
						authored_at: None,
						committed_at: None,
						author_name: None,
						author_email: None,
					}),
					review_request: None,
				}),
				related_issues: vec![HostedIssueRef {
					provider: HostingProviderKind::GitHub,
					host: Some("github.com".to_string()),
					id: "#99".to_string(),
					title: None,
					url: None,
					relationship: HostedIssueRelationshipKind::Mentioned,
				}],
			}),
		}],
	};

	let rendered = render_changeset_diagnostics(&report);
	assert!(rendered.contains("changeset: .changeset/feature.md"));
	assert!(rendered.contains("summary: ship feature"));
	assert!(rendered.contains("details: long details"));
	assert!(rendered.contains("- package core (bump: minor, origin: manual)"));
	assert!(rendered.contains("caused by: core"));
	assert!(rendered.contains("evidence: src/lib.rs"));
	assert!(rendered.contains("introduced: abc1234"));
	assert!(rendered.contains("last-updated: def1234"));
	assert!(rendered.contains("review request: #42 (https://github.com/example/repo/pull/42)"));
	assert!(rendered.contains("related issues: #99"));

	let minimal = render_changeset_diagnostics(&ChangesetDiagnosticsReport {
		requested_changesets: vec![PathBuf::from(".changeset/minimal.md")],
		changesets: vec![PreparedChangeset {
			path: PathBuf::from(".changeset/minimal.md"),
			summary: None,
			details: None,
			targets: Vec::new(),
			context: Some(ChangesetContext {
				provider: HostingProviderKind::GenericGit,
				host: None,
				capabilities: HostingCapabilities::default(),
				introduced: None,
				last_updated: Some(ChangesetRevision {
					actor: None,
					commit: None,
					review_request: Some(HostedReviewRequestRef {
						provider: HostingProviderKind::GitHub,
						host: Some("github.com".to_string()),
						kind: HostedReviewRequestKind::PullRequest,
						id: "#77".to_string(),
						title: None,
						url: None,
						author: None,
					}),
				}),
				related_issues: Vec::new(),
			}),
		}],
	});
	assert!(minimal.contains("summary: <missing summary>"));
	assert!(minimal.contains("review request: #77"));
	assert!(!minimal.contains("targets:"));
	assert_eq!(
		render_changeset_diagnostics(&ChangesetDiagnosticsReport {
			requested_changesets: Vec::new(),
			changesets: Vec::new(),
		}),
		"no matching changesets found"
	);
}

#[test]
fn build_prepared_changesets_uses_generic_context_without_a_git_repository() {
	let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let loaded = vec![monochange_config::LoadedChangesetFile {
		path: tempdir.path().join(".changeset/feature.md"),
		summary: Some("feature".to_string()),
		details: Some("details".to_string()),
		targets: vec![monochange_config::LoadedChangesetTarget {
			id: "core".to_string(),
			kind: ChangesetTargetKind::Package,
			bump: Some(BumpSeverity::Patch),
			explicit_version: None,
			origin: "manual".to_string(),
			evidence_refs: vec!["src/lib.rs".to_string()],
			change_type: Some("fix".to_string()),
			caused_by: Vec::new(),
		}],
		signals: Vec::new(),
	}];

	let prepared = build_prepared_changesets(tempdir.path(), &loaded);
	assert_eq!(prepared.len(), 1);
	assert!(prepared[0].path.ends_with(".changeset/feature.md"));
	assert_eq!(prepared[0].summary.as_deref(), Some("feature"));
	assert_eq!(prepared[0].details.as_deref(), Some("details"));
	assert_eq!(prepared[0].targets[0].id, "core");
	let context = prepared[0]
		.context
		.as_ref()
		.unwrap_or_else(|| panic!("expected generic git context"));
	assert_eq!(context.provider, HostingProviderKind::GenericGit);
	assert!(context.introduced.is_none());
	assert!(context.last_updated.is_none());
	assert!(context.related_issues.is_empty());
}

#[test]
fn batch_git_log_helpers_cover_empty_and_malformed_output_paths() {
	assert!(batch_load_changeset_contexts(Path::new("."), &[]).is_empty());
	assert_eq!(
		batch_git_log(Path::new("."), &[]),
		(
			std::collections::HashMap::default(),
			std::collections::HashMap::default(),
		)
	);
	assert_eq!(
		parse_batch_git_log_bytes(&[0xff, 0xfe], &[PathBuf::from(".changeset/feature.md")]),
		(
			std::collections::HashMap::default(),
			std::collections::HashMap::default(),
		)
	);

	let malformed = "\
header-without-separators
M .changeset/feature.md

sha\x1fauthor\x1femail\x1fauthored\x1fcommitted
M
M .changeset/ignored.md
";
	let (introduced, last_updated) =
		parse_batch_git_log_output(malformed, &[PathBuf::from(".changeset/feature.md")]);
	assert!(introduced.is_empty());
	assert!(last_updated.is_empty());
}