1use std::fmt;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::cargo::CargoVersionFile;
12use crate::gradle::GradleVersionFile;
13use crate::json::{DenoVersionFile, JsonVersionFile};
14use crate::pubspec::PubspecVersionFile;
15use crate::pyproject::PyprojectVersionFile;
16use crate::regex_engine::RegexVersionFile;
17use crate::version_plain::PlainVersionFile;
18
19#[derive(Debug)]
25#[non_exhaustive]
26pub enum VersionFileError {
27 FileNotFound(PathBuf),
29 NoVersionField,
31 WriteFailed(std::io::Error),
33 ReadFailed(std::io::Error),
35 InvalidRegex(String),
37}
38
39impl fmt::Display for VersionFileError {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 match self {
42 Self::FileNotFound(p) => write!(f, "file not found: {}", p.display()),
43 Self::NoVersionField => write!(f, "no version field found"),
44 Self::WriteFailed(e) => write!(f, "write failed: {e}"),
45 Self::ReadFailed(e) => write!(f, "read failed: {e}"),
46 Self::InvalidRegex(msg) => write!(f, "invalid regex: {msg}"),
47 }
48 }
49}
50
51impl std::error::Error for VersionFileError {
52 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
53 match self {
54 Self::WriteFailed(e) | Self::ReadFailed(e) => Some(e),
55 _ => None,
56 }
57 }
58}
59
60pub trait VersionFile {
67 fn name(&self) -> &str;
69
70 fn filenames(&self) -> &[&str];
72
73 fn detect(&self, content: &str) -> bool;
75
76 fn read_version(&self, content: &str) -> Option<String>;
78
79 fn write_version(&self, content: &str, new_version: &str) -> Result<String, VersionFileError>;
81
82 fn extra_info(&self, _old_content: &str, _new_content: &str) -> Option<String> {
87 None
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct UpdateResult {
98 pub path: PathBuf,
100 pub name: String,
102 pub old_version: String,
104 pub new_version: String,
106 pub extra: Option<String>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct DetectedFile {
117 pub path: PathBuf,
119 pub name: String,
121 pub old_version: String,
123}
124
125#[derive(Debug, Clone)]
134pub struct CustomVersionFile {
135 pub path: PathBuf,
137 pub pattern: String,
139}
140
141pub fn update_version_files(
161 root: &Path,
162 new_version: &str,
163 custom_files: &[CustomVersionFile],
164) -> Result<Vec<UpdateResult>, VersionFileError> {
165 let custom_engines: Vec<RegexVersionFile> = custom_files
167 .iter()
168 .map(RegexVersionFile::new)
169 .collect::<Result<Vec<_>, _>>()?;
170
171 let engines: Vec<Box<dyn VersionFile>> = vec![
172 Box::new(CargoVersionFile),
173 Box::new(PyprojectVersionFile),
174 Box::new(JsonVersionFile),
175 Box::new(DenoVersionFile),
176 Box::new(PubspecVersionFile),
177 Box::new(GradleVersionFile),
178 Box::new(PlainVersionFile),
179 ];
180
181 let mut results = Vec::new();
182
183 for engine in &engines {
184 for filename in engine.filenames() {
185 let path = root.join(filename);
186 if !path.exists() {
187 continue;
188 }
189
190 let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
191
192 if !engine.detect(&content) {
193 continue;
194 }
195
196 let old_version = match engine.read_version(&content) {
197 Some(v) => v,
198 None => continue,
199 };
200
201 let updated = engine.write_version(&content, new_version)?;
202 let extra = engine.extra_info(&content, &updated);
203 let actual_new_version = engine
206 .read_version(&updated)
207 .unwrap_or_else(|| new_version.to_string());
208 fs::write(&path, &updated).map_err(VersionFileError::WriteFailed)?;
209
210 results.push(UpdateResult {
211 path,
212 name: engine.name().to_string(),
213 old_version,
214 new_version: actual_new_version,
215 extra,
216 });
217 }
218 }
219
220 for engine in &custom_engines {
222 let path = root.join(engine.path());
223 if !path.exists() {
224 continue;
225 }
226 let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
227 if !engine.detect(&content) {
228 continue;
229 }
230 let old_version = match engine.read_version(&content) {
231 Some(v) => v,
232 None => continue,
233 };
234 let updated = engine.write_version(&content, new_version)?;
235 let actual_new_version = engine
236 .read_version(&updated)
237 .unwrap_or_else(|| new_version.to_string());
238 fs::write(&path, &updated).map_err(VersionFileError::WriteFailed)?;
239 results.push(UpdateResult {
240 path,
241 name: engine.name(),
242 old_version,
243 new_version: actual_new_version,
244 extra: None,
245 });
246 }
247
248 Ok(results)
249}
250
251pub fn detect_version_files(
265 root: &Path,
266 custom_files: &[CustomVersionFile],
267) -> Result<Vec<DetectedFile>, VersionFileError> {
268 let custom_engines: Vec<RegexVersionFile> = custom_files
270 .iter()
271 .map(RegexVersionFile::new)
272 .collect::<Result<Vec<_>, _>>()?;
273
274 let engines: Vec<Box<dyn VersionFile>> = vec![
275 Box::new(CargoVersionFile),
276 Box::new(PyprojectVersionFile),
277 Box::new(JsonVersionFile),
278 Box::new(DenoVersionFile),
279 Box::new(PubspecVersionFile),
280 Box::new(GradleVersionFile),
281 Box::new(PlainVersionFile),
282 ];
283
284 let mut results = Vec::new();
285
286 for engine in &engines {
287 for filename in engine.filenames() {
288 let path = root.join(filename);
289 if !path.exists() {
290 continue;
291 }
292 let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
293 if !engine.detect(&content) {
294 continue;
295 }
296 let old_version = match engine.read_version(&content) {
297 Some(v) => v,
298 None => continue,
299 };
300 results.push(DetectedFile {
301 path,
302 name: engine.name().to_string(),
303 old_version,
304 });
305 }
306 }
307
308 for engine in &custom_engines {
309 let path = root.join(engine.path());
310 if !path.exists() {
311 continue;
312 }
313 let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
314 if !engine.detect(&content) {
315 continue;
316 }
317 let old_version = match engine.read_version(&content) {
318 Some(v) => v,
319 None => continue,
320 };
321 results.push(DetectedFile {
322 path,
323 name: engine.name(),
324 old_version,
325 });
326 }
327
328 Ok(results)
329}
330
331#[cfg(test)]
336mod tests {
337 use super::*;
338 use std::fs;
339
340 #[test]
341 fn update_version_files_updates_cargo_toml() {
342 let dir = tempfile::tempdir().unwrap();
343 let cargo_toml = dir.path().join("Cargo.toml");
344 fs::write(
345 &cargo_toml,
346 r#"[package]
347name = "example"
348version = "0.1.0"
349edition = "2024"
350"#,
351 )
352 .unwrap();
353
354 let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
355
356 assert_eq!(results.len(), 1);
357 assert_eq!(results[0].old_version, "0.1.0");
358 assert_eq!(results[0].new_version, "2.0.0");
359 assert_eq!(results[0].name, "Cargo.toml");
360 assert_eq!(results[0].path, cargo_toml);
361
362 let on_disk = fs::read_to_string(&cargo_toml).unwrap();
363 assert!(on_disk.contains("version = \"2.0.0\""));
364 }
365
366 #[test]
367 fn update_version_files_skips_missing_file() {
368 let dir = tempfile::tempdir().unwrap();
369 let results = update_version_files(dir.path(), "1.0.0", &[]).unwrap();
371 assert!(results.is_empty());
372 }
373
374 #[test]
375 fn update_version_files_skips_undetected() {
376 let dir = tempfile::tempdir().unwrap();
377 let cargo_toml = dir.path().join("Cargo.toml");
378 fs::write(&cargo_toml, "[dependencies]\nfoo = \"1\"\n").unwrap();
380
381 let results = update_version_files(dir.path(), "1.0.0", &[]).unwrap();
382 assert!(results.is_empty());
383 }
384
385 #[test]
386 fn update_version_files_updates_pyproject_toml() {
387 let dir = tempfile::tempdir().unwrap();
388 let pyproject = dir.path().join("pyproject.toml");
389 fs::write(
390 &pyproject,
391 r#"[project]
392name = "example"
393version = "0.1.0"
394requires-python = ">=3.8"
395"#,
396 )
397 .unwrap();
398
399 let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
400
401 assert_eq!(results.len(), 1);
402 assert_eq!(results[0].old_version, "0.1.0");
403 assert_eq!(results[0].new_version, "2.0.0");
404 assert_eq!(results[0].name, "pyproject.toml");
405 assert_eq!(results[0].path, pyproject);
406
407 let on_disk = fs::read_to_string(&pyproject).unwrap();
408 assert!(on_disk.contains("version = \"2.0.0\""));
409 }
410
411 #[test]
412 fn update_version_files_updates_pubspec_yaml() {
413 let dir = tempfile::tempdir().unwrap();
414 let pubspec = dir.path().join("pubspec.yaml");
415 fs::write(
416 &pubspec,
417 "name: my_app\nversion: 1.0.0\ndescription: test\n",
418 )
419 .unwrap();
420
421 let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
422
423 assert_eq!(results.len(), 1);
424 assert_eq!(results[0].old_version, "1.0.0");
425 assert_eq!(results[0].new_version, "2.0.0");
426 assert_eq!(results[0].name, "pubspec.yaml");
427
428 let on_disk = fs::read_to_string(&pubspec).unwrap();
429 assert!(on_disk.contains("version: 2.0.0"));
430 }
431
432 #[test]
433 fn update_version_files_updates_gradle_properties() {
434 let dir = tempfile::tempdir().unwrap();
435 let gradle = dir.path().join("gradle.properties");
436 fs::write(&gradle, "VERSION_NAME=1.0.0\nVERSION_CODE=10\n").unwrap();
437
438 let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
439
440 assert_eq!(results.len(), 1);
441 assert_eq!(results[0].old_version, "1.0.0");
442 assert_eq!(results[0].name, "gradle.properties");
443 assert_eq!(
444 results[0].extra,
445 Some("VERSION_CODE: 10 \u{2192} 11".to_string()),
446 );
447
448 let on_disk = fs::read_to_string(&gradle).unwrap();
449 assert!(on_disk.contains("VERSION_NAME=2.0.0"));
450 assert!(on_disk.contains("VERSION_CODE=11"));
451 }
452
453 #[test]
454 fn update_version_files_updates_version_file() {
455 let dir = tempfile::tempdir().unwrap();
456 let version = dir.path().join("VERSION");
457 fs::write(&version, "1.0.0\n").unwrap();
458
459 let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
460
461 assert_eq!(results.len(), 1);
462 assert_eq!(results[0].old_version, "1.0.0");
463 assert_eq!(results[0].name, "VERSION");
464
465 let on_disk = fs::read_to_string(&version).unwrap();
466 assert_eq!(on_disk, "2.0.0\n");
467 }
468
469 #[test]
470 fn update_version_files_updates_multiple_files() {
471 let dir = tempfile::tempdir().unwrap();
472 fs::write(
473 dir.path().join("Cargo.toml"),
474 "[package]\nname = \"x\"\nversion = \"1.0.0\"\n",
475 )
476 .unwrap();
477 fs::write(dir.path().join("pubspec.yaml"), "name: x\nversion: 1.0.0\n").unwrap();
478 fs::write(dir.path().join("VERSION"), "1.0.0\n").unwrap();
479
480 let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
481 assert_eq!(results.len(), 3);
482 }
483
484 #[test]
485 fn error_display() {
486 let err = VersionFileError::NoVersionField;
487 assert_eq!(err.to_string(), "no version field found");
488
489 let err = VersionFileError::FileNotFound(PathBuf::from("/tmp/gone"));
490 assert!(err.to_string().contains("/tmp/gone"));
491 }
492}