diff --git a/src/bump/apply.rs b/src/bump/apply.rs index 919dd14..fd0b373 100644 --- a/src/bump/apply.rs +++ b/src/bump/apply.rs @@ -5,7 +5,7 @@ use crate::changelog::{ }; use crate::config::OxrlsConfig; use crate::error::{OxrlsError, Result}; -use crate::package_json::PackageJson; +use crate::workspace::PackageJson; use crate::release::ReleaseManifest; use crate::release_file::{BumpType, consume_release_file, parse_release_file}; use crate::workspace::Workspace; @@ -80,7 +80,7 @@ pub fn apply_release_plan( { // 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( + let new_range = crate::workspace::compute_new_range( ¤t_range, &bump.old_version, &bump.new_version, diff --git a/src/bump/mod.rs b/src/bump/mod.rs index 4bb7623..0b9174a 100644 --- a/src/bump/mod.rs +++ b/src/bump/mod.rs @@ -17,7 +17,7 @@ pub(crate) use groups::resolve_group_patterns; mod tests { use super::*; use crate::config::OxrlsConfig; - use crate::package_json::PackageJson; + use crate::workspace::PackageJson; use crate::release_file::BumpType; use crate::workspace::load_workspace; use crate::workspace::Workspace; diff --git a/src/bump/plan.rs b/src/bump/plan.rs index b08a15f..33ad633 100644 --- a/src/bump/plan.rs +++ b/src/bump/plan.rs @@ -3,7 +3,7 @@ use crate::bump::deps::compute_internal_dep_updates; use crate::bump::groups::{apply_fixed_groups, apply_linked_groups}; use crate::config::OxrlsConfig; use crate::error::{OxrlsError, Result}; -use crate::premode::{apply_pre_release, resolve_pre_release, PreState}; +use crate::prerelease::{apply_pre_release, resolve_pre_release, PreState}; use crate::release_file::{parse_release_file, BumpType, ReleaseFile}; use crate::version_bump::bump_version; use crate::workspace::Workspace; diff --git a/src/commands/pre.rs b/src/commands/pre.rs index 53ed284..1a76216 100644 --- a/src/commands/pre.rs +++ b/src/commands/pre.rs @@ -10,7 +10,7 @@ use inquire::{MultiSelect, Text}; use crate::config::{OxrlsConfig, PreModeEntry}; use crate::error::{OxrlsError, Result}; -use crate::premode::PreState; +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<()> { diff --git a/src/lib.rs b/src/lib.rs index 9f9fad9..487b0b2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,8 +9,7 @@ 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; diff --git a/src/premode.rs b/src/premode.rs deleted file mode 100644 index 56778e6..0000000 --- a/src/premode.rs +++ /dev/null @@ -1,271 +0,0 @@ -use crate::config::OxrlsConfig; -use crate::error::{OxrlsError, Result}; -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, -) -> 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| { - 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(), - }) -} - -#[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(); - - 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/logic.rs b/src/prerelease/logic.rs new file mode 100644 index 0000000..74f5f9a --- /dev/null +++ b/src/prerelease/logic.rs @@ -0,0 +1,58 @@ +use crate::config::OxrlsConfig; +use crate::error::Result; +use crate::prerelease::state::PreState; +use glob::Pattern; + +/// Determine the pre-release tag and counter for a package, if it's in pre-mode. +/// Returns `Some((tag, counter))` if the package should produce a pre-release version. +/// The counter is the *next* value to use (already incremented in the returned state). +pub fn resolve_pre_release( + package_name: &str, + config: &OxrlsConfig, + pre_state: &mut PreState, +) -> Option<(String, u64)> { + // Check the package against each preMode entry + for entry in &config.pre_mode { + let matches = entry.packages.iter().any(|pattern| { + if let Ok(pat) = Pattern::new(pattern) { + pat.matches(package_name) + } else { + eprintln!( + "Warning: invalid glob pattern \"{}\" in pre-mode config, falling back to exact match", + pattern + ); + package_name == pattern + } + }); + + if matches { + // Increment and get the new counter value + let count = pre_state.increment(package_name, &entry.tag); + return Some((entry.tag.clone(), count)); + } + } + + None +} + +/// Apply a pre-release tag and counter to a base version string. +pub fn apply_pre_release( + base_version: &semver::Version, + tag: &str, + count: u64, +) -> Result { + let pre = format!("{}.{}", tag, count); + let prerelease = semver::Prerelease::new(&pre).map_err(|e| { + crate::error::OxrlsError::Version(format!( + "Invalid pre-release identifier '{}': {}", + pre, e + )) + })?; + Ok(semver::Version { + major: base_version.major, + minor: base_version.minor, + patch: base_version.patch, + pre: prerelease, + build: Default::default(), + }) +} diff --git a/src/prerelease/mod.rs b/src/prerelease/mod.rs new file mode 100644 index 0000000..ca3792a --- /dev/null +++ b/src/prerelease/mod.rs @@ -0,0 +1,136 @@ +pub mod state; +pub mod logic; + +pub use state::*; +pub use logic::*; + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{OxrlsConfig, PreModeEntry}; + + #[test] + fn test_pre_state_increment() { + let mut state = PreState::default(); + assert_eq!(state.get_count("@scope/core", "beta"), 0); + + let c1 = state.increment("@scope/core", "beta"); + assert_eq!(c1, 1); + assert_eq!(state.get_count("@scope/core", "beta"), 1); + + let c2 = state.increment("@scope/core", "beta"); + assert_eq!(c2, 2); + } + + #[test] + fn test_pre_state_replacing_tag_resets_count() { + let mut state = PreState::default(); + state.increment("@scope/pkg", "beta"); + state.increment("@scope/pkg", "beta"); + + assert_eq!(state.get_count("@scope/pkg", "beta"), 2); + + // Moving to a new tag replaces the entry, resetting the counter + state.increment("@scope/pkg", "alpha"); + assert_eq!(state.get_count("@scope/pkg", "alpha"), 1); + assert_eq!(state.get_count("@scope/pkg", "beta"), 0); + } + + #[test] + fn test_resolve_pre_release() { + let config = OxrlsConfig { + pre_mode: vec![PreModeEntry { + tag: "beta".to_string(), + packages: vec!["@scope/core".to_string(), "@scope/react".to_string()], + }], + ..Default::default() + }; + let mut pre_state = PreState::default(); + + let result = resolve_pre_release("@scope/core", &config, &mut pre_state); + assert_eq!(result, Some(("beta".to_string(), 1))); + + // Second call increments + let result = resolve_pre_release("@scope/core", &config, &mut pre_state); + assert_eq!(result, Some(("beta".to_string(), 2))); + + // Package not in pre-mode + let result = resolve_pre_release("@scope/other", &config, &mut pre_state); + assert_eq!(result, None); + } + + #[test] + fn test_resolve_with_glob() { + let config = OxrlsConfig { + pre_mode: vec![PreModeEntry { + tag: "alpha".to_string(), + packages: vec!["@scope/pre-*".to_string()], + }], + ..Default::default() + }; + let mut pre_state = PreState::default(); + + let result = resolve_pre_release("@scope/pre-alpha", &config, &mut pre_state); + assert_eq!(result, Some(("alpha".to_string(), 1))); + + // Should not match + let result = resolve_pre_release("@scope/other", &config, &mut pre_state); + assert_eq!(result, None); + } + + #[test] + fn test_apply_pre_release() { + let base = semver::Version::new(2, 0, 0); + let result = apply_pre_release(&base, "beta", 1).unwrap(); + assert_eq!(result.to_string(), "2.0.0-beta.1"); + + let result = apply_pre_release(&base, "beta", 3).unwrap(); + assert_eq!(result.to_string(), "2.0.0-beta.3"); + + let result = apply_pre_release(&base, "rc", 1).unwrap(); + assert_eq!(result.to_string(), "2.0.0-rc.1"); + } + + #[test] + fn test_pre_state_persistence() { + let tmp = tempfile::TempDir::new().unwrap(); + let release_dir = tmp.path().join(".oxrls"); + + let mut state = PreState::default(); + state.increment("@scope/core", "beta"); + state.increment("@scope/core", "beta"); + state.save(&release_dir).unwrap(); + + let loaded = PreState::load(&release_dir).unwrap(); + assert_eq!(loaded.get_count("@scope/core", "beta"), 2); + } + + #[test] + fn test_pre_state_remove() { + let mut state = PreState::default(); + state.increment("@scope/pkg", "beta"); + assert!(state.is_in_pre("@scope/pkg")); + + state.remove("@scope/pkg"); + assert!(!state.is_in_pre("@scope/pkg")); + } + + #[test] + fn test_apply_pre_release_invalid_tag_returns_error() { + let base = semver::Version::new(1, 0, 0); + + // Empty tag should fail (empty pre-release identifier not allowed by semver) + let result = apply_pre_release(&base, "", 1); + assert!(result.is_err(), "Empty tag should produce an error"); + + // Tag with special characters should fail + // (semver prerelease only allows alphanumeric and hyphens) + let result = apply_pre_release(&base, "beta!@#", 1); + assert!(result.is_err(), "Tag with special chars should produce an error"); + + // Valid tags should still work + let result = apply_pre_release(&base, "beta", 1); + assert!(result.is_ok(), "Valid tag should succeed"); + assert_eq!(result.unwrap().to_string(), "1.0.0-beta.1"); + } +} diff --git a/src/prerelease/state.rs b/src/prerelease/state.rs new file mode 100644 index 0000000..2962d71 --- /dev/null +++ b/src/prerelease/state.rs @@ -0,0 +1,84 @@ +use crate::error::{OxrlsError, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// The pre-release state persisted in `.oxrls/pre.json`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PreState { + /// Map of package name → (tag, current counter). + /// The counter tracks how many pre-releases have been issued for this + /// package+tag combination. Starts at 1, increments on each bump. + #[serde(default)] + pub pre_versions: BTreeMap, +} + +/// Single entry tracking a package's pre-release counter. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PreVersionEntry { + pub tag: String, + pub count: u64, +} + +impl PreState { + /// Path to the pre.json file within the release directory. + pub fn path(release_dir: &Path) -> PathBuf { + release_dir.join("pre.json") + } + + /// Load pre-state from the release directory. + pub fn load(release_dir: &Path) -> Result { + let path = Self::path(release_dir); + if path.exists() { + let content = std::fs::read_to_string(&path) + .map_err(|e| OxrlsError::Config(format!("Failed to read pre.json: {}", e)))?; + Ok(serde_json::from_str(&content)?) + } else { + Ok(PreState::default()) + } + } + + /// Save pre-state to the release directory. + pub fn save(&self, release_dir: &Path) -> Result<()> { + let path = Self::path(release_dir); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(OxrlsError::Io)?; + } + let content = serde_json::to_string_pretty(self)?; + std::fs::write(&path, content).map_err(OxrlsError::Io)?; + Ok(()) + } + + /// Get the current counter for a package+tag pair (0 if not yet started). + pub fn get_count(&self, package_name: &str, tag: &str) -> u64 { + self + .pre_versions + .get(package_name) + .filter(|e| e.tag == tag) + .map(|e| e.count) + .unwrap_or(0) + } + + /// Increment the counter for a package+tag pair. Starts at 1 on first call. + pub fn increment(&mut self, package_name: &str, tag: &str) -> u64 { + let new_count = self.get_count(package_name, tag) + 1; + self.pre_versions.insert( + package_name.to_string(), + PreVersionEntry { + tag: tag.to_string(), + count: new_count, + }, + ); + new_count + } + + /// Remove a package from pre-release tracking (when exiting pre-mode). + pub fn remove(&mut self, package_name: &str) { + self.pre_versions.remove(package_name); + } + + /// Check if a package is currently in pre-release mode. + pub fn is_in_pre(&self, package_name: &str) -> bool { + self.pre_versions.contains_key(package_name) + } +} diff --git a/src/release.rs b/src/release.rs index f118faf..5c7bf1a 100644 --- a/src/release.rs +++ b/src/release.rs @@ -1,7 +1,7 @@ use crate::bump::PlannedBump; use crate::config::OxrlsConfig; use crate::error::{OxrlsError, Result}; -use crate::package_json::PackageJson; +use crate::workspace::PackageJson; use crate::workspace::Workspace; use indexmap::IndexMap; use std::path::{Path, PathBuf}; diff --git a/src/workspace.rs b/src/workspace/loader.rs similarity index 99% rename from src/workspace.rs rename to src/workspace/loader.rs index c949507..866e26e 100644 --- a/src/workspace.rs +++ b/src/workspace/loader.rs @@ -1,9 +1,10 @@ use crate::error::{OxrlsError, Result}; -use crate::package_json::PackageJson; use glob::glob; use indexmap::IndexMap; use std::path::{Path, PathBuf}; +use super::package_json::PackageJson; + /// A discovered workspace package. #[derive(Debug, Clone)] pub struct WorkspacePackage { diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs new file mode 100644 index 0000000..9c188bb --- /dev/null +++ b/src/workspace/mod.rs @@ -0,0 +1,13 @@ +//! Workspace resolution and package.json handling. +//! +//! This module provides: +//! - [`Workspace`] / [`WorkspacePackage`] — types representing a workspace and its packages. +//! - [`find_workspace_root`], [`load_workspace`], [`get_workspace_globs`] — workspace discovery. +//! - [`PackageJson`] — model for `package.json` files with read/write/version helpers. +//! - [`compute_new_range`], [`get_range_prefix`], [`format_version_like`] — version-range bumping utilities. + +pub use loader::*; +pub use package_json::*; + +mod loader; +mod package_json; diff --git a/src/package_json.rs b/src/workspace/package_json.rs similarity index 100% rename from src/package_json.rs rename to src/workspace/package_json.rs diff --git a/tests/megatest.rs b/tests/megatest.rs index 7979186..5fffcec 100644 --- a/tests/megatest.rs +++ b/tests/megatest.rs @@ -6,7 +6,7 @@ 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::package_json::PackageJson; +use oxrls::workspace::PackageJson; use oxrls::release_file::{BumpType, create_release_file}; use oxrls::workspace::load_workspace; use semver::Version; @@ -270,7 +270,7 @@ fn test_full_user_flow_init_pre_mode_bump() { 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::premode::PreState::load(&oxrls_dir).unwrap(); + 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!");