1use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::cargo::CargoVersionFile;
11use crate::gradle::GradleVersionFile;
12use crate::json::{DenoVersionFile, JsonVersionFile};
13use crate::pubspec::PubspecVersionFile;
14use crate::pyproject::PyprojectVersionFile;
15use crate::regex_engine::RegexVersionFile;
16use crate::version_plain::PlainVersionFile;
17
18#[derive(Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum VersionFileError {
26 #[error("file not found: {}", .0.display())]
28 FileNotFound(PathBuf),
29 #[error("no version field found")]
31 NoVersionField,
32 #[error("write failed: {0}")]
34 WriteFailed(#[source] std::io::Error),
35 #[error("read failed: {0}")]
37 ReadFailed(#[source] std::io::Error),
38 #[error("invalid regex: {0}")]
40 InvalidRegex(String),
41}
42
43pub trait VersionFile {
50 fn name(&self) -> &str;
52
53 fn filenames(&self) -> &[&str];
55
56 fn detect(&self, content: &str) -> bool;
58
59 fn read_version(&self, content: &str) -> Option<String>;
61
62 fn write_version(&self, content: &str, new_version: &str) -> Result<String, VersionFileError>;
64
65 fn extra_info(&self, _old_content: &str, _new_content: &str) -> Option<String> {
70 None
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct UpdateResult {
81 pub path: PathBuf,
83 pub name: String,
85 pub old_version: String,
87 pub new_version: String,
89 pub extra: Option<String>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct DetectedFile {
100 pub path: PathBuf,
102 pub name: String,
104 pub old_version: String,
106}
107
108#[derive(Debug, Clone)]
117pub struct CustomVersionFile {
118 pub path: PathBuf,
120 pub pattern: String,
122}
123
124pub fn update_version_files(
144 root: &Path,
145 new_version: &str,
146 custom_files: &[CustomVersionFile],
147) -> Result<Vec<UpdateResult>, VersionFileError> {
148 let custom_engines: Vec<RegexVersionFile> = custom_files
150 .iter()
151 .map(RegexVersionFile::new)
152 .collect::<Result<Vec<_>, _>>()?;
153
154 let engines: Vec<Box<dyn VersionFile>> = vec![
155 Box::new(CargoVersionFile),
156 Box::new(PyprojectVersionFile),
157 Box::new(JsonVersionFile),
158 Box::new(DenoVersionFile),
159 Box::new(PubspecVersionFile),
160 Box::new(GradleVersionFile),
161 Box::new(PlainVersionFile),
162 ];
163
164 let mut results = Vec::new();
165
166 for engine in &engines {
167 for filename in engine.filenames() {
168 let path = root.join(filename);
169 if !path.exists() {
170 continue;
171 }
172
173 let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
174
175 if !engine.detect(&content) {
176 continue;
177 }
178
179 let old_version = match engine.read_version(&content) {
180 Some(v) => v,
181 None => continue,
182 };
183
184 let updated = engine.write_version(&content, new_version)?;
185 let extra = engine.extra_info(&content, &updated);
186 let actual_new_version = engine
189 .read_version(&updated)
190 .unwrap_or_else(|| new_version.to_string());
191 fs::write(&path, &updated).map_err(VersionFileError::WriteFailed)?;
192
193 results.push(UpdateResult {
194 path,
195 name: engine.name().to_string(),
196 old_version,
197 new_version: actual_new_version,
198 extra,
199 });
200 }
201 }
202
203 for engine in &custom_engines {
205 let path = root.join(engine.path());
206 if !path.exists() {
207 continue;
208 }
209 let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
210 if !engine.detect(&content) {
211 continue;
212 }
213 let old_version = match engine.read_version(&content) {
214 Some(v) => v,
215 None => continue,
216 };
217 let updated = engine.write_version(&content, new_version)?;
218 let actual_new_version = engine
219 .read_version(&updated)
220 .unwrap_or_else(|| new_version.to_string());
221 fs::write(&path, &updated).map_err(VersionFileError::WriteFailed)?;
222 results.push(UpdateResult {
223 path,
224 name: engine.name(),
225 old_version,
226 new_version: actual_new_version,
227 extra: None,
228 });
229 }
230
231 Ok(results)
232}
233
234pub fn detect_version_files(
248 root: &Path,
249 custom_files: &[CustomVersionFile],
250) -> Result<Vec<DetectedFile>, VersionFileError> {
251 let custom_engines: Vec<RegexVersionFile> = custom_files
253 .iter()
254 .map(RegexVersionFile::new)
255 .collect::<Result<Vec<_>, _>>()?;
256
257 let engines: Vec<Box<dyn VersionFile>> = vec![
258 Box::new(CargoVersionFile),
259 Box::new(PyprojectVersionFile),
260 Box::new(JsonVersionFile),
261 Box::new(DenoVersionFile),
262 Box::new(PubspecVersionFile),
263 Box::new(GradleVersionFile),
264 Box::new(PlainVersionFile),
265 ];
266
267 let mut results = Vec::new();
268
269 for engine in &engines {
270 for filename in engine.filenames() {
271 let path = root.join(filename);
272 if !path.exists() {
273 continue;
274 }
275 let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
276 if !engine.detect(&content) {
277 continue;
278 }
279 let old_version = match engine.read_version(&content) {
280 Some(v) => v,
281 None => continue,
282 };
283 results.push(DetectedFile {
284 path,
285 name: engine.name().to_string(),
286 old_version,
287 });
288 }
289 }
290
291 for engine in &custom_engines {
292 let path = root.join(engine.path());
293 if !path.exists() {
294 continue;
295 }
296 let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
297 if !engine.detect(&content) {
298 continue;
299 }
300 let old_version = match engine.read_version(&content) {
301 Some(v) => v,
302 None => continue,
303 };
304 results.push(DetectedFile {
305 path,
306 name: engine.name(),
307 old_version,
308 });
309 }
310
311 Ok(results)
312}
313
314#[cfg(test)]
319mod tests {
320 use super::*;
321 use std::fs;
322
323 #[test]
324 fn update_version_files_updates_cargo_toml() {
325 let dir = tempfile::tempdir().unwrap();
326 let cargo_toml = dir.path().join("Cargo.toml");
327 fs::write(
328 &cargo_toml,
329 r#"[package]
330name = "example"
331version = "0.1.0"
332edition = "2024"
333"#,
334 )
335 .unwrap();
336
337 let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
338
339 assert_eq!(results.len(), 1);
340 assert_eq!(results[0].old_version, "0.1.0");
341 assert_eq!(results[0].new_version, "2.0.0");
342 assert_eq!(results[0].name, "Cargo.toml");
343 assert_eq!(results[0].path, cargo_toml);
344
345 let on_disk = fs::read_to_string(&cargo_toml).unwrap();
346 assert!(on_disk.contains("version = \"2.0.0\""));
347 }
348
349 #[test]
350 fn update_version_files_skips_missing_file() {
351 let dir = tempfile::tempdir().unwrap();
352 let results = update_version_files(dir.path(), "1.0.0", &[]).unwrap();
354 assert!(results.is_empty());
355 }
356
357 #[test]
358 fn update_version_files_skips_undetected() {
359 let dir = tempfile::tempdir().unwrap();
360 let cargo_toml = dir.path().join("Cargo.toml");
361 fs::write(&cargo_toml, "[dependencies]\nfoo = \"1\"\n").unwrap();
363
364 let results = update_version_files(dir.path(), "1.0.0", &[]).unwrap();
365 assert!(results.is_empty());
366 }
367
368 #[test]
369 fn update_version_files_updates_pyproject_toml() {
370 let dir = tempfile::tempdir().unwrap();
371 let pyproject = dir.path().join("pyproject.toml");
372 fs::write(
373 &pyproject,
374 r#"[project]
375name = "example"
376version = "0.1.0"
377requires-python = ">=3.8"
378"#,
379 )
380 .unwrap();
381
382 let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
383
384 assert_eq!(results.len(), 1);
385 assert_eq!(results[0].old_version, "0.1.0");
386 assert_eq!(results[0].new_version, "2.0.0");
387 assert_eq!(results[0].name, "pyproject.toml");
388 assert_eq!(results[0].path, pyproject);
389
390 let on_disk = fs::read_to_string(&pyproject).unwrap();
391 assert!(on_disk.contains("version = \"2.0.0\""));
392 }
393
394 #[test]
395 fn update_version_files_updates_pubspec_yaml() {
396 let dir = tempfile::tempdir().unwrap();
397 let pubspec = dir.path().join("pubspec.yaml");
398 fs::write(
399 &pubspec,
400 "name: my_app\nversion: 1.0.0\ndescription: test\n",
401 )
402 .unwrap();
403
404 let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
405
406 assert_eq!(results.len(), 1);
407 assert_eq!(results[0].old_version, "1.0.0");
408 assert_eq!(results[0].new_version, "2.0.0");
409 assert_eq!(results[0].name, "pubspec.yaml");
410
411 let on_disk = fs::read_to_string(&pubspec).unwrap();
412 assert!(on_disk.contains("version: 2.0.0"));
413 }
414
415 #[test]
416 fn update_version_files_updates_gradle_properties() {
417 let dir = tempfile::tempdir().unwrap();
418 let gradle = dir.path().join("gradle.properties");
419 fs::write(&gradle, "VERSION_NAME=1.0.0\nVERSION_CODE=10\n").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].name, "gradle.properties");
426 assert_eq!(
427 results[0].extra,
428 Some("VERSION_CODE: 10 \u{2192} 11".to_string()),
429 );
430
431 let on_disk = fs::read_to_string(&gradle).unwrap();
432 assert!(on_disk.contains("VERSION_NAME=2.0.0"));
433 assert!(on_disk.contains("VERSION_CODE=11"));
434 }
435
436 #[test]
437 fn update_version_files_updates_version_file() {
438 let dir = tempfile::tempdir().unwrap();
439 let version = dir.path().join("VERSION");
440 fs::write(&version, "1.0.0\n").unwrap();
441
442 let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
443
444 assert_eq!(results.len(), 1);
445 assert_eq!(results[0].old_version, "1.0.0");
446 assert_eq!(results[0].name, "VERSION");
447
448 let on_disk = fs::read_to_string(&version).unwrap();
449 assert_eq!(on_disk, "2.0.0\n");
450 }
451
452 #[test]
453 fn update_version_files_updates_multiple_files() {
454 let dir = tempfile::tempdir().unwrap();
455 fs::write(
456 dir.path().join("Cargo.toml"),
457 "[package]\nname = \"x\"\nversion = \"1.0.0\"\n",
458 )
459 .unwrap();
460 fs::write(dir.path().join("pubspec.yaml"), "name: x\nversion: 1.0.0\n").unwrap();
461 fs::write(dir.path().join("VERSION"), "1.0.0\n").unwrap();
462
463 let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
464 assert_eq!(results.len(), 3);
465 }
466
467 #[test]
468 fn error_display() {
469 let err = VersionFileError::NoVersionField;
470 assert_eq!(err.to_string(), "no version field found");
471
472 let err = VersionFileError::FileNotFound(PathBuf::from("/tmp/gone"));
473 assert!(err.to_string().contains("/tmp/gone"));
474 }
475}