monochange 0.6.2

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

use monochange_test_helpers::fs::fixture_path_from;
use monochange_test_helpers::fs::setup_scenario_workspace_from;

use super::*;

fn fixture_readme_contents() -> String {
	fs::read_to_string(fixture_path_from(
		env!("CARGO_MANIFEST_DIR"),
		"subagents/basic/workspace/README.md",
	))
	.unwrap_or_else(|error| panic!("read fixture README: {error}"))
}

#[test]
fn push_generated_file_handles_duplicate_and_conflicting_paths() {
	let contents = fixture_readme_contents();
	let mut drafts = Vec::new();
	let shared_path = PathBuf::from(".github/agents/monochange-release-agent.agent.md");

	push_generated_file(
		&mut drafts,
		GeneratedFileDraft {
			path: shared_path.clone(),
			description: "VS Code agent definition".to_string(),
			contents: contents.clone(),
		},
	)
	.unwrap_or_else(|error| panic!("push first draft: {error}"));
	push_generated_file(
		&mut drafts,
		GeneratedFileDraft {
			path: shared_path.clone(),
			description: "GitHub Copilot agent definition".to_string(),
			contents: contents.clone(),
		},
	)
	.unwrap_or_else(|error| panic!("push matching draft: {error}"));
	assert_eq!(drafts.len(), 1);

	let error = push_generated_file(
		&mut drafts,
		GeneratedFileDraft {
			path: shared_path,
			description: "conflicting agent definition".to_string(),
			contents: format!("{contents}\nconflict\n"),
		},
	)
	.err()
	.unwrap_or_else(|| panic!("expected conflicting draft error"));
	assert!(
		error
			.to_string()
			.contains("multiple subagent targets attempted to generate different contents")
	);
}

#[test]
fn classify_generated_file_reports_create_skip_overwrite_and_read_errors() {
	let workspace = setup_scenario_workspace_from(env!("CARGO_MANIFEST_DIR"), "subagents/basic");
	let readme_path = workspace.path().join("README.md");
	let contents = fixture_readme_contents();

	assert_eq!(
		classify_generated_file(&workspace.path().join("missing.md"), &contents)
			.unwrap_or_else(|error| panic!("classify missing file: {error}")),
		GeneratedFileOperation::Create
	);
	assert_eq!(
		classify_generated_file(&readme_path, &contents)
			.unwrap_or_else(|error| panic!("classify matching file: {error}")),
		GeneratedFileOperation::Skip
	);
	assert_eq!(
		classify_generated_file(&readme_path, "different contents")
			.unwrap_or_else(|error| panic!("classify overwritten file: {error}")),
		GeneratedFileOperation::Overwrite
	);

	let error = classify_generated_file(workspace.path(), &contents)
		.err()
		.unwrap_or_else(|| panic!("expected classify read error"));
	assert!(
		error
			.to_string()
			.contains(&format!("failed to read {}", workspace.path().display()))
	);
}

#[test]
fn write_subagent_plan_reports_conflicts_and_io_failures() {
	let workspace = setup_scenario_workspace_from(env!("CARGO_MANIFEST_DIR"), "subagents/basic");
	let contents = fixture_readme_contents();
	let plan = SubagentPlan {
		targets: vec![SubagentTarget::Claude],
		files: vec![
			GeneratedFile {
				path: PathBuf::from("README.md"),
				description: "README".to_string(),
				operation: GeneratedFileOperation::Overwrite,
				contents: contents.clone(),
			},
			GeneratedFile {
				path: PathBuf::from(".mcp.json"),
				description: "MCP".to_string(),
				operation: GeneratedFileOperation::Overwrite,
				contents: contents.clone(),
			},
		],
		notes: Vec::new(),
		dry_run: false,
	};
	let error = write_subagent_plan(workspace.path(), &plan, false)
		.err()
		.unwrap_or_else(|| panic!("expected overwrite conflict"));
	assert_eq!(
		error.to_string(),
		"config error: refusing to overwrite existing subagent files without --force: README.md, .mcp.json"
	);

	let parent_file_path = workspace.path().join("parent-file");
	fs::write(&parent_file_path, &contents)
		.unwrap_or_else(|error| panic!("write parent-file fixture: {error}"));
	let parent_error_plan = SubagentPlan {
		targets: vec![SubagentTarget::Pi],
		files: vec![GeneratedFile {
			path: PathBuf::from("parent-file/child.md"),
			description: "nested child".to_string(),
			operation: GeneratedFileOperation::Create,
			contents: contents.clone(),
		}],
		notes: Vec::new(),
		dry_run: false,
	};
	let parent_error = write_subagent_plan(workspace.path(), &parent_error_plan, true)
		.err()
		.unwrap_or_else(|| panic!("expected create_dir_all failure"));
	assert!(
		parent_error
			.to_string()
			.contains(&format!("failed to create {}", parent_file_path.display()))
	);

	let directory_path = workspace.path().join("existing-directory");
	fs::create_dir_all(&directory_path)
		.unwrap_or_else(|error| panic!("create existing-directory fixture: {error}"));
	let write_error_plan = SubagentPlan {
		targets: vec![SubagentTarget::Pi],
		files: vec![GeneratedFile {
			path: PathBuf::from("existing-directory"),
			description: "directory write error".to_string(),
			operation: GeneratedFileOperation::Create,
			contents,
		}],
		notes: Vec::new(),
		dry_run: false,
	};
	let write_error = write_subagent_plan(workspace.path(), &write_error_plan, true)
		.err()
		.unwrap_or_else(|| panic!("expected file write failure"));
	assert!(
		write_error
			.to_string()
			.contains(&format!("failed to write {}", directory_path.display()))
	);

	let no_parent_plan = SubagentPlan {
		targets: vec![SubagentTarget::Cursor],
		files: vec![GeneratedFile {
			path: PathBuf::new(),
			description: "no parent path".to_string(),
			operation: GeneratedFileOperation::Create,
			contents: String::new(),
		}],
		notes: Vec::new(),
		dry_run: false,
	};
	write_subagent_plan(Path::new(""), &no_parent_plan, true)
		.unwrap_or_else(|error| panic!("write empty path plan: {error}"));
}

