use std::path::Path;
use std::str::FromStr;
use semver::{Prerelease, Version};
use crate::error::{DkpError, DkpResult};
const DEFAULT_PRERELEASE_ID: &str = "pre";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionBump {
Major,
Minor,
Patch,
Premajor,
Preminor,
Prepatch,
Prerelease,
Explicit(Version),
}
impl FromStr for VersionBump {
type Err = DkpError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"major" => Ok(VersionBump::Major),
"minor" => Ok(VersionBump::Minor),
"patch" => Ok(VersionBump::Patch),
"premajor" => Ok(VersionBump::Premajor),
"preminor" => Ok(VersionBump::Preminor),
"prepatch" => Ok(VersionBump::Prepatch),
"prerelease" => Ok(VersionBump::Prerelease),
other => Version::parse(other)
.map(VersionBump::Explicit)
.map_err(|e| DkpError::VersionBumpInvalid {
version: other.to_string(),
reason: e.to_string(),
}),
}
}
}
pub fn bump_version(current: &Version, bump: &VersionBump) -> Version {
match bump {
VersionBump::Major => Version::new(current.major + 1, 0, 0),
VersionBump::Minor => Version::new(current.major, current.minor + 1, 0),
VersionBump::Patch => Version::new(current.major, current.minor, current.patch + 1),
VersionBump::Premajor => prerelease_of(Version::new(current.major + 1, 0, 0)),
VersionBump::Preminor => prerelease_of(Version::new(current.major, current.minor + 1, 0)),
VersionBump::Prepatch => prerelease_of(Version::new(
current.major,
current.minor,
current.patch + 1,
)),
VersionBump::Prerelease => bump_prerelease(current),
VersionBump::Explicit(v) => v.clone(),
}
}
fn prerelease_of(mut v: Version) -> Version {
v.pre = Prerelease::new(&format!("{DEFAULT_PRERELEASE_ID}.0")).expect("valid prerelease");
v
}
fn bump_prerelease(current: &Version) -> Version {
if !current.pre.is_empty() {
if let Some((prefix, num)) = current.pre.as_str().rsplit_once('.')
&& let Ok(n) = num.parse::<u64>()
{
let mut v = current.clone();
v.pre = Prerelease::new(&format!("{prefix}.{}", n + 1)).expect("valid prerelease");
return v;
}
let mut v = current.clone();
v.pre = Prerelease::new(&format!("{}.0", current.pre.as_str())).expect("valid prerelease");
return v;
}
let mut v = Version::new(current.major, current.minor, current.patch + 1);
v.pre = Prerelease::new(&format!("{DEFAULT_PRERELEASE_ID}.0")).expect("valid prerelease");
v
}
pub fn bump_manifest_version(
pack_root: &Path,
bump: &VersionBump,
allow_same_version: bool,
) -> DkpResult<(Version, Version)> {
let manifest_path = pack_root.join("manifest.json");
if !manifest_path.exists() {
return Err(DkpError::ManifestMissing(pack_root.to_path_buf()));
}
let bytes = std::fs::read(&manifest_path)?;
let mut value: serde_json::Value = serde_json::from_slice(&bytes)?;
let current_str = value
.get("version")
.and_then(|v| v.as_str())
.ok_or(DkpError::ManifestFieldMissing { field: "version" })?
.to_string();
let current = Version::parse(¤t_str).map_err(|e| DkpError::VersionInvalid {
version: current_str.clone(),
reason: e.to_string(),
})?;
let new_version = bump_version(¤t, bump);
if new_version == current && !allow_same_version {
return Err(DkpError::VersionUnchanged {
current: current.to_string(),
new: new_version.to_string(),
});
}
if let Some(obj) = value.as_object_mut() {
obj.insert(
"version".to_string(),
serde_json::Value::String(new_version.to_string()),
);
}
let serialized = serde_json::to_string_pretty(&value)?;
std::fs::write(&manifest_path, serialized + "\n")?;
Ok((current, new_version))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn v(s: &str) -> Version {
Version::parse(s).unwrap()
}
#[test]
fn patch_minor_major_bumps() {
assert_eq!(bump_version(&v("1.0.0"), &VersionBump::Patch), v("1.0.1"));
assert_eq!(bump_version(&v("1.0.0"), &VersionBump::Minor), v("1.1.0"));
assert_eq!(bump_version(&v("1.0.0"), &VersionBump::Major), v("2.0.0"));
}
#[test]
fn minor_bump_on_major_zero_has_no_special_case() {
assert_eq!(bump_version(&v("0.1.0"), &VersionBump::Minor), v("0.2.0"));
}
#[test]
fn pre_bumps_start_at_dot_zero() {
assert_eq!(
bump_version(&v("1.2.3"), &VersionBump::Premajor),
v("2.0.0-pre.0")
);
assert_eq!(
bump_version(&v("1.2.3"), &VersionBump::Preminor),
v("1.3.0-pre.0")
);
assert_eq!(
bump_version(&v("1.2.3"), &VersionBump::Prepatch),
v("1.2.4-pre.0")
);
}
#[test]
fn prerelease_bump_increments_existing_numeral() {
assert_eq!(
bump_version(&v("1.2.3"), &VersionBump::Prerelease),
v("1.2.4-pre.0")
);
assert_eq!(
bump_version(&v("1.2.4-pre.0"), &VersionBump::Prerelease),
v("1.2.4-pre.1")
);
}
#[test]
fn explicit_bump_uses_given_version() {
assert_eq!(
bump_version(&v("1.0.0"), &VersionBump::Explicit(v("9.9.9"))),
v("9.9.9")
);
}
#[test]
fn from_str_parses_keywords_and_explicit_versions() {
assert_eq!("patch".parse::<VersionBump>().unwrap(), VersionBump::Patch);
assert_eq!(
"1.2.3".parse::<VersionBump>().unwrap(),
VersionBump::Explicit(v("1.2.3"))
);
assert!("not-a-version".parse::<VersionBump>().is_err());
}
fn write_manifest(dir: &Path, contents: &str) {
std::fs::write(dir.join("manifest.json"), contents).unwrap();
}
fn minimal_manifest(version: &str) -> String {
format!(
r#"{{
"spec": "1.0.0",
"name": "test-pack",
"version": "{version}",
"domain": "testing",
"audience": "internal",
"intended_use": "unit tests",
"known_limitations": "none",
"update_date": "2026-01-01"
}}"#
)
}
#[test]
fn bump_manifest_version_updates_only_version_field() {
let tmp = TempDir::new().unwrap();
write_manifest(tmp.path(), &minimal_manifest("1.0.0"));
let (old, new) = bump_manifest_version(tmp.path(), &VersionBump::Patch, false).unwrap();
assert_eq!(old, v("1.0.0"));
assert_eq!(new, v("1.0.1"));
let written = std::fs::read_to_string(tmp.path().join("manifest.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(parsed["version"], "1.0.1");
assert_eq!(parsed["name"], "test-pack");
assert_eq!(parsed["update_date"], "2026-01-01");
}
#[test]
fn bump_manifest_version_preserves_unknown_fields_and_order() {
let tmp = TempDir::new().unwrap();
let contents = r#"{
"spec": "1.0.0",
"name": "test-pack",
"version": "1.0.0",
"domain": "testing",
"audience": "internal",
"intended_use": "unit tests",
"known_limitations": "none",
"update_date": "2026-01-01",
"future_field": {"nested": true}
}"#;
write_manifest(tmp.path(), contents);
bump_manifest_version(tmp.path(), &VersionBump::Minor, false).unwrap();
let written = std::fs::read_to_string(tmp.path().join("manifest.json")).unwrap();
let keys: Vec<&str> = {
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
parsed
.as_object()
.unwrap()
.keys()
.map(|k| Box::leak(k.clone().into_boxed_str()) as &str)
.collect()
};
assert_eq!(
keys,
vec![
"spec",
"name",
"version",
"domain",
"audience",
"intended_use",
"known_limitations",
"update_date",
"future_field",
]
);
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(parsed["future_field"]["nested"], true);
assert_eq!(parsed["version"], "1.1.0");
}
#[test]
fn bump_manifest_version_explicit_same_version_errors_without_flag() {
let tmp = TempDir::new().unwrap();
write_manifest(tmp.path(), &minimal_manifest("2.0.0"));
let err = bump_manifest_version(tmp.path(), &VersionBump::Explicit(v("2.0.0")), false)
.unwrap_err();
assert!(matches!(err, DkpError::VersionUnchanged { .. }));
let written = std::fs::read_to_string(tmp.path().join("manifest.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(parsed["version"], "2.0.0");
}
#[test]
fn bump_manifest_version_explicit_same_version_succeeds_with_flag() {
let tmp = TempDir::new().unwrap();
write_manifest(tmp.path(), &minimal_manifest("2.0.0"));
let (old, new) =
bump_manifest_version(tmp.path(), &VersionBump::Explicit(v("2.0.0")), true).unwrap();
assert_eq!(old, new);
}
#[test]
fn bump_manifest_version_invalid_current_version_errors() {
let tmp = TempDir::new().unwrap();
write_manifest(tmp.path(), &minimal_manifest("not-a-semver"));
let err = bump_manifest_version(tmp.path(), &VersionBump::Patch, false).unwrap_err();
assert!(matches!(err, DkpError::VersionInvalid { .. }));
}
#[test]
fn bump_manifest_version_missing_manifest_errors() {
let tmp = TempDir::new().unwrap();
let err = bump_manifest_version(tmp.path(), &VersionBump::Patch, false).unwrap_err();
assert!(matches!(err, DkpError::ManifestMissing(_)));
}
}