diff --git a/README.md b/README.md index 88824fad..8d97da90 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,8 @@ ferrflow migrate --from semantic-release It maps `tagFormat`, `branches`, and the common plugins (`changelog`, `exec`, `github`/`gitlab`) to their FerrFlow equivalents, and prints a summary of what mapped, what was ignored, and what needs manual review. Anything without a FerrFlow equivalent is surfaced, never guessed. JSON `.releaserc` is supported today; YAML `.releaserc` and JS `release.config.js` are reported as unsupported rather than mis-parsed. +`ferrflow migrate --from release-please` also maps `extra-files` (`generic` markers to a `txt` selector, `toml` on `Cargo.toml` and `json` on `$.version` to their formats), `bump-minor-pre-major` and `bump-patch-for-minor-pre-major` to `preMajor` (`breaking: minor`, `feat: patch`), and writes `autoMergeReleases: false` since release-please never merges its release PR. Lockfile updates are enabled when a versioned manifest has one. Entries it cannot express are listed under "Review manually". Only non-default keys are written. + ### JSON Schema Add `$schema` to get autocompletion and validation in VS Code, WebStorm, and any JSON-aware editor: diff --git a/src/config/migrate.rs b/src/config/migrate.rs index 7ba2236a..69cee346 100644 --- a/src/config/migrate.rs +++ b/src/config/migrate.rs @@ -14,6 +14,7 @@ use super::types::{ use super::workspace::WorkspaceConfig; mod changesets; +mod prune; mod release_please; mod standard_version; @@ -96,9 +97,10 @@ pub fn migrate(from: Option, dry_run: bool) -> Result<()> { } fn emit(migration: &Migration, dir: &Path, dry_run: bool) -> Result<(String, String)> { - let handler = format_handler(ConfigFileFormat::Json); - let content = handler.serialize(&migration.config)?; - let filename = handler.filename().to_string(); + let filename = format_handler(ConfigFileFormat::Json) + .filename() + .to_string(); + let content = prune::to_pretty_json(&migration.config)?; if !dry_run { std::fs::write(dir.join(&filename), &content)?; } @@ -377,9 +379,17 @@ pub(super) fn default_package_name() -> String { .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(str::to_string)) + .or_else(current_dir_name) .unwrap_or_else(|| "app".to_string()) } +fn current_dir_name() -> Option { + std::env::current_dir() + .ok()? + .file_name() + .map(|n| n.to_string_lossy().into_owned()) +} + fn convert_tag_format(tag_format: &str) -> String { tag_format.replace("${version}", "{version}") } diff --git a/src/config/migrate/prune.rs b/src/config/migrate/prune.rs new file mode 100644 index 00000000..cd20906f --- /dev/null +++ b/src/config/migrate/prune.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use serde_json::{Map, Value}; + +use crate::config::Config; +use crate::config::format::to_camel_case_keys; +use crate::config::package::PackageConfig; +use crate::config::workspace::WorkspaceConfig; + +const ALWAYS_WRITTEN_PACKAGE_KEYS: &[&str] = &["name", "path"]; + +pub(super) fn to_pretty_json(config: &Config) -> Result { + let mut root = serde_json::to_value(config)?; + if let Value::Object(map) = &mut root { + strip_defaults(map, &serde_json::to_value(Config::default())?, &[]); + if let Some(Value::Object(workspace)) = map.get_mut("workspace") { + strip_defaults( + workspace, + &serde_json::to_value(WorkspaceConfig::default())?, + &[], + ); + } + map.retain(|_, v| v.as_object().is_none_or(|o| !o.is_empty())); + if let Some(Value::Array(packages)) = map.get_mut("package") { + let defaults = serde_json::to_value(PackageConfig::default())?; + for package in packages.iter_mut().filter_map(Value::as_object_mut) { + strip_defaults(package, &defaults, ALWAYS_WRITTEN_PACKAGE_KEYS); + } + } + } + let mut out = serde_json::to_string_pretty(&to_camel_case_keys(root))?; + out.push('\n'); + Ok(out) +} + +fn strip_defaults(map: &mut Map, defaults: &Value, keep: &[&str]) { + map.retain(|key, value| keep.contains(&key.as_str()) || defaults.get(key) != Some(value)); +} diff --git a/src/config/migrate/release_please.rs b/src/config/migrate/release_please.rs index 8988aa61..a892ce9c 100644 --- a/src/config/migrate/release_please.rs +++ b/src/config/migrate/release_please.rs @@ -5,12 +5,16 @@ use std::path::PathBuf; use crate::config::Config; use crate::config::package::{FileFormat, PackageConfig, VersionedFile}; -use crate::config::types::ReleaseCommitMode; +use crate::config::types::{PreMajorBump, PreMajorRules, ReleaseCommitMode}; use crate::config::workspace::WorkspaceConfig; use crate::error_code::{self, ErrorCodeExt}; +use crate::formats::lockfiles::Manifest; use super::{Migration, MigrationReport, Source}; +mod extra_files; +use extra_files::Mapped; + pub(super) const CONFIG_FILE: &str = "release-please-config.json"; pub(super) fn detect() -> Option { @@ -38,6 +42,10 @@ struct ReleasePleaseConfig { tag_separator: Option, #[serde(default, rename = "separate-pull-requests")] separate_pull_requests: Option, + #[serde(default, rename = "bump-minor-pre-major")] + bump_minor_pre_major: Option, + #[serde(default, rename = "bump-patch-for-minor-pre-major")] + bump_patch_for_minor_pre_major: Option, #[serde(default)] plugins: Vec, } @@ -54,6 +62,12 @@ struct PackageEntry { changelog_path: Option, #[serde(default, rename = "version-file")] version_file: Option, + #[serde(default, rename = "extra-files")] + extra_files: Vec, + #[serde(default, rename = "bump-minor-pre-major")] + bump_minor_pre_major: Option, + #[serde(default, rename = "bump-patch-for-minor-pre-major")] + bump_patch_for_minor_pre_major: Option, } #[derive(Debug, Deserialize)] @@ -81,6 +95,10 @@ pub(super) fn build(raw: &str) -> Result<(Config, MigrationReport)> { report .mapped .push("release-please PR flow → releaseCommitMode: pr".to_string()); + workspace.auto_merge_releases = false; + report + .mapped + .push("release-please never merges its release PR → autoMergeReleases: false".to_string()); if rp.include_component_in_tag.unwrap_or(false) { let sep = rp.tag_separator.as_deref().unwrap_or("-"); @@ -104,24 +122,26 @@ pub(super) fn build(raw: &str) -> Result<(Config, MigrationReport)> { } let default_type = rp.release_type.as_deref(); - let mut packages = Vec::new(); - for (path, entry) in &rp.packages { - packages.push(build_package(path, entry, default_type, &mut report)); - } - - if packages.is_empty() { - packages.push(build_package( - ".", - &PackageEntry::default(), - default_type, - &mut report, - )); + let fallback = PackageEntry::default(); + let entries: Vec<(&str, &PackageEntry)> = if rp.packages.is_empty() { report.warnings.push( - "no `packages` in the config — scaffolded a single root package. Add entries if this \ + "no `packages` in the config. Scaffolded a single root package. Add entries if this \ is a monorepo." .to_string(), ); + vec![(".", &fallback)] + } else { + rp.packages.iter().map(|(p, e)| (p.as_str(), e)).collect() + }; + + let mut packages = Vec::new(); + let mut pre_major = Vec::new(); + for (path, entry) in entries { + packages.push(build_package(path, entry, default_type, &mut report)); + pre_major.push(pre_major_rules(path, entry, &rp, &mut report)); } + apply_pre_major(&mut packages, &pre_major, &mut workspace); + enable_lockfile_updates(&packages, &mut workspace, &mut report); Ok(( Config { @@ -177,17 +197,22 @@ fn build_package( )); vec![vf] } - None => { - report.warnings.push(format!( - "package {path}: release-type {} has no direct ferrflow file mapping — set \ - `versionedFiles` by hand.", - rt.unwrap_or("(none)") - )); - Vec::new() - } + None => Vec::new(), } }; + let mut versioned_files = versioned_files; + for extra in &entry.extra_files { + add_extra_file(path, extra, &mut versioned_files, report); + } + if versioned_files.is_empty() && entry.version_file.is_none() { + report.warnings.push(format!( + "package {path}: release-type {} has no direct ferrflow file mapping — set \ + `versionedFiles` by hand.", + rt.unwrap_or("(none)") + )); + } + PackageConfig { name, path: path.to_string(), @@ -202,6 +227,112 @@ fn build_package( } } +fn add_extra_file( + pkg_path: &str, + extra: &serde_json::Value, + versioned_files: &mut Vec, + report: &mut MigrationReport, +) { + let (vf, generic) = match extra_files::map(pkg_path, extra) { + Mapped::Generic(vf) => (vf, true), + Mapped::Direct(vf) => (vf, false), + Mapped::Manual(msg) => { + report.warnings.push(msg); + return; + } + }; + if versioned_files.contains(&vf) { + report.mapped.push(format!( + "package {pkg_path}: extra-files {} already covered", + vf.path + )); + return; + } + if generic { + report.mapped.push(format!( + "package {pkg_path}: extra-files {} (generic) → txt selector on `x-release-please-version`", + vf.path + )); + report.warnings.push(format!( + "package {pkg_path}: {} is matched on single lines carrying `x-release-please-version`. \ + The `x-release-please-start-version` ... `x-release-please-end` block form is not \ + supported, review manually if the file uses it.", + vf.path + )); + } else { + report.mapped.push(format!( + "package {pkg_path}: extra-files {} → {:?}", + vf.path, vf.format + )); + } + versioned_files.push(vf); +} + +fn pre_major_rules( + path: &str, + entry: &PackageEntry, + rp: &ReleasePleaseConfig, + report: &mut MigrationReport, +) -> Option { + let minor = entry + .bump_minor_pre_major + .or(rp.bump_minor_pre_major) + .unwrap_or(false); + let patch = entry + .bump_patch_for_minor_pre_major + .or(rp.bump_patch_for_minor_pre_major) + .unwrap_or(false); + if minor { + report.mapped.push(format!( + "package {path}: bump-minor-pre-major → preMajor.breaking: minor" + )); + } + if patch { + report.mapped.push(format!( + "package {path}: bump-patch-for-minor-pre-major → preMajor.feat: patch" + )); + } + let rules = PreMajorRules { + breaking: minor.then_some(PreMajorBump::Minor), + feat: patch.then_some(PreMajorBump::Patch), + }; + (rules != PreMajorRules::default()).then_some(rules) +} + +fn apply_pre_major( + packages: &mut [PackageConfig], + rules: &[Option], + workspace: &mut WorkspaceConfig, +) { + if let Some(first) = rules.first().copied().flatten() + && rules.iter().all(|r| *r == Some(first)) + { + workspace.pre_major = Some(first); + return; + } + for (package, rule) in packages.iter_mut().zip(rules) { + package.pre_major = *rule; + } +} + +fn enable_lockfile_updates( + packages: &[PackageConfig], + workspace: &mut WorkspaceConfig, + report: &mut MigrationReport, +) { + let has_lockfile = packages + .iter() + .flat_map(|p| &p.versioned_files) + .filter_map(|vf| vf.path.rsplit('/').next()) + .any(|name| Manifest::from_manifest_filename(name).is_some()); + if has_lockfile { + workspace.update_lockfiles = true; + report + .mapped + .push("versioned manifest with a lockfile → updateLockfiles: true".to_string()); + } +} + fn name_from_path(path: &str) -> String { if path == "." || path.is_empty() { super::default_package_name() @@ -298,6 +429,7 @@ fn apply_plugin(plugin: &Plugin, workspace: &mut WorkspaceConfig, report: &mut M #[cfg(test)] mod tests { use super::*; + use crate::config::types::{PreMajorBump, PreMajorRules}; fn build_ok(raw: &str) -> (Config, MigrationReport) { build(raw).expect("valid release-please config") @@ -388,6 +520,20 @@ mod tests { assert!(report.warnings.iter().any(|w| w.contains("go"))); } + #[test] + fn unmappable_release_type_does_not_warn_when_extra_files_fill_versioned_files() { + let (cfg, report) = build_ok( + r#"{"packages": {".": {"release-type": "go", "extra-files": ["README.md"]}}}"#, + ); + assert_eq!(cfg.packages[0].versioned_files.len(), 1); + assert!( + !report + .warnings + .iter() + .any(|w| w.contains("no direct ferrflow file mapping")) + ); + } + #[test] fn separate_pull_requests_warns() { let (_, report) = build_ok( @@ -504,4 +650,196 @@ mod tests { ); assert!(report.ignored.iter().any(|i| i.contains("node-workspace"))); } + + #[test] + fn extra_files_map_alongside_the_release_type_file() { + let (cfg, report) = build_ok( + r#"{"packages": {".": {"release-type": "rust", "extra-files": [ + {"type": "generic", "path": "README.md"}, + {"type": "json", "path": "manifest.json", "jsonpath": "$.version"} + ]}}}"#, + ); + let files = &cfg.packages[0].versioned_files; + assert_eq!(files.len(), 3); + assert_eq!(files[1].path, "README.md"); + assert!(matches!(files[1].format, FileFormat::Txt)); + assert!(files[1].selector.is_some()); + assert_eq!(files[2].path, "manifest.json"); + assert!(matches!(files[2].format, FileFormat::Json)); + assert!(report.mapped.iter().any(|m| m.contains("README.md"))); + } + + #[test] + fn generic_extra_file_warns_about_the_block_form() { + let (_, report) = build_ok( + r#"{"packages": {".": {"release-type": "simple", "extra-files": ["README.md"]}}}"#, + ); + assert!( + report + .warnings + .iter() + .any(|w| w.contains("x-release-please-start-version") && w.contains("README.md")) + ); + } + + #[test] + fn extra_file_duplicating_the_release_type_file_is_not_added_twice() { + let (cfg, _) = build_ok( + r#"{"packages": {".": {"release-type": "rust", "extra-files": [ + {"type": "toml", "path": "Cargo.toml", "jsonpath": "$.package.version"} + ]}}}"#, + ); + assert_eq!(cfg.packages[0].versioned_files.len(), 1); + } + + #[test] + fn unsupported_extra_files_are_reported_with_the_exact_entry() { + let (cfg, report) = build_ok( + r#"{"packages": {"svc": {"release-type": "simple", "extra-files": [ + {"type": "yaml", "path": "chart.yaml", "jsonpath": "$.version"} + ]}}}"#, + ); + assert_eq!(cfg.packages[0].versioned_files.len(), 1); + assert!(report.warnings.iter().any(|w| { + w.contains(r#"{"type":"yaml","path":"chart.yaml","jsonpath":"$.version"}"#) + })); + } + + fn rules(breaking: Option, feat: Option) -> Option { + Some(PreMajorRules { breaking, feat }) + } + + #[test] + fn bump_minor_pre_major_alone_sets_workspace_breaking_minor() { + let (cfg, report) = build_ok( + r#"{"packages": {".": {"release-type": "node", "bump-minor-pre-major": true}}}"#, + ); + assert_eq!( + cfg.workspace.pre_major, + rules(Some(PreMajorBump::Minor), None) + ); + assert_eq!(cfg.workspace.versioning, None); + assert_eq!(cfg.packages[0].pre_major, None); + assert!( + report + .mapped + .iter() + .any(|m| m.contains("preMajor.breaking: minor")) + ); + assert!(report.warnings.iter().all(|w| !w.contains("zerover"))); + } + + #[test] + fn bump_patch_for_minor_pre_major_alone_sets_workspace_feat_patch() { + let (cfg, report) = build_ok( + r#"{"packages": {".": {"release-type": "node", "bump-patch-for-minor-pre-major": true}}}"#, + ); + assert_eq!( + cfg.workspace.pre_major, + rules(None, Some(PreMajorBump::Patch)) + ); + assert!( + report + .mapped + .iter() + .any(|m| m.contains("preMajor.feat: patch")) + ); + assert!(report.warnings.iter().all(|w| !w.contains("#1376"))); + } + + #[test] + fn both_flags_set_both_rules() { + let (cfg, _) = build_ok( + r#"{"packages": {".": {"release-type": "node", "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true}}}"#, + ); + assert_eq!( + cfg.workspace.pre_major, + rules(Some(PreMajorBump::Minor), Some(PreMajorBump::Patch)) + ); + } + + #[test] + fn no_flags_leave_pre_major_unset() { + let (cfg, _) = build_ok(r#"{"packages": {".": {"release-type": "node"}}}"#); + assert_eq!(cfg.workspace.pre_major, None); + assert_eq!(cfg.packages[0].pre_major, None); + } + + #[test] + fn flags_on_some_packages_are_placed_per_package() { + let (cfg, _) = build_ok( + r#"{"packages": { + "a": {"release-type": "node", "bump-minor-pre-major": true}, + "b": {"release-type": "node", "bump-patch-for-minor-pre-major": true}, + "c": {"release-type": "node"} + }}"#, + ); + assert_eq!(cfg.workspace.pre_major, None); + assert_eq!( + cfg.packages[0].pre_major, + rules(Some(PreMajorBump::Minor), None) + ); + assert_eq!( + cfg.packages[1].pre_major, + rules(None, Some(PreMajorBump::Patch)) + ); + assert_eq!(cfg.packages[2].pre_major, None); + } + + #[test] + fn top_level_flags_apply_to_every_package() { + let (cfg, _) = build_ok( + r#"{"bump-minor-pre-major": true, "packages": {"a": {"release-type": "node"}, "b": {"release-type": "node"}}}"#, + ); + assert_eq!( + cfg.workspace.pre_major, + rules(Some(PreMajorBump::Minor), None) + ); + } + + #[test] + fn package_flag_false_overrides_the_top_level_flag() { + let (cfg, _) = build_ok( + r#"{"bump-minor-pre-major": true, "packages": {"a": {"release-type": "node", "bump-minor-pre-major": false}}}"#, + ); + assert_eq!(cfg.workspace.pre_major, None); + assert_eq!(cfg.packages[0].pre_major, None); + } + + #[test] + fn release_please_disables_auto_merge() { + let (cfg, report) = build_ok(r#"{"packages": {".": {"release-type": "node"}}}"#); + assert!(!cfg.workspace.auto_merge_releases); + assert!( + report + .mapped + .iter() + .any(|m| m.contains("autoMergeReleases")) + ); + } + + #[test] + fn manifests_with_a_lockfile_enable_lockfile_updates() { + let (cfg, _) = build_ok(r#"{"packages": {".": {"release-type": "rust"}}}"#); + assert!(cfg.workspace.update_lockfiles); + } + + #[test] + fn lockfile_updates_stay_off_without_a_lockfile_manifest() { + let (cfg, _) = build_ok( + r#"{"packages": {".": {"release-type": "simple", "extra-files": ["README.md"]}}}"#, + ); + assert!(!cfg.workspace.update_lockfiles); + } + + #[test] + fn package_name_wins_over_component_and_path() { + let (cfg, _) = build_ok( + r#"{"packages": {"x": {"release-type": "node", "package-name": "pn", "component": "c"}}}"#, + ); + assert_eq!(cfg.packages[0].name, "pn"); + let (cfg, _) = + build_ok(r#"{"packages": {"x": {"release-type": "node", "component": "c"}}}"#); + assert_eq!(cfg.packages[0].name, "c"); + } } diff --git a/src/config/migrate/release_please/extra_files.rs b/src/config/migrate/release_please/extra_files.rs new file mode 100644 index 00000000..1c958058 --- /dev/null +++ b/src/config/migrate/release_please/extra_files.rs @@ -0,0 +1,240 @@ +use serde_json::Value; + +use crate::config::package::{FileFormat, VersionedFile}; + +use super::join_path; + +const GENERIC_MARKER_SELECTOR: &str = + r"v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z][0-9A-Za-z.+-]*)?)[^\n]*x-release-please-version"; + +const CARGO_VERSION_PATHS: &[&str] = &["$.package.version", "$.workspace.package.version"]; + +#[derive(Debug, PartialEq)] +pub(super) enum Mapped { + Generic(VersionedFile), + Direct(VersionedFile), + Manual(String), +} + +pub(super) fn map(pkg_path: &str, entry: &Value) -> Mapped { + let (kind, path, jsonpath) = match entry { + Value::String(path) => ("generic", path.as_str(), None), + Value::Object(map) => { + let Some(path) = map.get("path").and_then(Value::as_str) else { + return Mapped::Manual(manual(pkg_path, entry, "entry has no `path`")); + }; + let kind = map.get("type").and_then(Value::as_str).unwrap_or("generic"); + (kind, path, map.get("jsonpath").and_then(Value::as_str)) + } + _ => return Mapped::Manual(manual(pkg_path, entry, "entry is not a string or object")), + }; + let resolved = resolve(pkg_path, path); + + match (kind, jsonpath) { + ("generic", _) => Mapped::Generic(VersionedFile { + path: resolved, + format: FileFormat::Txt, + selector: Some(GENERIC_MARKER_SELECTOR.to_string()), + }), + ("toml", Some(jp)) if is_cargo_manifest(&resolved) && CARGO_VERSION_PATHS.contains(&jp) => { + Mapped::Direct(VersionedFile { + path: resolved, + format: FileFormat::Toml, + selector: None, + }) + } + ("json", Some("$.version")) => Mapped::Direct(VersionedFile { + path: resolved, + format: FileFormat::Json, + selector: None, + }), + _ => Mapped::Manual(manual( + pkg_path, + entry, + "no ferrflow versionedFiles equivalent", + )), + } +} + +fn resolve(pkg_path: &str, path: &str) -> String { + match path.strip_prefix('/') { + Some(from_root) => from_root.to_string(), + None => join_path(pkg_path, path), + } +} + +fn is_cargo_manifest(path: &str) -> bool { + path.rsplit('/').next() == Some("Cargo.toml") +} + +fn manual(pkg_path: &str, entry: &Value, reason: &str) -> String { + format!( + "package {pkg_path}: extra-files entry {entry} ({reason}), add it to `versionedFiles` by hand." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::formats::VersionFile; + use crate::formats::txt::TxtVersionFile; + use serde_json::json; + use std::io::Write; + use tempfile::NamedTempFile; + + const LUA_LINE: &str = "return \"0.5.0\" -- x-release-please-version\n"; + const README_LINE: &str = + " version='v0.5.0' -- use the latest tagged releases x-release-please-version\n"; + + fn generic_selector() -> String { + match map(".", &json!({"type": "generic", "path": "f"})) { + Mapped::Generic(vf) => vf.selector.expect("selector"), + other => panic!("expected generic, got {other:?}"), + } + } + + fn file_with(content: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f + } + + #[test] + fn selector_reads_a_quoted_version_on_the_marker_line() { + let f = file_with(LUA_LINE); + let v = TxtVersionFile + .read_version_with_selector(f.path(), Some(&generic_selector())) + .unwrap(); + assert_eq!(v, "0.5.0"); + } + + #[test] + fn selector_leaves_a_leading_v_outside_the_capture() { + let f = file_with(README_LINE); + let v = TxtVersionFile + .read_version_with_selector(f.path(), Some(&generic_selector())) + .unwrap(); + assert_eq!(v, "0.5.0"); + } + + #[test] + fn selector_write_replaces_only_the_version() { + let f = file_with(README_LINE); + TxtVersionFile + .write_version_with_selector(f.path(), "0.6.0", Some(&generic_selector())) + .unwrap(); + assert_eq!( + std::fs::read_to_string(f.path()).unwrap(), + " version='v0.6.0' -- use the latest tagged releases x-release-please-version\n" + ); + } + + #[test] + fn selector_ignores_versions_on_lines_without_the_marker() { + let f = file_with(&format!("local lua = \"5.1.0\"\n{LUA_LINE}")); + TxtVersionFile + .write_version_with_selector(f.path(), "0.6.0", Some(&generic_selector())) + .unwrap(); + assert_eq!( + std::fs::read_to_string(f.path()).unwrap(), + "local lua = \"5.1.0\"\nreturn \"0.6.0\" -- x-release-please-version\n" + ); + } + + #[test] + fn selector_keeps_a_prerelease_suffix_in_the_capture() { + let f = file_with("v = \"1.0.0-rc.1\" # x-release-please-version\n"); + let v = TxtVersionFile + .read_version_with_selector(f.path(), Some(&generic_selector())) + .unwrap(); + assert_eq!(v, "1.0.0-rc.1"); + } + + #[test] + fn selector_does_not_match_a_file_without_the_marker() { + let f = file_with("return \"0.5.0\"\n"); + assert!( + TxtVersionFile + .read_version_with_selector(f.path(), Some(&generic_selector())) + .is_err() + ); + } + + #[test] + fn plain_string_entry_is_a_generic_file() { + assert!(matches!( + map("pkg", &json!("docs/VERSION.md")), + Mapped::Generic(vf) if vf.path == "pkg/docs/VERSION.md" + )); + } + + #[test] + fn leading_slash_anchors_the_path_at_the_repo_root() { + assert!(matches!( + map("pkg", &json!({"type": "generic", "path": "/README.md"})), + Mapped::Generic(vf) if vf.path == "README.md" + )); + } + + #[test] + fn toml_workspace_package_version_on_cargo_toml_maps_to_toml() { + let mapped = map( + ".", + &json!({"type": "toml", "path": "Cargo.toml", "jsonpath": "$.workspace.package.version"}), + ); + assert_eq!( + mapped, + Mapped::Direct(VersionedFile { + path: "Cargo.toml".to_string(), + format: FileFormat::Toml, + selector: None, + }) + ); + } + + #[test] + fn toml_package_version_on_a_nested_cargo_toml_maps_to_toml() { + assert!(matches!( + map( + "crates/core", + &json!({"type": "toml", "path": "Cargo.toml", "jsonpath": "$.package.version"}) + ), + Mapped::Direct(vf) if vf.path == "crates/core/Cargo.toml" + )); + } + + #[test] + fn json_version_maps_to_json() { + assert!(matches!( + map(".", &json!({"type": "json", "path": "manifest.json", "jsonpath": "$.version"})), + Mapped::Direct(vf) if matches!(vf.format, FileFormat::Json) && vf.path == "manifest.json" + )); + } + + #[test] + fn other_jsonpaths_need_manual_review_with_the_exact_entry() { + for entry in [ + json!({"type": "json", "path": "package.json", "jsonpath": "$.metadata.version"}), + json!({"type": "toml", "path": "pyproject.toml", "jsonpath": "$.project.version"}), + json!({"type": "toml", "path": "Cargo.toml", "jsonpath": "$.dependencies.foo.version"}), + json!({"type": "toml", "path": "Cargo.toml"}), + json!({"type": "yaml", "path": "chart.yaml", "jsonpath": "$.version"}), + json!({"type": "xml", "path": "pom.xml", "xpath": "//project/version"}), + json!({"type": "pom", "path": "pom.xml"}), + ] { + match map(".", &entry) { + Mapped::Manual(msg) => assert!(msg.contains(&entry.to_string()), "{msg}"), + other => panic!("{entry} should need review, got {other:?}"), + } + } + } + + #[test] + fn malformed_entries_need_manual_review() { + assert!(matches!(map(".", &json!(42)), Mapped::Manual(_))); + assert!(matches!( + map(".", &json!({"type": "generic"})), + Mapped::Manual(_) + )); + } +} diff --git a/src/config/migrate/tests.rs b/src/config/migrate/tests.rs index ae4cadaa..79475fca 100644 --- a/src/config/migrate/tests.rs +++ b/src/config/migrate/tests.rs @@ -482,3 +482,190 @@ mod end_to_end { assert!(!dir.path().join("ferrflow.json").exists()); } } + +const AVANTE_CONFIG: &str = r#"{"packages":{".":{"prerelease":false,"bump-patch-for-minor-pre-major":true,"bump-minor-pre-major":true,"release-type":"go","include-component-in-tag":false,"extra-files":[{"type":"generic","path":"lua/avante/version.lua"},{"type":"generic","path":"README.md"},{"type":"toml","path":"Cargo.toml","jsonpath":"$.workspace.package.version"}]}}}"#; + +mod release_please_avante { + use super::super::{Migration, Source, emit, release_please}; + use crate::formats::VersionFile; + use crate::formats::txt::TxtVersionFile; + use std::path::PathBuf; + + use super::AVANTE_CONFIG as AVANTE; + + fn migrate_avante(dir: &std::path::Path) -> (serde_json::Value, super::MigrationReport) { + let _guard = crate::test_utils::CWD_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let saved = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir).unwrap(); + let built = release_please::build(AVANTE); + std::env::set_current_dir(saved).unwrap(); + let (config, report) = built.unwrap(); + let migration = Migration::new( + Source::ReleasePlease, + PathBuf::from("release-please-config.json"), + config, + report, + ); + let (_, content) = emit(&migration, dir, true).unwrap(); + (serde_json::from_str(&content).unwrap(), migration.report) + } + + #[test] + fn avante_config_produces_the_expected_ferrflow_json() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("avante.nvim"); + std::fs::create_dir(&dir).unwrap(); + let (json, report) = migrate_avante(&dir); + + assert_eq!( + json["workspace"]["preMajor"], + serde_json::json!({"breaking": "minor", "feat": "patch"}) + ); + assert!(json["workspace"].get("versioning").is_none()); + assert!(json["package"][0].get("preMajor").is_none()); + assert_eq!(json["workspace"]["releaseCommitMode"], "pr"); + assert_eq!(json["workspace"]["autoMergeReleases"], false); + assert_eq!(json["workspace"]["update_lockfiles"], true); + assert!(json["workspace"].get("commitFormats").is_none()); + assert_eq!(json["package"][0]["name"], "avante.nvim"); + assert_eq!(json["package"][0]["path"], "."); + assert!(json["package"][0].get("hooks").is_none()); + + let files = json["package"][0]["versionedFiles"].as_array().unwrap(); + assert_eq!(files.len(), 3); + assert_eq!(files[0]["path"], "lua/avante/version.lua"); + assert_eq!(files[0]["format"], "txt"); + assert_eq!(files[1]["path"], "README.md"); + assert_eq!(files[2]["path"], "Cargo.toml"); + assert_eq!(files[2]["format"], "toml"); + assert!(files[2].get("selector").is_none()); + + assert!( + report + .mapped + .iter() + .any(|m| m.contains("preMajor.breaking")) + ); + assert!(report.mapped.iter().any(|m| m.contains("preMajor.feat"))); + assert!(report.warnings.iter().all(|w| !w.contains("zerover"))); + } + + #[test] + fn avante_generated_selector_reads_and_rewrites_both_marker_lines() { + let root = tempfile::tempdir().unwrap(); + let (json, _) = migrate_avante(root.path()); + let selector = json["package"][0]["versionedFiles"][0]["selector"] + .as_str() + .unwrap() + .to_string(); + + let lua = root.path().join("version.lua"); + std::fs::write(&lua, "return \"0.5.0\" -- x-release-please-version\n").unwrap(); + let readme = root.path().join("README.md"); + std::fs::write( + &readme, + "```lua\n {\n version='v0.5.0' -- use the latest tagged releases x-release-please-version\n }\n```\n", + ) + .unwrap(); + + for file in [&lua, &readme] { + assert_eq!( + TxtVersionFile + .read_version_with_selector(file, Some(&selector)) + .unwrap(), + "0.5.0" + ); + TxtVersionFile + .write_version_with_selector(file, "0.6.0", Some(&selector)) + .unwrap(); + } + assert_eq!( + std::fs::read_to_string(&lua).unwrap(), + "return \"0.6.0\" -- x-release-please-version\n" + ); + assert_eq!( + std::fs::read_to_string(&readme).unwrap(), + "```lua\n {\n version='v0.6.0' -- use the latest tagged releases x-release-please-version\n }\n```\n" + ); + } +} + +mod round_trip { + use super::super::{Migration, Source, changesets, emit, release_please, standard_version}; + use super::{AVANTE_CONFIG, build}; + use crate::config::Config; + use crate::config::format::{ConfigFileFormat, format_handler}; + use std::path::PathBuf; + + fn assert_round_trips(label: &str, config: Config, report: super::MigrationReport) { + let migration = Migration::new(Source::ReleasePlease, PathBuf::from("src"), config, report); + let dir = tempfile::tempdir().unwrap(); + let (_, content) = emit(&migration, dir.path(), true).unwrap(); + let reloaded = format_handler(ConfigFileFormat::Json) + .parse(&content) + .unwrap_or_else(|e| panic!("{label}: emitted config does not load: {e:#}")); + assert_eq!( + serde_json::to_value(&reloaded).unwrap(), + serde_json::to_value(&migration.config).unwrap(), + "{label}: reloaded config differs from the migrated one\n{content}" + ); + } + + #[test] + fn every_migrator_output_reloads_to_the_same_config() { + let changesets_root = tempfile::tempdir().unwrap(); + + let mut cases: Vec<(&str, Config, super::MigrationReport)> = Vec::new(); + for (label, raw) in [ + ("release-please avante", AVANTE_CONFIG), + ( + "release-please plain", + r#"{"packages": {".": {"release-type": "node"}}}"#, + ), + ( + "release-please monorepo", + r#"{"include-component-in-tag": true, "bump-minor-pre-major": true, + "plugins": [{"type": "linked-versions", "groupName": "g", "components": ["a", "b"]}], + "packages": {"a": {"release-type": "rust"}, "b": {"release-type": "node", "changelog-path": "X.md"}}}"#, + ), + ("release-please empty", r#"{}"#), + ] { + let (c, r) = release_please::build(raw).unwrap(); + cases.push((label, c, r)); + } + for (label, raw) in [ + ("semantic-release minimal", "{}"), + ( + "semantic-release full", + r#"{"tagFormat": "v${version}", "branches": ["main", {"name": "beta", "prerelease": true}], + "plugins": [["@semantic-release/exec", {"prepareCmd": "x", "successCmd": "y"}], "@semantic-release/gitlab"]}"#, + ), + ] { + let (c, r) = build(raw); + cases.push((label, c, r)); + } + for (label, raw) in [ + ("changesets minimal", "{}"), + ("changesets base branch", r#"{"baseBranch": "develop"}"#), + ] { + let (c, r) = changesets::build(raw, changesets_root.path()).unwrap(); + cases.push((label, c, r)); + } + for (label, raw) in [ + ("standard-version minimal", "{}"), + ( + "standard-version bump files", + r#"{"tagPrefix": "release-", "bumpFiles": [{"filename": "package.json", "type": "json"}, {"filename": "VERSION", "type": "plain-text"}]}"#, + ), + ] { + let (c, r) = standard_version::build(raw).unwrap(); + cases.push((label, c, r)); + } + + for (label, config, report) in cases { + assert_round_trips(label, config, report); + } + } +} diff --git a/src/config/package.rs b/src/config/package.rs index 64775df0..2d4ee332 100644 --- a/src/config/package.rs +++ b/src/config/package.rs @@ -288,7 +288,7 @@ impl PackageConfig { } } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] pub struct VersionedFile { pub path: String, pub format: FileFormat,