#[test]
fn render_and_generate_helpers_cover_notes_and_target_variants() {
	let mut notes = Vec::new();
	let claude_files = generate_target_files(SubagentTarget::Claude, true, &mut notes)
		.unwrap_or_else(|error| panic!("generate claude files: {error}"));
	assert_eq!(claude_files.len(), 2);
	assert!(
		claude_files
			.iter()
			.any(|file| file.path == Path::new(".mcp.json"))
	);
	let claude_files_without_mcp = generate_target_files(SubagentTarget::Claude, false, &mut notes)
		.unwrap_or_else(|error| panic!("generate claude files without mcp: {error}"));
	assert_eq!(claude_files_without_mcp.len(), 1);

	let vscode_files = generate_target_files(SubagentTarget::Vscode, true, &mut notes)
		.unwrap_or_else(|error| panic!("generate vscode files: {error}"));
	assert!(
		vscode_files
			.iter()
			.any(|file| file.path == Path::new(".vscode/mcp.json"))
	);
	assert_eq!(vscode_files[0].description, "VS Code agent definition");

	let copilot_files = generate_target_files(SubagentTarget::Copilot, true, &mut notes)
		.unwrap_or_else(|error| panic!("generate copilot files: {error}"));
	assert_eq!(
		copilot_files[0].description,
		"GitHub Copilot agent definition"
	);

	let cursor_files = generate_target_files(SubagentTarget::Cursor, true, &mut notes)
		.unwrap_or_else(|error| panic!("generate cursor files: {error}"));
	assert_eq!(
		cursor_files[0].path,
		PathBuf::from(".cursor/rules/monochange.mdc")
	);
	assert_eq!(notes.len(), 1);
	assert!(notes[0].contains("repo-local workspace rule"));

	let plan = SubagentPlan {
		targets: vec![
			SubagentTarget::Claude,
			SubagentTarget::Vscode,
			SubagentTarget::Copilot,
			SubagentTarget::Pi,
			SubagentTarget::Codex,
			SubagentTarget::Cursor,
		],
		files: vec![GeneratedFile {
			path: PathBuf::from(".cursor/rules/monochange.mdc"),
			description: "Cursor workspace rule".to_string(),
			operation: GeneratedFileOperation::Create,
			contents: render_cursor_rule(),
		}],
		notes,
		dry_run: true,
	};
	let output = render_subagent_plan_text(&plan);
	assert!(output.contains("- claude"));
	assert!(output.contains("- vscode"));
	assert!(output.contains("- copilot"));
	assert!(output.contains("- pi"));
	assert!(output.contains("- codex"));
	assert!(output.contains("- cursor"));
	assert!(output.contains("Notes:"));
	assert!(output.contains("Dry run only. No files were written."));

	assert_eq!(subagent_target_name(SubagentTarget::Claude), "claude");
	assert_eq!(subagent_target_name(SubagentTarget::Vscode), "vscode");
	assert_eq!(subagent_target_name(SubagentTarget::Copilot), "copilot");
	assert_eq!(subagent_target_name(SubagentTarget::Pi), "pi");
	assert_eq!(subagent_target_name(SubagentTarget::Codex), "codex");
	assert_eq!(subagent_target_name(SubagentTarget::Cursor), "cursor");
	assert!(
		render_vscode_mcp_config()
			.unwrap_or_else(|error| panic!("render vscode mcp config: {error}"))
			.contains("\"servers\"")
	);
}