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