use crate::{
config::version::{VersionComponentConfigs, VersionComponentSpec},
f_string::PythonFormatString,
};
use indexmap::IndexMap;
use std::collections::{HashMap, HashSet};
pub type RawVersion<'a> = HashMap<&'a str, &'a str>;
pub mod values {
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("{value:?} must be one of {allowed:?}")]
InvalidValue {
value: String,
allowed: Vec<String>,
},
#[error("the component has already the maximum value among {allowed:?} and cannot be bumped")]
MaxReached {
allowed: Vec<String>,
},
}
#[derive(Debug)]
pub struct ValuesFunction<'a> {
pub values: &'a [String],
}
impl<'a> ValuesFunction<'a> {
#[must_use]
pub fn new(values: &'a [String]) -> Self {
Self { values }
}
pub fn bump(&self, value: &str) -> Result<&'a str, Error> {
let current_idx = self
.values
.iter()
.position(|v| *v == value)
.ok_or_else(|| Error::InvalidValue {
value: value.to_string(),
allowed: self.values.to_vec(),
})?;
let next_value = self
.values
.get(current_idx + 1)
.ok_or_else(|| Error::MaxReached {
allowed: self.values.to_vec(),
})?;
Ok(next_value)
}
}
}
pub mod numeric {
pub static FIRST_NUMERIC_REGEX: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| {
#[expect(
clippy::expect_used,
reason = "static regex is a compile-time literal and known to be valid"
)]
let regex =
regex::RegexBuilder::new(r"(?P<prefix>[^-0-9]*)(?P<number>-?\d+)(?P<suffix>.*)")
.build()
.expect("static numeric parsing regex must be valid");
regex
});
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("version {0:?} does not contain any digit")]
MissingDigit(String),
#[error("version {0:?} has no prefix")]
MissingPrefix(String),
#[error("version {0:?} has no number")]
MissingNumber(String),
#[error("version {0:?} has no suffix")]
MissingSuffix(String),
#[error("{value:?} is not a valid number")]
InvalidNumber {
#[source]
source: std::num::ParseIntError,
value: String,
},
#[error("{value:?} of version {version:?} is not a valid number")]
InvalidNumeric {
#[source]
source: std::num::ParseIntError,
version: String,
value: String,
},
#[error("{value:?} is lower than the first value {first_value:?} and cannot be bumped")]
LessThanFirstValue {
first_value: usize,
value: usize,
},
#[error("version component {component:?} exceeds bounds and cannot be bumped")]
OutOfBounds {
component: usize,
},
}
#[derive(Debug)]
pub struct NumericFunction {
pub first_value: usize,
pub optional_value: usize,
}
impl NumericFunction {
pub fn new(first_value: Option<&str>, optional_value: Option<&str>) -> Result<Self, Error> {
let first_value = first_value
.map(|value| {
value.parse().map_err(|source| Error::InvalidNumber {
source,
value: value.to_string(),
})
})
.transpose()?
.unwrap_or(0);
let optional_value = optional_value
.map(|value| {
value.parse().map_err(|source| Error::InvalidNumber {
source,
value: value.to_string(),
})
})
.transpose()?
.unwrap_or(first_value);
Ok(Self {
first_value,
optional_value,
})
}
pub fn bump(&self, value: &str) -> Result<String, Error> {
let first_numeric = FIRST_NUMERIC_REGEX
.captures(value)
.ok_or_else(|| Error::MissingDigit(value.to_string()))?;
let prefix_part = first_numeric
.name("prefix")
.ok_or_else(|| Error::MissingPrefix(value.to_string()))?;
let numeric_part = first_numeric
.name("number")
.ok_or_else(|| Error::MissingNumber(value.to_string()))?;
let suffix_part = first_numeric
.name("suffix")
.ok_or_else(|| Error::MissingSuffix(value.to_string()))?;
let numeric_part: usize =
numeric_part
.as_str()
.parse()
.map_err(|source| Error::InvalidNumeric {
source,
version: value.to_string(),
value: numeric_part.as_str().to_string(),
})?;
if numeric_part < self.first_value {
return Err(Error::LessThanFirstValue {
first_value: self.first_value,
value: numeric_part,
});
}
let Some(bumped_numeric) = numeric_part.checked_add(1) else {
return Err(Error::OutOfBounds {
component: numeric_part,
});
};
Ok(format!(
"{}{}{}",
prefix_part.as_str(),
bumped_numeric.to_string().as_str(),
suffix_part.as_str()
))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Component {
value: Option<String>,
spec: VersionComponentSpec,
}
impl AsRef<str> for Component {
fn as_ref(&self) -> &str {
self.value().unwrap_or_default()
}
}
#[derive(thiserror::Error, Debug)]
pub enum BumpError {
#[error(transparent)]
Numeric(#[from] numeric::Error),
#[error(transparent)]
Values(#[from] values::Error),
#[error("invalid version component {0:?}")]
InvalidComponent(String),
}
impl Component {
#[must_use]
pub fn new(value: Option<&str>, spec: VersionComponentSpec) -> Self {
let mut spec = spec;
if spec.first_value.is_none() {
if !spec.values.is_empty() {
spec.first_value = spec.values.first().cloned();
} else if spec.calver_format.is_none() {
spec.first_value = Some("0".to_string());
}
}
if spec.optional_value.is_none() {
if !spec.values.is_empty() {
spec.optional_value = spec.values.first().cloned();
} else if spec.calver_format.is_none() {
spec.optional_value = spec.first_value.clone();
}
}
Self {
value: value.map(std::string::ToString::to_string),
spec,
}
}
#[must_use]
pub fn value(&self) -> Option<&str> {
self.value
.as_deref()
.or(self.spec.optional_value.as_deref())
.or(self.spec.first_value.as_deref())
}
#[must_use]
pub fn first(&self) -> Self {
Self {
value: self.spec.first_value.clone(),
..self.clone()
}
}
pub fn bump(&self) -> Result<Self, BumpError> {
let value = if self.spec.values.is_empty() {
let func = numeric::NumericFunction::new(
self.spec.first_value.as_deref(),
self.spec.optional_value.as_deref(),
)?;
let value = self
.value
.as_deref()
.unwrap_or(self.spec.first_value.as_deref().unwrap_or("0"));
func.bump(value)?
} else {
let func = values::ValuesFunction::new(self.spec.values.as_slice());
let Some(first_value) = self.spec.values.first() else {
return Err(BumpError::Values(values::Error::MaxReached {
allowed: self.spec.values.clone(),
}));
};
let value = self
.value
.as_deref()
.or(self.spec.optional_value.as_deref())
.unwrap_or(first_value.as_str());
func.bump(value).map(ToString::to_string)?
};
Ok(Self {
value: Some(value),
..self.clone()
})
}
}
#[derive(Debug, Clone)]
pub struct Version {
components: IndexMap<String, Component>,
spec: VersionSpec,
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_map()
.entries(self.components.iter().map(|(k, v)| (k, v.value())))
.finish()
}
}
impl IntoIterator for Version {
type Item = (String, Component);
type IntoIter = indexmap::map::IntoIter<String, Component>;
fn into_iter(self) -> Self::IntoIter {
self.components.into_iter()
}
}
impl<'a> IntoIterator for &'a Version {
type Item = (&'a String, &'a Component);
type IntoIter = indexmap::map::Iter<'a, String, Component>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl Version {
#[must_use]
pub fn parse(value: &str, regex: ®ex::Regex, version_spec: &VersionSpec) -> Option<Self> {
let parsed = parse_raw_version(value, regex);
if parsed.is_empty() {
return None;
}
let version = version_spec.build(&parsed);
Some(version)
}
pub fn serialize<'a, K, V, S>(
&self,
serialize_version_patterns: impl IntoIterator<Item = &'a PythonFormatString>,
ctx: &HashMap<K, V, S>,
) -> Result<String, SerializeError>
where
K: std::borrow::Borrow<str> + std::hash::Hash + Eq + std::fmt::Debug,
V: AsRef<str> + std::fmt::Debug,
S: std::hash::BuildHasher,
{
serialize_version(self, serialize_version_patterns, ctx)
}
pub fn get<Q>(&self, component: &Q) -> Option<&Component>
where
Q: ?Sized + std::hash::Hash + indexmap::Equivalent<String>,
{
self.components.get(component)
}
#[must_use]
pub fn iter(&self) -> indexmap::map::Iter<'_, String, Component> {
self.components.iter()
}
pub fn required_component_names(&self) -> impl Iterator<Item = &str> {
self.iter()
.filter(|(_, v)| v.value() != v.spec.optional_value.as_deref())
.map(|(k, _)| k.as_str())
}
fn always_incr_dependencies(&self) -> HashMap<&str, HashSet<&str>> {
self.spec
.components_to_always_increment
.iter()
.map(|comp_name| (comp_name.as_str(), self.spec.dependents(comp_name)))
.collect()
}
fn increment_always_incr(&self) -> Result<HashMap<&str, Component>, BumpError> {
let components = self
.spec
.components_to_always_increment
.iter()
.map(|comp_name| {
let component = self
.components
.get(comp_name)
.ok_or_else(|| BumpError::InvalidComponent(comp_name.clone()))?;
component
.bump()
.map(|bumped_comp| (comp_name.as_str(), bumped_comp))
})
.collect::<Result<_, _>>()?;
Ok(components)
}
fn always_increment(&self) -> Result<(HashMap<&str, Component>, HashSet<&str>), BumpError> {
let values = self.increment_always_incr()?;
let mut dependents = self.always_incr_dependencies();
for (comp_name, value) in &values {
if self
.components
.get(*comp_name)
.is_some_and(|current_value| value == current_value)
{
dependents.remove(comp_name);
}
}
let unique_dependents: HashSet<&str> = dependents.values().flatten().copied().collect();
Ok((values, unique_dependents))
}
pub fn bump(&self, component: &str) -> Result<Self, BumpError> {
if !self.components.contains_key(component) {
return Err(BumpError::InvalidComponent(component.to_string()));
}
let mut new_components = self.components.clone();
let (always_increment_values, mut components_to_reset) = self.always_increment()?;
new_components.extend(
always_increment_values
.into_iter()
.map(|(k, v)| (k.to_string(), v)),
);
let should_reset = components_to_reset.contains(component);
if !should_reset {
let bumped = self
.components
.get(component)
.ok_or_else(|| BumpError::InvalidComponent(component.to_string()))?
.bump()?;
new_components.insert(component.to_string(), bumped);
let dependants = self.spec.dependents(component);
components_to_reset.extend(dependants);
}
for comp_name in components_to_reset {
let is_independent = self
.components
.get(comp_name)
.ok_or_else(|| BumpError::InvalidComponent(comp_name.to_string()))?
.spec
.independent
== Some(true);
if !is_independent {
let first = self
.components
.get(comp_name)
.ok_or_else(|| BumpError::InvalidComponent(comp_name.to_string()))?
.first();
new_components.insert(comp_name.to_string(), first);
}
}
Ok(Self {
components: new_components,
..self.clone()
})
}
}
#[derive(Debug, Clone, Default)]
#[allow(clippy::module_name_repetitions)]
pub struct VersionSpec {
components: VersionComponentConfigs,
dependency_map: HashMap<String, Vec<String>>,
components_to_always_increment: Vec<String>,
}
impl VersionSpec {
#[must_use]
pub fn from_components(components: VersionComponentConfigs) -> Self {
let mut dependency_map: HashMap<String, Vec<String>> = HashMap::new();
let components_to_always_increment: Vec<String> = components
.iter()
.filter_map(|(comp_name, comp_config)| {
if comp_config.always_increment {
Some(comp_name)
} else {
None
}
})
.cloned()
.collect();
for (previous_component, (comp_name, comp_config)) in
components.keys().zip(components.iter().skip(1))
{
if comp_config.independent == Some(true) {
continue;
}
if let Some(ref depends_on) = comp_config.depends_on {
dependency_map
.entry(depends_on.clone())
.or_default()
.push(comp_name.clone());
} else {
dependency_map
.entry(previous_component.clone())
.or_default()
.push(comp_name.clone());
}
}
Self {
components,
dependency_map,
components_to_always_increment,
}
}
#[must_use]
pub fn dependents(&self, comp_name: &str) -> HashSet<&str> {
use std::collections::VecDeque;
let mut stack: VecDeque<&String> = self
.dependency_map
.get(comp_name)
.map(|deps| deps.iter())
.unwrap_or_default()
.collect();
let mut visited: HashSet<&str> = HashSet::new();
while let Some(e) = stack.pop_front() {
if !visited.contains(e.as_str()) {
visited.insert(e);
for dep in self
.dependency_map
.get(e)
.map(|deps| deps.iter())
.unwrap_or_default()
{
stack.push_front(dep);
}
}
}
visited
}
#[must_use]
pub fn build(&self, raw_components: &RawVersion) -> Version {
let components = self
.components
.iter()
.map(|(comp_name, comp_config)| {
let comp_value = raw_components.get(comp_name.as_str()).copied();
let component = Component::new(comp_value, comp_config.clone());
(comp_name.clone(), component)
})
.collect();
Version {
components,
spec: self.clone(),
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum SerializeError {
#[error("version {version} has no valid formats")]
NoValidFormat {
version: Box<Version>,
formats: Vec<(usize, PythonFormatString)>,
},
#[error(transparent)]
MissingArgument(#[from] crate::f_string::MissingArgumentError),
}
fn serialize_version<'a, K, V, S>(
version: &Version,
serialize_patterns: impl IntoIterator<Item = &'a PythonFormatString>,
ctx: &HashMap<K, V, S>,
) -> Result<String, SerializeError>
where
K: std::borrow::Borrow<str> + std::hash::Hash + Eq + std::fmt::Debug,
V: AsRef<str> + std::fmt::Debug,
S: std::hash::BuildHasher,
{
tracing::debug!(?version, "serializing");
let ctx: HashMap<&str, &str> = ctx
.iter()
.map(|(k, v)| (k.borrow(), v.as_ref()))
.chain(version.iter().map(|(k, v)| (k.as_str(), v.as_ref())))
.collect();
let required_component_names: HashSet<_> = version.required_component_names().collect();
let mut patterns: Vec<(usize, &'a PythonFormatString)> =
serialize_patterns.into_iter().enumerate().collect();
patterns.sort_by_key(|(idx, pattern)| {
let labels: HashSet<&str> = pattern.named_arguments().collect();
let has_required_components = required_component_names.is_subset(&labels);
let num_labels = labels.len();
(std::cmp::Reverse(has_required_components), num_labels, *idx)
});
let (_, chosen_pattern) =
patterns
.first()
.copied()
.ok_or_else(|| SerializeError::NoValidFormat {
version: Box::new(version.clone()),
formats: patterns
.into_iter()
.map(|(idx, format_string)| (idx, format_string.clone()))
.collect(),
})?;
tracing::debug!(format = ?chosen_pattern, "serialization format");
let serialized = chosen_pattern.format(&ctx, true)?;
tracing::debug!(serialized, "serialized");
Ok(serialized)
}
fn parse_raw_version<'a>(version: &'a str, pattern: &'a regex::Regex) -> RawVersion<'a> {
if version.is_empty() {
tracing::warn!("version string is empty");
return RawVersion::default();
}
tracing::debug!(version, ?pattern, "parsing version");
let Some(matches) = pattern.captures(version) else {
tracing::debug!(?pattern, ?version, "pattern does not parse current version",);
return RawVersion::default();
};
let parsed: RawVersion = pattern
.capture_names()
.flatten()
.filter_map(|name| matches.name(name).map(|value| (name, value.as_str())))
.collect();
tracing::debug!(?parsed, "parsed version");
parsed
}
#[cfg(test)]
mod tests {
use crate::{
config,
diagnostics::BufferedPrinter,
version::{Version, VersionSpec},
};
use color_eyre::eyre;
use similar_asserts::assert_eq as sim_assert_eq;
use std::collections::HashMap;
#[test]
fn test_parse_raw_version() -> eyre::Result<()> {
crate::tests::init();
let parse_regex = regex::Regex::new(r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)")?;
sim_assert_eq!(
super::parse_raw_version("2.1.3", &parse_regex),
[("major", "2"), ("minor", "1"), ("patch", "3")]
.into_iter()
.collect::<super::RawVersion>(),
);
Ok(())
}
#[test]
fn test_minor_bump_keeps_patch_component() -> eyre::Result<()> {
crate::tests::init();
let toml = indoc::indoc! {r#"
[tool.bumpversion]
current_version = "0.0.4"
tag = true
tag_name = "{new_version}"
allow_dirty = false
commit = true
pre_commit_hooks = []
post_commit_hooks = []
"#};
let printer = BufferedPrinter::default();
let file_id = printer.add_source_file("pyproject.toml".to_string(), toml.to_string());
let strict = true;
let mut diagnostics = vec![];
let config = config::Config::from_pyproject_toml(toml, file_id, strict, &mut diagnostics)?
.ok_or_else(|| eyre::eyre!("expected config to be present"))?
.finalize();
let components = config::version::version_component_configs(&config);
let version_spec = VersionSpec::from_components(components);
let current_version = Version::parse(
"0.0.4",
&config.global.parse_version_pattern,
&version_spec,
)
.ok_or_else(|| eyre::eyre!("expected current version to parse"))?;
let new_version = current_version.bump("minor")?;
let ctx = HashMap::<String, String>::new();
let new_version_serialized =
new_version.serialize(&config.global.serialize_version_patterns, &ctx)?;
sim_assert_eq!(new_version_serialized, "0.1.0");
Ok(())
}
#[test]
fn test_values_bump() -> eyre::Result<()> {
crate::tests::init();
let values = vec![
"alpha".to_string(),
"beta".to_string(),
"rc".to_string(),
"final".to_string(),
];
let func = super::values::ValuesFunction::new(&values);
sim_assert_eq!(func.bump("alpha")?, "beta");
sim_assert_eq!(func.bump("beta")?, "rc");
sim_assert_eq!(func.bump("rc")?, "final");
assert!(func.bump("final").is_err());
assert!(func.bump("invalid").is_err());
Ok(())
}
}