From defa42e1b583a22007c1daf9971209f96b4aa615 Mon Sep 17 00:00:00 2001 From: bdbch <6538827+bdbch@users.noreply.github.com> Date: Sat, 23 May 2026 05:31:56 +0200 Subject: [PATCH] Code review fixes + structural refactoring (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: address all 12 code review findings (bugs, hardenings, cleanups) πŸ› BUG FIXES: - init_wizard: apply user's npm access selection instead of discarding it - lib: fix pre-state path inconsistency (use get_release_dir everywhere) - release_file: normalize CRLF line endings in frontmatter parser - lib: replace parse_from with try_parse_from to avoid process::exit in NAPI - lib: propagate workspace detection errors in cmd_init instead of swallowing πŸ›‘οΈ HARDENINGS: - changelog: flexible header stripping with case-insensitive fallback - package_json: simplify semver range matching logic, fix false positives - workspace: propagate pnpm-workspace.yaml read/parse errors - bump, premode: warn on invalid glob patterns instead of silent failure 🧹 CLEANUPS: - premode: remove dead version_is_pre_release function - lib: extract shared resolve_package_patterns helper (70% duplication) - changelog: extract indent_continuations helper πŸ§ͺ TESTS: - 10 new tests for strip_changelog_header, indent_continuations, CRLF parsing - 79 total tests passing * test: add megatest covering full user flow (8 phases) Covers scaffold β†’ config β†’ release files β†’ plan verification β†’ dry-run β†’ real bump β†’ internal dep updates β†’ pre-release counter. 80 tests passing. Workspace with 8 packages across linked, fixed, pre-mode, and independent scenarios. * refactor: split bump.rs into 6-module dir + extract commands from lib.rs Phase 1: bump.rs (1484 lines) β†’ src/bump/ with mod.rs, plan.rs, groups.rs, deps.rs, apply.rs, discovery.rs β€” each with a single named responsibility. Phase 2: lib.rs (731β†’138 lines) β†’ src/commands/ with init.rs, new.rs, status.rs, bump_cmd.rs, check.rs, release.rs, pre.rs β€” each command has its own file matching the CLI subcommand. 80 tests passing, zero warnings, no API breakage. * refactor: group workspace + pre-release logic into module directories Phase 3: workspace module directory - workspace.rs + package_json.rs β†’ src/workspace/{mod,loader,package_json}.rs - package_json module moved under workspace (re-exported via mod.rs) Phase 4: pre-release module directory - premode.rs β†’ src/prerelease/{mod,state,logic}.rs - Now split into state management (PreState) and logic (resolve/apply) 80 tests passing, zero warnings, no API breakage. * style: run cargo fmt on restructured files --- Cargo.toml | 2 - src/bump.rs | 1416 --------------------- src/bump/apply.rs | 276 ++++ src/bump/deps.rs | 68 + src/bump/discovery.rs | 39 + src/bump/groups.rs | 272 ++++ src/bump/mod.rs | 707 ++++++++++ src/bump/plan.rs | 165 +++ src/changelog.rs | 138 +- src/commands/bump_cmd.rs | 65 + src/commands/check.rs | 35 + src/commands/init.rs | 114 ++ src/commands/mod.rs | 21 + src/commands/new.rs | 94 ++ src/commands/pre.rs | 199 +++ src/commands/release.rs | 36 + src/commands/status.rs | 88 ++ src/init_wizard.rs | 6 +- src/lib.rs | 718 ++--------- src/main.rs | 18 +- src/premode.rs | 305 ----- src/prerelease/logic.rs | 55 + src/prerelease/mod.rs | 139 ++ src/prerelease/state.rs | 84 ++ src/release.rs | 5 +- src/release_file.rs | 43 +- src/{workspace.rs => workspace/loader.rs} | 31 +- src/workspace/mod.rs | 13 + src/{ => workspace}/package_json.rs | 65 +- tests/megatest.rs | 334 +++++ 30 files changed, 3091 insertions(+), 2460 deletions(-) delete mode 100644 src/bump.rs create mode 100644 src/bump/apply.rs create mode 100644 src/bump/deps.rs create mode 100644 src/bump/discovery.rs create mode 100644 src/bump/groups.rs create mode 100644 src/bump/mod.rs create mode 100644 src/bump/plan.rs create mode 100644 src/commands/bump_cmd.rs create mode 100644 src/commands/check.rs create mode 100644 src/commands/init.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/new.rs create mode 100644 src/commands/pre.rs create mode 100644 src/commands/release.rs create mode 100644 src/commands/status.rs delete mode 100644 src/premode.rs create mode 100644 src/prerelease/logic.rs create mode 100644 src/prerelease/mod.rs create mode 100644 src/prerelease/state.rs rename src/{workspace.rs => workspace/loader.rs} (93%) create mode 100644 src/workspace/mod.rs rename src/{ => workspace}/package_json.rs (80%) create mode 100644 tests/megatest.rs diff --git a/Cargo.toml b/Cargo.toml index 30868ab..eb596d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,8 +21,6 @@ serde_json = "1" serde_yaml = "0.9" semver = "1" glob = "0.3" -walkdir = "2" -anyhow = "1" thiserror = "2" inquire = "0.7" rand = "0.8" diff --git a/src/bump.rs b/src/bump.rs deleted file mode 100644 index 384018e..0000000 --- a/src/bump.rs +++ /dev/null @@ -1,1416 +0,0 @@ -use crate::changelog::{ - ChangelogEntry, generate_changelog_section, generate_global_changelog_section, update_changelog, - update_global_changelog, -}; -use crate::config::{InternalDepUpdate, OxrlsConfig}; -use crate::error::{OxrlsError, Result}; -use crate::package_json::PackageJson; -use crate::premode::{PreState, apply_pre_release, resolve_pre_release}; -use crate::release::ReleaseManifest; -use crate::release_file::{BumpType, ReleaseFile, consume_release_file, parse_release_file}; -use crate::version_bump::bump_version; -use crate::workspace::{Workspace, WorkspacePackage}; -use glob::Pattern; -use indexmap::IndexMap; -use std::collections::HashSet; -use std::path::{Path, PathBuf}; - -/// A planned version bump for a single package. -#[derive(Debug, Clone)] -pub struct PlannedBump { - pub package_name: String, - pub old_version: semver::Version, - pub new_version: semver::Version, - pub bump_type: BumpType, - pub summaries: Vec, - /// The release files that caused this bump. - pub release_files: Vec, -} - -/// A complete release plan built from pending release files. -#[derive(Debug, Clone)] -pub struct ReleasePlan { - pub bumps: IndexMap, - pub internal_dep_updates: Vec, -} - -/// Information about an internal dependency range update. -#[derive(Debug, Clone)] -pub struct InternalDepUpdateInfo { - pub dependent_package_path: PathBuf, - pub dependent_package_name: String, - pub dep_name: String, - pub dep_type: String, - pub old_range: String, - pub new_range: String, -} - -/// Build a release plan by reading all release files and computing bumps. -pub fn build_release_plan( - workspace: &Workspace, - config: &OxrlsConfig, - release_dir: &Path, - dry_run: bool, -) -> Result { - // Find all release files - let release_files = find_release_files(release_dir)?; - - if release_files.is_empty() { - return Err(OxrlsError::Bump( - "No pending release files found.".to_string(), - )); - } - - // Parse all release files - let parsed: Vec = release_files - .iter() - .map(|path| parse_release_file(path)) - .collect::>>()?; - - // Validate all package references - for rf in &parsed { - for pkg_name in rf.releases.keys() { - if !workspace.packages.contains_key(pkg_name) { - return Err(OxrlsError::Bump(format!( - "Release file {} references \"{}\", but no workspace package with that name exists.", - rf.path.display(), - pkg_name - ))); - } - } - } - - // Merge bump types per package (highest priority wins) - let mut merged_bumps: IndexMap)> = IndexMap::new(); - for rf in &parsed { - for (pkg_name, bump_type) in &rf.releases { - let entry = merged_bumps - .entry(pkg_name.clone()) - .or_insert((*bump_type, vec![])); - entry.0 = BumpType::max(Some(entry.0), *bump_type); - entry.1.push(rf); - } - } - - // Compute new versions - let mut bumps: IndexMap = IndexMap::new(); - let mut pre_state = PreState::load(release_dir)?; - - for (pkg_name, (bump_type, refs)) in &merged_bumps { - let pkg = workspace - .packages - .get(pkg_name) - .ok_or_else(|| OxrlsError::Bump(format!("Package '{}' not found in workspace", pkg_name)))?; - - let old_version = pkg.package_json.semver_version()?; - - // Apply pre-release tag if the package is in pre-mode - // On the FIRST pre-release bump (count == 1), apply the bump type - // to the base version before adding the pre-release tag. - // Subsequent bumps only increment the pre-release counter. - let new_version = if let Some((tag, count)) = - resolve_pre_release(pkg_name, config, &mut pre_state, workspace) - { - if count == 1 { - // First pre-release: bump the base version, then add pre-release tag - let bumped = bump_version(&old_version, *bump_type); - let base = semver::Version::new(bumped.major, bumped.minor, bumped.patch); - apply_pre_release(&base, &tag, count) - } else { - // Subsequent pre-release: keep the base version, just increment counter - let base = semver::Version::new(old_version.major, old_version.minor, old_version.patch); - apply_pre_release(&base, &tag, count) - } - } else { - // Normal bump β€” not in pre-release mode - bump_version(&old_version, *bump_type) - }; - - // Collect summaries from the release files that reference this package - let summaries: Vec = refs.iter().map(|rf| rf.summary.clone()).collect(); - - let release_files: Vec = refs.iter().map(|rf| rf.path.clone()).collect(); - - bumps.insert( - pkg_name.clone(), - PlannedBump { - package_name: pkg_name.clone(), - old_version, - new_version, - bump_type: *bump_type, - summaries, - release_files, - }, - ); - } - - // Save pre-state after all bumps (counters are already incremented) - if !dry_run { - pre_state.save(release_dir)?; - } - - // Apply fixed group constraints β€” all packages in a fixed group share the same version - apply_fixed_groups(&mut bumps, workspace, config)?; - - // Apply linked group constraints β€” all packages in a linked group share the same bump type - apply_linked_groups(&mut bumps, workspace, config)?; - - // Compute internal dependency updates - let internal_updates = compute_internal_dep_updates(workspace, &bumps, config)?; - - Ok(ReleasePlan { - bumps, - internal_dep_updates: internal_updates, - }) -} - -/// Resolve a group of package name patterns (with optional `!` negation) -/// against the workspace package names. -/// -/// Supports glob patterns via `glob::Pattern`: -/// - `"@scope/*"` matches all packages under `@scope/` -/// - `"!@scope/special"` excludes `@scope/special` from the resolved set -/// -/// The resolution order is: all inclusions are applied first, then exclusions. -fn resolve_group_patterns( - patterns: &[String], - packages: &IndexMap, -) -> Result> { - if patterns.is_empty() { - return Ok(vec![]); - } - - // If no patterns use glob or negation, skip resolution and return as-is - let needs_resolution = patterns - .iter() - .any(|p| p.contains('*') || p.contains('?') || p.contains('[') || p.starts_with('!')); - - if !needs_resolution { - return Ok(patterns.to_vec()); - } - - let mut result: Vec = Vec::new(); - - // Phase 1: process inclusion patterns (no `!` prefix) - let inclusion_patterns: Vec<&str> = patterns - .iter() - .filter(|p| !p.starts_with('!')) - .map(|p| p.as_str()) - .collect(); - - if inclusion_patterns.is_empty() { - // No explicit inclusions means all packages - for name in packages.keys() { - result.push(name.clone()); - } - } else { - for pattern_str in &inclusion_patterns { - let pat = Pattern::new(pattern_str).map_err(|e| { - OxrlsError::Config(format!("Invalid glob pattern \"{}\": {}", pattern_str, e)) - })?; - for name in packages.keys() { - if pat.matches(name) { - result.push(name.clone()); - } - } - } - } - - // Phase 2: process exclusion patterns (prefixed with `!`) - let exclusion_patterns: Vec<&str> = patterns - .iter() - .filter(|p| p.starts_with('!')) - .map(|p| &p[1..]) - .collect(); - - if !exclusion_patterns.is_empty() { - result.retain(|name| { - !exclusion_patterns.iter().any(|pat_str| { - Pattern::new(pat_str) - .map(|pat| pat.matches(name)) - .unwrap_or(false) - }) - }); - } - - result.sort(); - result.dedup(); - Ok(result) -} - -/// Apply fixed group constraints: all packages in a fixed group share the same version. -/// If any member of a fixed group is bumped, every member gets bumped to the same new version -/// (computed from the highest bump type Γ— the highest old version in the group). -/// -/// Supports glob patterns and `!` negation in group definitions: -/// ```json -/// { "fixed": [["@scope/*", "!@scope/special"]] } -/// ``` -fn apply_fixed_groups( - bumps: &mut IndexMap, - workspace: &Workspace, - config: &OxrlsConfig, -) -> Result<()> { - for group_patterns in &config.fixed { - if group_patterns.is_empty() { - continue; - } - - let group = resolve_group_patterns(group_patterns, &workspace.packages)?; - - // Collect current state of all group members - let mut group_bumps: Vec<(String, semver::Version, BumpType)> = Vec::new(); - let mut any_bumped = false; - - for pkg_name in &group { - if let Some(bump) = bumps.get(pkg_name) { - any_bumped = true; - group_bumps.push((pkg_name.clone(), bump.old_version.clone(), bump.bump_type)); - } else if let Some(pkg) = workspace.packages.get(pkg_name) - && let Ok(ver) = pkg.package_json.semver_version() - { - group_bumps.push((pkg_name.clone(), ver, BumpType::Patch)); - } - } - - // Only apply fixed constraint if at least one member was bumped - if !any_bumped { - continue; - } - - // Find the highest old version and max bump type in the group - let mut max_bump = BumpType::Patch; - let mut highest_old_version = semver::Version::new(0, 0, 0); - - for (_, old_ver, bump_type) in &group_bumps { - if old_ver > &highest_old_version { - highest_old_version = old_ver.clone(); - } - if bump_type.priority() > max_bump.priority() { - max_bump = *bump_type; - } - } - - // Compute the shared new version - let shared_new_version = bump_version(&highest_old_version, max_bump); - - // Collect summaries from the packages that were directly bumped in this group - let direct_summaries: Vec = group_bumps - .iter() - .filter(|(name, _, _)| bumps.contains_key(name.as_str())) - .flat_map(|(name, _, _)| { - bumps - .get(name) - .map(|b| b.summaries.clone()) - .unwrap_or_default() - }) - .collect(); - let direct_release_files: Vec = group_bumps - .iter() - .filter(|(name, _, _)| bumps.contains_key(name.as_str())) - .flat_map(|(name, _, _)| { - bumps - .get(name) - .map(|b| b.release_files.clone()) - .unwrap_or_default() - }) - .collect(); - - // Snapshot which packages were originally in the bump plan (before we mutate `bumps`) - let originally_bumped: std::collections::HashSet<&str> = group_bumps - .iter() - .filter(|(name, _, _)| bumps.contains_key(name.as_str())) - .map(|(name, _, _)| name.as_str()) - .collect(); - - // Apply to all group members - for (pkg_name, old_ver, _) in &group_bumps { - let existing_summaries = bumps - .get(pkg_name) - .map(|b| b.summaries.clone()) - .unwrap_or_default(); - let existing_release_files = bumps - .get(pkg_name) - .map(|b| b.release_files.clone()) - .unwrap_or_default(); - - // If this package wasn't directly bumped (no release file entries), - // derive the summary from what was bumped in the group - let summaries = if existing_summaries.is_empty() { - let deps: Vec<&str> = originally_bumped - .iter() - .filter(|name| **name != *pkg_name) - .copied() - .collect(); - if !deps.is_empty() { - vec![format!("Updated with {}.", deps.join(", "))] - } else if !direct_summaries.is_empty() { - direct_summaries.clone() - } else { - vec!["Updated to match fixed group version.".to_string()] - } - } else { - existing_summaries - }; - - let release_files = if existing_release_files.is_empty() { - direct_release_files.clone() - } else { - existing_release_files - }; - - bumps.insert( - pkg_name.clone(), - PlannedBump { - package_name: pkg_name.clone(), - old_version: old_ver.clone(), - new_version: shared_new_version.clone(), - bump_type: max_bump, - summaries, - release_files, - }, - ); - } - } - - Ok(()) -} - -/// Apply linked group constraints: all packages in a linked group share the same bump type. -/// If any member of a linked group receives a bump, every other member in the group -/// that is also being bumped gets the highest bump type found in the group. -/// -/// Supports glob patterns and `!` negation in group definitions: -/// ```json -/// { "linked": [["@scope/*", "!@scope/special"]] } -/// ``` -fn apply_linked_groups( - bumps: &mut IndexMap, - workspace: &Workspace, - config: &OxrlsConfig, -) -> Result<()> { - for group_patterns in &config.linked { - if group_patterns.is_empty() { - continue; - } - - let group = resolve_group_patterns(group_patterns, &workspace.packages)?; - - // Find the max bump type among group members that are in the plan - let mut max_bump: Option = None; - for pkg_name in &group { - if let Some(bump) = bumps.get(pkg_name) { - max_bump = Some(BumpType::max(max_bump, bump.bump_type)); - } - } - - let max_bump = match max_bump { - Some(b) => b, - None => continue, - }; - - // Apply the max bump type to all group members that are in the plan - for pkg_name in &group { - if let Some(bump) = bumps.get_mut(pkg_name) - && bump.bump_type != max_bump - { - bump.bump_type = max_bump; - bump.new_version = bump_version(&bump.old_version, max_bump); - } - } - } - - Ok(()) -} - -/// Compute which internal dependency ranges need updating. -fn compute_internal_dep_updates( - workspace: &Workspace, - bumps: &IndexMap, - config: &OxrlsConfig, -) -> Result> { - let mut updates = Vec::new(); - - // Check all workspace packages for dependencies on bumped packages - for (dep_name, pkg) in &workspace.packages { - // If this package is itself bumped, no need to update self-dependency - // Check all dependency fields - let dep_fields: Vec<(&str, &Option>)> = vec![ - ("dependencies", &pkg.package_json.dependencies), - ("devDependencies", &pkg.package_json.dev_dependencies), - ("peerDependencies", &pkg.package_json.peer_dependencies), - ( - "optionalDependencies", - &pkg.package_json.optional_dependencies, - ), - ]; - - for (field_name, field) in &dep_fields { - let deps = match field { - Some(d) => d, - None => continue, - }; - - for (dep_name_in_range, _range) in deps { - // Check if this dependency is being bumped - if let Some(bump) = bumps.get(dep_name_in_range) { - // Check if we should update based on config - if should_update_dependency(&config.update_internal_dependencies, &bump.bump_type) { - // We'll compute the new range when applying - updates.push(InternalDepUpdateInfo { - dependent_package_path: pkg.dir.join("package.json"), - dependent_package_name: dep_name.clone(), - dep_name: dep_name_in_range.clone(), - dep_type: field_name.to_string(), - old_range: _range.clone(), - new_range: String::new(), // filled in during apply - }); - } - } - } - } - } - - Ok(updates) -} - -/// Determine if we should update internal dependencies based on config. -fn should_update_dependency(config: &InternalDepUpdate, bump_type: &BumpType) -> bool { - match config { - InternalDepUpdate::Always => true, - InternalDepUpdate::Never => false, - InternalDepUpdate::Patch => true, - InternalDepUpdate::Minor => bump_type.priority() >= BumpType::Minor.priority(), - InternalDepUpdate::Major => bump_type.priority() >= BumpType::Major.priority(), - } -} - -/// Apply the release plan: write package.json files, update changelogs, remove release files. -/// If `dry_run` is true, only print what would happen without writing. -pub fn apply_release_plan( - workspace: &Workspace, - plan: &ReleasePlan, - config: &OxrlsConfig, - release_dir: &Path, - dry_run: bool, - archive: bool, -) -> Result<()> { - if dry_run { - print_plan(plan); - return Ok(()); - } - - // Phase 1: Update package.json files - for (_name, bump) in &plan.bumps { - let pkg = workspace - .packages - .get(&bump.package_name) - .ok_or_else(|| OxrlsError::Bump(format!("Package '{}' not found", bump.package_name)))?; - - let pkg_path = pkg.dir.join("package.json"); - let mut pkg_json = PackageJson::read(&pkg_path)?; - pkg_json.set_version(&bump.new_version); - PackageJson::write(&pkg_path, &pkg_json)?; - - println!( - " {} {} -> {}", - bump.package_name, bump.old_version, bump.new_version - ); - - // Optionally sync version to Cargo.toml - if config.sync_cargo_toml { - let cargo_path = pkg.dir.join("Cargo.toml"); - if cargo_path.exists() { - let cargo_content = std::fs::read_to_string(&cargo_path) - .map_err(|e| OxrlsError::Bump(format!("Failed to read Cargo.toml: {}", e)))?; - // Replace version = "..." in the package section (only the first occurrence) - let new_cargo = cargo_content.replacen( - &format!("version = \"{}\"", bump.old_version), - &format!("version = \"{}\"", bump.new_version), - 1, - ); - std::fs::write(&cargo_path, new_cargo) - .map_err(|e| OxrlsError::Bump(format!("Failed to write Cargo.toml: {}", e)))?; - println!(" Cargo.toml version synced"); - } - } - } - - // Phase 2: Update internal dependency ranges - for update in &plan.internal_dep_updates { - let mut pkg_json = PackageJson::read(&update.dependent_package_path)?; - let field = match update.dep_type.as_str() { - "dependencies" => &mut pkg_json.dependencies, - "devDependencies" => &mut pkg_json.dev_dependencies, - "peerDependencies" => &mut pkg_json.peer_dependencies, - "optionalDependencies" => &mut pkg_json.optional_dependencies, - _ => continue, - }; - - if let Some(deps) = field - && let Some(current_range) = deps.get(&update.dep_name).cloned() - { - // Find the old and new versions for this dependency - if let Some(bump) = plan.bumps.get(&update.dep_name) { - let new_range = crate::package_json::compute_new_range( - ¤t_range, - &bump.old_version, - &bump.new_version, - ); - if new_range != current_range { - deps.insert(update.dep_name.clone(), new_range.clone()); - println!( - " {} {} ({}: {} -> {})", - update.dependent_package_name, - update.dep_name, - update.dep_type, - current_range, - new_range - ); - } - } - } - - PackageJson::write(&update.dependent_package_path, &pkg_json)?; - } - - // Phase 3: Update changelogs - let is_solo_repo = workspace.packages.len() <= 1; - let changelog_mode = config.changelog_mode(is_solo_repo); - - if changelog_mode.per_package { - for (_name, bump) in &plan.bumps { - let pkg = workspace - .packages - .get(&bump.package_name) - .ok_or_else(|| OxrlsError::Bump(format!("Package '{}' not found", bump.package_name)))?; - - let changelog_path = pkg.dir.join("CHANGELOG.md"); - - // Group summaries by type for this package - let mut type_summaries: IndexMap> = IndexMap::new(); - for rf_path in &bump.release_files { - if let Ok(rf) = parse_release_file(rf_path) - && let Some(bt) = rf.releases.get(&bump.package_name) - { - type_summaries - .entry(*bt) - .or_default() - .push(rf.summary.clone()); - } - } - - if type_summaries.is_empty() { - type_summaries.insert(bump.bump_type, bump.summaries.clone()); - } - - let entry = ChangelogEntry { - package_name: bump.package_name.clone(), - version: bump.new_version.to_string(), - changes: type_summaries, - }; - - let section = generate_changelog_section(&entry); - update_changelog(&changelog_path, §ion)?; - } - } - - if changelog_mode.global { - // Collect all bumped packages with their summaries for the global changelog - let global_packages: Vec<(String, semver::Version, BumpType, Vec)> = plan - .bumps - .values() - .map(|bump| { - ( - bump.package_name.clone(), - bump.new_version.clone(), - bump.bump_type, - bump.summaries.clone(), - ) - }) - .collect(); - - let global_section = generate_global_changelog_section(&global_packages); - if !global_section.is_empty() { - let global_changelog_path = workspace.root.join("CHANGELOG.md"); - update_global_changelog(&global_changelog_path, &global_section)?; - } - } - - // Save the release manifest for `oxrls release` - let manifest = ReleaseManifest::from_bumps(&plan.bumps); - manifest.save(release_dir)?; - - // Phase 4: Consume release files - // Build a set of package names that are in pre-release mode - let mut pre_release_pkgs: HashSet = HashSet::new(); - for (_name, bump) in &plan.bumps { - if !bump.new_version.pre.as_str().is_empty() { - pre_release_pkgs.insert(bump.package_name.clone()); - } - } - - // Collect release files that touch at least one pre-release package - let mut pre_release_files: HashSet = HashSet::new(); - for (_name, bump) in &plan.bumps { - if pre_release_pkgs.contains(&bump.package_name) { - for rf_path in &bump.release_files { - pre_release_files.insert(rf_path.clone()); - } - } - } - - // Closure to check if a package name is in pre-release mode - let is_pre = |name: &str| -> bool { pre_release_pkgs.contains(name) }; - - if archive { - let archive_dir = release_dir.join("archive"); - for (_name, bump) in &plan.bumps { - for rf_path in &bump.release_files { - if pre_release_files.contains(rf_path) { - crate::release_file::strip_stable_entries(rf_path, is_pre)?; - println!( - " {} (consumed β€” pre-release entries already in changelog)", - rf_path.display() - ); - continue; - } - crate::release_file::archive_release_file(rf_path, &archive_dir)?; - println!(" {} (archived)", rf_path.display()); - } - } - } else { - let mut consumed: HashSet = HashSet::new(); - for (_name, bump) in &plan.bumps { - for rf_path in &bump.release_files { - if pre_release_files.contains(rf_path) { - if consumed.insert(rf_path.clone()) { - crate::release_file::strip_stable_entries(rf_path, is_pre)?; - println!( - " {} (consumed β€” pre-release entries already in changelog)", - rf_path.display() - ); - } - continue; - } - if consumed.insert(rf_path.clone()) { - consume_release_file(rf_path)?; - println!(" {}", rf_path.display()); - } - } - } - } - - Ok(()) -} - -/// Print the release plan without making changes. -pub fn print_plan(plan: &ReleasePlan) { - println!("Bumped packages:"); - for (_name, bump) in &plan.bumps { - println!( - " {} {} β†’ {} ({})", - bump.package_name, - bump.old_version, - bump.new_version, - bump.bump_type_str() - ); - } - - if !plan.internal_dep_updates.is_empty() { - println!("\nUpdated internal dependencies:"); - for update in &plan.internal_dep_updates { - println!( - " {} package.json\n {} {} -> {}", - update.dependent_package_name, - update.dep_name, - update.old_range, - if update.new_range.is_empty() { - "(computed during apply)".to_string() - } else { - update.new_range.clone() - } - ); - } - } - - println!("\nConsumed release files:"); - let mut seen: HashSet<&PathBuf> = HashSet::new(); - for (_name, bump) in &plan.bumps { - for rf_path in &bump.release_files { - if seen.insert(rf_path) { - println!(" {}", rf_path.display()); - } - } - } -} - -/// Find all markdown files in the release directory. -pub fn find_release_files(release_dir: &Path) -> Result> { - if !release_dir.exists() { - return Ok(vec![]); - } - - let mut files = Vec::new(); - let entries = std::fs::read_dir(release_dir).map_err(OxrlsError::Io)?; - - for entry in entries { - let entry = entry.map_err(OxrlsError::Io)?; - let path = entry.path(); - if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) { - // Skip README.md - if path.file_stem().map(|s| s == "README").unwrap_or(false) { - continue; - } - files.push(path); - } - } - - files.sort(); - Ok(files) -} - -impl PlannedBump { - pub fn bump_type_str(&self) -> &str { - match self.bump_type { - BumpType::Patch => "patch", - BumpType::Minor => "minor", - BumpType::Major => "major", - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::workspace::load_workspace; - use tempfile::TempDir; - - fn create_test_workspace(tmp: &TempDir) -> Workspace { - // Root package.json - let root_pkg = serde_json::json!({ - "name": "root", - "version": "1.0.0", - "workspaces": ["packages/*"] - }); - std::fs::create_dir_all(tmp.path().join("packages/core")).unwrap(); - std::fs::create_dir_all(tmp.path().join("packages/react")).unwrap(); - std::fs::write( - tmp.path().join("package.json"), - serde_json::to_string_pretty(&root_pkg).unwrap(), - ) - .unwrap(); - - // Core package - let core_pkg = serde_json::json!({ - "name": "@scope/core", - "version": "1.2.3" - }); - std::fs::write( - tmp.path().join("packages/core/package.json"), - serde_json::to_string_pretty(&core_pkg).unwrap(), - ) - .unwrap(); - - // React package with dependency - let react_pkg = serde_json::json!({ - "name": "@scope/react", - "version": "1.0.0", - "dependencies": { - "@scope/core": "^1.2.3" - } - }); - std::fs::write( - tmp.path().join("packages/react/package.json"), - serde_json::to_string_pretty(&react_pkg).unwrap(), - ) - .unwrap(); - - load_workspace(tmp.path()).unwrap() - } - - #[test] - fn test_resolve_patterns_exact() { - let tmp = TempDir::new().unwrap(); - let ws = create_test_workspace(&tmp); - let patterns = vec!["@scope/core".to_string()]; - let resolved = resolve_group_patterns(&patterns, &ws.packages).unwrap(); - assert_eq!(resolved, vec!["@scope/core"]); - } - - #[test] - fn test_resolve_patterns_glob() { - let tmp = TempDir::new().unwrap(); - let ws = create_test_workspace(&tmp); - let patterns = vec!["@scope/*".to_string()]; - let resolved = resolve_group_patterns(&patterns, &ws.packages).unwrap(); - assert_eq!(resolved.len(), 2); - assert!(resolved.contains(&"@scope/core".to_string())); - assert!(resolved.contains(&"@scope/react".to_string())); - } - - #[test] - fn test_resolve_patterns_glob_with_negation() { - let tmp = TempDir::new().unwrap(); - let ws = create_test_workspace(&tmp); - let patterns = vec!["@scope/*".to_string(), "!@scope/core".to_string()]; - let resolved = resolve_group_patterns(&patterns, &ws.packages).unwrap(); - assert_eq!(resolved, vec!["@scope/react"]); - } - - #[test] - fn test_fixed_group_with_glob_patterns() { - let tmp = TempDir::new().unwrap(); - let _ = create_test_workspace(&tmp); - let workspace = load_workspace(tmp.path()).unwrap(); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - let content = r#"--- -"@scope/core": patch ---- - -Fix bug."#; - std::fs::write(release_dir.join("test.md"), content).unwrap(); - - // Fix all @scope/* packages β€” both should get the same version - let config = OxrlsConfig { - fixed: vec![vec!["@scope/*".to_string()]], - ..Default::default() - }; - - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - let core = plan.bumps.get("@scope/core").unwrap(); - let react = plan.bumps.get("@scope/react").unwrap(); - - assert_eq!(core.new_version, react.new_version); - assert_eq!(core.new_version, semver::Version::new(1, 2, 4)); - assert_eq!(react.new_version, semver::Version::new(1, 2, 4)); - } - - #[test] - fn test_fixed_group_with_glob_and_negation() { - let tmp = TempDir::new().unwrap(); - let _ = create_test_workspace(&tmp); - let workspace = load_workspace(tmp.path()).unwrap(); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - let content = r#"--- -"@scope/core": patch ---- - -Fix bug."#; - std::fs::write(release_dir.join("test.md"), content).unwrap(); - - // Fix all @scope/* EXCEPT react β€” only core should be affected - let config = OxrlsConfig { - fixed: vec![vec!["@scope/*".to_string(), "!@scope/react".to_string()]], - ..Default::default() - }; - - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - assert!(plan.bumps.contains_key("@scope/core")); - assert!(!plan.bumps.contains_key("@scope/react")); - } - - #[test] - fn test_build_release_plan() { - let tmp = TempDir::new().unwrap(); - let workspace = create_test_workspace(&tmp); - - // Create release dir and file - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - let content = r#"--- -"@scope/core": patch ---- - -Fix transaction mapping bug."#; - std::fs::write(release_dir.join("calm-blue-fox.md"), content).unwrap(); - - let config = OxrlsConfig::default(); - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - assert_eq!(plan.bumps.len(), 1); - let bump = plan.bumps.get("@scope/core").unwrap(); - assert_eq!(bump.old_version, semver::Version::new(1, 2, 3)); - assert_eq!(bump.new_version, semver::Version::new(1, 2, 4)); - assert_eq!(bump.bump_type, BumpType::Patch); - } - - #[test] - fn test_build_plan_missing_package() { - let tmp = TempDir::new().unwrap(); - let workspace = create_test_workspace(&tmp); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - let content = r#"--- -"@scope/missing": patch ---- - -Fix something."#; - std::fs::write(release_dir.join("bad.md"), content).unwrap(); - - let config = OxrlsConfig::default(); - let result = build_release_plan(&workspace, &config, &release_dir, false); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("@scope/missing")); - } - - #[test] - fn test_build_plan_bump_precedence() { - let tmp = TempDir::new().unwrap(); - let workspace = create_test_workspace(&tmp); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - // Two release files with different bump types for same package - let content1 = r#"--- -"@scope/core": patch ---- - -Fix bug."#; - std::fs::write(release_dir.join("file1.md"), content1).unwrap(); - - let content2 = r#"--- -"@scope/core": minor ---- - -Add feature."#; - std::fs::write(release_dir.join("file2.md"), content2).unwrap(); - - let config = OxrlsConfig::default(); - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - let bump = plan.bumps.get("@scope/core").unwrap(); - assert_eq!(bump.new_version, semver::Version::new(1, 3, 0)); // minor wins over patch - } - - #[test] - fn test_apply_release_plan_dry_run() { - let tmp = TempDir::new().unwrap(); - let workspace = create_test_workspace(&tmp); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - let content = r#"--- -"@scope/core": patch ---- - -Fix bug."#; - std::fs::write(release_dir.join("test.md"), content).unwrap(); - - let config = OxrlsConfig::default(); - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - // Dry run should not modify files - apply_release_plan(&workspace, &plan, &config, &release_dir, true, false).unwrap(); - - // Check version unchanged - let core_pkg = PackageJson::read(&tmp.path().join("packages/core/package.json")).unwrap(); - assert_eq!(core_pkg.version.as_deref(), Some("1.2.3")); - } - - #[test] - fn test_pre_release_version_in_plan() { - let tmp = TempDir::new().unwrap(); - let workspace = create_test_workspace(&tmp); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - let content = r#"--- -"@scope/core": patch ---- - -Fix bug."#; - std::fs::write(release_dir.join("test.md"), content).unwrap(); - - let config = OxrlsConfig { - pre_mode: vec![crate::config::PreModeEntry { - tag: "beta".to_string(), - packages: vec!["@scope/core".to_string()], - }], - ..Default::default() - }; - - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - let core = plan.bumps.get("@scope/core").unwrap(); - // Should be 1.2.4-beta.1 instead of 1.2.4 - assert_eq!(core.new_version.to_string(), "1.2.4-beta.1"); - - // Second bump should increment the counter - let content2 = r#"--- -"@scope/core": patch ---- - -Fix another bug."#; - std::fs::write(release_dir.join("test2.md"), content2).unwrap(); - - let plan2 = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - let core2 = plan2.bumps.get("@scope/core").unwrap(); - assert_eq!(core2.new_version.to_string(), "1.2.3-beta.2"); - } - - #[test] - fn test_pre_release_major_version() { - let tmp = TempDir::new().unwrap(); - let workspace = create_test_workspace(&tmp); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - let content = r#"--- -"@scope/core": major ---- - -Breaking change."#; - std::fs::write(release_dir.join("test.md"), content).unwrap(); - - let config = OxrlsConfig { - pre_mode: vec![crate::config::PreModeEntry { - tag: "rc".to_string(), - packages: vec!["@scope/core".to_string()], - }], - ..Default::default() - }; - - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - let core = plan.bumps.get("@scope/core").unwrap(); - // Major bump from 1.2.3 -> 2.0.0-rc.1 - assert_eq!(core.new_version.to_string(), "2.0.0-rc.1"); - assert_eq!(core.old_version.to_string(), "1.2.3"); - } - - #[test] - fn test_pre_release_does_not_affect_other_packages() { - let tmp = TempDir::new().unwrap(); - let workspace = create_test_workspace(&tmp); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - let content = r#"--- -"@scope/core": patch -"@scope/react": minor ---- - -Multiple changes."#; - std::fs::write(release_dir.join("test.md"), content).unwrap(); - - let config = OxrlsConfig { - pre_mode: vec![crate::config::PreModeEntry { - tag: "beta".to_string(), - packages: vec!["@scope/core".to_string()], - }], - ..Default::default() - }; - - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - let core = plan.bumps.get("@scope/core").unwrap(); - assert_eq!(core.new_version.to_string(), "1.2.4-beta.1"); - - let react = plan.bumps.get("@scope/react").unwrap(); - assert_eq!(react.new_version.to_string(), "1.1.0"); // no pre-release - } - - #[test] - fn test_fixed_group_constraint() { - let tmp = TempDir::new().unwrap(); - let _workspace = create_test_workspace(&tmp); - - // Add a third package in a fixed group with core - let utils_pkg = serde_json::json!({ - "name": "@scope/utils", - "version": "0.5.0" - }); - std::fs::create_dir_all(tmp.path().join("packages/utils")).unwrap(); - std::fs::write( - tmp.path().join("packages/utils/package.json"), - serde_json::to_string_pretty(&utils_pkg).unwrap(), - ) - .unwrap(); - - // Reload workspace to pick up the new package - let workspace = load_workspace(tmp.path()).unwrap(); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - let content = r#"--- -"@scope/core": patch ---- - -Fix bug."#; - std::fs::write(release_dir.join("test.md"), content).unwrap(); - - let config = OxrlsConfig { - fixed: vec![vec!["@scope/core".to_string(), "@scope/utils".to_string()]], - ..Default::default() - }; - - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - // Both packages should be bumped to the same version (based on highest old version) - let core = plan.bumps.get("@scope/core").unwrap(); - let utils = plan.bumps.get("@scope/utils").unwrap(); - - assert_eq!(core.new_version, utils.new_version); - // Highest old version is @scope/core 1.2.3, patched -> 1.2.4 - assert_eq!(core.new_version, semver::Version::new(1, 2, 4)); - assert_eq!(utils.new_version, semver::Version::new(1, 2, 4)); - } - - #[test] - fn test_fixed_group_uses_highest_old_version() { - let tmp = TempDir::new().unwrap(); - let _workspace = create_test_workspace(&tmp); - - // Add a package with a higher version - let utils_pkg = serde_json::json!({ - "name": "@scope/utils", - "version": "2.0.0" - }); - std::fs::create_dir_all(tmp.path().join("packages/utils")).unwrap(); - std::fs::write( - tmp.path().join("packages/utils/package.json"), - serde_json::to_string_pretty(&utils_pkg).unwrap(), - ) - .unwrap(); - - // Reload workspace to pick up the new package - let workspace = load_workspace(tmp.path()).unwrap(); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - let content = r#"--- -"@scope/utils": major ---- - -Breaking change."#; - std::fs::write(release_dir.join("test.md"), content).unwrap(); - - let config = OxrlsConfig { - fixed: vec![vec!["@scope/core".to_string(), "@scope/utils".to_string()]], - ..Default::default() - }; - - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - let core = plan.bumps.get("@scope/core").unwrap(); - let utils = plan.bumps.get("@scope/utils").unwrap(); - - // Both should be 3.0.0 (highest old version 2.0.0 + major bump = 3.0.0) - assert_eq!(core.new_version, semver::Version::new(3, 0, 0)); - assert_eq!(utils.new_version, semver::Version::new(3, 0, 0)); - } - - #[test] - fn test_linked_group_shares_bump_type() { - let tmp = TempDir::new().unwrap(); - let workspace = create_test_workspace(&tmp); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - // Core gets patch, react gets minor β€” linked group means both get the max (minor) - let content1 = r#"--- -"@scope/core": patch ---- - -Fix bug."#; - std::fs::write(release_dir.join("f1.md"), content1).unwrap(); - - let content2 = r#"--- -"@scope/react": minor ---- - -Add feature."#; - std::fs::write(release_dir.join("f2.md"), content2).unwrap(); - - let config = OxrlsConfig { - linked: vec![vec!["@scope/core".to_string(), "@scope/react".to_string()]], - ..Default::default() - }; - - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - let core = plan.bumps.get("@scope/core").unwrap(); - let react = plan.bumps.get("@scope/react").unwrap(); - - // Both should be minor bumps - assert_eq!(core.bump_type, BumpType::Minor); - assert_eq!(react.bump_type, BumpType::Minor); - // Core: 1.2.3 -> 1.3.0, React: 1.0.0 -> 1.1.0 - assert_eq!(core.new_version, semver::Version::new(1, 3, 0)); - assert_eq!(react.new_version, semver::Version::new(1, 1, 0)); - } - - #[test] - fn test_internal_dependency_updates() { - let tmp = TempDir::new().unwrap(); - let workspace = create_test_workspace(&tmp); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - let content = r#"--- -"@scope/core": patch ---- - -Fix bug."#; - std::fs::write(release_dir.join("test.md"), content).unwrap(); - - let config = OxrlsConfig::default(); - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - // Check that @scope/react's dependency on @scope/core is flagged for update - let has_core_update = plan - .internal_dep_updates - .iter() - .any(|u| u.dep_name == "@scope/core" && u.dependent_package_name == "@scope/react"); - assert!(has_core_update); - } - - #[test] - fn test_fixed_group_summary_does_not_chain() { - // When a fixed group pulls in multiple packages, the generated summary - // for each package should only list the directly-bumped ones, not a growing chain. - let tmp = TempDir::new().unwrap(); - let _workspace = create_test_workspace(&tmp); - - // Add two more packages to the fixed group - let pkg_a = serde_json::json!({ "name": "@scope/utils", "version": "0.1.0" }); - let pkg_b = serde_json::json!({ "name": "@scope/tools", "version": "0.1.0" }); - std::fs::create_dir_all(tmp.path().join("packages/utils")).unwrap(); - std::fs::create_dir_all(tmp.path().join("packages/tools")).unwrap(); - std::fs::write( - tmp.path().join("packages/utils/package.json"), - serde_json::to_string_pretty(&pkg_a).unwrap(), - ) - .unwrap(); - std::fs::write( - tmp.path().join("packages/tools/package.json"), - serde_json::to_string_pretty(&pkg_b).unwrap(), - ) - .unwrap(); - - let workspace = load_workspace(tmp.path()).unwrap(); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - // Only core has a release file β€” utils and tools are pulled in by the fixed group - let content = r#"--- -"@scope/core": minor ---- - -Completely rewritten core logic."#; - std::fs::write(release_dir.join("test.md"), content).unwrap(); - - let config = OxrlsConfig { - fixed: vec![vec![ - "@scope/core".to_string(), - "@scope/utils".to_string(), - "@scope/tools".to_string(), - ]], - ..Default::default() - }; - - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - let utils = plan.bumps.get("@scope/utils").unwrap(); - let tools = plan.bumps.get("@scope/tools").unwrap(); - - // Both should list ONLY @scope/core, not a growing chain - assert_eq!( - utils.summaries, - vec!["Updated with @scope/core.".to_string()] - ); - assert_eq!( - tools.summaries, - vec!["Updated with @scope/core.".to_string()] - ); - } - - #[test] - fn test_mixed_release_file_strips_stable_entries() { - // When a release file mentions both a pre-release and a stable package, - // the bump should strip the stable entries from the file so they don't - // repeat on the next bump, while keeping the pre-release entries. - let tmp = TempDir::new().unwrap(); - let workspace = create_test_workspace(&tmp); - - let release_dir = tmp.path().join(".oxrls"); - std::fs::create_dir_all(&release_dir).unwrap(); - - // Create a release file that references both a pre-release and a stable package - let content = r#"--- -"@scope/core": patch -"@scope/react": minor ---- - -Mixed changes for pre-release and stable."#; - let rf_path = release_dir.join("mixed.md"); - std::fs::write(&rf_path, content).unwrap(); - - let config = OxrlsConfig { - pre_mode: vec![crate::config::PreModeEntry { - tag: "beta".to_string(), - packages: vec!["@scope/core".to_string()], - }], - ..Default::default() - }; - - let plan = build_release_plan(&workspace, &config, &release_dir, false).unwrap(); - - // Verify both packages are in the plan - assert!(plan.bumps.contains_key("@scope/core")); - assert!(plan.bumps.contains_key("@scope/react")); - - // Core is in pre-mode -> should have a pre-release version - assert!( - plan - .bumps - .get("@scope/core") - .unwrap() - .new_version - .to_string() - .contains("beta") - ); - // React is not in pre-mode -> should have a normal version - assert!( - !plan - .bumps - .get("@scope/react") - .unwrap() - .new_version - .to_string() - .contains("beta") - ); - - // Apply the release plan - apply_release_plan(&workspace, &plan, &config, &release_dir, false, false).unwrap(); - - // The release file should be consumed β€” changelog already captured the content - assert!( - !rf_path.exists(), - "Release file should be consumed as pre-release entries were already captured" - ); - } -} diff --git a/src/bump/apply.rs b/src/bump/apply.rs new file mode 100644 index 0000000..dc43ac2 --- /dev/null +++ b/src/bump/apply.rs @@ -0,0 +1,276 @@ +use crate::bump::plan::ReleasePlan; +use crate::changelog::{ + ChangelogEntry, generate_changelog_section, generate_global_changelog_section, update_changelog, + update_global_changelog, +}; +use crate::config::OxrlsConfig; +use crate::error::{OxrlsError, Result}; +use crate::release::ReleaseManifest; +use crate::release_file::{BumpType, consume_release_file, parse_release_file}; +use crate::workspace::PackageJson; +use crate::workspace::Workspace; +use indexmap::IndexMap; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// Apply the release plan: write package.json files, update changelogs, remove release files. +/// If `dry_run` is true, only print what would happen without writing. +pub fn apply_release_plan( + workspace: &Workspace, + plan: &ReleasePlan, + config: &OxrlsConfig, + release_dir: &Path, + dry_run: bool, + archive: bool, +) -> Result<()> { + if dry_run { + print_plan(plan); + return Ok(()); + } + + // Phase 1: Update package.json files + for (_name, bump) in &plan.bumps { + let pkg = workspace + .packages + .get(&bump.package_name) + .ok_or_else(|| OxrlsError::Bump(format!("Package '{}' not found", bump.package_name)))?; + + let pkg_path = pkg.dir.join("package.json"); + let mut pkg_json = PackageJson::read(&pkg_path)?; + pkg_json.set_version(&bump.new_version); + PackageJson::write(&pkg_path, &pkg_json)?; + + println!( + " {} {} -> {}", + bump.package_name, bump.old_version, bump.new_version + ); + + // Optionally sync version to Cargo.toml + if config.sync_cargo_toml { + let cargo_path = pkg.dir.join("Cargo.toml"); + if cargo_path.exists() { + let cargo_content = std::fs::read_to_string(&cargo_path) + .map_err(|e| OxrlsError::Bump(format!("Failed to read Cargo.toml: {}", e)))?; + // Replace version = "..." in the package section (only the first occurrence) + let new_cargo = cargo_content.replacen( + &format!("version = \"{}\"", bump.old_version), + &format!("version = \"{}\"", bump.new_version), + 1, + ); + std::fs::write(&cargo_path, new_cargo) + .map_err(|e| OxrlsError::Bump(format!("Failed to write Cargo.toml: {}", e)))?; + println!(" Cargo.toml version synced"); + } + } + } + + // Phase 2: Update internal dependency ranges + for update in &plan.internal_dep_updates { + let mut pkg_json = PackageJson::read(&update.dependent_package_path)?; + let field = match update.dep_type.as_str() { + "dependencies" => &mut pkg_json.dependencies, + "devDependencies" => &mut pkg_json.dev_dependencies, + "peerDependencies" => &mut pkg_json.peer_dependencies, + "optionalDependencies" => &mut pkg_json.optional_dependencies, + _ => continue, + }; + + if let Some(deps) = field + && let Some(current_range) = deps.get(&update.dep_name).cloned() + { + // Find the old and new versions for this dependency + if let Some(bump) = plan.bumps.get(&update.dep_name) { + let new_range = + crate::workspace::compute_new_range(¤t_range, &bump.old_version, &bump.new_version); + if new_range != current_range { + deps.insert(update.dep_name.clone(), new_range.clone()); + println!( + " {} {} ({}: {} -> {})", + update.dependent_package_name, + update.dep_name, + update.dep_type, + current_range, + new_range + ); + } + } + } + + PackageJson::write(&update.dependent_package_path, &pkg_json)?; + } + + // Phase 3: Update changelogs + let is_solo_repo = workspace.packages.len() <= 1; + let changelog_mode = config.changelog_mode(is_solo_repo); + + if changelog_mode.per_package { + for (_name, bump) in &plan.bumps { + let pkg = workspace + .packages + .get(&bump.package_name) + .ok_or_else(|| OxrlsError::Bump(format!("Package '{}' not found", bump.package_name)))?; + + let changelog_path = pkg.dir.join("CHANGELOG.md"); + + // Group summaries by type for this package + let mut type_summaries: IndexMap> = IndexMap::new(); + for rf_path in &bump.release_files { + if let Ok(rf) = parse_release_file(rf_path) + && let Some(bt) = rf.releases.get(&bump.package_name) + { + type_summaries + .entry(*bt) + .or_default() + .push(rf.summary.clone()); + } + } + + if type_summaries.is_empty() { + type_summaries.insert(bump.bump_type, bump.summaries.clone()); + } + + let entry = ChangelogEntry { + package_name: bump.package_name.clone(), + version: bump.new_version.to_string(), + changes: type_summaries, + }; + + let section = generate_changelog_section(&entry); + update_changelog(&changelog_path, §ion)?; + } + } + + if changelog_mode.global { + // Collect all bumped packages with their summaries for the global changelog + let global_packages: Vec<(String, semver::Version, BumpType, Vec)> = plan + .bumps + .values() + .map(|bump| { + ( + bump.package_name.clone(), + bump.new_version.clone(), + bump.bump_type, + bump.summaries.clone(), + ) + }) + .collect(); + + let global_section = generate_global_changelog_section(&global_packages); + if !global_section.is_empty() { + let global_changelog_path = workspace.root.join("CHANGELOG.md"); + update_global_changelog(&global_changelog_path, &global_section)?; + } + } + + // Save the release manifest for `oxrls release` + let manifest = ReleaseManifest::from_bumps(&plan.bumps); + manifest.save(release_dir)?; + + // Phase 4: Consume release files + // Build a set of package names that are in pre-release mode + let mut pre_release_pkgs: HashSet = HashSet::new(); + for (_name, bump) in &plan.bumps { + if !bump.new_version.pre.as_str().is_empty() { + pre_release_pkgs.insert(bump.package_name.clone()); + } + } + + // Collect release files that touch at least one pre-release package + let mut pre_release_files: HashSet = HashSet::new(); + for (_name, bump) in &plan.bumps { + if pre_release_pkgs.contains(&bump.package_name) { + for rf_path in &bump.release_files { + pre_release_files.insert(rf_path.clone()); + } + } + } + + if archive { + let archive_dir = release_dir.join("archive"); + for (_name, bump) in &plan.bumps { + for rf_path in &bump.release_files { + if pre_release_files.contains(rf_path) { + // Pre-release entries were already recorded in the changelog + // during Phase 3, so just consume the file to avoid replaying. + consume_release_file(rf_path)?; + println!( + " {} (consumed β€” pre-release entries already in changelog)", + rf_path.display() + ); + continue; + } + crate::release_file::archive_release_file(rf_path, &archive_dir)?; + println!(" {} (archived)", rf_path.display()); + } + } + } else { + let mut consumed: HashSet = HashSet::new(); + for (_name, bump) in &plan.bumps { + for rf_path in &bump.release_files { + if pre_release_files.contains(rf_path) { + if consumed.insert(rf_path.clone()) { + // Pre-release entries already recorded in changelog. + consume_release_file(rf_path)?; + println!( + " {} (consumed β€” pre-release entries already in changelog)", + rf_path.display() + ); + } + continue; + } + if consumed.insert(rf_path.clone()) { + consume_release_file(rf_path)?; + println!(" {}", rf_path.display()); + } + } + } + } + + // Persist the pre-release counters only after all writes have succeeded. + // This is critical for atomicity β€” if an earlier step failed, the counters + // remain unchanged and a retry produces the same pre-release versions. + plan.pre_state.save(release_dir)?; + + Ok(()) +} + +/// Print the release plan without making changes. +pub fn print_plan(plan: &ReleasePlan) { + println!("Bumped packages:"); + for (_name, bump) in &plan.bumps { + println!( + " {} {} β†’ {} ({})", + bump.package_name, + bump.old_version, + bump.new_version, + bump.bump_type_str() + ); + } + + if !plan.internal_dep_updates.is_empty() { + println!("\nUpdated internal dependencies:"); + for update in &plan.internal_dep_updates { + println!( + " {} package.json\n {} {} -> {}", + update.dependent_package_name, + update.dep_name, + update.old_range, + if update.new_range.is_empty() { + "(computed during apply)".to_string() + } else { + update.new_range.clone() + } + ); + } + } + + println!("\nConsumed release files:"); + let mut seen: HashSet<&PathBuf> = HashSet::new(); + for (_name, bump) in &plan.bumps { + for rf_path in &bump.release_files { + if seen.insert(rf_path) { + println!(" {}", rf_path.display()); + } + } + } +} diff --git a/src/bump/deps.rs b/src/bump/deps.rs new file mode 100644 index 0000000..f1b923e --- /dev/null +++ b/src/bump/deps.rs @@ -0,0 +1,68 @@ +use crate::bump::plan::{InternalDepUpdateInfo, PlannedBump}; +use crate::config::{InternalDepUpdate, OxrlsConfig}; +use crate::error::Result; +use crate::release_file::BumpType; +use crate::workspace::Workspace; +use indexmap::IndexMap; + +/// Compute which internal dependency ranges need updating. +pub(crate) fn compute_internal_dep_updates( + workspace: &Workspace, + bumps: &IndexMap, + config: &OxrlsConfig, +) -> Result> { + let mut updates = Vec::new(); + + // Check all workspace packages for dependencies on bumped packages + for (dep_name, pkg) in &workspace.packages { + // If this package is itself bumped, no need to update self-dependency + // Check all dependency fields + let dep_fields: Vec<(&str, &Option>)> = vec![ + ("dependencies", &pkg.package_json.dependencies), + ("devDependencies", &pkg.package_json.dev_dependencies), + ("peerDependencies", &pkg.package_json.peer_dependencies), + ( + "optionalDependencies", + &pkg.package_json.optional_dependencies, + ), + ]; + + for (field_name, field) in &dep_fields { + let deps = match field { + Some(d) => d, + None => continue, + }; + + for (dep_name_in_range, _range) in deps { + // Check if this dependency is being bumped + if let Some(bump) = bumps.get(dep_name_in_range) { + // Check if we should update based on config + if should_update_dependency(&config.update_internal_dependencies, &bump.bump_type) { + // We'll compute the new range when applying + updates.push(InternalDepUpdateInfo { + dependent_package_path: pkg.dir.join("package.json"), + dependent_package_name: dep_name.clone(), + dep_name: dep_name_in_range.clone(), + dep_type: field_name.to_string(), + old_range: _range.clone(), + new_range: String::new(), // filled in during apply + }); + } + } + } + } + } + + Ok(updates) +} + +/// Determine if we should update internal dependencies based on config. +fn should_update_dependency(config: &InternalDepUpdate, bump_type: &BumpType) -> bool { + match config { + InternalDepUpdate::Always => true, + InternalDepUpdate::Never => false, + InternalDepUpdate::Patch => true, + InternalDepUpdate::Minor => bump_type.priority() >= BumpType::Minor.priority(), + InternalDepUpdate::Major => bump_type.priority() >= BumpType::Major.priority(), + } +} diff --git a/src/bump/discovery.rs b/src/bump/discovery.rs new file mode 100644 index 0000000..a8ae724 --- /dev/null +++ b/src/bump/discovery.rs @@ -0,0 +1,39 @@ +use crate::bump::plan::PlannedBump; +use crate::error::{OxrlsError, Result}; +use crate::release_file::BumpType; +use std::path::{Path, PathBuf}; + +/// Find all markdown files in the release directory. +pub fn find_release_files(release_dir: &Path) -> Result> { + if !release_dir.exists() { + return Ok(vec![]); + } + + let mut files = Vec::new(); + let entries = std::fs::read_dir(release_dir).map_err(OxrlsError::Io)?; + + for entry in entries { + let entry = entry.map_err(OxrlsError::Io)?; + let path = entry.path(); + if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) { + // Skip README.md + if path.file_stem().map(|s| s == "README").unwrap_or(false) { + continue; + } + files.push(path); + } + } + + files.sort(); + Ok(files) +} + +impl PlannedBump { + pub fn bump_type_str(&self) -> &str { + match self.bump_type { + BumpType::Patch => "patch", + BumpType::Minor => "minor", + BumpType::Major => "major", + } + } +} diff --git a/src/bump/groups.rs b/src/bump/groups.rs new file mode 100644 index 0000000..027fff0 --- /dev/null +++ b/src/bump/groups.rs @@ -0,0 +1,272 @@ +use crate::bump::plan::PlannedBump; +use crate::config::OxrlsConfig; +use crate::error::{OxrlsError, Result}; +use crate::release_file::BumpType; +use crate::version_bump::bump_version; +use crate::workspace::{Workspace, WorkspacePackage}; +use glob::Pattern; +use indexmap::IndexMap; +use std::collections::HashSet; +use std::path::PathBuf; + +/// Resolve a group of package name patterns (with optional `!` negation) +/// against the workspace package names. +/// +/// Supports glob patterns via `glob::Pattern`: +/// - `"@scope/*"` matches all packages under `@scope/` +/// - `"!@scope/special"` excludes `@scope/special` from the resolved set +/// +/// The resolution order is: all inclusions are applied first, then exclusions. +pub(crate) fn resolve_group_patterns( + patterns: &[String], + packages: &IndexMap, +) -> Result> { + if patterns.is_empty() { + return Ok(vec![]); + } + + // If no patterns use glob or negation, skip resolution and return as-is + let needs_resolution = patterns + .iter() + .any(|p| p.contains('*') || p.contains('?') || p.contains('[') || p.starts_with('!')); + + if !needs_resolution { + return Ok(patterns.to_vec()); + } + + let mut result: Vec = Vec::new(); + + // Phase 1: process inclusion patterns (no `!` prefix) + let inclusion_patterns: Vec<&str> = patterns + .iter() + .filter(|p| !p.starts_with('!')) + .map(|p| p.as_str()) + .collect(); + + if inclusion_patterns.is_empty() { + // No explicit inclusions means all packages + for name in packages.keys() { + result.push(name.clone()); + } + } else { + for pattern_str in &inclusion_patterns { + let pat = Pattern::new(pattern_str).map_err(|e| { + OxrlsError::Config(format!("Invalid glob pattern \"{}\": {}", pattern_str, e)) + })?; + for name in packages.keys() { + if pat.matches(name) { + result.push(name.clone()); + } + } + } + } + + // Phase 2: process exclusion patterns (prefixed with `!`) + let exclusion_patterns: Vec<&str> = patterns + .iter() + .filter(|p| p.starts_with('!')) + .map(|p| &p[1..]) + .collect(); + + if !exclusion_patterns.is_empty() { + result.retain(|name| { + !exclusion_patterns.iter().any(|pat_str| { + Pattern::new(pat_str) + .map(|pat| pat.matches(name)) + .unwrap_or_else(|e| { + eprintln!("Warning: invalid glob pattern \"!{}\": {}", pat_str, e); + false + }) + }) + }); + } + + result.sort(); + result.dedup(); + Ok(result) +} + +/// Apply fixed group constraints: all packages in a fixed group share the same version. +/// If any member of a fixed group is bumped, every member gets bumped to the same new version +/// (computed from the highest bump type Γ— the highest old version in the group). +/// +/// Supports glob patterns and `!` negation in group definitions: +/// ```json +/// { "fixed": [["@scope/*", "!@scope/special"]] } +/// ``` +pub(crate) fn apply_fixed_groups( + bumps: &mut IndexMap, + workspace: &Workspace, + config: &OxrlsConfig, +) -> Result<()> { + for group_patterns in &config.fixed { + if group_patterns.is_empty() { + continue; + } + + let group = resolve_group_patterns(group_patterns, &workspace.packages)?; + + // Collect current state of all group members + let mut group_bumps: Vec<(String, semver::Version, BumpType)> = Vec::new(); + let mut any_bumped = false; + + for pkg_name in &group { + if let Some(bump) = bumps.get(pkg_name) { + any_bumped = true; + group_bumps.push((pkg_name.clone(), bump.old_version.clone(), bump.bump_type)); + } else if let Some(pkg) = workspace.packages.get(pkg_name) + && let Ok(ver) = pkg.package_json.semver_version() + { + group_bumps.push((pkg_name.clone(), ver, BumpType::Patch)); + } + } + + // Only apply fixed constraint if at least one member was bumped + if !any_bumped { + continue; + } + + // Find the highest old version and max bump type in the group + let mut max_bump = BumpType::Patch; + let mut highest_old_version = semver::Version::new(0, 0, 0); + + for (_, old_ver, bump_type) in &group_bumps { + if old_ver > &highest_old_version { + highest_old_version = old_ver.clone(); + } + if bump_type.priority() > max_bump.priority() { + max_bump = *bump_type; + } + } + + // Compute the shared new version + let shared_new_version = bump_version(&highest_old_version, max_bump); + + // Collect summaries from the packages that were directly bumped in this group + let direct_summaries: Vec = group_bumps + .iter() + .filter(|(name, _, _)| bumps.contains_key(name.as_str())) + .flat_map(|(name, _, _)| { + bumps + .get(name) + .map(|b| b.summaries.clone()) + .unwrap_or_default() + }) + .collect(); + let direct_release_files: Vec = group_bumps + .iter() + .filter(|(name, _, _)| bumps.contains_key(name.as_str())) + .flat_map(|(name, _, _)| { + bumps + .get(name) + .map(|b| b.release_files.clone()) + .unwrap_or_default() + }) + .collect(); + + // Snapshot which packages were originally in the bump plan (before we mutate `bumps`) + let originally_bumped: HashSet<&str> = group_bumps + .iter() + .filter(|(name, _, _)| bumps.contains_key(name.as_str())) + .map(|(name, _, _)| name.as_str()) + .collect(); + + // Apply to all group members + for (pkg_name, old_ver, _) in &group_bumps { + let existing_summaries = bumps + .get(pkg_name) + .map(|b| b.summaries.clone()) + .unwrap_or_default(); + let existing_release_files = bumps + .get(pkg_name) + .map(|b| b.release_files.clone()) + .unwrap_or_default(); + + // If this package wasn't directly bumped (no release file entries), + // derive the summary from what was bumped in the group + let summaries = if existing_summaries.is_empty() { + let deps: Vec<&str> = originally_bumped + .iter() + .filter(|name| **name != *pkg_name) + .copied() + .collect(); + if !deps.is_empty() { + vec![format!("Updated with {}.", deps.join(", "))] + } else if !direct_summaries.is_empty() { + direct_summaries.clone() + } else { + vec!["Updated to match fixed group version.".to_string()] + } + } else { + existing_summaries + }; + + let release_files = if existing_release_files.is_empty() { + direct_release_files.clone() + } else { + existing_release_files + }; + + bumps.insert( + pkg_name.clone(), + PlannedBump { + package_name: pkg_name.clone(), + old_version: old_ver.clone(), + new_version: shared_new_version.clone(), + bump_type: max_bump, + summaries, + release_files, + }, + ); + } + } + + Ok(()) +} + +/// Apply linked group constraints: all packages in a linked group share the same bump type. +/// If any member of a linked group receives a bump, every other member in the group +/// that is also being bumped gets the highest bump type found in the group. +/// +/// Supports glob patterns and `!` negation in group definitions: +/// ```json +/// { "linked": [["@scope/*", "!@scope/special"]] } +/// ``` +pub(crate) fn apply_linked_groups( + bumps: &mut IndexMap, + workspace: &Workspace, + config: &OxrlsConfig, +) -> Result<()> { + for group_patterns in &config.linked { + if group_patterns.is_empty() { + continue; + } + + let group = resolve_group_patterns(group_patterns, &workspace.packages)?; + + // Find the max bump type among group members that are in the plan + let mut max_bump: Option = None; + for pkg_name in &group { + if let Some(bump) = bumps.get(pkg_name) { + max_bump = Some(BumpType::max(max_bump, bump.bump_type)); + } + } + + let max_bump = match max_bump { + Some(b) => b, + None => continue, + }; + + // Apply the max bump type to all group members that are in the plan + for pkg_name in &group { + if let Some(bump) = bumps.get_mut(pkg_name) + && bump.bump_type != max_bump + { + bump.bump_type = max_bump; + bump.new_version = bump_version(&bump.old_version, max_bump); + } + } + } + + Ok(()) +} diff --git a/src/bump/mod.rs b/src/bump/mod.rs new file mode 100644 index 0000000..d2f2a36 --- /dev/null +++ b/src/bump/mod.rs @@ -0,0 +1,707 @@ +mod apply; +mod deps; +mod discovery; +mod groups; +mod plan; + +// Public API β€” re-export everything that was `pub` in the original bump.rs +pub use apply::{apply_release_plan, print_plan}; +pub use discovery::find_release_files; +pub use plan::{InternalDepUpdateInfo, PlannedBump, ReleasePlan, build_release_plan}; + +// Internal items β€” re-exported as pub(crate) for cross-module access and tests +#[cfg(test)] +pub(crate) use groups::resolve_group_patterns; + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::OxrlsConfig; + use crate::release_file::BumpType; + use crate::workspace::PackageJson; + use crate::workspace::Workspace; + use crate::workspace::load_workspace; + use tempfile::TempDir; + + fn create_test_workspace(tmp: &TempDir) -> Workspace { + // Root package.json + let root_pkg = serde_json::json!({ + "name": "root", + "version": "1.0.0", + "workspaces": ["packages/*"] + }); + std::fs::create_dir_all(tmp.path().join("packages/core")).unwrap(); + std::fs::create_dir_all(tmp.path().join("packages/react")).unwrap(); + std::fs::write( + tmp.path().join("package.json"), + serde_json::to_string_pretty(&root_pkg).unwrap(), + ) + .unwrap(); + + // Core package + let core_pkg = serde_json::json!({ + "name": "@scope/core", + "version": "1.2.3" + }); + std::fs::write( + tmp.path().join("packages/core/package.json"), + serde_json::to_string_pretty(&core_pkg).unwrap(), + ) + .unwrap(); + + // React package with dependency + let react_pkg = serde_json::json!({ + "name": "@scope/react", + "version": "1.0.0", + "dependencies": { + "@scope/core": "^1.2.3" + } + }); + std::fs::write( + tmp.path().join("packages/react/package.json"), + serde_json::to_string_pretty(&react_pkg).unwrap(), + ) + .unwrap(); + + load_workspace(tmp.path()).unwrap() + } + + #[test] + fn test_resolve_patterns_exact() { + let tmp = TempDir::new().unwrap(); + let ws = create_test_workspace(&tmp); + let patterns = vec!["@scope/core".to_string()]; + let resolved = resolve_group_patterns(&patterns, &ws.packages).unwrap(); + assert_eq!(resolved, vec!["@scope/core"]); + } + + #[test] + fn test_resolve_patterns_glob() { + let tmp = TempDir::new().unwrap(); + let ws = create_test_workspace(&tmp); + let patterns = vec!["@scope/*".to_string()]; + let resolved = resolve_group_patterns(&patterns, &ws.packages).unwrap(); + assert_eq!(resolved.len(), 2); + assert!(resolved.contains(&"@scope/core".to_string())); + assert!(resolved.contains(&"@scope/react".to_string())); + } + + #[test] + fn test_resolve_patterns_glob_with_negation() { + let tmp = TempDir::new().unwrap(); + let ws = create_test_workspace(&tmp); + let patterns = vec!["@scope/*".to_string(), "!@scope/core".to_string()]; + let resolved = resolve_group_patterns(&patterns, &ws.packages).unwrap(); + assert_eq!(resolved, vec!["@scope/react"]); + } + + #[test] + fn test_fixed_group_with_glob_patterns() { + let tmp = TempDir::new().unwrap(); + let _ = create_test_workspace(&tmp); + let workspace = load_workspace(tmp.path()).unwrap(); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + let content = r#"--- +"@scope/core": patch +--- + +Fix bug."#; + std::fs::write(release_dir.join("test.md"), content).unwrap(); + + // Fix all @scope/* packages β€” both should get the same version + let config = OxrlsConfig { + fixed: vec![vec!["@scope/*".to_string()]], + ..Default::default() + }; + + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + let core = plan.bumps.get("@scope/core").unwrap(); + let react = plan.bumps.get("@scope/react").unwrap(); + + assert_eq!(core.new_version, react.new_version); + assert_eq!(core.new_version, semver::Version::new(1, 2, 4)); + assert_eq!(react.new_version, semver::Version::new(1, 2, 4)); + } + + #[test] + fn test_fixed_group_with_glob_and_negation() { + let tmp = TempDir::new().unwrap(); + let _ = create_test_workspace(&tmp); + let workspace = load_workspace(tmp.path()).unwrap(); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + let content = r#"--- +"@scope/core": patch +--- + +Fix bug."#; + std::fs::write(release_dir.join("test.md"), content).unwrap(); + + // Fix all @scope/* EXCEPT react β€” only core should be affected + let config = OxrlsConfig { + fixed: vec![vec!["@scope/*".to_string(), "!@scope/react".to_string()]], + ..Default::default() + }; + + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + assert!(plan.bumps.contains_key("@scope/core")); + assert!(!plan.bumps.contains_key("@scope/react")); + } + + #[test] + fn test_build_release_plan() { + let tmp = TempDir::new().unwrap(); + let workspace = create_test_workspace(&tmp); + + // Create release dir and file + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + let content = r#"--- +"@scope/core": patch +--- + +Fix transaction mapping bug."#; + std::fs::write(release_dir.join("calm-blue-fox.md"), content).unwrap(); + + let config = OxrlsConfig::default(); + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + assert_eq!(plan.bumps.len(), 1); + let bump = plan.bumps.get("@scope/core").unwrap(); + assert_eq!(bump.old_version, semver::Version::new(1, 2, 3)); + assert_eq!(bump.new_version, semver::Version::new(1, 2, 4)); + assert_eq!(bump.bump_type, BumpType::Patch); + } + + #[test] + fn test_build_plan_missing_package() { + let tmp = TempDir::new().unwrap(); + let workspace = create_test_workspace(&tmp); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + let content = r#"--- +"@scope/missing": patch +--- + +Fix something."#; + std::fs::write(release_dir.join("bad.md"), content).unwrap(); + + let config = OxrlsConfig::default(); + let result = build_release_plan(&workspace, &config, &release_dir); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("@scope/missing")); + } + + #[test] + fn test_build_plan_bump_precedence() { + let tmp = TempDir::new().unwrap(); + let workspace = create_test_workspace(&tmp); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + // Two release files with different bump types for same package + let content1 = r#"--- +"@scope/core": patch +--- + +Fix bug."#; + std::fs::write(release_dir.join("file1.md"), content1).unwrap(); + + let content2 = r#"--- +"@scope/core": minor +--- + +Add feature."#; + std::fs::write(release_dir.join("file2.md"), content2).unwrap(); + + let config = OxrlsConfig::default(); + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + let bump = plan.bumps.get("@scope/core").unwrap(); + assert_eq!(bump.new_version, semver::Version::new(1, 3, 0)); // minor wins over patch + } + + #[test] + fn test_apply_release_plan_dry_run() { + let tmp = TempDir::new().unwrap(); + let workspace = create_test_workspace(&tmp); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + let content = r#"--- +"@scope/core": patch +--- + +Fix bug."#; + std::fs::write(release_dir.join("test.md"), content).unwrap(); + + let config = OxrlsConfig::default(); + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + // Dry run should not modify files + apply_release_plan(&workspace, &plan, &config, &release_dir, true, false).unwrap(); + + // Check version unchanged + let core_pkg = PackageJson::read(&tmp.path().join("packages/core/package.json")).unwrap(); + assert_eq!(core_pkg.version.as_deref(), Some("1.2.3")); + } + + #[test] + fn test_pre_release_version_in_plan() { + let tmp = TempDir::new().unwrap(); + let workspace = create_test_workspace(&tmp); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + let content = r#"--- +"@scope/core": patch +--- + +Fix bug."#; + std::fs::write(release_dir.join("test.md"), content).unwrap(); + + let config = OxrlsConfig { + pre_mode: vec![crate::config::PreModeEntry { + tag: "beta".to_string(), + packages: vec!["@scope/core".to_string()], + }], + ..Default::default() + }; + + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + let core = plan.bumps.get("@scope/core").unwrap(); + // Should be 1.2.4-beta.1 instead of 1.2.4 + assert_eq!(core.new_version.to_string(), "1.2.4-beta.1"); + + // Persist the pre-state as `apply_release_plan` would do. + // Pre-release counters are only saved after a successful apply, + // so we simulate that here before the second plan build. + plan.pre_state.save(&release_dir).unwrap(); + + // Second bump should increment the counter + let content2 = r#"--- +"@scope/core": patch +--- + +Fix another bug."#; + std::fs::write(release_dir.join("test2.md"), content2).unwrap(); + + let plan2 = build_release_plan(&workspace, &config, &release_dir).unwrap(); + let core2 = plan2.bumps.get("@scope/core").unwrap(); + assert_eq!(core2.new_version.to_string(), "1.2.3-beta.2"); + } + + #[test] + fn test_pre_release_major_version() { + let tmp = TempDir::new().unwrap(); + let workspace = create_test_workspace(&tmp); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + let content = r#"--- +"@scope/core": major +--- + +Breaking change."#; + std::fs::write(release_dir.join("test.md"), content).unwrap(); + + let config = OxrlsConfig { + pre_mode: vec![crate::config::PreModeEntry { + tag: "rc".to_string(), + packages: vec!["@scope/core".to_string()], + }], + ..Default::default() + }; + + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + let core = plan.bumps.get("@scope/core").unwrap(); + // Major bump from 1.2.3 -> 2.0.0-rc.1 + assert_eq!(core.new_version.to_string(), "2.0.0-rc.1"); + assert_eq!(core.old_version.to_string(), "1.2.3"); + } + + #[test] + fn test_pre_release_does_not_affect_other_packages() { + let tmp = TempDir::new().unwrap(); + let workspace = create_test_workspace(&tmp); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + let content = r#"--- +"@scope/core": patch +"@scope/react": minor +--- + +Multiple changes."#; + std::fs::write(release_dir.join("test.md"), content).unwrap(); + + let config = OxrlsConfig { + pre_mode: vec![crate::config::PreModeEntry { + tag: "beta".to_string(), + packages: vec!["@scope/core".to_string()], + }], + ..Default::default() + }; + + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + let core = plan.bumps.get("@scope/core").unwrap(); + assert_eq!(core.new_version.to_string(), "1.2.4-beta.1"); + + let react = plan.bumps.get("@scope/react").unwrap(); + assert_eq!(react.new_version.to_string(), "1.1.0"); // no pre-release + } + + #[test] + fn test_fixed_group_constraint() { + let tmp = TempDir::new().unwrap(); + let _workspace = create_test_workspace(&tmp); + + // Add a third package in a fixed group with core + let utils_pkg = serde_json::json!({ + "name": "@scope/utils", + "version": "0.5.0" + }); + std::fs::create_dir_all(tmp.path().join("packages/utils")).unwrap(); + std::fs::write( + tmp.path().join("packages/utils/package.json"), + serde_json::to_string_pretty(&utils_pkg).unwrap(), + ) + .unwrap(); + + // Reload workspace to pick up the new package + let workspace = load_workspace(tmp.path()).unwrap(); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + let content = r#"--- +"@scope/core": patch +--- + +Fix bug."#; + std::fs::write(release_dir.join("test.md"), content).unwrap(); + + let config = OxrlsConfig { + fixed: vec![vec!["@scope/core".to_string(), "@scope/utils".to_string()]], + ..Default::default() + }; + + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + // Both packages should be bumped to the same version (based on highest old version) + let core = plan.bumps.get("@scope/core").unwrap(); + let utils = plan.bumps.get("@scope/utils").unwrap(); + + assert_eq!(core.new_version, utils.new_version); + // Highest old version is @scope/core 1.2.3, patched -> 1.2.4 + assert_eq!(core.new_version, semver::Version::new(1, 2, 4)); + assert_eq!(utils.new_version, semver::Version::new(1, 2, 4)); + } + + #[test] + fn test_fixed_group_uses_highest_old_version() { + let tmp = TempDir::new().unwrap(); + let _workspace = create_test_workspace(&tmp); + + // Add a package with a higher version + let utils_pkg = serde_json::json!({ + "name": "@scope/utils", + "version": "2.0.0" + }); + std::fs::create_dir_all(tmp.path().join("packages/utils")).unwrap(); + std::fs::write( + tmp.path().join("packages/utils/package.json"), + serde_json::to_string_pretty(&utils_pkg).unwrap(), + ) + .unwrap(); + + // Reload workspace to pick up the new package + let workspace = load_workspace(tmp.path()).unwrap(); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + let content = r#"--- +"@scope/utils": major +--- + +Breaking change."#; + std::fs::write(release_dir.join("test.md"), content).unwrap(); + + let config = OxrlsConfig { + fixed: vec![vec!["@scope/core".to_string(), "@scope/utils".to_string()]], + ..Default::default() + }; + + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + let core = plan.bumps.get("@scope/core").unwrap(); + let utils = plan.bumps.get("@scope/utils").unwrap(); + + // Both should be 3.0.0 (highest old version 2.0.0 + major bump = 3.0.0) + assert_eq!(core.new_version, semver::Version::new(3, 0, 0)); + assert_eq!(utils.new_version, semver::Version::new(3, 0, 0)); + } + + #[test] + fn test_linked_group_shares_bump_type() { + let tmp = TempDir::new().unwrap(); + let workspace = create_test_workspace(&tmp); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + // Core gets patch, react gets minor β€” linked group means both get the max (minor) + let content1 = r#"--- +"@scope/core": patch +--- + +Fix bug."#; + std::fs::write(release_dir.join("f1.md"), content1).unwrap(); + + let content2 = r#"--- +"@scope/react": minor +--- + +Add feature."#; + std::fs::write(release_dir.join("f2.md"), content2).unwrap(); + + let config = OxrlsConfig { + linked: vec![vec!["@scope/core".to_string(), "@scope/react".to_string()]], + ..Default::default() + }; + + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + let core = plan.bumps.get("@scope/core").unwrap(); + let react = plan.bumps.get("@scope/react").unwrap(); + + // Both should be minor bumps + assert_eq!(core.bump_type, BumpType::Minor); + assert_eq!(react.bump_type, BumpType::Minor); + // Core: 1.2.3 -> 1.3.0, React: 1.0.0 -> 1.1.0 + assert_eq!(core.new_version, semver::Version::new(1, 3, 0)); + assert_eq!(react.new_version, semver::Version::new(1, 1, 0)); + } + + #[test] + fn test_internal_dependency_updates() { + let tmp = TempDir::new().unwrap(); + let workspace = create_test_workspace(&tmp); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + let content = r#"--- +"@scope/core": patch +--- + +Fix bug."#; + std::fs::write(release_dir.join("test.md"), content).unwrap(); + + let config = OxrlsConfig::default(); + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + // Check that @scope/react's dependency on @scope/core is flagged for update + let has_core_update = plan + .internal_dep_updates + .iter() + .any(|u| u.dep_name == "@scope/core" && u.dependent_package_name == "@scope/react"); + assert!(has_core_update); + } + + #[test] + fn test_fixed_group_summary_does_not_chain() { + // When a fixed group pulls in multiple packages, the generated summary + // for each package should only list the directly-bumped ones, not a growing chain. + let tmp = TempDir::new().unwrap(); + let _workspace = create_test_workspace(&tmp); + + // Add two more packages to the fixed group + let pkg_a = serde_json::json!({ "name": "@scope/utils", "version": "0.1.0" }); + let pkg_b = serde_json::json!({ "name": "@scope/tools", "version": "0.1.0" }); + std::fs::create_dir_all(tmp.path().join("packages/utils")).unwrap(); + std::fs::create_dir_all(tmp.path().join("packages/tools")).unwrap(); + std::fs::write( + tmp.path().join("packages/utils/package.json"), + serde_json::to_string_pretty(&pkg_a).unwrap(), + ) + .unwrap(); + std::fs::write( + tmp.path().join("packages/tools/package.json"), + serde_json::to_string_pretty(&pkg_b).unwrap(), + ) + .unwrap(); + + let workspace = load_workspace(tmp.path()).unwrap(); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + // Only core has a release file β€” utils and tools are pulled in by the fixed group + let content = r#"--- +"@scope/core": minor +--- + +Completely rewritten core logic."#; + std::fs::write(release_dir.join("test.md"), content).unwrap(); + + let config = OxrlsConfig { + fixed: vec![vec![ + "@scope/core".to_string(), + "@scope/utils".to_string(), + "@scope/tools".to_string(), + ]], + ..Default::default() + }; + + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + let utils = plan.bumps.get("@scope/utils").unwrap(); + let tools = plan.bumps.get("@scope/tools").unwrap(); + + // Both should list ONLY @scope/core, not a growing chain + assert_eq!( + utils.summaries, + vec!["Updated with @scope/core.".to_string()] + ); + assert_eq!( + tools.summaries, + vec!["Updated with @scope/core.".to_string()] + ); + } + + #[test] + fn test_mixed_release_file_strips_stable_entries() { + // When a release file mentions both a pre-release and a stable package, + // the bump should strip the stable entries from the file so they don't + // repeat on the next bump, while keeping the pre-release entries. + let tmp = TempDir::new().unwrap(); + let workspace = create_test_workspace(&tmp); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + // Create a release file that references both a pre-release and a stable package + let content = r#"--- +"@scope/core": patch +"@scope/react": minor +--- + +Mixed changes for pre-release and stable."#; + let rf_path = release_dir.join("mixed.md"); + std::fs::write(&rf_path, content).unwrap(); + + let config = OxrlsConfig { + pre_mode: vec![crate::config::PreModeEntry { + tag: "beta".to_string(), + packages: vec!["@scope/core".to_string()], + }], + ..Default::default() + }; + + let plan = build_release_plan(&workspace, &config, &release_dir).unwrap(); + + // Verify both packages are in the plan + assert!(plan.bumps.contains_key("@scope/core")); + assert!(plan.bumps.contains_key("@scope/react")); + + // Core is in pre-mode -> should have a pre-release version + assert!( + plan + .bumps + .get("@scope/core") + .unwrap() + .new_version + .to_string() + .contains("beta") + ); + // React is not in pre-mode -> should have a normal version + assert!( + !plan + .bumps + .get("@scope/react") + .unwrap() + .new_version + .to_string() + .contains("beta") + ); + + // Apply the release plan + apply_release_plan(&workspace, &plan, &config, &release_dir, false, false).unwrap(); + + // The release file should be consumed β€” changelog already captured the content + assert!( + !rf_path.exists(), + "Release file should be consumed as pre-release entries were already captured" + ); + } + + #[test] + fn test_pre_state_not_saved_by_build_plan_alone() { + // When build_release_plan is called without a subsequent apply, + // the pre-release counters should NOT be persisted to disk. + // This ensures that if apply_release_plan fails, a retry + // produces the same pre-release version (counter stays unchanged + // until the apply actually succeeds). + let tmp = TempDir::new().unwrap(); + let workspace = create_test_workspace(&tmp); + + let release_dir = tmp.path().join(".oxrls"); + std::fs::create_dir_all(&release_dir).unwrap(); + + let content = r#"--- +"@scope/core": patch +--- + +Fix bug."#; + std::fs::write(release_dir.join("test.md"), content).unwrap(); + + let config = OxrlsConfig { + pre_mode: vec![crate::config::PreModeEntry { + tag: "beta".to_string(), + packages: vec!["@scope/core".to_string()], + }], + ..Default::default() + }; + + // First build: counter goes 0β†’1 in memory only + let plan1 = build_release_plan(&workspace, &config, &release_dir).unwrap(); + let core1 = plan1.bumps.get("@scope/core").unwrap(); + assert_eq!(core1.new_version.to_string(), "1.2.4-beta.1"); + + // Second build WITHOUT saving pre-state: + // if counters were persisted, we'd get beta.2. + // With the atomicity fix, we should still get beta.1 + // because the save only happens in apply_release_plan. + let plan2 = build_release_plan(&workspace, &config, &release_dir).unwrap(); + let core2 = plan2.bumps.get("@scope/core").unwrap(); + assert_eq!(core2.new_version.to_string(), "1.2.4-beta.1"); + + // Now simulate the apply: save the pre-state and verify + // the NEXT build sees the incremented counter. + plan2.pre_state.save(&release_dir).unwrap(); + let plan3 = build_release_plan(&workspace, &config, &release_dir).unwrap(); + let core3 = plan3.bumps.get("@scope/core").unwrap(); + assert_eq!(core3.new_version.to_string(), "1.2.3-beta.2"); + } +} diff --git a/src/bump/plan.rs b/src/bump/plan.rs new file mode 100644 index 0000000..f92ccc7 --- /dev/null +++ b/src/bump/plan.rs @@ -0,0 +1,165 @@ +use crate::bump::deps::compute_internal_dep_updates; +use crate::bump::discovery::find_release_files; +use crate::bump::groups::{apply_fixed_groups, apply_linked_groups}; +use crate::config::OxrlsConfig; +use crate::error::{OxrlsError, Result}; +use crate::prerelease::{PreState, apply_pre_release, resolve_pre_release}; +use crate::release_file::{BumpType, ReleaseFile, parse_release_file}; +use crate::version_bump::bump_version; +use crate::workspace::Workspace; +use indexmap::IndexMap; +use std::path::{Path, PathBuf}; + +/// A planned version bump for a single package. +#[derive(Debug, Clone)] +pub struct PlannedBump { + pub package_name: String, + pub old_version: semver::Version, + pub new_version: semver::Version, + pub bump_type: BumpType, + pub summaries: Vec, + /// The release files that caused this bump. + pub release_files: Vec, +} + +/// A complete release plan built from pending release files. +#[derive(Debug, Clone)] +pub struct ReleasePlan { + pub bumps: IndexMap, + pub internal_dep_updates: Vec, + /// The pre-release state after counter increments. + /// We carry this through to `apply_release_plan` so the counters + /// only get persisted after a successful apply (atomicity). + pub pre_state: PreState, +} + +/// Information about an internal dependency range update. +#[derive(Debug, Clone)] +pub struct InternalDepUpdateInfo { + pub dependent_package_path: PathBuf, + pub dependent_package_name: String, + pub dep_name: String, + pub dep_type: String, + pub old_range: String, + pub new_range: String, +} + +/// Build a release plan by reading all release files and computing bumps. +pub fn build_release_plan( + workspace: &Workspace, + config: &OxrlsConfig, + release_dir: &Path, +) -> Result { + // Find all release files + let release_files = find_release_files(release_dir)?; + + if release_files.is_empty() { + return Err(OxrlsError::Bump( + "No pending release files found.".to_string(), + )); + } + + // Parse all release files + let parsed: Vec = release_files + .iter() + .map(|path| parse_release_file(path)) + .collect::>>()?; + + // Validate all package references + for rf in &parsed { + for pkg_name in rf.releases.keys() { + if !workspace.packages.contains_key(pkg_name) { + return Err(OxrlsError::Bump(format!( + "Release file {} references \"{}\", but no workspace package with that name exists.", + rf.path.display(), + pkg_name + ))); + } + } + } + + // Merge bump types per package (highest priority wins) + let mut merged_bumps: IndexMap)> = IndexMap::new(); + for rf in &parsed { + for (pkg_name, bump_type) in &rf.releases { + let entry = merged_bumps + .entry(pkg_name.clone()) + .or_insert((*bump_type, vec![])); + entry.0 = BumpType::max(Some(entry.0), *bump_type); + entry.1.push(rf); + } + } + + // Compute new versions + let mut bumps: IndexMap = IndexMap::new(); + let mut pre_state = PreState::load(release_dir)?; + + for (pkg_name, (bump_type, refs)) in &merged_bumps { + let pkg = workspace + .packages + .get(pkg_name) + .ok_or_else(|| OxrlsError::Bump(format!("Package '{}' not found in workspace", pkg_name)))?; + + let old_version = pkg.package_json.semver_version()?; + + // Apply pre-release tag if the package is in pre-mode + // On the FIRST pre-release bump (count == 1), apply the bump type + // to the base version before adding the pre-release tag. + // Subsequent bumps only increment the pre-release counter. + let new_version = + if let Some((tag, count)) = resolve_pre_release(pkg_name, config, &mut pre_state) { + if count == 1 { + // First pre-release: bump the base version, then add pre-release tag + let bumped = bump_version(&old_version, *bump_type); + let base = semver::Version::new(bumped.major, bumped.minor, bumped.patch); + apply_pre_release(&base, &tag, count)? + } else { + // Subsequent pre-release: keep the base version, just increment counter + let base = semver::Version::new(old_version.major, old_version.minor, old_version.patch); + apply_pre_release(&base, &tag, count)? + } + } else { + // Normal bump β€” not in pre-release mode + bump_version(&old_version, *bump_type) + }; + + // Collect summaries from the release files that reference this package + let summaries: Vec = refs.iter().map(|rf| rf.summary.clone()).collect(); + + let release_files: Vec = refs.iter().map(|rf| rf.path.clone()).collect(); + + bumps.insert( + pkg_name.clone(), + PlannedBump { + package_name: pkg_name.clone(), + old_version, + new_version, + bump_type: *bump_type, + summaries, + release_files, + }, + ); + } + + // NOTE: We do NOT save pre_state here, even if !dry_run. + // The pre_state is carried forward in ReleasePlan and saved in + // `apply_release_plan` after all writes succeed. This ensures + // the counter is only persisted when the bump actually completes + // (atomicity β€” if applying the plan fails, the counter stays + // unchanged, so a retry produces the same pre-release version). + + // Apply fixed group constraints β€” all packages in a fixed group share the same version + apply_fixed_groups(&mut bumps, workspace, config)?; + + // Apply linked group constraints β€” all packages in a linked group share the same bump type + apply_linked_groups(&mut bumps, workspace, config)?; + + // Compute internal dependency updates + let internal_updates = compute_internal_dep_updates(workspace, &bumps, config)?; + + Ok(ReleasePlan { + bumps, + internal_dep_updates: internal_updates, + pre_state, + }) +} diff --git a/src/changelog.rs b/src/changelog.rs index c1d2de3..16566b3 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -14,6 +14,26 @@ pub struct ChangelogEntry { pub changes: IndexMap>, } +/// Format a multi-line change entry into a markdown list item. +/// The first line is prefixed with `first_line_prefix`, continuation lines +/// are indented by 2 spaces so they stay inside the list item. +fn indent_continuations(text: &str, first_line_prefix: &str) -> String { + text + .lines() + .enumerate() + .map(|(i, line)| { + if i == 0 { + format!("{}{}", first_line_prefix, line) + } else if line.trim().is_empty() { + String::new() + } else { + format!(" {}", line) + } + }) + .collect::>() + .join("\n") +} + /// Generate the changelog section string for a single version. pub fn generate_changelog_section(entry: &ChangelogEntry) -> String { let mut lines = Vec::new(); @@ -34,22 +54,7 @@ pub fn generate_changelog_section(entry: &ChangelogEntry) -> String { lines.push(format!("### {}", heading)); lines.push(String::new()); for change in changes { - // Indent continuation lines by 2 spaces so they stay in the list item - let indented: String = change - .lines() - .enumerate() - .map(|(i, line)| { - if i == 0 { - format!("- {}", line) - } else if line.trim().is_empty() { - String::new() - } else { - format!(" {}", line) - } - }) - .collect::>() - .join("\n"); - lines.push(indented); + lines.push(indent_continuations(change, "- ")); } lines.push(String::new()); } @@ -63,6 +68,25 @@ pub fn generate_changelog_section(entry: &ChangelogEntry) -> String { lines.join("\n") } +/// Strip the `# Changelog` header from existing changelog content. +/// Handles standard headers, missing trailing newlines, and case variations. +fn strip_changelog_header(content: &str) -> &str { + content + .strip_prefix("# Changelog\n\n") + .or_else(|| content.strip_prefix("# Changelog\n")) + .or_else(|| { + // Fallback: find any heading that starts with "# Changelog" + let trimmed = content.trim_start(); + if trimmed.to_lowercase().starts_with("# changelog") { + let end = trimmed.find('\n').map(|i| i + 1).unwrap_or(0); + Some(&trimmed[end..]) + } else { + None + } + }) + .unwrap_or(content) +} + /// Update a CHANGELOG.md file with a new version entry. /// Creates the file if it doesn't exist, prepends the new entry if it does. pub fn update_changelog(changelog_path: &Path, new_section: &str) -> Result<()> { @@ -70,10 +94,7 @@ pub fn update_changelog(changelog_path: &Path, new_section: &str) -> Result<()> let content = if changelog_path.exists() { let existing = std::fs::read_to_string(changelog_path) .map_err(|e| OxrlsError::Changelog(format!("Failed to read changelog: {}", e)))?; - let body = existing - .strip_prefix("# Changelog\n\n") - .or_else(|| existing.strip_prefix("# Changelog\n")) - .unwrap_or(&existing); + let body = strip_changelog_header(&existing); format!("# Changelog\n\n{}\n\n{}", new_section, body.trim()) } else { format!("# Changelog\n\n{}", new_section) @@ -128,21 +149,8 @@ pub fn generate_global_changelog_section( for (pkg_name, version, bump_type, summaries) in packages { for summary in summaries { - // Indent continuation lines by 2 spaces - let entry: String = summary - .lines() - .enumerate() - .map(|(i, line)| { - if i == 0 { - format!("- **{}** (v{}): {}", pkg_name, version, line) - } else if line.trim().is_empty() { - String::new() - } else { - format!(" {}", line) - } - }) - .collect::>() - .join("\n"); + let prefix = format!("- **{}** (v{}): ", pkg_name, version); + let entry = indent_continuations(summary, &prefix); match bump_type { BumpType::Major => major_entries.push(entry), BumpType::Minor => minor_entries.push(entry), @@ -189,10 +197,7 @@ pub fn update_global_changelog(changelog_path: &Path, new_section: &str) -> Resu let content = if changelog_path.exists() { let existing = std::fs::read_to_string(changelog_path) .map_err(|e| OxrlsError::Changelog(format!("Failed to read changelog: {}", e)))?; - let body = existing - .strip_prefix("# Changelog\n\n") - .or_else(|| existing.strip_prefix("# Changelog\n")) - .unwrap_or(&existing); + let body = strip_changelog_header(&existing); format!("# Changelog\n\n{}\n\n{}", new_section, body.trim()) } else { format!("# Changelog\n\n{}", new_section) @@ -275,4 +280,59 @@ mod tests { assert_eq!(grouped.get(&BumpType::Minor).unwrap().len(), 1); assert!(grouped.get(&BumpType::Major).is_none()); } + + #[test] + fn test_strip_changelog_header_standard() { + let content = "# Changelog\n\n## 1.0.0\n\n### Patch Changes\n\n- Fix bug\n"; + let body = strip_changelog_header(content); + assert_eq!(body, "## 1.0.0\n\n### Patch Changes\n\n- Fix bug\n"); + } + + #[test] + fn test_strip_changelog_header_no_trailing_newline() { + let content = "# Changelog\n## 1.0.0\n\n- Fix bug\n"; + let body = strip_changelog_header(content); + assert_eq!(body, "## 1.0.0\n\n- Fix bug\n"); + } + + #[test] + fn test_strip_changelog_header_case_insensitive() { + let content = "# CHANGELOG\n\n## 1.0.0\n\n- Fix bug\n"; + let body = strip_changelog_header(content); + // The fallback strips past the first \n (right after "CHANGELOG"), + // leaving the blank line which the caller's .trim() removes. + assert_eq!(body, "\n## 1.0.0\n\n- Fix bug\n"); + } + + #[test] + fn test_strip_changelog_header_no_header() { + let content = "## 1.0.0\n\n- Fix bug\n"; + let body = strip_changelog_header(content); + // No header to strip β€” returns original + assert_eq!(body, content); + } + + #[test] + fn test_indent_continuations_single_line() { + let result = indent_continuations("Fix bug", "- "); + assert_eq!(result, "- Fix bug"); + } + + #[test] + fn test_indent_continuations_multi_line() { + let result = indent_continuations("Fix bug\nWith details\nMore info", "- "); + assert_eq!(result, "- Fix bug\n With details\n More info"); + } + + #[test] + fn test_indent_continuations_empty_lines_skipped() { + let result = indent_continuations("Header\n\n\nTrailing", "- "); + assert_eq!(result, "- Header\n\n\n Trailing"); + } + + #[test] + fn test_indent_continuations_custom_prefix() { + let result = indent_continuations("Fix bug\nDetails", "- **pkg** (v1.0.0): "); + assert_eq!(result, "- **pkg** (v1.0.0): Fix bug\n Details"); + } } diff --git a/src/commands/bump_cmd.rs b/src/commands/bump_cmd.rs new file mode 100644 index 0000000..85a84a6 --- /dev/null +++ b/src/commands/bump_cmd.rs @@ -0,0 +1,65 @@ +//! `oxrls bump` β€” apply pending release files to workspace packages. +//! +//! Builds a release plan from all pending release files, then applies +//! version bumps and consumes the files. Supports `--dry-run` and `--archive`. + +use std::collections::HashSet; +use std::path::Path; + +use crate::bump::{apply_release_plan, build_release_plan, print_plan}; +use crate::config::OxrlsConfig; +use crate::error::Result; +use crate::workspace::{find_workspace_root, load_workspace}; + +pub fn cmd_bump(dry_run: bool, archive: bool) -> Result<()> { + let root = find_workspace_root(Path::new("."))?; + let workspace = load_workspace(&root)?; + + let (config, config_path) = OxrlsConfig::load(&root)?; + let release_dir = crate::get_release_dir(&root, &config, &config_path); + + let plan = build_release_plan(&workspace, &config, &release_dir)?; + + if dry_run { + println!("[DRY RUN] Would apply the following release plan:\n"); + print_plan(&plan); + return Ok(()); + } + + println!("Bumped packages:"); + for (_name, bump) in &plan.bumps { + println!( + " {} {} β†’ {} ({})", + bump.package_name, + bump.old_version, + bump.new_version, + bump.bump_type_str() + ); + } + let plan_clone = plan.clone(); + apply_release_plan(&workspace, &plan, &config, &release_dir, false, archive)?; + + if !plan_clone.internal_dep_updates.is_empty() { + println!("\nUpdated internal dependencies:"); + for update in &plan_clone.internal_dep_updates { + println!( + " {} {}", + update.dependent_package_path.display(), + update.dep_name + ); + } + } + + println!("\nConsumed release files:"); + let mut seen: HashSet = HashSet::new(); + for (_name, bump) in &plan_clone.bumps { + for rf_path in &bump.release_files { + if seen.insert(rf_path.clone()) { + println!(" {}", rf_path.display()); + } + } + } + + println!("\nDone!"); + Ok(()) +} diff --git a/src/commands/check.rs b/src/commands/check.rs new file mode 100644 index 0000000..e975170 --- /dev/null +++ b/src/commands/check.rs @@ -0,0 +1,35 @@ +//! `oxrls check` β€” CI-friendly status check for release readiness. +//! +//! Returns a `CheckStatus` indicating whether there are pending releases, +//! a ready release plan, or nothing to do. This is used by CI pipelines +//! to decide whether to trigger a publish step. + +use std::path::Path; + +use crate::bump::find_release_files; +use crate::config::OxrlsConfig; +use crate::error::Result; +use crate::release::ReleaseManifest; +use crate::workspace::find_workspace_root; + +pub fn cmd_check() -> Result { + let root = find_workspace_root(Path::new("."))?; + let (config, config_path) = OxrlsConfig::load(&root)?; + let release_dir = crate::get_release_dir(&root, &config, &config_path); + + let release_files = find_release_files(&release_dir).unwrap_or_default(); + let has_release_plan = ReleaseManifest::path(&release_dir).exists(); + + if !release_files.is_empty() { + println!("Release files exist, skip release"); + return Ok(crate::CheckStatus::PendingReleases); + } + + if has_release_plan { + println!("Release plan exists and files are clean, can release"); + return Ok(crate::CheckStatus::ReadyToRelease); + } + + println!("Nothing to release"); + Ok(crate::CheckStatus::NothingToRelease) +} diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..3ed1352 --- /dev/null +++ b/src/commands/init.rs @@ -0,0 +1,114 @@ +//! `oxrls init` β€” initialize oxrls configuration in a workspace. +//! +//! This command creates a config file (`.oxrls/config.json`) and a release +//! directory. It also runs an interactive wizard in non-`--force` mode. + +use std::path::Path; + +use glob::Pattern; + +use crate::config::OxrlsConfig; +use crate::error::{OxrlsError, Result}; +use crate::init_wizard::run_init_wizard; +use crate::workspace::{Workspace, find_workspace_root, load_workspace}; + +/// Resolve package name patterns against the workspace, returning matched package names. +/// Supports glob patterns (`*`/`?`), exact package name matches, and fuzzy suffix matching. +pub(crate) fn resolve_package_patterns( + patterns: &[String], + workspace: &Workspace, +) -> Result> { + let mut matched: Vec = Vec::new(); + for pattern in patterns { + if pattern.contains('*') || pattern.contains('?') { + let pat = Pattern::new(pattern) + .map_err(|e| OxrlsError::Config(format!("Invalid glob pattern: {}", e)))?; + for name in workspace.packages.keys() { + if pat.matches(name) && !matched.contains(name) { + matched.push(name.clone()); + } + } + } else if workspace.packages.contains_key(pattern) { + if !matched.contains(pattern) { + matched.push(pattern.clone()); + } + } else { + let matches: Vec = workspace + .packages + .keys() + .filter(|name| *name == pattern || name.ends_with(pattern.as_str())) + .cloned() + .collect(); + if matches.is_empty() { + return Err(OxrlsError::Config(format!( + "No workspace package matches \"{}\". Available packages:\n {}", + pattern, + workspace + .packages + .keys() + .cloned() + .collect::>() + .join("\n ") + ))); + } + for name in matches { + if !matched.contains(&name) { + matched.push(name); + } + } + } + } + if matched.is_empty() { + return Err(OxrlsError::Config( + "No packages matched the given patterns.".to_string(), + )); + } + Ok(matched) +} + +pub fn cmd_init(force: bool, release_dir: Option<&str>, non_interactive: bool) -> Result<()> { + let cwd = std::env::current_dir().map_err(OxrlsError::Io)?; + + let root = find_workspace_root(Path::new(".")).map_err(|e| { + OxrlsError::Config(format!( + "No workspace found: {}. Run from a repo with a package.json.", + e + )) + })?; + let workspace = load_workspace(&root)?; + let is_monorepo = workspace.packages.len() > 1; + + let config_path = cwd.join(".oxrls").join("config.json"); + + let mut config = OxrlsConfig::default(); + + if let Some(dir) = release_dir { + config.release_dir = dir.to_string(); + } + + if !non_interactive { + run_init_wizard(&mut config, &workspace, is_monorepo)?; + } + + OxrlsConfig::write_to(&config_path, &config, force)?; + println!("Created config file: {}", config_path.display()); + + let release_dir = cwd.join(&config.release_dir); + std::fs::create_dir_all(&release_dir) + .map_err(|e| OxrlsError::Config(format!("Failed to create release dir: {}", e)))?; + + let readme_path = release_dir.join("README.md"); + if !readme_path.exists() { + let readme_content = format!( + "# {} Release Files\n\nThis directory contains pending release files.\n\ + Use `oxrls new` to create a release file and `oxrls bump` to apply them.\n", + config.release_dir.trim_start_matches('.') + ); + std::fs::write(&readme_path, readme_content) + .map_err(|e| OxrlsError::Config(format!("Failed to create README: {}", e)))?; + println!("Created release directory: {}", release_dir.display()); + } + + println!("\noxrls is ready! Use `oxrls new` to create a release file."); + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..6e57b30 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,21 @@ +//! CLI command implementations. +//! +//! Each submodule implements one command (or family of related commands). +//! This module re-exports all public command functions. + +mod bump_cmd; +mod check; +mod init; +mod new; +mod pre; +mod release; +mod status; + +pub use bump_cmd::cmd_bump; +pub use check::cmd_check; +pub use init::cmd_init; +pub(crate) use init::resolve_package_patterns; +pub use new::cmd_new; +pub use pre::{cmd_pre_enter, cmd_pre_exit, cmd_pre_interactive, cmd_pre_status}; +pub use release::cmd_release; +pub use status::cmd_status; diff --git a/src/commands/new.rs b/src/commands/new.rs new file mode 100644 index 0000000..93146e6 --- /dev/null +++ b/src/commands/new.rs @@ -0,0 +1,94 @@ +//! `oxrls new` β€” create a new release file for one or more packages. +//! +//! In interactive mode (no package arguments) it prompts for packages, +//! bump type, summary, and details. In CLI mode it accepts +//! `@scope/pkg:bumptype` pairs plus `--summary` / `--details`. + +use std::path::Path; + +use indexmap::IndexMap; +use inquire::{MultiSelect, Select, Text}; + +use crate::config::OxrlsConfig; +use crate::error::{OxrlsError, Result}; +use crate::release_file::{BumpType, create_release_file}; +use crate::workspace::{find_workspace_root, load_workspace}; + +pub fn cmd_new(packages: &[String], summary: Option<&str>, details: Option<&str>) -> Result<()> { + let root = find_workspace_root(Path::new("."))?; + let workspace = load_workspace(&root)?; + + let (config, config_path) = OxrlsConfig::load(&root)?; + let release_dir = crate::get_release_dir(&root, &config, &config_path); + + let releases: IndexMap; + + if packages.is_empty() { + if workspace.packages.is_empty() { + return Err(OxrlsError::ReleaseFile( + "No workspace packages found. Run `oxrls init` first or add packages.".to_string(), + )); + } + + let package_names: Vec<&String> = workspace.packages.keys().collect(); + + let selected = MultiSelect::new("Which packages changed?", package_names) + .prompt() + .map_err(|e| OxrlsError::Other(format!("Selection failed: {}", e)))?; + + if selected.is_empty() { + return Err(OxrlsError::ReleaseFile("No packages selected.".to_string())); + } + + let bump_options = vec!["patch", "minor", "major"]; + let bump = Select::new("Bump type for all selected packages:", bump_options) + .prompt() + .map_err(|e| OxrlsError::Other(format!("Selection failed: {}", e)))?; + let bump_type: BumpType = bump.parse::()?; + + let mut releases_map: IndexMap = IndexMap::new(); + for pkg_name in selected { + releases_map.insert(pkg_name.clone(), bump_type); + } + + let summary_text = Text::new("Summary of the change:") + .prompt() + .map_err(|e| OxrlsError::Other(format!("Input failed: {}", e)))?; + + let details_text: Option = Text::new("Optional details (enter to skip):") + .prompt() + .ok() + .filter(|s: &String| !s.is_empty()); + + releases = releases_map; + let path = create_release_file( + &release_dir, + &releases, + &summary_text, + details_text.as_deref(), + )?; + println!("\nCreated release file: {}", path.display()); + } else { + let mut releases_map: IndexMap = IndexMap::new(); + for pkg_arg in packages { + let parts: Vec<&str> = pkg_arg.splitn(2, ':').collect(); + let pkg_name = parts[0]; + let bump_str = parts.get(1).ok_or_else(|| { + OxrlsError::ReleaseFile(format!( + "Invalid package format: \"{}\". Expected format: \"@scope/pkg:bumptype\"", + pkg_arg + )) + })?; + let bump_type = bump_str.parse::()?; + releases_map.insert(pkg_name.to_string(), bump_type); + } + + let summary_text = summary.unwrap_or("No summary provided."); + + releases = releases_map; + let path = create_release_file(&release_dir, &releases, summary_text, details)?; + println!("Created release file: {}", path.display()); + } + + Ok(()) +} diff --git a/src/commands/pre.rs b/src/commands/pre.rs new file mode 100644 index 0000000..ffdf186 --- /dev/null +++ b/src/commands/pre.rs @@ -0,0 +1,199 @@ +//! `oxrls pre` β€” pre-release mode management. +//! +//! Commands for entering, exiting, and inspecting pre-release mode. +//! Pre-release mode applies pre-release version suffixes (e.g. `-beta.1`) +//! to version bumps for packages configured to a given tag. + +use std::path::Path; + +use inquire::{MultiSelect, Text}; + +use crate::config::{OxrlsConfig, PreModeEntry}; +use crate::error::{OxrlsError, Result}; +use crate::prerelease::PreState; +use crate::workspace::{find_workspace_root, load_workspace}; + +pub fn cmd_pre_enter(tag: &str, package_patterns: &[String], force: bool) -> Result<()> { + let root = find_workspace_root(Path::new("."))?; + let workspace = load_workspace(&root)?; + + let (mut config, config_path) = OxrlsConfig::load(&root)?; + if config_path.as_os_str().is_empty() { + return Err(OxrlsError::Config( + "No oxrls.json found. Run `oxrls init` first.".to_string(), + )); + } + + let resolved_packages = crate::commands::resolve_package_patterns(package_patterns, &workspace)?; + + let entry_idx = config.pre_mode.iter().position(|e| e.tag == tag); + let idx = if let Some(i) = entry_idx { + i + } else { + config.pre_mode.push(PreModeEntry { + tag: tag.to_string(), + packages: vec![], + }); + config.pre_mode.len() - 1 + }; + + if !force { + for pkg_name in &resolved_packages { + for (other_idx, entry) in config.pre_mode.iter().enumerate() { + if other_idx == idx { + continue; + } + if entry.packages.iter().any(|p| p == pkg_name) { + return Err(OxrlsError::Config(format!( + "Package \"{}\" is already in pre-mode \"{}\". Use --force to migrate.", + pkg_name, entry.tag + ))); + } + } + } + } + + if force { + for other_entry in &mut config.pre_mode { + other_entry + .packages + .retain(|p| !resolved_packages.contains(p)); + } + let release_dir = crate::get_release_dir(&root, &config, &config_path); + let mut pre_state = PreState::load(&release_dir)?; + for pkg_name in &resolved_packages { + if pre_state.is_in_pre(pkg_name) + && let Some(entry) = pre_state.pre_versions.get(pkg_name) + && entry.tag != tag + { + pre_state.remove(pkg_name); + } + } + pre_state.save(&release_dir)?; + } + + let entry = &mut config.pre_mode[idx]; + for pkg_name in &resolved_packages { + if !entry.packages.contains(pkg_name) { + entry.packages.push(pkg_name.clone()); + } + } + + OxrlsConfig::write_to(&config_path, &config, true)?; + + println!( + "Entered pre-release mode \"{}\" for {} package(s):", + tag, + resolved_packages.len() + ); + for pkg_name in &resolved_packages { + println!(" {}", pkg_name); + } + + Ok(()) +} + +pub fn cmd_pre_exit(package_patterns: &[String]) -> Result<()> { + let root = find_workspace_root(Path::new("."))?; + let workspace = load_workspace(&root)?; + + let (mut config, config_path) = OxrlsConfig::load(&root)?; + if config_path.as_os_str().is_empty() { + return Err(OxrlsError::Config("No oxrls.json found.".to_string())); + } + + let to_remove = crate::commands::resolve_package_patterns(package_patterns, &workspace)?; + + for entry in &mut config.pre_mode { + entry.packages.retain(|p| !to_remove.contains(p)); + } + config.pre_mode.retain(|e| !e.packages.is_empty()); + + OxrlsConfig::write_to(&config_path, &config, true)?; + + let release_dir = crate::get_release_dir(&root, &config, &config_path); + let mut pre_state = PreState::load(&release_dir)?; + for pkg_name in &to_remove { + pre_state.remove(pkg_name); + } + pre_state.save(&release_dir)?; + + println!( + "Exited pre-release mode for {} package(s):", + to_remove.len() + ); + for pkg_name in &to_remove { + println!(" {}", pkg_name); + } + + Ok(()) +} + +pub fn cmd_pre_status() -> Result<()> { + let root = find_workspace_root(Path::new("."))?; + + let (config, config_path) = OxrlsConfig::load(&root)?; + let release_dir = crate::get_release_dir(&root, &config, &config_path); + + if config.pre_mode.is_empty() { + println!("No pre-release mode configured."); + return Ok(()); + } + + let pre_state = PreState::load(&release_dir)?; + + println!("Pre-release mode:\n"); + for entry in &config.pre_mode { + println!(" Tag \"{}\":", entry.tag); + for pattern in &entry.packages { + println!(" - {}", pattern); + } + println!(); + } + + if !pre_state.pre_versions.is_empty() { + println!("Current pre-release counters:\n"); + for (pkg, pve) in &pre_state.pre_versions { + println!(" {} {} (counter: {})", pkg, pve.tag, pve.count); + } + } + + Ok(()) +} + +pub fn cmd_pre_interactive() -> Result<()> { + let root = find_workspace_root(Path::new("."))?; + let workspace = load_workspace(&root)?; + + if workspace.packages.is_empty() { + return Err(OxrlsError::Config( + "No packages found in workspace.".to_string(), + )); + } + + let package_names: Vec<&String> = workspace.packages.keys().collect(); + let selected = MultiSelect::new( + "Which packages should enter pre-release mode?", + package_names, + ) + .prompt() + .map_err(|e| OxrlsError::Other(format!("Selection failed: {}", e)))?; + + if selected.is_empty() { + return Err(OxrlsError::Other("No packages selected.".to_string())); + } + + let tag = Text::new("Pre-release tag (e.g., beta, alpha, rc):") + .with_placeholder("beta") + .prompt() + .map_err(|e| OxrlsError::Other(format!("Input failed: {}", e)))?; + + let tag = if tag.trim().is_empty() { + "beta".to_string() + } else { + tag.trim().to_lowercase() + }; + + let package_patterns: Vec = selected.iter().map(|s| (*s).clone()).collect(); + cmd_pre_enter(&tag, &package_patterns, false) +} diff --git a/src/commands/release.rs b/src/commands/release.rs new file mode 100644 index 0000000..afe971c --- /dev/null +++ b/src/commands/release.rs @@ -0,0 +1,36 @@ +//! `oxrls release` β€” publish packages from a release manifest. +//! +//! Reads the release manifest created by a previous `oxrls bump` and +//! publishes each package. Supports `--dry-run` and `--tag` override. + +use std::path::Path; + +use crate::config::OxrlsConfig; +use crate::error::Result; +use crate::release::{ReleaseManifest, publish_manifest}; +use crate::workspace::{find_workspace_root, load_workspace}; + +pub fn cmd_release(dry_run: bool, tag_override: Option<&str>) -> Result<()> { + let root = find_workspace_root(Path::new("."))?; + let workspace = load_workspace(&root)?; + + let (config, config_path) = OxrlsConfig::load(&root)?; + let release_dir = crate::get_release_dir(&root, &config, &config_path); + + let manifest = ReleaseManifest::load(&release_dir)?; + + if manifest.packages.is_empty() { + println!("No packages to release."); + return Ok(()); + } + + println!("Releasing {} package(s):\n", manifest.packages.len()); + publish_manifest( + &manifest, + &workspace, + &config, + &release_dir, + dry_run, + tag_override, + ) +} diff --git a/src/commands/status.rs b/src/commands/status.rs new file mode 100644 index 0000000..26ee59f --- /dev/null +++ b/src/commands/status.rs @@ -0,0 +1,88 @@ +//! `oxrls status` β€” display pending release files and calculated bumps. +//! +//! Lists all unreleased release files and shows what version bumps +//! would be applied by `oxrls bump`. + +use std::path::Path; + +use crate::bump::{build_release_plan, find_release_files}; +use crate::config::OxrlsConfig; +use crate::error::Result; +use crate::release_file::parse_release_file; +use crate::workspace::{find_workspace_root, load_workspace}; + +pub fn cmd_status() -> Result<()> { + let root = find_workspace_root(Path::new("."))?; + let workspace = load_workspace(&root)?; + + let (config, config_path) = OxrlsConfig::load(&root)?; + let release_dir = crate::get_release_dir(&root, &config, &config_path); + + let files = find_release_files(&release_dir)?; + + if files.is_empty() { + println!( + "No pending release files found in {}.\n", + release_dir.display() + ); + return Ok(()); + } + + println!("Pending release files:\n"); + + for file_path in &files { + match parse_release_file(file_path) { + Ok(rf) => { + let fname = file_path + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + println!(" {}", fname); + for (pkg, bump) in &rf.releases { + println!(" {} {}", pkg, bump); + } + } + Err(e) => { + eprintln!(" ERROR parsing {}: {}", file_path.display(), e); + } + } + } + + println!(); + + match build_release_plan(&workspace, &config, &release_dir) { + Ok(plan) => { + let _max_name = plan + .bumps + .values() + .map(|b| b.package_name.len()) + .max() + .unwrap_or(20); + println!("Calculated bumps:"); + for (_name, bump) in &plan.bumps { + println!( + " {} {} β†’ {} ({})", + bump.package_name, + bump.old_version, + bump.new_version, + bump.bump_type_str() + ); + } + if !plan.internal_dep_updates.is_empty() { + println!("\nInternal dependency updates:\n"); + for update in &plan.internal_dep_updates { + println!( + " {} will update {} ({})", + update.dependent_package_name, update.dep_name, update.dep_type + ); + } + } + } + Err(e) => { + eprintln!("\nCould not calculate bumps: {}", e); + return Err(e); + } + } + + Ok(()) +} diff --git a/src/init_wizard.rs b/src/init_wizard.rs index 70bc340..ea2eeaf 100644 --- a/src/init_wizard.rs +++ b/src/init_wizard.rs @@ -78,12 +78,16 @@ pub fn run_init_wizard( }; // 5. Access - let _access = Select::new( + let access = Select::new( "Default npm access (can be overridden per-package via publishConfig.access):", vec!["public", "restricted"], ) .prompt() .map_err(|e| OxrlsError::Other(format!("Input failed: {}", e)))?; + config.access = match access { + "restricted" => crate::config::Access::Restricted, + _ => crate::config::Access::Public, + }; // 6. Sync Cargo.toml let sync_cargo = Confirm::new( "Sync version with Cargo.toml files alongside package.json? (useful for Rust/NAPI projects)", diff --git a/src/lib.rs b/src/lib.rs index 2fffd7a..2d5f2e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,11 +5,11 @@ use napi_derive::napi; pub mod bump; pub mod changelog; pub mod cli; +pub mod commands; pub mod config; pub mod error; pub mod init_wizard; -pub mod package_json; -pub mod premode; +pub mod prerelease; pub mod release; pub mod release_file; pub mod version_bump; @@ -18,55 +18,98 @@ pub mod workspace; use std::path::Path; use clap::Parser; -use glob::Pattern; -use indexmap::IndexMap; -use inquire::{MultiSelect, Select, Text}; -use crate::bump::{apply_release_plan, build_release_plan, find_release_files, print_plan}; use crate::cli::{Cli, Commands, PreAction}; -use crate::config::{OxrlsConfig, PreModeEntry}; +use crate::config::OxrlsConfig; use crate::error::{OxrlsError, Result}; -use crate::init_wizard::run_init_wizard; -use crate::package_json::PackageJson; -use crate::premode::PreState; -use crate::release::{ReleaseManifest, publish_manifest}; -use crate::release_file::{BumpType, create_release_file, parse_release_file}; -use crate::workspace::{Workspace, find_workspace_root, load_workspace}; + +// Bring all command functions into scope +use crate::commands::*; + +/// Result of the `check` command β€” determines what CI should do. +/// These replace exit-code-based signaling that was previously done +/// with `std::process::exit()` in library code (which is dangerous +/// when called from NAPI β€” it would kill the entire Node.js process). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckStatus { + /// Release files exist β†’ don't release yet, there are pending changes. + PendingReleases, + /// Release plan exists and files are clean β†’ ready to publish. + ReadyToRelease, + /// No pending releases β†’ nothing to do. + NothingToRelease, +} + +/// The result of running a command β€” used to carry extra info like +/// exit codes from `check` up to the caller (main.rs) without using +/// `process::exit()` inside library functions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CmdResult { + /// Command completed normally. + Ok, + /// The `check` command ran and produced a status. + CheckStatus(CheckStatus), +} /// Run the CLI with the given argument list. /// Shared by both the standalone binary (main.rs) and the NAPI entry point. -pub fn run_with_args(args: I) -> Result<()> +/// Returns a `CmdResult` so that commands like `check` can signal +/// exit-code-style results (e.g., "ready to publish") without calling +/// `std::process::exit()` inside library code. +pub fn run_with_args(args: I) -> Result where I: IntoIterator, S: Into + Clone, { - let cli = Cli::parse_from(args); + let cli = Cli::try_parse_from(args).map_err(|e| { + // Use eprint so help/version text still reaches stderr + // (clap::Error::print() would call process::exit, so we manually format) + OxrlsError::Cli(e.to_string()) + })?; match &cli.command { Commands::Init { force, release_dir, non_interactive, - } => cmd_init(*force, release_dir.as_deref(), *non_interactive), + } => { + cmd_init(*force, release_dir.as_deref(), *non_interactive)?; + Ok(CmdResult::Ok) + } Commands::New { packages, summary, details, - } => cmd_new(packages, summary.as_deref(), details.as_deref()), - Commands::Status => cmd_status(), - Commands::Bump { dry_run, archive } => cmd_bump(*dry_run, *archive), - Commands::Check => cmd_check(), - Commands::Release { dry_run, tag } => cmd_release(*dry_run, tag.as_deref()), - Commands::Pre { action } => match action { - Some(PreAction::Enter { - tag, - packages, - force, - }) => cmd_pre_enter(tag, packages, *force), - Some(PreAction::Exit { packages }) => cmd_pre_exit(packages), - Some(PreAction::Status) => cmd_pre_status(), - None => cmd_pre_interactive(), - }, + } => { + cmd_new(packages, summary.as_deref(), details.as_deref())?; + Ok(CmdResult::Ok) + } + Commands::Status => { + cmd_status()?; + Ok(CmdResult::Ok) + } + Commands::Bump { dry_run, archive } => { + cmd_bump(*dry_run, *archive)?; + Ok(CmdResult::Ok) + } + Commands::Check => Ok(CmdResult::CheckStatus(cmd_check()?)), + Commands::Release { dry_run, tag } => { + cmd_release(*dry_run, tag.as_deref())?; + Ok(CmdResult::Ok) + } + Commands::Pre { action } => { + match action { + Some(PreAction::Enter { + tag, + packages, + force, + }) => cmd_pre_enter(tag, packages, *force)?, + Some(PreAction::Exit { packages }) => cmd_pre_exit(packages)?, + Some(PreAction::Status) => cmd_pre_status()?, + None => cmd_pre_interactive()?, + } + Ok(CmdResult::Ok) + } } } @@ -76,612 +119,23 @@ pub fn run_cli(args: Vec) -> napi::Result<()> { let full_args = std::iter::once("oxrls".to_string()) .chain(args) .collect::>(); - run_with_args(&full_args).map_err(|e| napi::Error::from_reason(format!("{:#}", e))) -} - -#[napi] -pub fn plus_100(input: u32) -> u32 { - input + 100 + // For the NAPI entry point, we ignore CmdResult and just propagate errors. + // Exit-code-style results from `check` are irrelevant when called from Node. + run_with_args(&full_args).map_err(|e| napi::Error::from_reason(format!("{:#}", e)))?; + Ok(()) } -// --------------------------------------------------------------------------- -// Command implementations (extracted from main.rs) -// --------------------------------------------------------------------------- - -fn get_release_dir(root: &Path, config: &OxrlsConfig, config_path: &Path) -> std::path::PathBuf { +/// Resolve the absolute path to the release directory. +/// Uses the config's `release_dir` field, resolving relative to the +/// config file location when a config path is available. +pub(crate) fn get_release_dir( + root: &Path, + config: &OxrlsConfig, + config_path: &Path, +) -> std::path::PathBuf { if !config_path.as_os_str().is_empty() { config.release_dir_abs(config_path) } else { root.join(&config.release_dir) } } - -fn cmd_init(force: bool, release_dir: Option<&str>, non_interactive: bool) -> Result<()> { - let cwd = std::env::current_dir().map_err(OxrlsError::Io)?; - - let root = find_workspace_root(Path::new(".")).unwrap_or_else(|_| cwd.clone()); - let workspace = load_workspace(&root).unwrap_or_else(|_| Workspace { - root: root.clone(), - root_package_json: PackageJson { - name: None, - version: None, - private: None, - dependencies: None, - dev_dependencies: None, - peer_dependencies: None, - optional_dependencies: None, - extra: std::collections::BTreeMap::new(), - }, - packages: IndexMap::new(), - }); - let is_monorepo = workspace.packages.len() > 1; - - let config_path = cwd.join(".oxrls").join("config.json"); - - let mut config = OxrlsConfig::default(); - - if let Some(dir) = release_dir { - config.release_dir = dir.to_string(); - } - - if !non_interactive { - run_init_wizard(&mut config, &workspace, is_monorepo)?; - } - - OxrlsConfig::write_to(&config_path, &config, force)?; - println!("Created config file: {}", config_path.display()); - - let release_dir = cwd.join(&config.release_dir); - std::fs::create_dir_all(&release_dir) - .map_err(|e| OxrlsError::Config(format!("Failed to create release dir: {}", e)))?; - - let readme_path = release_dir.join("README.md"); - if !readme_path.exists() { - let readme_content = format!( - "# {} Release Files\n\nThis directory contains pending release files.\n\ - Use `oxrls new` to create a release file and `oxrls bump` to apply them.\n", - config.release_dir.trim_start_matches('.') - ); - std::fs::write(&readme_path, readme_content) - .map_err(|e| OxrlsError::Config(format!("Failed to create README: {}", e)))?; - println!("Created release directory: {}", release_dir.display()); - } - - println!("\noxrls is ready! Use `oxrls new` to create a release file."); - Ok(()) -} - -fn cmd_new(packages: &[String], summary: Option<&str>, details: Option<&str>) -> Result<()> { - let root = find_workspace_root(Path::new("."))?; - let workspace = load_workspace(&root)?; - - let (config, config_path) = OxrlsConfig::load(&root)?; - let release_dir = get_release_dir(&root, &config, &config_path); - - let releases: IndexMap; - - if packages.is_empty() { - if workspace.packages.is_empty() { - return Err(OxrlsError::ReleaseFile( - "No workspace packages found. Run `oxrls init` first or add packages.".to_string(), - )); - } - - let package_names: Vec<&String> = workspace.packages.keys().collect(); - - let selected = MultiSelect::new("Which packages changed?", package_names) - .prompt() - .map_err(|e| OxrlsError::Other(format!("Selection failed: {}", e)))?; - - if selected.is_empty() { - return Err(OxrlsError::ReleaseFile("No packages selected.".to_string())); - } - - let bump_options = vec!["patch", "minor", "major"]; - let bump = Select::new("Bump type for all selected packages:", bump_options) - .prompt() - .map_err(|e| OxrlsError::Other(format!("Selection failed: {}", e)))?; - let bump_type: BumpType = bump.parse::()?; - - let mut releases_map: IndexMap = IndexMap::new(); - for pkg_name in selected { - releases_map.insert(pkg_name.clone(), bump_type); - } - - let summary_text = Text::new("Summary of the change:") - .prompt() - .map_err(|e| OxrlsError::Other(format!("Input failed: {}", e)))?; - - let details_text: Option = Text::new("Optional details (enter to skip):") - .prompt() - .ok() - .filter(|s: &String| !s.is_empty()); - - releases = releases_map; - let path = create_release_file( - &release_dir, - &releases, - &summary_text, - details_text.as_deref(), - )?; - println!("\nCreated release file: {}", path.display()); - } else { - let mut releases_map: IndexMap = IndexMap::new(); - for pkg_arg in packages { - let parts: Vec<&str> = pkg_arg.splitn(2, ':').collect(); - let pkg_name = parts[0]; - let bump_str = parts.get(1).ok_or_else(|| { - OxrlsError::ReleaseFile(format!( - "Invalid package format: \"{}\". Expected format: \"@scope/pkg:bumptype\"", - pkg_arg - )) - })?; - let bump_type = bump_str.parse::()?; - releases_map.insert(pkg_name.to_string(), bump_type); - } - - let summary_text = summary.unwrap_or("No summary provided."); - - releases = releases_map; - let path = create_release_file(&release_dir, &releases, summary_text, details)?; - println!("Created release file: {}", path.display()); - } - - Ok(()) -} - -fn cmd_status() -> Result<()> { - let root = find_workspace_root(Path::new("."))?; - let workspace = load_workspace(&root)?; - - let (config, config_path) = OxrlsConfig::load(&root)?; - let release_dir = get_release_dir(&root, &config, &config_path); - - let files = find_release_files(&release_dir)?; - - if files.is_empty() { - println!( - "No pending release files found in {}.\n", - release_dir.display() - ); - return Ok(()); - } - - println!("Pending release files:\n"); - - for file_path in &files { - match parse_release_file(file_path) { - Ok(rf) => { - let fname = file_path - .file_name() - .map(|n| n.to_string_lossy()) - .unwrap_or_default(); - println!(" {}", fname); - for (pkg, bump) in &rf.releases { - println!(" {} {}", pkg, bump); - } - } - Err(e) => { - eprintln!(" ERROR parsing {}: {}", file_path.display(), e); - } - } - } - - println!(); - - match build_release_plan(&workspace, &config, &release_dir, true) { - Ok(plan) => { - let _max_name = plan - .bumps - .values() - .map(|b| b.package_name.len()) - .max() - .unwrap_or(20); - println!("Calculated bumps:"); - for (_name, bump) in &plan.bumps { - println!( - " {} {} β†’ {} ({})", - bump.package_name, - bump.old_version, - bump.new_version, - bump.bump_type_str() - ); - } - if !plan.internal_dep_updates.is_empty() { - println!("\nInternal dependency updates:\n"); - for update in &plan.internal_dep_updates { - println!( - " {} will update {} ({})", - update.dependent_package_name, update.dep_name, update.dep_type - ); - } - } - } - Err(e) => { - eprintln!("\nCould not calculate bumps: {}", e); - std::process::exit(1); - } - } - - Ok(()) -} - -fn cmd_bump(dry_run: bool, archive: bool) -> Result<()> { - let root = find_workspace_root(Path::new("."))?; - let workspace = load_workspace(&root)?; - - let (config, config_path) = OxrlsConfig::load(&root)?; - let release_dir = get_release_dir(&root, &config, &config_path); - - let plan = build_release_plan(&workspace, &config, &release_dir, dry_run)?; - - if dry_run { - println!("[DRY RUN] Would apply the following release plan:\n"); - print_plan(&plan); - return Ok(()); - } - - println!("Bumped packages:"); - for (_name, bump) in &plan.bumps { - println!( - " {} {} β†’ {} ({})", - bump.package_name, - bump.old_version, - bump.new_version, - bump.bump_type_str() - ); - } - let plan_clone = plan.clone(); - apply_release_plan(&workspace, &plan, &config, &release_dir, false, archive)?; - - if !plan_clone.internal_dep_updates.is_empty() { - println!("\nUpdated internal dependencies:"); - for update in &plan_clone.internal_dep_updates { - println!( - " {} {}", - update.dependent_package_path.display(), - update.dep_name - ); - } - } - - println!("\nConsumed release files:"); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - for (_name, bump) in &plan_clone.bumps { - for rf_path in &bump.release_files { - if seen.insert(rf_path.clone()) { - println!(" {}", rf_path.display()); - } - } - } - - println!("\nDone!"); - Ok(()) -} - -fn cmd_check() -> Result<()> { - let root = find_workspace_root(Path::new("."))?; - let (config, config_path) = OxrlsConfig::load(&root)?; - let release_dir = get_release_dir(&root, &config, &config_path); - - let release_files = find_release_files(&release_dir).unwrap_or_default(); - let has_release_plan = ReleaseManifest::path(&release_dir).exists(); - - if !release_files.is_empty() { - println!("Release files exist, skip release"); - std::process::exit(0); - } - - if has_release_plan { - println!("Release plan exists and files are clean, can release"); - std::process::exit(1); - } - - println!("Nothing to release"); - std::process::exit(0); -} - -fn cmd_release(dry_run: bool, tag_override: Option<&str>) -> Result<()> { - let root = find_workspace_root(Path::new("."))?; - let workspace = load_workspace(&root)?; - - let (config, config_path) = OxrlsConfig::load(&root)?; - let release_dir = get_release_dir(&root, &config, &config_path); - - let manifest = ReleaseManifest::load(&release_dir)?; - - if manifest.packages.is_empty() { - println!("No packages to release."); - return Ok(()); - } - - println!("Releasing {} package(s):\n", manifest.packages.len()); - publish_manifest(&manifest, &workspace, &config, dry_run, tag_override) -} - -fn cmd_pre_enter(tag: &str, package_patterns: &[String], force: bool) -> Result<()> { - let root = find_workspace_root(Path::new("."))?; - let workspace = load_workspace(&root)?; - - let (mut config, config_path) = OxrlsConfig::load(&root)?; - if config_path.as_os_str().is_empty() { - return Err(OxrlsError::Config( - "No oxrls.json found. Run `oxrls init` first.".to_string(), - )); - } - - let mut resolved_packages: Vec = Vec::new(); - for pattern in package_patterns { - if pattern.contains('*') || pattern.contains('?') { - let pat = Pattern::new(pattern) - .map_err(|e| OxrlsError::Config(format!("Invalid glob pattern: {}", e)))?; - for name in workspace.packages.keys() { - if pat.matches(name) && !resolved_packages.contains(name) { - resolved_packages.push(name.clone()); - } - } - } else if workspace.packages.contains_key(pattern) { - if !resolved_packages.contains(pattern) { - resolved_packages.push(pattern.clone()); - } - } else { - let matches: Vec = workspace - .packages - .keys() - .filter(|name| *name == pattern || name.ends_with(pattern.as_str())) - .cloned() - .collect(); - if matches.is_empty() { - return Err(OxrlsError::Config(format!( - "No workspace package matches \"{}\". Available packages:\n {}", - pattern, - workspace - .packages - .keys() - .cloned() - .collect::>() - .join("\n ") - ))); - } - for name in matches { - if !resolved_packages.contains(&name) { - resolved_packages.push(name); - } - } - } - } - - if resolved_packages.is_empty() { - return Err(OxrlsError::Config( - "No packages matched the given patterns.".to_string(), - )); - } - - let entry_idx = config.pre_mode.iter().position(|e| e.tag == tag); - let idx = if let Some(i) = entry_idx { - i - } else { - config.pre_mode.push(PreModeEntry { - tag: tag.to_string(), - packages: vec![], - }); - config.pre_mode.len() - 1 - }; - - if !force { - for pkg_name in &resolved_packages { - for (other_idx, entry) in config.pre_mode.iter().enumerate() { - if other_idx == idx { - continue; - } - if entry.packages.iter().any(|p| p == pkg_name) { - return Err(OxrlsError::Config(format!( - "Package \"{}\" is already in pre-mode \"{}\". Use --force to migrate.", - pkg_name, entry.tag - ))); - } - } - } - } - - if force { - for other_entry in &mut config.pre_mode { - other_entry - .packages - .retain(|p| !resolved_packages.contains(p)); - } - let mut pre_state = PreState::load( - &config_path - .parent() - .unwrap_or(&root) - .join(&config.release_dir), - )?; - for pkg_name in &resolved_packages { - if pre_state.is_in_pre(pkg_name) - && let Some(entry) = pre_state.pre_versions.get(pkg_name) - && entry.tag != tag - { - pre_state.remove(pkg_name); - } - } - pre_state.save( - &config_path - .parent() - .unwrap_or(&root) - .join(&config.release_dir), - )?; - } - - let entry = &mut config.pre_mode[idx]; - for pkg_name in &resolved_packages { - if !entry.packages.contains(pkg_name) { - entry.packages.push(pkg_name.clone()); - } - } - - OxrlsConfig::write_to(&config_path, &config, true)?; - - println!( - "Entered pre-release mode \"{}\" for {} package(s):", - tag, - resolved_packages.len() - ); - for pkg_name in &resolved_packages { - println!(" {}", pkg_name); - } - - Ok(()) -} - -fn cmd_pre_exit(package_patterns: &[String]) -> Result<()> { - let root = find_workspace_root(Path::new("."))?; - let workspace = load_workspace(&root)?; - - let (mut config, config_path) = OxrlsConfig::load(&root)?; - if config_path.as_os_str().is_empty() { - return Err(OxrlsError::Config("No oxrls.json found.".to_string())); - } - - let mut to_remove: Vec = Vec::new(); - for pattern in package_patterns { - if pattern.contains('*') || pattern.contains('?') { - let pat = Pattern::new(pattern) - .map_err(|e| OxrlsError::Config(format!("Invalid glob pattern: {}", e)))?; - for name in workspace.packages.keys() { - if pat.matches(name) && !to_remove.contains(name) { - to_remove.push(name.clone()); - } - } - } else if workspace.packages.contains_key(pattern) { - if !to_remove.contains(pattern) { - to_remove.push(pattern.clone()); - } - } else { - let matches: Vec = workspace - .packages - .keys() - .filter(|name| *name == pattern || name.ends_with(pattern.as_str())) - .cloned() - .collect(); - if matches.is_empty() { - return Err(OxrlsError::Config(format!( - "No workspace package matches \"{}\". Available packages:\n {}", - pattern, - workspace - .packages - .keys() - .cloned() - .collect::>() - .join("\n ") - ))); - } - for name in matches { - if !to_remove.contains(&name) { - to_remove.push(name); - } - } - } - } - - for entry in &mut config.pre_mode { - entry.packages.retain(|p| !to_remove.contains(p)); - } - config.pre_mode.retain(|e| !e.packages.is_empty()); - - OxrlsConfig::write_to(&config_path, &config, true)?; - - let mut pre_state = PreState::load( - &config_path - .parent() - .unwrap_or(&root) - .join(&config.release_dir), - )?; - for pkg_name in &to_remove { - pre_state.remove(pkg_name); - } - pre_state.save( - &config_path - .parent() - .unwrap_or(&root) - .join(&config.release_dir), - )?; - - println!( - "Exited pre-release mode for {} package(s):", - to_remove.len() - ); - for pkg_name in &to_remove { - println!(" {}", pkg_name); - } - - Ok(()) -} - -fn cmd_pre_status() -> Result<()> { - let root = find_workspace_root(Path::new("."))?; - - let (config, config_path) = OxrlsConfig::load(&root)?; - let release_dir = get_release_dir(&root, &config, &config_path); - - if config.pre_mode.is_empty() { - println!("No pre-release mode configured."); - return Ok(()); - } - - let pre_state = PreState::load(&release_dir)?; - - println!("Pre-release mode:\n"); - for entry in &config.pre_mode { - println!(" Tag \"{}\":", entry.tag); - for pattern in &entry.packages { - println!(" - {}", pattern); - } - println!(); - } - - if !pre_state.pre_versions.is_empty() { - println!("Current pre-release counters:\n"); - for (pkg, pve) in &pre_state.pre_versions { - println!(" {} {} (counter: {})", pkg, pve.tag, pve.count); - } - } - - Ok(()) -} - -fn cmd_pre_interactive() -> Result<()> { - let root = find_workspace_root(Path::new("."))?; - let workspace = load_workspace(&root)?; - - if workspace.packages.is_empty() { - return Err(OxrlsError::Config( - "No packages found in workspace.".to_string(), - )); - } - - let package_names: Vec<&String> = workspace.packages.keys().collect(); - let selected = MultiSelect::new( - "Which packages should enter pre-release mode?", - package_names, - ) - .prompt() - .map_err(|e| OxrlsError::Other(format!("Selection failed: {}", e)))?; - - if selected.is_empty() { - return Err(OxrlsError::Other("No packages selected.".to_string())); - } - - let tag = Text::new("Pre-release tag (e.g., beta, alpha, rc):") - .with_placeholder("beta") - .prompt() - .map_err(|e| OxrlsError::Other(format!("Input failed: {}", e)))?; - - let tag = if tag.trim().is_empty() { - "beta".to_string() - } else { - tag.trim().to_lowercase() - }; - - let package_patterns: Vec = selected.iter().map(|s| (*s).clone()).collect(); - cmd_pre_enter(&tag, &package_patterns, false) -} diff --git a/src/main.rs b/src/main.rs index a6d86ce..2005f42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,21 @@ fn main() { let args: Vec = std::env::args().collect(); match oxrls::run_with_args(&args) { - Ok(()) => {} + Ok(oxrls::CmdResult::Ok) => {} + Ok(oxrls::CmdResult::CheckStatus(status)) => { + // Map check status to exit codes for CI scripting: + // 0 = nothing pending or ready (success) + // 1 = ready to publish (trigger downstream) + match status { + oxrls::CheckStatus::PendingReleases => std::process::exit(0), + oxrls::CheckStatus::ReadyToRelease => std::process::exit(1), + oxrls::CheckStatus::NothingToRelease => std::process::exit(0), + } + } Err(e) => { - // run_with_args uses Cli::parse_from which handles --help, --version, - // and invalid args by printing the message and calling process::exit - // with the appropriate code. We only reach here for internal errors. + // run_with_args uses Cli::try_parse_from which converts clap errors + // (including --help, --version, and invalid args) into OxrlsError::Cli. + // Those propagate here as Err; internal errors arrive the same way. eprintln!("Error: {:#}", e); std::process::exit(1); } diff --git a/src/premode.rs b/src/premode.rs deleted file mode 100644 index e16b187..0000000 --- a/src/premode.rs +++ /dev/null @@ -1,305 +0,0 @@ -use crate::config::OxrlsConfig; -use crate::error::{OxrlsError, Result}; -use crate::workspace::Workspace; -use glob::Pattern; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -/// The pre-release state persisted in `.oxrls/pre.json`. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct PreState { - /// Map of package name β†’ (tag, current counter). - /// The counter tracks how many pre-releases have been issued for this - /// package+tag combination. Starts at 1, increments on each bump. - #[serde(default)] - pub pre_versions: BTreeMap, -} - -/// Single entry tracking a package's pre-release counter. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct PreVersionEntry { - pub tag: String, - pub count: u64, -} - -impl PreState { - /// Path to the pre.json file within the release directory. - pub fn path(release_dir: &Path) -> PathBuf { - release_dir.join("pre.json") - } - - /// Load pre-state from the release directory. - pub fn load(release_dir: &Path) -> Result { - let path = Self::path(release_dir); - if path.exists() { - let content = std::fs::read_to_string(&path) - .map_err(|e| OxrlsError::Config(format!("Failed to read pre.json: {}", e)))?; - Ok(serde_json::from_str(&content)?) - } else { - Ok(PreState::default()) - } - } - - /// Save pre-state to the release directory. - pub fn save(&self, release_dir: &Path) -> Result<()> { - let path = Self::path(release_dir); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(OxrlsError::Io)?; - } - let content = serde_json::to_string_pretty(self)?; - std::fs::write(&path, content).map_err(OxrlsError::Io)?; - Ok(()) - } - - /// Get the current counter for a package+tag pair (0 if not yet started). - pub fn get_count(&self, package_name: &str, tag: &str) -> u64 { - self - .pre_versions - .get(package_name) - .filter(|e| e.tag == tag) - .map(|e| e.count) - .unwrap_or(0) - } - - /// Increment the counter for a package+tag pair. Starts at 1 on first call. - pub fn increment(&mut self, package_name: &str, tag: &str) -> u64 { - let new_count = self.get_count(package_name, tag) + 1; - self.pre_versions.insert( - package_name.to_string(), - PreVersionEntry { - tag: tag.to_string(), - count: new_count, - }, - ); - new_count - } - - /// Remove a package from pre-release tracking (when exiting pre-mode). - pub fn remove(&mut self, package_name: &str) { - self.pre_versions.remove(package_name); - } - - /// Check if a package is currently in pre-release mode. - pub fn is_in_pre(&self, package_name: &str) -> bool { - self.pre_versions.contains_key(package_name) - } -} - -/// Determine the pre-release tag and counter for a package, if it's in pre-mode. -/// Returns `Some((tag, counter))` if the package should produce a pre-release version. -/// The counter is the *next* value to use (already incremented in the returned state). -pub fn resolve_pre_release( - package_name: &str, - config: &OxrlsConfig, - pre_state: &mut PreState, - _workspace: &Workspace, -) -> Option<(String, u64)> { - // Check the package against each preMode entry - for entry in &config.pre_mode { - let matches = entry.packages.iter().any(|pattern| { - if let Ok(pat) = Pattern::new(pattern) { - pat.matches(package_name) - } else { - package_name == pattern - } - }); - - if matches { - // Increment and get the new counter value - let count = pre_state.increment(package_name, &entry.tag); - return Some((entry.tag.clone(), count)); - } - } - - None -} - -/// Check whether a pre-release version string matches a tag and counter. -/// Used to detect if a package is already in pre-release state. -pub fn version_is_pre_release(version: &str) -> Option<(String, u64)> { - let v = semver::Version::parse(version).ok()?; - if v.pre.is_empty() { - return None; - } - // Parse the pre-release identifiers - let parts: Vec<&str> = v.pre.split('.').collect(); - if parts.len() == 2 { - let tag = parts[0].to_string(); - let count = parts[1].parse::().ok()?; - Some((tag, count)) - } else { - // Just return the tag with counter 0 if we can't parse it - Some((v.pre.to_string(), 0)) - } -} - -/// Apply a pre-release tag and counter to a base version string. -pub fn apply_pre_release(base_version: &semver::Version, tag: &str, count: u64) -> semver::Version { - let pre = format!("{}.{}", tag, count); - semver::Version { - major: base_version.major, - minor: base_version.minor, - patch: base_version.patch, - pre: semver::Prerelease::new(&pre).unwrap_or_default(), - build: Default::default(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::PreModeEntry; - - #[test] - fn test_pre_state_increment() { - let mut state = PreState::default(); - assert_eq!(state.get_count("@scope/core", "beta"), 0); - - let c1 = state.increment("@scope/core", "beta"); - assert_eq!(c1, 1); - assert_eq!(state.get_count("@scope/core", "beta"), 1); - - let c2 = state.increment("@scope/core", "beta"); - assert_eq!(c2, 2); - } - - #[test] - fn test_pre_state_replacing_tag_resets_count() { - let mut state = PreState::default(); - state.increment("@scope/pkg", "beta"); - state.increment("@scope/pkg", "beta"); - - assert_eq!(state.get_count("@scope/pkg", "beta"), 2); - - // Moving to a new tag replaces the entry, resetting the counter - state.increment("@scope/pkg", "alpha"); - assert_eq!(state.get_count("@scope/pkg", "alpha"), 1); - assert_eq!(state.get_count("@scope/pkg", "beta"), 0); - } - - #[test] - fn test_resolve_pre_release() { - let config = OxrlsConfig { - pre_mode: vec![PreModeEntry { - tag: "beta".to_string(), - packages: vec!["@scope/core".to_string(), "@scope/react".to_string()], - }], - ..Default::default() - }; - let mut pre_state = PreState::default(); - - // Create a minimal workspace with the right package names - let tmp = tempfile::TempDir::new().unwrap(); - let root_pkg = - serde_json::json!({"name": "root", "version": "1.0.0", "workspaces": ["packages/*"]}); - std::fs::write( - tmp.path().join("package.json"), - serde_json::to_string_pretty(&root_pkg).unwrap(), - ) - .unwrap(); - std::fs::create_dir_all(tmp.path().join("packages/core")).unwrap(); - std::fs::write( - tmp.path().join("packages/core/package.json"), - serde_json::to_string_pretty(&serde_json::json!({"name": "@scope/core", "version": "1.0.0"})) - .unwrap(), - ) - .unwrap(); - std::fs::create_dir_all(tmp.path().join("packages/react")).unwrap(); - std::fs::write( - tmp.path().join("packages/react/package.json"), - serde_json::to_string_pretty( - &serde_json::json!({"name": "@scope/react", "version": "1.0.0"}), - ) - .unwrap(), - ) - .unwrap(); - let workspace = crate::workspace::load_workspace(tmp.path()).unwrap(); - - let result = resolve_pre_release("@scope/core", &config, &mut pre_state, &workspace); - assert_eq!(result, Some(("beta".to_string(), 1))); - - // Second call increments - let result = resolve_pre_release("@scope/core", &config, &mut pre_state, &workspace); - assert_eq!(result, Some(("beta".to_string(), 2))); - - // Package not in pre-mode - let result = resolve_pre_release("@scope/other", &config, &mut pre_state, &workspace); - assert_eq!(result, None); - } - - #[test] - fn test_resolve_with_glob() { - let config = OxrlsConfig { - pre_mode: vec![PreModeEntry { - tag: "alpha".to_string(), - packages: vec!["@scope/pre-*".to_string()], - }], - ..Default::default() - }; - let mut pre_state = PreState::default(); - - let tmp = tempfile::TempDir::new().unwrap(); - let root_pkg = - serde_json::json!({"name": "root", "version": "1.0.0", "workspaces": ["packages/*"]}); - std::fs::write( - tmp.path().join("package.json"), - serde_json::to_string_pretty(&root_pkg).unwrap(), - ) - .unwrap(); - std::fs::create_dir_all(tmp.path().join("packages/pre-alpha")).unwrap(); - std::fs::write( - tmp.path().join("packages/pre-alpha/package.json"), - serde_json::to_string_pretty( - &serde_json::json!({"name": "@scope/pre-alpha", "version": "1.0.0"}), - ) - .unwrap(), - ) - .unwrap(); - let workspace = crate::workspace::load_workspace(tmp.path()).unwrap(); - - let result = resolve_pre_release("@scope/pre-alpha", &config, &mut pre_state, &workspace); - assert_eq!(result, Some(("alpha".to_string(), 1))); - - // Should not match - let result = resolve_pre_release("@scope/other", &config, &mut pre_state, &workspace); - assert_eq!(result, None); - } - - #[test] - fn test_apply_pre_release() { - let base = semver::Version::new(2, 0, 0); - let result = apply_pre_release(&base, "beta", 1); - assert_eq!(result.to_string(), "2.0.0-beta.1"); - - let result = apply_pre_release(&base, "beta", 3); - assert_eq!(result.to_string(), "2.0.0-beta.3"); - - let result = apply_pre_release(&base, "rc", 1); - assert_eq!(result.to_string(), "2.0.0-rc.1"); - } - - #[test] - fn test_pre_state_persistence() { - let tmp = tempfile::TempDir::new().unwrap(); - let release_dir = tmp.path().join(".oxrls"); - - let mut state = PreState::default(); - state.increment("@scope/core", "beta"); - state.increment("@scope/core", "beta"); - state.save(&release_dir).unwrap(); - - let loaded = PreState::load(&release_dir).unwrap(); - assert_eq!(loaded.get_count("@scope/core", "beta"), 2); - } - - #[test] - fn test_pre_state_remove() { - let mut state = PreState::default(); - state.increment("@scope/pkg", "beta"); - assert!(state.is_in_pre("@scope/pkg")); - - state.remove("@scope/pkg"); - assert!(!state.is_in_pre("@scope/pkg")); - } -} diff --git a/src/prerelease/logic.rs b/src/prerelease/logic.rs new file mode 100644 index 0000000..b0e4718 --- /dev/null +++ b/src/prerelease/logic.rs @@ -0,0 +1,55 @@ +use crate::config::OxrlsConfig; +use crate::error::Result; +use crate::prerelease::state::PreState; +use glob::Pattern; + +/// Determine the pre-release tag and counter for a package, if it's in pre-mode. +/// Returns `Some((tag, counter))` if the package should produce a pre-release version. +/// The counter is the *next* value to use (already incremented in the returned state). +pub fn resolve_pre_release( + package_name: &str, + config: &OxrlsConfig, + pre_state: &mut PreState, +) -> Option<(String, u64)> { + // Check the package against each preMode entry + for entry in &config.pre_mode { + let matches = entry.packages.iter().any(|pattern| { + if let Ok(pat) = Pattern::new(pattern) { + pat.matches(package_name) + } else { + eprintln!( + "Warning: invalid glob pattern \"{}\" in pre-mode config, falling back to exact match", + pattern + ); + package_name == pattern + } + }); + + if matches { + // Increment and get the new counter value + let count = pre_state.increment(package_name, &entry.tag); + return Some((entry.tag.clone(), count)); + } + } + + None +} + +/// Apply a pre-release tag and counter to a base version string. +pub fn apply_pre_release( + base_version: &semver::Version, + tag: &str, + count: u64, +) -> Result { + let pre = format!("{}.{}", tag, count); + let prerelease = semver::Prerelease::new(&pre).map_err(|e| { + crate::error::OxrlsError::Version(format!("Invalid pre-release identifier '{}': {}", pre, e)) + })?; + Ok(semver::Version { + major: base_version.major, + minor: base_version.minor, + patch: base_version.patch, + pre: prerelease, + build: Default::default(), + }) +} diff --git a/src/prerelease/mod.rs b/src/prerelease/mod.rs new file mode 100644 index 0000000..f6c665e --- /dev/null +++ b/src/prerelease/mod.rs @@ -0,0 +1,139 @@ +pub mod logic; +pub mod state; + +pub use logic::*; +pub use state::*; + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{OxrlsConfig, PreModeEntry}; + + #[test] + fn test_pre_state_increment() { + let mut state = PreState::default(); + assert_eq!(state.get_count("@scope/core", "beta"), 0); + + let c1 = state.increment("@scope/core", "beta"); + assert_eq!(c1, 1); + assert_eq!(state.get_count("@scope/core", "beta"), 1); + + let c2 = state.increment("@scope/core", "beta"); + assert_eq!(c2, 2); + } + + #[test] + fn test_pre_state_replacing_tag_resets_count() { + let mut state = PreState::default(); + state.increment("@scope/pkg", "beta"); + state.increment("@scope/pkg", "beta"); + + assert_eq!(state.get_count("@scope/pkg", "beta"), 2); + + // Moving to a new tag replaces the entry, resetting the counter + state.increment("@scope/pkg", "alpha"); + assert_eq!(state.get_count("@scope/pkg", "alpha"), 1); + assert_eq!(state.get_count("@scope/pkg", "beta"), 0); + } + + #[test] + fn test_resolve_pre_release() { + let config = OxrlsConfig { + pre_mode: vec![PreModeEntry { + tag: "beta".to_string(), + packages: vec!["@scope/core".to_string(), "@scope/react".to_string()], + }], + ..Default::default() + }; + let mut pre_state = PreState::default(); + + let result = resolve_pre_release("@scope/core", &config, &mut pre_state); + assert_eq!(result, Some(("beta".to_string(), 1))); + + // Second call increments + let result = resolve_pre_release("@scope/core", &config, &mut pre_state); + assert_eq!(result, Some(("beta".to_string(), 2))); + + // Package not in pre-mode + let result = resolve_pre_release("@scope/other", &config, &mut pre_state); + assert_eq!(result, None); + } + + #[test] + fn test_resolve_with_glob() { + let config = OxrlsConfig { + pre_mode: vec![PreModeEntry { + tag: "alpha".to_string(), + packages: vec!["@scope/pre-*".to_string()], + }], + ..Default::default() + }; + let mut pre_state = PreState::default(); + + let result = resolve_pre_release("@scope/pre-alpha", &config, &mut pre_state); + assert_eq!(result, Some(("alpha".to_string(), 1))); + + // Should not match + let result = resolve_pre_release("@scope/other", &config, &mut pre_state); + assert_eq!(result, None); + } + + #[test] + fn test_apply_pre_release() { + let base = semver::Version::new(2, 0, 0); + let result = apply_pre_release(&base, "beta", 1).unwrap(); + assert_eq!(result.to_string(), "2.0.0-beta.1"); + + let result = apply_pre_release(&base, "beta", 3).unwrap(); + assert_eq!(result.to_string(), "2.0.0-beta.3"); + + let result = apply_pre_release(&base, "rc", 1).unwrap(); + assert_eq!(result.to_string(), "2.0.0-rc.1"); + } + + #[test] + fn test_pre_state_persistence() { + let tmp = tempfile::TempDir::new().unwrap(); + let release_dir = tmp.path().join(".oxrls"); + + let mut state = PreState::default(); + state.increment("@scope/core", "beta"); + state.increment("@scope/core", "beta"); + state.save(&release_dir).unwrap(); + + let loaded = PreState::load(&release_dir).unwrap(); + assert_eq!(loaded.get_count("@scope/core", "beta"), 2); + } + + #[test] + fn test_pre_state_remove() { + let mut state = PreState::default(); + state.increment("@scope/pkg", "beta"); + assert!(state.is_in_pre("@scope/pkg")); + + state.remove("@scope/pkg"); + assert!(!state.is_in_pre("@scope/pkg")); + } + + #[test] + fn test_apply_pre_release_invalid_tag_returns_error() { + let base = semver::Version::new(1, 0, 0); + + // Empty tag should fail (empty pre-release identifier not allowed by semver) + let result = apply_pre_release(&base, "", 1); + assert!(result.is_err(), "Empty tag should produce an error"); + + // Tag with special characters should fail + // (semver prerelease only allows alphanumeric and hyphens) + let result = apply_pre_release(&base, "beta!@#", 1); + assert!( + result.is_err(), + "Tag with special chars should produce an error" + ); + + // Valid tags should still work + let result = apply_pre_release(&base, "beta", 1); + assert!(result.is_ok(), "Valid tag should succeed"); + assert_eq!(result.unwrap().to_string(), "1.0.0-beta.1"); + } +} diff --git a/src/prerelease/state.rs b/src/prerelease/state.rs new file mode 100644 index 0000000..2962d71 --- /dev/null +++ b/src/prerelease/state.rs @@ -0,0 +1,84 @@ +use crate::error::{OxrlsError, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// The pre-release state persisted in `.oxrls/pre.json`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PreState { + /// Map of package name β†’ (tag, current counter). + /// The counter tracks how many pre-releases have been issued for this + /// package+tag combination. Starts at 1, increments on each bump. + #[serde(default)] + pub pre_versions: BTreeMap, +} + +/// Single entry tracking a package's pre-release counter. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PreVersionEntry { + pub tag: String, + pub count: u64, +} + +impl PreState { + /// Path to the pre.json file within the release directory. + pub fn path(release_dir: &Path) -> PathBuf { + release_dir.join("pre.json") + } + + /// Load pre-state from the release directory. + pub fn load(release_dir: &Path) -> Result { + let path = Self::path(release_dir); + if path.exists() { + let content = std::fs::read_to_string(&path) + .map_err(|e| OxrlsError::Config(format!("Failed to read pre.json: {}", e)))?; + Ok(serde_json::from_str(&content)?) + } else { + Ok(PreState::default()) + } + } + + /// Save pre-state to the release directory. + pub fn save(&self, release_dir: &Path) -> Result<()> { + let path = Self::path(release_dir); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(OxrlsError::Io)?; + } + let content = serde_json::to_string_pretty(self)?; + std::fs::write(&path, content).map_err(OxrlsError::Io)?; + Ok(()) + } + + /// Get the current counter for a package+tag pair (0 if not yet started). + pub fn get_count(&self, package_name: &str, tag: &str) -> u64 { + self + .pre_versions + .get(package_name) + .filter(|e| e.tag == tag) + .map(|e| e.count) + .unwrap_or(0) + } + + /// Increment the counter for a package+tag pair. Starts at 1 on first call. + pub fn increment(&mut self, package_name: &str, tag: &str) -> u64 { + let new_count = self.get_count(package_name, tag) + 1; + self.pre_versions.insert( + package_name.to_string(), + PreVersionEntry { + tag: tag.to_string(), + count: new_count, + }, + ); + new_count + } + + /// Remove a package from pre-release tracking (when exiting pre-mode). + pub fn remove(&mut self, package_name: &str) { + self.pre_versions.remove(package_name); + } + + /// Check if a package is currently in pre-release mode. + pub fn is_in_pre(&self, package_name: &str) -> bool { + self.pre_versions.contains_key(package_name) + } +} diff --git a/src/release.rs b/src/release.rs index ceea6b4..5c7bf1a 100644 --- a/src/release.rs +++ b/src/release.rs @@ -1,7 +1,7 @@ use crate::bump::PlannedBump; use crate::config::OxrlsConfig; use crate::error::{OxrlsError, Result}; -use crate::package_json::PackageJson; +use crate::workspace::PackageJson; use crate::workspace::Workspace; use indexmap::IndexMap; use std::path::{Path, PathBuf}; @@ -119,6 +119,7 @@ pub fn publish_manifest( manifest: &ReleaseManifest, workspace: &Workspace, config: &OxrlsConfig, + release_dir: &Path, dry_run: bool, tag_override: Option<&str>, ) -> Result<()> { @@ -220,7 +221,7 @@ pub fn publish_manifest( would_publish, skipped ); } else { - ReleaseManifest::remove(&workspace.root.join(".oxrls"))?; + ReleaseManifest::remove(release_dir)?; println!("\nDone! {} published, {} skipped.", published, skipped); } diff --git a/src/release_file.rs b/src/release_file.rs index f01a1d9..d1ebd55 100644 --- a/src/release_file.rs +++ b/src/release_file.rs @@ -190,8 +190,10 @@ fn parse_frontmatter(content: &str) -> Option<(&str, &str)> { // Look for "\n---" followed by end-of-line or newline let closing_marker = after_opening.find("\n---")?; - // Split at the closing marker - let yaml_content = &after_opening[..closing_marker]; + // Split at the closing marker. + // Trim trailing \r to handle CRLF line endings: the marker search splits on \n, + // leaving \r at the end of the YAML content when the file uses \r\n. + let yaml_content = after_opening[..closing_marker].trim_end_matches('\r'); let after_closing = &after_opening[closing_marker + 4..]; // skip "\n---" let body = after_closing.trim_start(); @@ -341,17 +343,6 @@ pub fn archive_release_file(path: &Path, archive_dir: &Path) -> Result<()> { Ok(()) } -/// Strip stable (non-pre-release) entries from a release file. -/// Used when a mixed release file contains entries for both pre-release -/// and stable packages β€” the stable entries are removed so they don't -/// repeat on the next bump, while pre-release entries are kept. -pub fn strip_stable_entries(path: &Path, _is_pre_release: impl Fn(&str) -> bool) -> Result<()> { - // The changelog already captured the content during Phase 3. - // Any remaining entries would just cause re-processing on the next bump, - // so consume the file regardless of which entries it contains. - consume_release_file(path) -} - #[cfg(test)] mod tests { use super::*; @@ -445,4 +436,30 @@ Summary here."#; let parent = path.parent().unwrap(); assert_eq!(parent, tmp.path()); } + + #[test] + fn test_parse_frontmatter_crlf() { + // CRLF line endings should not break frontmatter parsing + let content = "---\r\n\"@scope/pkg-a\": patch\r\n---\r\n\r\nSummary with CRLF.\r\n"; + let (yaml, body) = parse_frontmatter(content).unwrap(); + assert!( + !yaml.ends_with('\r'), + "YAML should not have trailing CR: {:?}", + yaml + ); + assert_eq!(body, "Summary with CRLF."); + } + + #[test] + fn test_parse_release_crlf() { + let content = "---\r\n\"@scope/pkg-a\": patch\r\n---\r\n\r\nSummary with CRLF.\r\n"; + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("crlf.md"); + std::fs::write(&path, content).unwrap(); + + let result = parse_release_file(&path).unwrap(); + assert_eq!(result.releases.len(), 1); + assert_eq!(result.summary, "Summary with CRLF."); + assert!(result.releases.contains_key("@scope/pkg-a")); + } } diff --git a/src/workspace.rs b/src/workspace/loader.rs similarity index 93% rename from src/workspace.rs rename to src/workspace/loader.rs index 7022f90..5b972f4 100644 --- a/src/workspace.rs +++ b/src/workspace/loader.rs @@ -1,9 +1,10 @@ use crate::error::{OxrlsError, Result}; -use crate::package_json::PackageJson; use glob::glob; use indexmap::IndexMap; use std::path::{Path, PathBuf}; +use super::package_json::PackageJson; + /// A discovered workspace package. #[derive(Debug, Clone)] pub struct WorkspacePackage { @@ -151,17 +152,23 @@ pub fn load_workspace(root: &Path) -> Result { fn get_workspace_globs(root: &Path) -> Result> { // Check pnpm-workspace.yaml first let pnpm_yaml = root.join("pnpm-workspace.yaml"); - if pnpm_yaml.exists() - && let Ok(content) = std::fs::read_to_string(&pnpm_yaml) - && let Ok(yaml) = serde_yaml::from_str::(&content) - && let Some(packages) = yaml.get("packages").and_then(|v| v.as_sequence()) - { - let globs: Vec = packages - .iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect(); - if !globs.is_empty() { - return Ok(globs); + if pnpm_yaml.exists() { + let content = std::fs::read_to_string(&pnpm_yaml).map_err(|e| { + OxrlsError::Workspace(format!( + "pnpm-workspace.yaml exists but cannot be read: {}", + e + )) + })?; + let yaml: serde_yaml::Value = serde_yaml::from_str(&content) + .map_err(|e| OxrlsError::Workspace(format!("pnpm-workspace.yaml has invalid YAML: {}", e)))?; + if let Some(packages) = yaml.get("packages").and_then(|v| v.as_sequence()) { + let globs: Vec = packages + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + if !globs.is_empty() { + return Ok(globs); + } } } diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs new file mode 100644 index 0000000..9c188bb --- /dev/null +++ b/src/workspace/mod.rs @@ -0,0 +1,13 @@ +//! Workspace resolution and package.json handling. +//! +//! This module provides: +//! - [`Workspace`] / [`WorkspacePackage`] β€” types representing a workspace and its packages. +//! - [`find_workspace_root`], [`load_workspace`], [`get_workspace_globs`] β€” workspace discovery. +//! - [`PackageJson`] β€” model for `package.json` files with read/write/version helpers. +//! - [`compute_new_range`], [`get_range_prefix`], [`format_version_like`] β€” version-range bumping utilities. + +pub use loader::*; +pub use package_json::*; + +mod loader; +mod package_json; diff --git a/src/package_json.rs b/src/workspace/package_json.rs similarity index 80% rename from src/package_json.rs rename to src/workspace/package_json.rs index 38ffab7..120db93 100644 --- a/src/package_json.rs +++ b/src/workspace/package_json.rs @@ -72,31 +72,6 @@ impl PackageJson { } } -/// Update a dependency range in the package.json for a given dependency name. -pub fn update_dependency_range( - deps: &mut Option>, - dep_name: &str, - old_version: &semver::Version, - new_version: &semver::Version, -) -> bool { - let deps = match deps { - Some(d) => d, - None => return false, - }; - - let current_range = match deps.get(dep_name) { - Some(r) => r.clone(), - None => return false, - }; - - let new_range = compute_new_range(¤t_range, old_version, new_version); - if new_range != current_range { - deps.insert(dep_name.to_string(), new_range); - return true; - } - false -} - /// Given a current dependency range like "^1.2.3", "~0.4.0", "workspace:^1.0.0", etc., /// compute the updated range after a version bump. pub fn compute_new_range( @@ -143,22 +118,44 @@ fn compute_simple_range( // Try to parse the rest as a semver version if let Ok(ver) = semver::Version::parse(rest) { - // Only update if the old version matches what was specified - if ver.major == old_version.major - && (prefix == "^" || ver.minor == old_version.minor) - && (prefix == "~" || prefix.is_empty() || ver.patch == old_version.patch || prefix == "^") - { + // Only update if the version in the range corresponds to the old_version: + // + // Prefix | Condition for "matches this dependency" + // --------+------------------------------------------ + // ^ | Major version matches (caret allows any minor/patch) + // ~ | Major AND minor match (tilde allows only patch) + // (none) | Major AND minor match (exact match on patch too, but + // | already verified by parsing rest == old_version's string) + // >= etc. | Major AND minor match (conservative: keep in sync) + let version_matches = match prefix { + "^" => ver.major == old_version.major, + "" | "~" => ver.major == old_version.major && ver.minor == old_version.minor, + _ => ver.major == old_version.major && ver.minor == old_version.minor, + }; + + if version_matches { // Construct the new version with the same prefix let new_ver_str = format_version_like(rest, new_version); return format!("{}{}", prefix, new_ver_str); } } - // Fallback: just replace the old version string within the range + // Fallback: replace the old version string within the range, + // but only when bounded by non-digit characters or string edges + // to avoid false positives like replacing "1.2.3" inside "^1.2.30". let old_str = old_version.to_string(); - if trimmed.contains(&old_str) { - let new_str = new_version.to_string(); - return trimmed.replacen(&old_str, &new_str, 1); + if let Some(pos) = trimmed.find(&old_str) { + let before = &trimmed[..pos]; + let after = &trimmed[pos + old_str.len()..]; + let at_start = pos == 0; + let at_end = pos + old_str.len() == trimmed.len(); + let prev_is_non_digit = pos > 0 && !trimmed.as_bytes()[pos - 1].is_ascii_digit(); + let next_is_non_digit = pos + old_str.len() < trimmed.len() + && !trimmed.as_bytes()[pos + old_str.len()].is_ascii_digit(); + if (at_start || prev_is_non_digit) && (at_end || next_is_non_digit) { + let new_str = new_version.to_string(); + return format!("{}{}{}", before, new_str, after); + } } range.to_string() diff --git a/tests/megatest.rs b/tests/megatest.rs new file mode 100644 index 0000000..bad90ae --- /dev/null +++ b/tests/megatest.rs @@ -0,0 +1,334 @@ +//! Megatest: Full user flow from init through bump with pre-mode, linked, fixed packages. +//! +//! Workspace: 8 packages (independent, linked, fixed, pre-mode). +//! Phases: scaffold β†’ config β†’ release files β†’ plan β†’ dry-run β†’ bump β†’ deps β†’ counter. + +use indexmap::IndexMap; +use oxrls::bump::{apply_release_plan, build_release_plan, find_release_files, print_plan}; +use oxrls::config::{Access, InternalDepUpdate, OxrlsConfig, PreModeEntry}; +use oxrls::release_file::{BumpType, create_release_file}; +use oxrls::workspace::PackageJson; +use oxrls::workspace::load_workspace; +use semver::Version; +use std::path::Path; +use tempfile::TempDir; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn write_pkg(dir: &Path, name: &str, version: &str, deps: Option<&[(&str, &str)]>) { + let mut map = serde_json::Map::new(); + map.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + map.insert( + "version".to_string(), + serde_json::Value::String(version.to_string()), + ); + if let Some(d) = deps { + let dep_map: serde_json::Map = d + .iter() + .map(|(k, v)| (k.to_string(), serde_json::Value::String(v.to_string()))) + .collect(); + map.insert( + "dependencies".to_string(), + serde_json::Value::Object(dep_map), + ); + } + std::fs::create_dir_all(dir).unwrap(); + std::fs::write( + dir.join("package.json"), + serde_json::to_string_pretty(&map).unwrap(), + ) + .unwrap(); +} + +fn scaffold_workspace(tmp: &TempDir) { + let root = tmp.path(); + let mut root_map = serde_json::Map::new(); + root_map.insert( + "name".to_string(), + serde_json::Value::String("root".to_string()), + ); + root_map.insert( + "version".to_string(), + serde_json::Value::String("1.0.0".to_string()), + ); + root_map.insert("private".to_string(), serde_json::Value::Bool(true)); + root_map.insert( + "workspaces".to_string(), + serde_json::Value::Array( + ["packages/*"] + .iter() + .map(|s| serde_json::Value::String(s.to_string())) + .collect(), + ), + ); + std::fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&root_map).unwrap(), + ) + .unwrap(); + write_pkg(&root.join("packages/core"), "@scope/core", "1.0.0", None); + write_pkg( + &root.join("packages/utils"), + "@scope/utils", + "1.0.0", + Some(&[("@scope/core", "^1.0.0")]), + ); + write_pkg(&root.join("packages/ui"), "@scope/ui", "2.0.0", None); + write_pkg(&root.join("packages/icons"), "@scope/icons", "2.0.0", None); + write_pkg(&root.join("packages/app"), "@scope/app", "3.0.0", None); + write_pkg(&root.join("packages/web"), "@scope/web", "3.0.0", None); + write_pkg( + &root.join("packages/internal"), + "@scope/internal", + "1.0.0", + None, + ); + write_pkg(&root.join("packages/tools"), "@scope/tools", "1.5.0", None); +} + +fn create_config(release_dir: &Path) -> OxrlsConfig { + let config_path = release_dir.join("config.json"); + let config = OxrlsConfig { + changelog: false, + generate_packages_changelog: false, + generate_global_changelog: false, + update_internal_dependencies: InternalDepUpdate::Patch, + base_branch: "main".to_string(), + access: Access::Public, + fixed: vec![vec!["@scope/app".to_string(), "@scope/web".to_string()]], + linked: vec![vec!["@scope/ui".to_string(), "@scope/icons".to_string()]], + pre_mode: vec![ + PreModeEntry { + tag: "alpha".to_string(), + packages: vec!["@scope/internal".to_string()], + }, + PreModeEntry { + tag: "beta".to_string(), + packages: vec!["@scope/tools".to_string()], + }, + ], + ..OxrlsConfig::default() + }; + OxrlsConfig::write_to(&config_path, &config, true).unwrap(); + config +} + +/// Create release files. Note: @scope/icons gets a PATCH bump so the linked +/// group elevates it to MINOR (matching @scope/ui's higher bump type). +fn create_releases(release_dir: &Path) { + std::fs::create_dir_all(release_dir).unwrap(); + + let mut r1 = IndexMap::new(); + r1.insert("@scope/core".to_string(), BumpType::Patch); + create_release_file(release_dir, &r1, "Fix core transaction bug", None).unwrap(); + + let mut r2 = IndexMap::new(); + r2.insert("@scope/ui".to_string(), BumpType::Minor); + create_release_file(release_dir, &r2, "Add Button component", None).unwrap(); + + // @scope/icons gets Patch β€” linked group will bump it to Minor + let mut r2b = IndexMap::new(); + r2b.insert("@scope/icons".to_string(), BumpType::Patch); + create_release_file(release_dir, &r2b, "Fix icon alignment", None).unwrap(); + + let mut r3 = IndexMap::new(); + r3.insert("@scope/app".to_string(), BumpType::Major); + create_release_file(release_dir, &r3, "Breaking: Redesign API", None).unwrap(); + + let mut r4 = IndexMap::new(); + r4.insert("@scope/internal".to_string(), BumpType::Minor); + create_release_file(release_dir, &r4, "Internal refactor", None).unwrap(); + + let mut r5 = IndexMap::new(); + r5.insert("@scope/tools".to_string(), BumpType::Patch); + create_release_file(release_dir, &r5, "Fix tools config", None).unwrap(); +} + +fn assert_version(dir: &Path, expected: &str) { + let pkg = PackageJson::read(&dir.join("package.json")).unwrap(); + assert_eq!( + pkg.version.as_deref(), + Some(expected), + "{} has wrong version", + dir.display() + ); +} + +fn assert_not_bumped(plan: &oxrls::bump::ReleasePlan, pkg_name: &str) { + assert!( + !plan.bumps.contains_key(pkg_name), + "{pkg_name} should NOT be bumped" + ); +} + +// --------------------------------------------------------------------------- +// Megatest +// --------------------------------------------------------------------------- + +#[test] +fn test_full_user_flow_init_pre_mode_bump() { + let tmp = TempDir::new().unwrap(); + + // ── Phase 1: Scaffold workspace ── + scaffold_workspace(&tmp); + let workspace = load_workspace(tmp.path()).unwrap(); + assert_eq!(workspace.packages.len(), 8); + for name in &[ + "@scope/core", + "@scope/utils", + "@scope/ui", + "@scope/icons", + "@scope/app", + "@scope/web", + "@scope/internal", + "@scope/tools", + ] { + assert!(workspace.packages.contains_key(*name), "Missing: {name}"); + } + + // ── Phase 2: Config with linked, fixed, pre-mode ── + let oxrls_dir = tmp.path().join(".oxrls"); + let config = create_config(&oxrls_dir); + assert_eq!(config.linked[0].len(), 2); + assert_eq!(config.fixed.len(), 1); + assert_eq!(config.pre_mode.len(), 2); + + // ── Phase 3: Create release files ── + create_releases(&oxrls_dir); + assert_eq!(find_release_files(&oxrls_dir).unwrap().len(), 6); + + // ── Phase 4: Build plan & verify every bump ── + println!("\n--- Status (before bump) ---"); + let plan = build_release_plan(&workspace, &config, &oxrls_dir).unwrap(); + print_plan(&plan); + + assert_eq!(plan.bumps.len(), 7, "Expected 7 bumps"); + + // @scope/core: patch + let core = plan.bumps.get("@scope/core").unwrap(); + assert_eq!(core.old_version, Version::new(1, 0, 0)); + assert_eq!(core.new_version, Version::new(1, 0, 1)); + assert_eq!(core.bump_type, BumpType::Patch); + + // @scope/ui: minor + let ui = plan.bumps.get("@scope/ui").unwrap(); + assert_eq!(ui.old_version, Version::new(2, 0, 0)); + assert_eq!(ui.new_version, Version::new(2, 1, 0)); + assert_eq!(ui.bump_type, BumpType::Minor); + + // @scope/icons: linked, was patch β†’ elevated to minor + let icons = plan.bumps.get("@scope/icons").unwrap(); + assert_eq!(icons.old_version, Version::new(2, 0, 0)); + assert_eq!(icons.new_version, Version::new(2, 1, 0)); + assert_eq!( + icons.bump_type, + BumpType::Minor, + "Linked group should elevate patchβ†’minor" + ); + + // @scope/app: major (fixed group anchor) + let app = plan.bumps.get("@scope/app").unwrap(); + assert_eq!(app.old_version, Version::new(3, 0, 0)); + assert_eq!(app.new_version, Version::new(4, 0, 0)); + assert_eq!(app.bump_type, BumpType::Major); + + // @scope/web: fixed group β€” added automatically, shares @scope/app's version + let web = plan.bumps.get("@scope/web").unwrap(); + assert_eq!(web.old_version, Version::new(3, 0, 0)); + assert_eq!(web.new_version, Version::new(4, 0, 0)); + assert_eq!( + web.new_version, app.new_version, + "Fixed group must share version" + ); + + // @scope/internal: pre-mode alpha, first bump β†’ 1.1.0-alpha.1 + let internal = plan.bumps.get("@scope/internal").unwrap(); + assert_eq!(internal.old_version, Version::new(1, 0, 0)); + assert_eq!( + internal.new_version, + Version::parse("1.1.0-alpha.1").unwrap() + ); + assert_eq!(internal.bump_type, BumpType::Minor); + + // @scope/tools: pre-mode beta, first bump β†’ 1.5.1-beta.1 + let tools = plan.bumps.get("@scope/tools").unwrap(); + assert_eq!(tools.old_version, Version::new(1, 5, 0)); + assert_eq!(tools.new_version, Version::parse("1.5.1-beta.1").unwrap()); + assert_eq!(tools.bump_type, BumpType::Patch); + + // @scope/utils: NOT bumped + assert_not_bumped(&plan, "@scope/utils"); + + // ── Phase 5: Dry-run β€” no file changes ── + println!("\n--- Dry-run bump ---"); + apply_release_plan(&workspace, &plan, &config, &oxrls_dir, true, false).unwrap(); + assert_version(&tmp.path().join("packages/core"), "1.0.0"); + assert_version(&tmp.path().join("packages/ui"), "2.0.0"); + assert_version(&tmp.path().join("packages/icons"), "2.0.0"); + assert_version(&tmp.path().join("packages/app"), "3.0.0"); + assert_version(&tmp.path().join("packages/web"), "3.0.0"); + assert_version(&tmp.path().join("packages/internal"), "1.0.0"); + assert_version(&tmp.path().join("packages/tools"), "1.5.0"); + assert_version(&tmp.path().join("packages/utils"), "1.0.0"); + assert_eq!(find_release_files(&oxrls_dir).unwrap().len(), 6); + + // ── Phase 6: Real bump ── + println!("\n--- Real bump ---"); + apply_release_plan(&workspace, &plan, &config, &oxrls_dir, false, false).unwrap(); + + assert_version(&tmp.path().join("packages/core"), "1.0.1"); + assert_version(&tmp.path().join("packages/ui"), "2.1.0"); + assert_version(&tmp.path().join("packages/icons"), "2.1.0"); + assert_version(&tmp.path().join("packages/app"), "4.0.0"); + assert_version(&tmp.path().join("packages/web"), "4.0.0"); + assert_version(&tmp.path().join("packages/internal"), "1.1.0-alpha.1"); + assert_version(&tmp.path().join("packages/tools"), "1.5.1-beta.1"); + assert_version(&tmp.path().join("packages/utils"), "1.0.0"); + assert_eq!( + find_release_files(&oxrls_dir).unwrap().len(), + 0, + "Release files consumed" + ); + + // ── Phase 7: Internal dependency updates ── + let utils_pkg = PackageJson::read(&tmp.path().join("packages/utils/package.json")).unwrap(); + let deps = utils_pkg.dependencies.unwrap(); + assert_eq!(deps.get("@scope/core").map(|s| s.as_str()), Some("^1.0.1")); + + // ── Phase 8: Pre-mode counter bump ── + println!("\n--- Second pre-release bump (counter) ---"); + let mut r6 = IndexMap::new(); + r6.insert("@scope/internal".to_string(), BumpType::Patch); + create_release_file(&oxrls_dir, &r6, "Second internal fix", None).unwrap(); + + let workspace2 = load_workspace(tmp.path()).unwrap(); + let plan2 = build_release_plan(&workspace2, &config, &oxrls_dir).unwrap(); + + assert_eq!(plan2.bumps.len(), 1); + let internal2 = plan2.bumps.get("@scope/internal").unwrap(); + assert_eq!( + internal2.old_version, + Version::parse("1.1.0-alpha.1").unwrap() + ); + // Subsequent pre-release: base stays 1.1.0, counter β†’ 2 + assert_eq!( + internal2.new_version, + Version::parse("1.1.0-alpha.2").unwrap() + ); + + apply_release_plan(&workspace2, &plan2, &config, &oxrls_dir, false, false).unwrap(); + assert_version(&tmp.path().join("packages/internal"), "1.1.0-alpha.2"); + + let pre_state = oxrls::prerelease::PreState::load(&oxrls_dir).unwrap(); + assert_eq!( + pre_state.pre_versions.get("@scope/internal").unwrap().count, + 2 + ); + + println!("\nβœ… Megatest passed: all 8 phases verified successfully!"); +} -- 2.51.2