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