diff --git a/src/bump.rs b/src/bump.rs deleted file mode 100644 index 34cdf57..0000000 --- a/src/bump.rs +++ /dev/null @@ -1,1484 +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, - /// 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, - }) -} - -/// 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_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"]] } -/// ``` -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()); - } - } - } - - 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()); - } - } - } -} - -/// 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).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/apply.rs b/src/bump/apply.rs new file mode 100644 index 0000000..919dd14 --- /dev/null +++ b/src/bump/apply.rs @@ -0,0 +1,279 @@ +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::package_json::PackageJson; +use crate::release::ReleaseManifest; +use crate::release_file::{BumpType, consume_release_file, parse_release_file}; +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::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()); + } + } + } + + 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..4bb7623 --- /dev/null +++ b/src/bump/mod.rs @@ -0,0 +1,707 @@ +mod plan; +mod groups; +mod deps; +mod apply; +mod discovery; + +// Public API — re-export everything that was `pub` in the original bump.rs +pub use plan::{PlannedBump, ReleasePlan, InternalDepUpdateInfo, build_release_plan}; +pub use apply::{apply_release_plan, print_plan}; +pub use discovery::find_release_files; + +// 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::package_json::PackageJson; + use crate::release_file::BumpType; + use crate::workspace::load_workspace; + use crate::workspace::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..b08a15f --- /dev/null +++ b/src/bump/plan.rs @@ -0,0 +1,166 @@ +use crate::bump::discovery::find_release_files; +use crate::bump::deps::compute_internal_dep_updates; +use crate::bump::groups::{apply_fixed_groups, apply_linked_groups}; +use crate::config::OxrlsConfig; +use crate::error::{OxrlsError, Result}; +use crate::premode::{apply_pre_release, resolve_pre_release, PreState}; +use crate::release_file::{parse_release_file, BumpType, ReleaseFile}; +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/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..6b0c2b4 --- /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::{find_workspace_root, load_workspace, 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..53ed284 --- /dev/null +++ b/src/commands/pre.rs @@ -0,0 +1,201 @@ +//! `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::premode::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..505fc25 --- /dev/null +++ b/src/commands/release.rs @@ -0,0 +1,29 @@ +//! `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::{publish_manifest, ReleaseManifest}; +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..750e5ed --- /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::config::OxrlsConfig; +use crate::error::Result; +use crate::bump::{build_release_plan, find_release_files}; +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/lib.rs b/src/lib.rs index 90df56e..9f9fad9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ 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; @@ -18,20 +19,13 @@ 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::premode::PreState; -use crate::release::{ReleaseManifest, publish_manifest}; -use crate::release_file::{BumpType, create_release_file, parse_release_file}; -use crate::workspace::{find_workspace_root, load_workspace, 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 @@ -132,548 +126,13 @@ pub fn run_cli(args: Vec) -> napi::Result<()> { 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) } } - -/// Resolve package name patterns against the workspace, returning matched package names. -/// Supports glob patterns (`*`/`?`), exact package name matches, and fuzzy suffix matching. -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) -} - -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(()) -} - -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) { - 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(()) -} - -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)?; - - 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"); - return Ok(CheckStatus::PendingReleases); - } - - if has_release_plan { - println!("Release plan exists and files are clean, can release"); - return Ok(CheckStatus::ReadyToRelease); - } - - println!("Nothing to release"); - Ok(CheckStatus::NothingToRelease) -} - -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, &release_dir, 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 resolved_packages = 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 = 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(()) -} - -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 = 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 = 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(()) -} - -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) -}