monochange 0.6.0

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

use insta::assert_snapshot;
use regex::Regex;
use serde_json::Value;

mod test_support;
use test_support::TtyAction;
use test_support::assert_readable_json_snapshot;
use test_support::current_test_name;
use test_support::monochange_command;
use test_support::run_in_tty;
use test_support::setup_fixture;
use test_support::setup_scenario_workspace;
use test_support::snapshot_settings;

fn normalize_duration_text(text: &str) -> String {
	let duration_pattern = Regex::new(r"\b\d+(?:\.\d+)?(?:ms|s|µs)\b")
		.unwrap_or_else(|error| panic!("regex: {error}"));
	duration_pattern
		.replace_all(text, "[duration]")
		.into_owned()
}

fn normalized_ascii_progress(stderr: &str) -> String {
	let normalized = normalize_duration_text(&normalize_terminal_transcript(stderr));
	normalized
		.lines()
		.filter(|line| !line.starts_with("  - "))
		.collect::<Vec<_>>()
		.join("\n")
}

fn normalized_progress_events(stderr: &str) -> Vec<Value> {
	let mut events = stderr
		.lines()
		.filter(|line| !line.trim().is_empty())
		.map(|line| {
			serde_json::from_str::<Value>(line)
				.unwrap_or_else(|error| panic!("parse progress event `{line}`: {error}"))
		})
		.collect::<Vec<_>>();
	for event in &mut events {
		let Some(object) = event.as_object_mut() else {
			panic!("progress event should be an object: {event}");
		};
		if let Some(duration) = object.get_mut("durationMs") {
			*duration = Value::String("[duration_ms]".to_string());
		}
		if let Some(phase_timings) = object.get_mut("phaseTimings").and_then(Value::as_array_mut) {
			phase_timings.sort_by_key(|phase| {
				match phase.get("label").and_then(Value::as_str) {
					Some("load workspace configuration") => 0,
					Some("discover release workspace") => 1,
					Some("discover changeset paths") => 2,
					Some("read changeset files") => 3,
					Some("parse changeset files") => 4,
					Some("build prepared changesets") => 5,
					Some("build release plan") => 6,
					Some("resolve changelog targets") => 7,
					Some("build manifest updates") => 8,
					Some("build versioned file updates") => 9,
					Some("build release targets") => 10,
					Some("build lockfile refresh plan") => 11,
					Some("build changelog updates") => 12,
					Some("apply release changes") => 13,
					_ => usize::MAX,
				}
			});
			for phase in phase_timings {
				if let Some(duration) = phase.get_mut("durationMs") {
					*duration = Value::String("[duration_ms]".to_string());
				}
			}
		}
	}
	let mut normalized = Vec::with_capacity(events.len());
	let mut index = 0;
	while index < events.len() {
		let Some(event) = events.get(index) else {
			break;
		};
		if event.get("event").and_then(Value::as_str) != Some("command_output") {
			normalized.push(event.clone());
			index += 1;
			continue;
		}
		let start = index;
		while events.get(index).is_some_and(|event| {
			event.get("event").and_then(Value::as_str) == Some("command_output")
		}) {
			index += 1;
		}
		let mut output_events = events
			.get(start..index)
			.map_or_else(Vec::new, <[_]>::to_vec);
		output_events.sort_by(|left, right| {
			let left_key = (
				left.get("stepIndex")
					.and_then(Value::as_u64)
					.unwrap_or_default(),
				left.get("stream")
					.and_then(Value::as_str)
					.unwrap_or_default(),
				left.get("text").and_then(Value::as_str).unwrap_or_default(),
			);
			let right_key = (
				right
					.get("stepIndex")
					.and_then(Value::as_u64)
					.unwrap_or_default(),
				right
					.get("stream")
					.and_then(Value::as_str)
					.unwrap_or_default(),
				right
					.get("text")
					.and_then(Value::as_str)
					.unwrap_or_default(),
			);
			left_key.cmp(&right_key)
		});
		normalized.extend(output_events);
	}
	for (sequence, event) in normalized.iter_mut().enumerate() {
		if let Some(object) = event.as_object_mut() {
			object.insert(
				"sequence".to_string(),
				Value::String(format!("[sequence:{sequence}]")),
			);
		}
	}
	normalized
}

fn normalize_terminal_transcript(text: &str) -> String {
	let mut normalized = String::with_capacity(text.len());
	let mut chars = text.chars().peekable();
	while let Some(ch) = chars.next() {
		if ch == '\u{1b}' && chars.peek() == Some(&'[') {
			let _ = chars.next();
			for escape_ch in chars.by_ref() {
				if ('@'..='~').contains(&escape_ch) {
					break;
				}
			}
			continue;
		}
		if ch != '\r' {
			normalized.push(ch);
		}
	}
	normalized
}

#[cfg(unix)]
fn run_tty_command_result(workspace: &Path, command_name: &str) -> (i32, String) {
	let (status, transcript) = run_in_tty(workspace, &[command_name], None, &[]);
	(status, normalize_terminal_transcript(&transcript))
}

#[cfg(unix)]
fn run_tty_command(workspace: &Path, command_name: &str) -> String {
	let (status, transcript) = run_tty_command_result(workspace, command_name);
	assert_eq!(status, 0, "{transcript}");
	transcript
}

