use std::fmt::Write as _;
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use clap::ArgMatches;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use monochange_core::RELEASE_RECORD_SCHEMA_VERSION;
use monochange_core::migrate_release_record_json_value;
use serde::Serialize;
use crate::OutputFormat;
use crate::parse_output_format;
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum MigrationAuditStatus {
Ready,
MigrationNeeded,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct MigrationAuditReport {
pub status: MigrationAuditStatus,
pub root: String,
pub signals: Vec<MigrationAuditSignal>,
pub recommendations: Vec<MigrationAuditRecommendation>,
pub next_steps: Vec<String>,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct MigrationAuditSignal {
pub kind: String,
pub tool: String,
pub path: String,
pub message: String,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct MigrationAuditRecommendation {
pub id: String,
pub title: String,
pub detail: String,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ReleaseRecordMigrationStatus {
Current,
Migrated,
WouldMigrate,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct ReleaseRecordMigrationEntry {
pub path: String,
pub status: ReleaseRecordMigrationStatus,
pub from_schema_version: String,
pub to_schema_version: String,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct ReleaseRecordMigrationReport {
pub dry_run: bool,
pub current_schema_version: String,
pub scanned: usize,
pub migrated: usize,
pub unchanged: usize,
pub records: Vec<ReleaseRecordMigrationEntry>,
}
pub(crate) fn run_migration_command(
root: &Path,
quiet: bool,
migrate_matches: &ArgMatches,
) -> MonochangeResult<String> {
if quiet {
return Ok(String::new());
}
match migrate_matches.subcommand() {
Some(("release-records", record_matches)) => {
let format = record_matches
.get_one::<String>("format")
.map_or(Ok(OutputFormat::Text), |value| parse_output_format(value))?;
let report = migrate_release_records(root, record_matches.get_flag("dry-run"))?;
Ok(render_release_record_migration_report(&report, format))
}
Some(("audit", audit_matches)) => {
let format = audit_matches
.get_one::<String>("format")
.map_or(Ok(OutputFormat::Text), |value| parse_output_format(value))?;
run_migration_audit(root, format)
}
_ => run_migration_audit(root, OutputFormat::Text),
}
}
pub(crate) fn run_migration_audit(root: &Path, format: OutputFormat) -> MonochangeResult<String> {
let report = audit_migration(root)?;
Ok(render_migration_audit_report(&report, format))
}
pub(crate) fn migrate_release_records(
root: &Path,
dry_run: bool,
) -> MonochangeResult<ReleaseRecordMigrationReport> {
let mut records = Vec::new();
for path in release_record_paths(root)? {
let original = fs::read_to_string(&path).map_err(|error| {
MonochangeError::Io(format!(
"read release record at {}: {error}",
path.display()
))
})?;
let schema_version = release_record_schema_version(&original)?;
let from_schema_version = schema_version.version;
let migrated_value = migrate_release_record_json_value(&original).map_err(|error| {
MonochangeError::Config(format!(
"migrate release record at {}: {error}",
path.display()
))
})?;
let mut migrated = serde_json::to_string_pretty(&migrated_value)
.unwrap_or_else(|error| panic!("serde_json::Value serializes to JSON text: {error}"));
migrated.push('\n');
let is_current =
!schema_version.legacy && from_schema_version == RELEASE_RECORD_SCHEMA_VERSION;
let status = if is_current {
ReleaseRecordMigrationStatus::Current
} else if dry_run {
ReleaseRecordMigrationStatus::WouldMigrate
} else {
fs::write(&path, migrated).map_err(|error| {
MonochangeError::Io(format!(
"write migrated release record at {}: {error}",
path.display()
))
})?;
ReleaseRecordMigrationStatus::Migrated
};
records.push(ReleaseRecordMigrationEntry {
path: root_relative_string(root, &path),
status,
from_schema_version,
to_schema_version: RELEASE_RECORD_SCHEMA_VERSION.to_string(),
});
}
let migrated = records
.iter()
.filter(|record| {
matches!(
record.status,
ReleaseRecordMigrationStatus::Migrated | ReleaseRecordMigrationStatus::WouldMigrate
)
})
.count();
let unchanged = records
.iter()
.filter(|record| matches!(record.status, ReleaseRecordMigrationStatus::Current))
.count();
Ok(ReleaseRecordMigrationReport {
dry_run,
current_schema_version: RELEASE_RECORD_SCHEMA_VERSION.to_string(),
scanned: records.len(),
migrated,
unchanged,
records,
})
}
fn release_record_paths(root: &Path) -> MonochangeResult<Vec<PathBuf>> {
let releases_dir = root.join(".monochange/releases");
if !releases_dir.exists() {
return Ok(Vec::new());
}
let mut paths = Vec::new();
for entry in fs::read_dir(&releases_dir)
.map_err(|error| MonochangeError::Io(format!("read release records dir: {error}")))?
.flatten()
{
let path = entry.path().join("release.json");
if path.is_file() {
paths.push(path);
}
}
paths.sort();
Ok(paths)
}
struct ReleaseRecordSchemaVersion {
version: String,
legacy: bool,
}
fn release_record_schema_version(json_text: &str) -> MonochangeResult<ReleaseRecordSchemaVersion> {
let value = serde_json::from_str::<serde_json::Value>(json_text).map_err(|error| {
MonochangeError::Config(format!(
"parse release record json before migration: {error}"
))
})?;
let Some(object) = value.as_object() else {
return Err(MonochangeError::Config(
"release record json root must be an object".to_string(),
));
};
if let Some(version) = object
.get("schema_version")
.or_else(|| object.get("schemaVersion"))
.and_then(serde_json::Value::as_str)
{
return Ok(ReleaseRecordSchemaVersion {
version: version.to_string(),
legacy: false,
});
}
if object
.get("schema_version")
.or_else(|| object.get("schemaVersion"))
.and_then(serde_json::Value::as_u64)
== Some(1)
{
return Ok(ReleaseRecordSchemaVersion {
version: "legacy-1".to_string(),
legacy: true,
});
}
if let Some(version) = object.get("v").and_then(serde_json::Value::as_str) {
return Ok(ReleaseRecordSchemaVersion {
version: version.to_string(),
legacy: true,
});
}
Err(MonochangeError::Config(
"release record is missing `schema_version` or legacy `schemaVersion`/`v`".to_string(),
))
}
fn root_relative_string(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.into_owned()
}
fn render_release_record_migration_report(
report: &ReleaseRecordMigrationReport,
format: OutputFormat,
) -> String {
match format {
OutputFormat::Json => serde_json::to_string_pretty(report).unwrap_or_default(),
OutputFormat::Markdown | OutputFormat::Text => text_release_record_migration_report(report),
}
}
fn text_release_record_migration_report(report: &ReleaseRecordMigrationReport) -> String {
let mut output = String::new();
let action = if report.dry_run {
"would migrate"
} else {
"migrated"
};
let _ = writeln!(
output,
"release-record migrations: {action} {}/{} record(s) to schema {}",
report.migrated, report.scanned, report.current_schema_version
);
let _ = writeln!(output, " unchanged: {}", report.unchanged);
for record in &report.records {
let status = match record.status {
ReleaseRecordMigrationStatus::Current => "current",
ReleaseRecordMigrationStatus::Migrated => "migrated",
ReleaseRecordMigrationStatus::WouldMigrate => "would migrate",
};
let _ = writeln!(
output,
" - {}: {} ({} -> {})",
record.path, status, record.from_schema_version, record.to_schema_version
);
}
output.trim_end().to_string()
}
pub(crate) fn audit_migration(root: &Path) -> MonochangeResult<MigrationAuditReport> {
let mut signals = Vec::new();
add_path_signal(
root,
&mut signals,
"monochange-config",
"monochange",
"monochange.toml",
"monochange configuration already exists",
);
add_path_signal(
root,
&mut signals,
"legacy-release-tool",
"knope",
"knope.toml",
"knope configuration detected",
);
add_path_signal(
root,
&mut signals,
"legacy-release-tool",
"knope",
".knope.toml",
"knope configuration detected",
);
add_path_signal(
root,
&mut signals,
"legacy-changeset-tool",
"changesets",
".changeset/config.json",
"Changesets configuration detected; audit frontmatter and changelog settings before reusing files with monochange",
);
add_path_signal(
root,
&mut signals,
"legacy-release-tool",
"release-please",
"release-please-config.json",
"release-please configuration detected",
);
add_path_signal(
root,
&mut signals,
"legacy-release-tool",
"release-please",
".release-please-manifest.json",
"release-please manifest detected",
);
for path in [
".releaserc",
".releaserc.json",
".releaserc.yml",
".releaserc.yaml",
".releaserc.js",
"release.config.js",
] {
add_path_signal(
root,
&mut signals,
"legacy-release-tool",
"semantic-release",
path,
"semantic-release configuration detected",
);
}
add_path_signal(
root,
&mut signals,
"legacy-release-tool",
"cargo-release",
"release.toml",
"cargo-release configuration detected",
);
add_path_signal(
root,
&mut signals,
"changelog-provider",
"mdt",
"mdt.toml",
"mdt changelog provider detected; compare generated changelogs with monochange changelog sections",
);
for path in ["CHANGELOG.md", "changelog.md"] {
add_path_signal(
root,
&mut signals,
"changelog-file",
"markdown-changelog",
path,
"existing changelog detected",
);
}
detect_package_json_release_tools(root, &mut signals)?;
detect_workflow_release_tools(root, &mut signals)?;
signals.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then_with(|| left.tool.cmp(&right.tool))
.then_with(|| left.kind.cmp(&right.kind))
});
signals.dedup();
let recommendations = build_recommendations(&signals);
let status = if migration_signals(&signals).next().is_some() {
MigrationAuditStatus::MigrationNeeded
} else {
MigrationAuditStatus::Ready
};
Ok(MigrationAuditReport {
status,
root: root.display().to_string(),
signals,
recommendations,
next_steps: next_steps(),
})
}
fn add_path_signal(
root: &Path,
signals: &mut Vec<MigrationAuditSignal>,
kind: &str,
tool: &str,
relative_path: &str,
message: &str,
) {
if root.join(relative_path).exists() {
push_signal(signals, kind, tool, relative_path, message);
}
}
fn detect_package_json_release_tools(
root: &Path,
signals: &mut Vec<MigrationAuditSignal>,
) -> MonochangeResult<()> {
let path = root.join("package.json");
let Some(contents) = optional_read_to_string(&path)? else {
return Ok(());
};
for (needle, tool, message) in [
(
"@changesets/cli",
"changesets",
"package.json references the Changesets CLI",
),
(
"changeset",
"changesets",
"package.json references Changesets",
),
(
"release-please",
"release-please",
"package.json references release-please",
),
(
"semantic-release",
"semantic-release",
"package.json references semantic-release",
),
("knope", "knope", "package.json references knope"),
] {
if contents.contains(needle) {
push_signal(signals, "package-script", tool, "package.json", message);
}
}
Ok(())
}
fn detect_workflow_release_tools(
root: &Path,
signals: &mut Vec<MigrationAuditSignal>,
) -> MonochangeResult<()> {
let workflows = root.join(".github/workflows");
let entries = match fs::read_dir(&workflows) {
Ok(entries) => entries,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(MonochangeError::Io(format!(
"failed to read {}: {error}",
workflows.display()
)));
}
};
for entry in entries.flatten() {
let path = entry.path();
if !is_workflow_file(&path) {
continue;
}
let contents = fs::read_to_string(&path).map_err(|error| {
MonochangeError::Io(format!("failed to read {}: {error}", path.display()))
})?;
let relative_path = path
.strip_prefix(root)
.unwrap_or(path.as_path())
.to_string_lossy()
.replace('\\', "/");
for (needle, tool, message) in [
(
"changesets/action",
"changesets",
"GitHub Actions workflow uses the Changesets action",
),
(
"release-please",
"release-please",
"GitHub Actions workflow references release-please",
),
(
"semantic-release",
"semantic-release",
"GitHub Actions workflow references semantic-release",
),
("knope", "knope", "GitHub Actions workflow references knope"),
(
"cargo release",
"cargo-release",
"GitHub Actions workflow references cargo-release",
),
] {
if contents.contains(needle) {
push_signal(signals, "ci-workflow", tool, &relative_path, message);
}
}
}
Ok(())
}
fn is_workflow_file(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| matches!(extension, "yml" | "yaml"))
}
fn optional_read_to_string(path: &Path) -> MonochangeResult<Option<String>> {
match fs::read_to_string(path) {
Ok(contents) => Ok(Some(contents)),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => {
Err(MonochangeError::Io(format!(
"failed to read {}: {error}",
path.display()
)))
}
}
}
fn push_signal(
signals: &mut Vec<MigrationAuditSignal>,
kind: &str,
tool: &str,
path: &str,
message: &str,
) {
signals.push(MigrationAuditSignal {
kind: kind.to_string(),
tool: tool.to_string(),
path: path.to_string(),
message: message.to_string(),
});
}
fn migration_signals(
signals: &[MigrationAuditSignal],
) -> impl Iterator<Item = &MigrationAuditSignal> {
signals
.iter()
.filter(|signal| signal.kind != "monochange-config" && signal.kind != "changelog-file")
}
fn build_recommendations(signals: &[MigrationAuditSignal]) -> Vec<MigrationAuditRecommendation> {
let has_monochange = signals
.iter()
.any(|signal| signal.kind == "monochange-config");
let has_legacy_release_tool = signals.iter().any(|signal| {
matches!(
signal.kind.as_str(),
"legacy-release-tool" | "legacy-changeset-tool" | "package-script" | "ci-workflow"
)
});
let has_changelog = signals.iter().any(|signal| {
matches!(
signal.kind.as_str(),
"changelog-provider" | "changelog-file"
)
});
let has_ci = signals.iter().any(|signal| signal.kind == "ci-workflow");
let mut recommendations = Vec::new();
if !has_monochange {
recommendations.push(MigrationAuditRecommendation {
id: "generate-config".to_string(),
title: "Generate monochange configuration".to_string(),
detail: "Run `mc init --provider github` or create `monochange.toml` manually, then review package groups, changelog sections, and publish settings.".to_string(),
});
}
if has_legacy_release_tool {
recommendations.push(MigrationAuditRecommendation {
id: "translate-release-tooling".to_string(),
title: "Translate existing release automation".to_string(),
detail: "Map legacy release rules, package ordering, tag formats, and release notes into monochange release commands before removing old tooling.".to_string(),
});
}
if has_changelog {
recommendations.push(MigrationAuditRecommendation {
id: "audit-changelogs".to_string(),
title: "Audit changelog ownership".to_string(),
detail: "Compare existing changelog files and providers with monochange changelog recommendations so releases do not produce duplicate or divergent notes.".to_string(),
});
}
if has_ci {
recommendations.push(MigrationAuditRecommendation {
id: "replace-ci-workflows".to_string(),
title: "Replace release workflows incrementally".to_string(),
detail: "Update CI to run `mc check`, release planning, `mc step:publish-readiness`, and trusted publishing setup before deleting the legacy workflow.".to_string(),
});
}
if has_legacy_release_tool || !has_monochange {
recommendations.push(MigrationAuditRecommendation {
id: "trusted-publishing-checklist".to_string(),
title: "Plan trusted publishing per package".to_string(),
detail: "Prefer trusted publishers for registries that support them and record repository, workflow, and environment context in each package publish configuration.".to_string(),
});
}
recommendations
}
fn next_steps() -> Vec<String> {
vec![
"Run `mc discover --format json` and compare discovered packages with existing release tooling.".to_string(),
"Draft or update `monochange.toml` with package groups, changelog sections, and publish settings.".to_string(),
"Dry-run release planning and publishing before removing legacy automation.".to_string(),
]
}
fn render_migration_audit_report(report: &MigrationAuditReport, format: OutputFormat) -> String {
match format {
OutputFormat::Json => serde_json::to_string_pretty(report).unwrap_or_default(),
OutputFormat::Markdown | OutputFormat::Text => render_text_report(report),
}
}
fn render_text_report(report: &MigrationAuditReport) -> String {
let mut output = String::new();
let _ = writeln!(output, "migration audit: {}", status_label(&report.status));
let _ = writeln!(output, "root: {}", report.root);
output.push('\n');
output.push_str("signals:\n");
if report.signals.is_empty() {
output.push_str("- none detected\n");
} else {
for signal in &report.signals {
let _ = writeln!(
output,
"- {} {} at {}: {}",
signal.kind, signal.tool, signal.path, signal.message
);
}
}
output.push('\n');
output.push_str("recommendations:\n");
if report.recommendations.is_empty() {
output.push_str("- no migration-specific recommendations detected\n");
} else {
for recommendation in &report.recommendations {
let _ = writeln!(
output,
"- {}: {}",
recommendation.title, recommendation.detail
);
}
}
output.push('\n');
output.push_str("next steps:\n");
for step in &report.next_steps {
let _ = writeln!(output, "- {step}");
}
output
}
fn status_label(status: &MigrationAuditStatus) -> &'static str {
match status {
MigrationAuditStatus::Ready => "ready",
MigrationAuditStatus::MigrationNeeded => "migration-needed",
}
}
#[cfg(test)]
#[path = "__tests__/migration_audit_tests.rs"]
mod tests;