1use std::path::Path;
2use std::str::FromStr;
3
4use semver::{Prerelease, Version};
5
6use crate::error::{DkpError, DkpResult};
7
8const DEFAULT_PRERELEASE_ID: &str = "pre";
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum VersionBump {
13 Major,
14 Minor,
15 Patch,
16 Premajor,
17 Preminor,
18 Prepatch,
19 Prerelease,
20 Explicit(Version),
21}
22
23impl FromStr for VersionBump {
24 type Err = DkpError;
25
26 fn from_str(s: &str) -> Result<Self, Self::Err> {
27 match s {
28 "major" => Ok(VersionBump::Major),
29 "minor" => Ok(VersionBump::Minor),
30 "patch" => Ok(VersionBump::Patch),
31 "premajor" => Ok(VersionBump::Premajor),
32 "preminor" => Ok(VersionBump::Preminor),
33 "prepatch" => Ok(VersionBump::Prepatch),
34 "prerelease" => Ok(VersionBump::Prerelease),
35 other => Version::parse(other)
36 .map(VersionBump::Explicit)
37 .map_err(|e| DkpError::VersionBumpInvalid {
38 version: other.to_string(),
39 reason: e.to_string(),
40 }),
41 }
42 }
43}
44
45pub fn bump_version(current: &Version, bump: &VersionBump) -> Version {
47 match bump {
48 VersionBump::Major => Version::new(current.major + 1, 0, 0),
49 VersionBump::Minor => Version::new(current.major, current.minor + 1, 0),
50 VersionBump::Patch => Version::new(current.major, current.minor, current.patch + 1),
51 VersionBump::Premajor => prerelease_of(Version::new(current.major + 1, 0, 0)),
52 VersionBump::Preminor => prerelease_of(Version::new(current.major, current.minor + 1, 0)),
53 VersionBump::Prepatch => prerelease_of(Version::new(
54 current.major,
55 current.minor,
56 current.patch + 1,
57 )),
58 VersionBump::Prerelease => bump_prerelease(current),
59 VersionBump::Explicit(v) => v.clone(),
60 }
61}
62
63fn prerelease_of(mut v: Version) -> Version {
64 v.pre = Prerelease::new(&format!("{DEFAULT_PRERELEASE_ID}.0")).expect("valid prerelease");
65 v
66}
67
68fn bump_prerelease(current: &Version) -> Version {
71 if !current.pre.is_empty() {
72 if let Some((prefix, num)) = current.pre.as_str().rsplit_once('.')
73 && let Ok(n) = num.parse::<u64>()
74 {
75 let mut v = current.clone();
76 v.pre = Prerelease::new(&format!("{prefix}.{}", n + 1)).expect("valid prerelease");
77 return v;
78 }
79 let mut v = current.clone();
81 v.pre = Prerelease::new(&format!("{}.0", current.pre.as_str())).expect("valid prerelease");
82 return v;
83 }
84
85 let mut v = Version::new(current.major, current.minor, current.patch + 1);
86 v.pre = Prerelease::new(&format!("{DEFAULT_PRERELEASE_ID}.0")).expect("valid prerelease");
87 v
88}
89
90pub fn bump_manifest_version(
97 pack_root: &Path,
98 bump: &VersionBump,
99 allow_same_version: bool,
100) -> DkpResult<(Version, Version)> {
101 let manifest_path = pack_root.join("manifest.json");
102 if !manifest_path.exists() {
103 return Err(DkpError::ManifestMissing(pack_root.to_path_buf()));
104 }
105
106 let bytes = std::fs::read(&manifest_path)?;
107 let mut value: serde_json::Value = serde_json::from_slice(&bytes)?;
108
109 let current_str = value
110 .get("version")
111 .and_then(|v| v.as_str())
112 .ok_or(DkpError::ManifestFieldMissing { field: "version" })?
113 .to_string();
114
115 let current = Version::parse(¤t_str).map_err(|e| DkpError::VersionInvalid {
116 version: current_str.clone(),
117 reason: e.to_string(),
118 })?;
119
120 let new_version = bump_version(¤t, bump);
121
122 if new_version == current && !allow_same_version {
123 return Err(DkpError::VersionUnchanged {
124 current: current.to_string(),
125 new: new_version.to_string(),
126 });
127 }
128
129 if let Some(obj) = value.as_object_mut() {
130 obj.insert(
131 "version".to_string(),
132 serde_json::Value::String(new_version.to_string()),
133 );
134 }
135
136 let serialized = serde_json::to_string_pretty(&value)?;
137 std::fs::write(&manifest_path, serialized + "\n")?;
138
139 Ok((current, new_version))
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use tempfile::TempDir;
146
147 fn v(s: &str) -> Version {
148 Version::parse(s).unwrap()
149 }
150
151 #[test]
152 fn patch_minor_major_bumps() {
153 assert_eq!(bump_version(&v("1.0.0"), &VersionBump::Patch), v("1.0.1"));
154 assert_eq!(bump_version(&v("1.0.0"), &VersionBump::Minor), v("1.1.0"));
155 assert_eq!(bump_version(&v("1.0.0"), &VersionBump::Major), v("2.0.0"));
156 }
157
158 #[test]
159 fn minor_bump_on_major_zero_has_no_special_case() {
160 assert_eq!(bump_version(&v("0.1.0"), &VersionBump::Minor), v("0.2.0"));
161 }
162
163 #[test]
164 fn pre_bumps_start_at_dot_zero() {
165 assert_eq!(
166 bump_version(&v("1.2.3"), &VersionBump::Premajor),
167 v("2.0.0-pre.0")
168 );
169 assert_eq!(
170 bump_version(&v("1.2.3"), &VersionBump::Preminor),
171 v("1.3.0-pre.0")
172 );
173 assert_eq!(
174 bump_version(&v("1.2.3"), &VersionBump::Prepatch),
175 v("1.2.4-pre.0")
176 );
177 }
178
179 #[test]
180 fn prerelease_bump_increments_existing_numeral() {
181 assert_eq!(
182 bump_version(&v("1.2.3"), &VersionBump::Prerelease),
183 v("1.2.4-pre.0")
184 );
185 assert_eq!(
186 bump_version(&v("1.2.4-pre.0"), &VersionBump::Prerelease),
187 v("1.2.4-pre.1")
188 );
189 }
190
191 #[test]
192 fn explicit_bump_uses_given_version() {
193 assert_eq!(
194 bump_version(&v("1.0.0"), &VersionBump::Explicit(v("9.9.9"))),
195 v("9.9.9")
196 );
197 }
198
199 #[test]
200 fn from_str_parses_keywords_and_explicit_versions() {
201 assert_eq!("patch".parse::<VersionBump>().unwrap(), VersionBump::Patch);
202 assert_eq!(
203 "1.2.3".parse::<VersionBump>().unwrap(),
204 VersionBump::Explicit(v("1.2.3"))
205 );
206 assert!("not-a-version".parse::<VersionBump>().is_err());
207 }
208
209 fn write_manifest(dir: &Path, contents: &str) {
210 std::fs::write(dir.join("manifest.json"), contents).unwrap();
211 }
212
213 fn minimal_manifest(version: &str) -> String {
214 format!(
215 r#"{{
216 "spec": "1.0.0",
217 "name": "test-pack",
218 "version": "{version}",
219 "domain": "testing",
220 "audience": "internal",
221 "intended_use": "unit tests",
222 "known_limitations": "none",
223 "update_date": "2026-01-01"
224 }}"#
225 )
226 }
227
228 #[test]
229 fn bump_manifest_version_updates_only_version_field() {
230 let tmp = TempDir::new().unwrap();
231 write_manifest(tmp.path(), &minimal_manifest("1.0.0"));
232
233 let (old, new) = bump_manifest_version(tmp.path(), &VersionBump::Patch, false).unwrap();
234 assert_eq!(old, v("1.0.0"));
235 assert_eq!(new, v("1.0.1"));
236
237 let written = std::fs::read_to_string(tmp.path().join("manifest.json")).unwrap();
238 let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
239 assert_eq!(parsed["version"], "1.0.1");
240 assert_eq!(parsed["name"], "test-pack");
241 assert_eq!(parsed["update_date"], "2026-01-01");
242 }
243
244 #[test]
245 fn bump_manifest_version_preserves_unknown_fields_and_order() {
246 let tmp = TempDir::new().unwrap();
247 let contents = r#"{
248 "spec": "1.0.0",
249 "name": "test-pack",
250 "version": "1.0.0",
251 "domain": "testing",
252 "audience": "internal",
253 "intended_use": "unit tests",
254 "known_limitations": "none",
255 "update_date": "2026-01-01",
256 "future_field": {"nested": true}
257 }"#;
258 write_manifest(tmp.path(), contents);
259
260 bump_manifest_version(tmp.path(), &VersionBump::Minor, false).unwrap();
261
262 let written = std::fs::read_to_string(tmp.path().join("manifest.json")).unwrap();
263 let keys: Vec<&str> = {
264 let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
265 parsed
266 .as_object()
267 .unwrap()
268 .keys()
269 .map(|k| Box::leak(k.clone().into_boxed_str()) as &str)
270 .collect()
271 };
272 assert_eq!(
273 keys,
274 vec![
275 "spec",
276 "name",
277 "version",
278 "domain",
279 "audience",
280 "intended_use",
281 "known_limitations",
282 "update_date",
283 "future_field",
284 ]
285 );
286 let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
287 assert_eq!(parsed["future_field"]["nested"], true);
288 assert_eq!(parsed["version"], "1.1.0");
289 }
290
291 #[test]
292 fn bump_manifest_version_explicit_same_version_errors_without_flag() {
293 let tmp = TempDir::new().unwrap();
294 write_manifest(tmp.path(), &minimal_manifest("2.0.0"));
295
296 let err = bump_manifest_version(tmp.path(), &VersionBump::Explicit(v("2.0.0")), false)
297 .unwrap_err();
298 assert!(matches!(err, DkpError::VersionUnchanged { .. }));
299
300 let written = std::fs::read_to_string(tmp.path().join("manifest.json")).unwrap();
302 let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
303 assert_eq!(parsed["version"], "2.0.0");
304 }
305
306 #[test]
307 fn bump_manifest_version_explicit_same_version_succeeds_with_flag() {
308 let tmp = TempDir::new().unwrap();
309 write_manifest(tmp.path(), &minimal_manifest("2.0.0"));
310
311 let (old, new) =
312 bump_manifest_version(tmp.path(), &VersionBump::Explicit(v("2.0.0")), true).unwrap();
313 assert_eq!(old, new);
314 }
315
316 #[test]
317 fn bump_manifest_version_invalid_current_version_errors() {
318 let tmp = TempDir::new().unwrap();
319 write_manifest(tmp.path(), &minimal_manifest("not-a-semver"));
320
321 let err = bump_manifest_version(tmp.path(), &VersionBump::Patch, false).unwrap_err();
322 assert!(matches!(err, DkpError::VersionInvalid { .. }));
323 }
324
325 #[test]
326 fn bump_manifest_version_missing_manifest_errors() {
327 let tmp = TempDir::new().unwrap();
328 let err = bump_manifest_version(tmp.path(), &VersionBump::Patch, false).unwrap_err();
329 assert!(matches!(err, DkpError::ManifestMissing(_)));
330 }
331}