Skip to main content

standard_version/
json.rs

1//! JSON version file engines for `package.json` and `deno.json`/`deno.jsonc`.
2//!
3//! Provides [`JsonVersionFile`] for npm/Node `package.json` files (parsed via
4//! `serde_json`) and [`DenoVersionFile`] for Deno manifests (handled with
5//! line-level regex to support JSONC comments).
6
7use std::sync::LazyLock;
8
9use crate::version_file::{VersionFile, VersionFileError};
10
11/// Regex pattern matching a JSON `"version"` field value.
12///
13/// Captures the version string in group 1.
14const VERSION_PATTERN: &str = r#""version"\s*:\s*"([^"]+)""#;
15
16/// Lazily compiled regex for `"version": "..."` fields in JSON files.
17static VERSION_RE: LazyLock<regex::Regex> =
18    LazyLock::new(|| regex::Regex::new(VERSION_PATTERN).expect("valid regex"));
19
20// ---------------------------------------------------------------------------
21// JsonVersionFile (package.json)
22// ---------------------------------------------------------------------------
23
24/// Version file engine for `package.json`.
25///
26/// Uses `serde_json` for detection and reading, and a line-level regex for
27/// writing to preserve key order and formatting.
28#[derive(Debug, Clone, Copy)]
29pub struct JsonVersionFile;
30
31impl VersionFile for JsonVersionFile {
32    fn name(&self) -> &str {
33        "package.json"
34    }
35
36    fn filenames(&self) -> &[&str] {
37        &["package.json"]
38    }
39
40    fn detect(&self, content: &str) -> bool {
41        let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
42            return false;
43        };
44        value.get("version").and_then(|v| v.as_str()).is_some()
45    }
46
47    fn read_version(&self, content: &str) -> Option<String> {
48        let value: serde_json::Value = serde_json::from_str(content).ok()?;
49        value
50            .get("version")
51            .and_then(|v| v.as_str())
52            .map(|s| s.to_string())
53    }
54
55    fn write_version(&self, content: &str, new_version: &str) -> Result<String, VersionFileError> {
56        let re = &*VERSION_RE;
57        if !re.is_match(content) {
58            return Err(VersionFileError::NoVersionField);
59        }
60
61        // Replace only the first occurrence, preserving surrounding text.
62        let mut replaced = false;
63        let result = re.replace(content, |caps: &regex::Captures<'_>| {
64            if replaced {
65                return caps[0].to_string();
66            }
67            replaced = true;
68            let full = &caps[0];
69            let version_start = caps.get(1).unwrap().start() - caps.get(0).unwrap().start();
70            let version_end = caps.get(1).unwrap().end() - caps.get(0).unwrap().start();
71            format!(
72                "{}{}{}",
73                &full[..version_start],
74                new_version,
75                &full[version_end..],
76            )
77        });
78
79        Ok(result.into_owned())
80    }
81}
82
83// ---------------------------------------------------------------------------
84// DenoVersionFile (deno.json / deno.jsonc)
85// ---------------------------------------------------------------------------
86
87/// Version file engine for `deno.json` and `deno.jsonc`.
88///
89/// Uses line-level matching to find and replace the `"version"` field so that
90/// JSONC comments are preserved. The regex matches the first occurrence of
91/// `"version": "..."` in the file content.
92#[derive(Debug, Clone, Copy)]
93pub struct DenoVersionFile;
94
95impl VersionFile for DenoVersionFile {
96    fn name(&self) -> &str {
97        "deno.json"
98    }
99
100    fn filenames(&self) -> &[&str] {
101        &["deno.json", "deno.jsonc"]
102    }
103
104    fn detect(&self, content: &str) -> bool {
105        VERSION_RE.is_match(content)
106    }
107
108    fn read_version(&self, content: &str) -> Option<String> {
109        VERSION_RE
110            .captures(content)
111            .and_then(|caps| caps.get(1))
112            .map(|m| m.as_str().to_string())
113    }
114
115    fn write_version(&self, content: &str, new_version: &str) -> Result<String, VersionFileError> {
116        let re = &*VERSION_RE;
117        if !re.is_match(content) {
118            return Err(VersionFileError::NoVersionField);
119        }
120
121        // Replace only the first occurrence.
122        let mut replaced = false;
123        let result = re.replace(content, |caps: &regex::Captures<'_>| {
124            if replaced {
125                // Return the original match unchanged for subsequent occurrences.
126                return caps[0].to_string();
127            }
128            replaced = true;
129            let full = &caps[0];
130            let version_start = caps.get(1).unwrap().start() - caps.get(0).unwrap().start();
131            let version_end = caps.get(1).unwrap().end() - caps.get(0).unwrap().start();
132            format!(
133                "{}{}{}",
134                &full[..version_start],
135                new_version,
136                &full[version_end..],
137            )
138        });
139
140        Ok(result.into_owned())
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Tests
146// ---------------------------------------------------------------------------
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    // =======================================================================
153    // JsonVersionFile
154    // =======================================================================
155
156    const PACKAGE_JSON: &str = r#"{
157  "name": "my-app",
158  "version": "1.2.3",
159  "description": "An example package"
160}
161"#;
162
163    const PACKAGE_JSON_NO_VERSION: &str = r#"{
164  "name": "my-app",
165  "description": "No version here"
166}
167"#;
168
169    // --- detect ---
170
171    #[test]
172    fn json_detect_with_version() {
173        assert!(JsonVersionFile.detect(PACKAGE_JSON));
174    }
175
176    #[test]
177    fn json_detect_without_version() {
178        assert!(!JsonVersionFile.detect(PACKAGE_JSON_NO_VERSION));
179    }
180
181    #[test]
182    fn json_detect_invalid_json() {
183        assert!(!JsonVersionFile.detect("not json at all"));
184    }
185
186    // --- read_version ---
187
188    #[test]
189    fn json_read_version() {
190        assert_eq!(
191            JsonVersionFile.read_version(PACKAGE_JSON),
192            Some("1.2.3".to_string()),
193        );
194    }
195
196    #[test]
197    fn json_read_version_missing() {
198        assert_eq!(JsonVersionFile.read_version(PACKAGE_JSON_NO_VERSION), None);
199    }
200
201    // --- write_version ---
202
203    #[test]
204    fn json_write_version_updates_value() {
205        let result = JsonVersionFile
206            .write_version(PACKAGE_JSON, "2.0.0")
207            .unwrap();
208        assert!(result.contains(r#""version": "2.0.0""#));
209    }
210
211    #[test]
212    fn json_write_version_preserves_other_fields() {
213        let result = JsonVersionFile
214            .write_version(PACKAGE_JSON, "2.0.0")
215            .unwrap();
216        assert!(result.contains(r#""name": "my-app""#));
217        assert!(result.contains(r#""description": "An example package""#));
218    }
219
220    #[test]
221    fn json_write_version_preserves_key_order() {
222        let input = r#"{
223  "name": "my-app",
224  "version": "1.0.0",
225  "description": "example",
226  "main": "index.js"
227}
228"#;
229        let result = JsonVersionFile.write_version(input, "2.0.0").unwrap();
230        // Key order must be identical to the input.
231        let expected = r#"{
232  "name": "my-app",
233  "version": "2.0.0",
234  "description": "example",
235  "main": "index.js"
236}
237"#;
238        assert_eq!(result, expected);
239    }
240
241    #[test]
242    fn json_write_version_trailing_newline() {
243        let result = JsonVersionFile
244            .write_version(PACKAGE_JSON, "2.0.0")
245            .unwrap();
246        assert!(result.ends_with('\n'));
247    }
248
249    #[test]
250    fn json_write_version_no_field_returns_error() {
251        let err = JsonVersionFile.write_version(PACKAGE_JSON_NO_VERSION, "1.0.0");
252        assert!(err.is_err());
253    }
254
255    // =======================================================================
256    // DenoVersionFile
257    // =======================================================================
258
259    const DENO_JSON: &str = r#"{
260  "version": "0.5.0",
261  "tasks": {
262    "dev": "deno run --watch main.ts"
263  }
264}
265"#;
266
267    const DENO_JSONC: &str = r#"{
268  // The current release version.
269  "version": "0.5.0",
270  "tasks": {
271    "dev": "deno run --watch main.ts"
272  }
273}
274"#;
275
276    const DENO_NO_VERSION: &str = r#"{
277  "tasks": {
278    "dev": "deno run --watch main.ts"
279  }
280}
281"#;
282
283    // --- detect ---
284
285    #[test]
286    fn deno_detect_json() {
287        assert!(DenoVersionFile.detect(DENO_JSON));
288    }
289
290    #[test]
291    fn deno_detect_jsonc() {
292        assert!(DenoVersionFile.detect(DENO_JSONC));
293    }
294
295    #[test]
296    fn deno_detect_no_version() {
297        assert!(!DenoVersionFile.detect(DENO_NO_VERSION));
298    }
299
300    // --- read_version ---
301
302    #[test]
303    fn deno_read_version_json() {
304        assert_eq!(
305            DenoVersionFile.read_version(DENO_JSON),
306            Some("0.5.0".to_string()),
307        );
308    }
309
310    #[test]
311    fn deno_read_version_jsonc() {
312        assert_eq!(
313            DenoVersionFile.read_version(DENO_JSONC),
314            Some("0.5.0".to_string()),
315        );
316    }
317
318    #[test]
319    fn deno_read_version_missing() {
320        assert_eq!(DenoVersionFile.read_version(DENO_NO_VERSION), None);
321    }
322
323    // --- write_version ---
324
325    #[test]
326    fn deno_write_version_json() {
327        let result = DenoVersionFile.write_version(DENO_JSON, "1.0.0").unwrap();
328        assert!(result.contains(r#""version": "1.0.0""#));
329        // Other content preserved.
330        assert!(result.contains("tasks"));
331    }
332
333    #[test]
334    fn deno_write_version_jsonc_preserves_comments() {
335        let result = DenoVersionFile.write_version(DENO_JSONC, "1.0.0").unwrap();
336        assert!(result.contains(r#""version": "1.0.0""#));
337        assert!(result.contains("// The current release version."));
338    }
339
340    #[test]
341    fn deno_write_version_no_field_returns_error() {
342        let err = DenoVersionFile.write_version(DENO_NO_VERSION, "1.0.0");
343        assert!(err.is_err());
344    }
345
346    // =======================================================================
347    // Integration tests with tempdir
348    // =======================================================================
349
350    #[test]
351    fn integration_update_package_json() {
352        use crate::version_file::update_version_files;
353
354        let dir = tempfile::tempdir().unwrap();
355        let pkg = dir.path().join("package.json");
356        std::fs::write(&pkg, PACKAGE_JSON).unwrap();
357
358        let results = update_version_files(dir.path(), "3.0.0", &[]).unwrap();
359
360        assert_eq!(results.len(), 1);
361        assert_eq!(results[0].old_version, "1.2.3");
362        assert_eq!(results[0].new_version, "3.0.0");
363        assert_eq!(results[0].name, "package.json");
364
365        let on_disk = std::fs::read_to_string(&pkg).unwrap();
366        assert!(on_disk.contains(r#""version": "3.0.0""#));
367    }
368
369    #[test]
370    fn integration_update_deno_json() {
371        use crate::version_file::update_version_files;
372
373        let dir = tempfile::tempdir().unwrap();
374        let deno = dir.path().join("deno.json");
375        std::fs::write(&deno, DENO_JSON).unwrap();
376
377        let results = update_version_files(dir.path(), "1.0.0", &[]).unwrap();
378
379        assert_eq!(results.len(), 1);
380        assert_eq!(results[0].old_version, "0.5.0");
381        assert_eq!(results[0].new_version, "1.0.0");
382        assert_eq!(results[0].name, "deno.json");
383
384        let on_disk = std::fs::read_to_string(&deno).unwrap();
385        assert!(on_disk.contains(r#""version": "1.0.0""#));
386    }
387
388    #[test]
389    fn integration_update_deno_jsonc() {
390        use crate::version_file::update_version_files;
391
392        let dir = tempfile::tempdir().unwrap();
393        let deno = dir.path().join("deno.jsonc");
394        std::fs::write(&deno, DENO_JSONC).unwrap();
395
396        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
397
398        assert_eq!(results.len(), 1);
399        assert_eq!(results[0].old_version, "0.5.0");
400        assert_eq!(results[0].new_version, "2.0.0");
401        assert_eq!(results[0].name, "deno.json");
402
403        let on_disk = std::fs::read_to_string(&deno).unwrap();
404        assert!(on_disk.contains(r#""version": "2.0.0""#));
405        assert!(on_disk.contains("// The current release version."));
406    }
407}