#[cfg(unix)]
fn run_tty_interactive_change(workspace: &Path, output_path: &Path) -> (i32, String) {
	let actions = [
		TtyAction::Sleep(std::time::Duration::from_millis(500)),
		TtyAction::Send {
			bytes: b" ",
			pause_after: std::time::Duration::from_millis(100),
		},
		TtyAction::Send {
			bytes: b"\r",
			pause_after: std::time::Duration::from_millis(500),
		},
		TtyAction::Send {
			bytes: b"\x1b[B",
			pause_after: std::time::Duration::from_millis(100),
		},
		TtyAction::Send {
			bytes: b"\r",
			pause_after: std::time::Duration::from_millis(500),
		},
		TtyAction::Send {
			bytes: b"\r",
			pause_after: std::time::Duration::from_millis(500),
		},
		TtyAction::Send {
			bytes: b"\r",
			pause_after: std::time::Duration::from_millis(500),
		},
	];
	let output = output_path.display().to_string();
	let (status, transcript) = run_in_tty(
		workspace,
		&[
			"change",
			"--interactive",
			"--reason",
			"interactive reason",
			"--details",
			"interactive details",
			"--output",
			output.as_str(),
		],
		None,
		&actions,
	);
	(status, normalize_terminal_transcript(&transcript))
}

#[cfg(not(unix))]
fn run_tty_command(_workspace: &Path, _command_name: &str) -> String {
	String::new()
}

#[test]
#[cfg(unix)]
fn release_progress_streams_named_steps_on_tty() {
	let tempdir = setup_fixture("monochange/release-progress");

	let transcript = run_tty_command(tempdir.path(), "progress-release");

	assert!(transcript.contains("[1/2] plan release (PrepareRelease)"));
	assert!(transcript.contains("[2/2] stream summary (Command)"));
	assert!(transcript.contains("stream summary [stdout] streamed line 1"));
	assert!(transcript.contains("stream summary [stdout] streamed line 2"));
	assert!(transcript.contains("`progress-release` finished"));
	assert!(transcript.contains("Summary"));
}

#[test]
#[cfg(unix)]
fn release_progress_renders_skipped_failed_steps_and_stderr_on_tty() {
	let tempdir = setup_fixture("monochange/release-progress-failure");

	let (status, transcript) = run_tty_command_result(tempdir.path(), "progress-failure");

	assert_ne!(status, 0, "expected failure transcript:\n{transcript}");
	assert!(transcript.contains("○ [1/3] skip validate (Validate) — skipped ({{ false }})"));
	assert!(transcript.contains("stderr only [stderr] warn line"));
	assert!(transcript.contains("✖ [3/3] fail loud (Command)"));
	assert!(transcript.contains("fail loud [stderr] bad line"));
	assert!(transcript.contains("└─ command `printf 'bad line\\n' >&2; exit 3` failed: bad line"));
}

#[test]
#[cfg(unix)]
fn interactive_change_cli_hides_progress_output_on_tty() {
	let tempdir = setup_scenario_workspace("monochange/release-base");
	let output_path = tempdir.path().join(".changeset/interactive.md");

	let (status, transcript) = run_tty_interactive_change(tempdir.path(), &output_path);

	assert_eq!(
		status, 0,
		"unexpected interactive transcript:\n{transcript}"
	);
	assert!(!transcript.contains("running `change`"), "{transcript}");
	assert!(!transcript.contains("[1/1]"), "{transcript}");
	assert!(!transcript.contains("finished"), "{transcript}");
	assert!(transcript.contains("wrote change file .changeset/interactive.md"));
	assert!(
		output_path.exists(),
		"interactive change file should be created"
	);
}

#[test]
fn ascii_progress_renders_clean_captured_output() {
	let mut settings = snapshot_settings();
	settings.set_snapshot_suffix(current_test_name());
	let _guard = settings.bind_to_scope();

	let tempdir = setup_fixture("monochange/release-progress");

	let output = monochange_command(Some("2026-04-06"))
		.current_dir(tempdir.path())
		.arg("progress-ascii")
		.arg("--progress-format")
		.arg("ascii")
		.output()
		.unwrap_or_else(|error| panic!("run ascii progress command: {error}"));
	assert!(
		output.status.success(),
		"{}",
		String::from_utf8_lossy(&output.stderr)
	);

	let stderr = String::from_utf8(output.stderr)
		.unwrap_or_else(|error| panic!("ascii stderr utf8: {error}"));
	assert_snapshot!(normalized_ascii_progress(&stderr));
}

#[test]
fn json_progress_emits_structured_events_for_machine_consumers() {
	let mut settings = snapshot_settings();
	settings.set_snapshot_suffix(current_test_name());
	let _guard = settings.bind_to_scope();

	let tempdir = setup_fixture("monochange/release-progress");

	let output = monochange_command(Some("2026-04-06"))
		.current_dir(tempdir.path())
		.arg("progress-json")
		.arg("--progress-format")
		.arg("json")
		.output()
		.unwrap_or_else(|error| panic!("run json progress command: {error}"));
	assert!(
		output.status.success(),
		"{}",
		String::from_utf8_lossy(&output.stderr)
	);

	let stderr = String::from_utf8(output.stderr)
		.unwrap_or_else(|error| panic!("json stderr utf8: {error}"));
	assert_readable_json_snapshot!(normalized_progress_events(&stderr));
}

#[test]
#[cfg(not(unix))]
fn release_progress_streams_named_steps_on_tty() {}

#[test]
#[cfg(not(unix))]
fn release_progress_renders_skipped_failed_steps_and_stderr_on_tty() {}

#[test]
#[cfg(not(unix))]
fn interactive_change_cli_hides_progress_output_on_tty() {}