1use std::sync::LazyLock;
8
9use crate::version_file::{VersionFile, VersionFileError};
10
11const VERSION_PATTERN: &str = r#""version"\s*:\s*"([^"]+)""#;
15
16static VERSION_RE: LazyLock<regex::Regex> =
18 LazyLock::new(|| regex::Regex::new(VERSION_PATTERN).expect("valid regex"));
19
20#[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 let mut replaced = false;
63 let result = re.replace(content, |caps: ®ex::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#[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 let mut replaced = false;
123 let result = re.replace(content, |caps: ®ex::Captures<'_>| {
124 if replaced {
125 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#[cfg(test)]
149mod tests {
150 use super::*;
151
152 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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 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 #[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}