Skip to main content

dkp_core/pack/
version.rs

1use std::path::Path;
2use std::str::FromStr;
3
4use semver::{Prerelease, Version};
5
6use crate::error::{DkpError, DkpResult};
7
8/// Default prerelease identifier used for `pre*` bumps, matching npm's default `pre` id.
9const 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
45/// Computes the version resulting from applying `bump` to `current`.
46pub 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
68/// If `current` already has a prerelease with a trailing numeral, increments it.
69/// Otherwise starts a new prerelease at `.0` on the current version's numbers.
70fn 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        // No trailing numeral to increment; restart numbering under the same id.
80        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
90/// Reads `manifest.json` at `pack_root`, applies `bump`, and writes the result back,
91/// mutating only the `"version"` key and leaving all other keys (including any
92/// unmodeled/future fields) untouched and in their original order.
93///
94/// Returns `(old_version, new_version)`. Errors (without writing) if the new version
95/// equals the current version and `allow_same_version` is false.
96pub 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(&current_str).map_err(|e| DkpError::VersionInvalid {
116        version: current_str.clone(),
117        reason: e.to_string(),
118    })?;
119
120    let new_version = bump_version(&current, 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        // Manifest must remain unchanged on disk.
301        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}