Skip to main content

standard_version/
scan.rs

1//! Version file discovery and updating.
2//!
3//! Scans a repository root for known version file formats and applies
4//! version updates. See [`update_version_files`] and [`detect_version_files`].
5
6use std::fs;
7use std::path::Path;
8
9use crate::cargo::CargoVersionFile;
10use crate::gradle::GradleVersionFile;
11use crate::json::{DenoVersionFile, JsonVersionFile};
12use crate::project::{ProjectJsonVersionFile, ProjectTomlVersionFile, ProjectYamlVersionFile};
13use crate::pubspec::PubspecVersionFile;
14use crate::pyproject::PyprojectVersionFile;
15use crate::regex_engine::RegexVersionFile;
16use crate::version_file::{
17    CustomVersionFile, DetectedFile, UpdateResult, VersionFile, VersionFileError,
18};
19use crate::version_plain::PlainVersionFile;
20
21/// Build the list of built-in version file engines.
22fn builtin_engines() -> Vec<Box<dyn VersionFile>> {
23    vec![
24        Box::new(CargoVersionFile),
25        Box::new(PyprojectVersionFile),
26        Box::new(JsonVersionFile),
27        Box::new(DenoVersionFile),
28        Box::new(PubspecVersionFile),
29        Box::new(GradleVersionFile),
30        Box::new(ProjectTomlVersionFile),
31        Box::new(ProjectJsonVersionFile),
32        Box::new(ProjectYamlVersionFile),
33        Box::new(PlainVersionFile),
34    ]
35}
36
37/// Discover and update version files at `root`.
38///
39/// Iterates all built-in version file engines ([`CargoVersionFile`],
40/// [`PyprojectVersionFile`], [`JsonVersionFile`], [`DenoVersionFile`],
41/// [`PubspecVersionFile`], [`GradleVersionFile`], [`PlainVersionFile`])
42/// and, for each file that is detected, replaces the version string with
43/// `new_version`. Then processes any user-defined `custom_files` using the
44/// [`RegexVersionFile`] engine.
45///
46/// Updated content is written back to disk.
47///
48/// # Errors
49///
50/// Returns a [`VersionFileError`] if a detected file cannot be read or
51/// written, or if a custom file has an invalid regex pattern.
52pub fn update_version_files(
53    root: &Path,
54    new_version: &str,
55    custom_files: &[CustomVersionFile],
56) -> Result<Vec<UpdateResult>, VersionFileError> {
57    // Validate all custom regexes upfront before any file writes.
58    let custom_engines: Vec<RegexVersionFile> = custom_files
59        .iter()
60        .map(RegexVersionFile::new)
61        .collect::<Result<Vec<_>, _>>()?;
62
63    let engines = builtin_engines();
64    let mut results = Vec::new();
65
66    for engine in &engines {
67        for filename in engine.filenames() {
68            let path = root.join(filename);
69            if !path.exists() {
70                continue;
71            }
72
73            let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
74
75            if !engine.detect(&content) {
76                continue;
77            }
78
79            let old_version = match engine.read_version(&content) {
80                Some(v) => v,
81                None => continue,
82            };
83
84            let updated = engine.write_version(&content, new_version)?;
85            let extra = engine.extra_info(&content, &updated);
86            // Read the actual version from updated content (may differ for
87            // pubspec build numbers or other engines with side-effects).
88            let actual_new_version = engine
89                .read_version(&updated)
90                .unwrap_or_else(|| new_version.to_string());
91            fs::write(&path, &updated).map_err(VersionFileError::WriteFailed)?;
92
93            results.push(UpdateResult {
94                path,
95                name: engine.name().to_string(),
96                old_version,
97                new_version: actual_new_version,
98                extra,
99            });
100        }
101    }
102
103    // Process custom version files (already validated).
104    for engine in &custom_engines {
105        let path = root.join(engine.path());
106        if !path.exists() {
107            continue;
108        }
109        let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
110        if !engine.detect(&content) {
111            continue;
112        }
113        let old_version = match engine.read_version(&content) {
114            Some(v) => v,
115            None => continue,
116        };
117        let updated = engine.write_version(&content, new_version)?;
118        let actual_new_version = engine
119            .read_version(&updated)
120            .unwrap_or_else(|| new_version.to_string());
121        fs::write(&path, &updated).map_err(VersionFileError::WriteFailed)?;
122        results.push(UpdateResult {
123            path,
124            name: engine.name(),
125            old_version,
126            new_version: actual_new_version,
127            extra: None,
128        });
129    }
130
131    Ok(results)
132}
133
134/// Detect version files at `root` without modifying them.
135///
136/// Returns a list of [`DetectedFile`] entries for each file that is found,
137/// detected, and has a readable version string. No files are written.
138///
139/// # Errors
140///
141/// Returns a [`VersionFileError`] if a file cannot be read or if a custom
142/// regex pattern is invalid.
143pub fn detect_version_files(
144    root: &Path,
145    custom_files: &[CustomVersionFile],
146) -> Result<Vec<DetectedFile>, VersionFileError> {
147    // Validate all custom regexes upfront.
148    let custom_engines: Vec<RegexVersionFile> = custom_files
149        .iter()
150        .map(RegexVersionFile::new)
151        .collect::<Result<Vec<_>, _>>()?;
152
153    let engines = builtin_engines();
154    let mut results = Vec::new();
155
156    for engine in &engines {
157        for filename in engine.filenames() {
158            let path = root.join(filename);
159            if !path.exists() {
160                continue;
161            }
162            let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
163            if !engine.detect(&content) {
164                continue;
165            }
166            let old_version = match engine.read_version(&content) {
167                Some(v) => v,
168                None => continue,
169            };
170            results.push(DetectedFile {
171                path,
172                name: engine.name().to_string(),
173                old_version,
174            });
175        }
176    }
177
178    for engine in &custom_engines {
179        let path = root.join(engine.path());
180        if !path.exists() {
181            continue;
182        }
183        let content = fs::read_to_string(&path).map_err(VersionFileError::ReadFailed)?;
184        if !engine.detect(&content) {
185            continue;
186        }
187        let old_version = match engine.read_version(&content) {
188            Some(v) => v,
189            None => continue,
190        };
191        results.push(DetectedFile {
192            path,
193            name: engine.name(),
194            old_version,
195        });
196    }
197
198    Ok(results)
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::fs;
205
206    #[test]
207    fn update_version_files_updates_cargo_toml() {
208        let dir = tempfile::tempdir().unwrap();
209        let cargo_toml = dir.path().join("Cargo.toml");
210        fs::write(
211            &cargo_toml,
212            r#"[package]
213name = "example"
214version = "0.1.0"
215edition = "2024"
216"#,
217        )
218        .unwrap();
219
220        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
221
222        assert_eq!(results.len(), 1);
223        assert_eq!(results[0].old_version, "0.1.0");
224        assert_eq!(results[0].new_version, "2.0.0");
225        assert_eq!(results[0].name, "Cargo.toml");
226        assert_eq!(results[0].path, cargo_toml);
227
228        let on_disk = fs::read_to_string(&cargo_toml).unwrap();
229        assert!(on_disk.contains("version = \"2.0.0\""));
230    }
231
232    #[test]
233    fn update_version_files_skips_missing_file() {
234        let dir = tempfile::tempdir().unwrap();
235        // No Cargo.toml present.
236        let results = update_version_files(dir.path(), "1.0.0", &[]).unwrap();
237        assert!(results.is_empty());
238    }
239
240    #[test]
241    fn update_version_files_skips_undetected() {
242        let dir = tempfile::tempdir().unwrap();
243        let cargo_toml = dir.path().join("Cargo.toml");
244        // File exists but has no [package] section.
245        fs::write(&cargo_toml, "[dependencies]\nfoo = \"1\"\n").unwrap();
246
247        let results = update_version_files(dir.path(), "1.0.0", &[]).unwrap();
248        assert!(results.is_empty());
249    }
250
251    #[test]
252    fn update_version_files_updates_pyproject_toml() {
253        let dir = tempfile::tempdir().unwrap();
254        let pyproject = dir.path().join("pyproject.toml");
255        fs::write(
256            &pyproject,
257            r#"[project]
258name = "example"
259version = "0.1.0"
260requires-python = ">=3.8"
261"#,
262        )
263        .unwrap();
264
265        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
266
267        assert_eq!(results.len(), 1);
268        assert_eq!(results[0].old_version, "0.1.0");
269        assert_eq!(results[0].new_version, "2.0.0");
270        assert_eq!(results[0].name, "pyproject.toml");
271        assert_eq!(results[0].path, pyproject);
272
273        let on_disk = fs::read_to_string(&pyproject).unwrap();
274        assert!(on_disk.contains("version = \"2.0.0\""));
275    }
276
277    #[test]
278    fn update_version_files_updates_pubspec_yaml() {
279        let dir = tempfile::tempdir().unwrap();
280        let pubspec = dir.path().join("pubspec.yaml");
281        fs::write(
282            &pubspec,
283            "name: my_app\nversion: 1.0.0\ndescription: test\n",
284        )
285        .unwrap();
286
287        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
288
289        assert_eq!(results.len(), 1);
290        assert_eq!(results[0].old_version, "1.0.0");
291        assert_eq!(results[0].new_version, "2.0.0");
292        assert_eq!(results[0].name, "pubspec.yaml");
293
294        let on_disk = fs::read_to_string(&pubspec).unwrap();
295        assert!(on_disk.contains("version: 2.0.0"));
296    }
297
298    #[test]
299    fn update_version_files_updates_gradle_properties() {
300        let dir = tempfile::tempdir().unwrap();
301        let gradle = dir.path().join("gradle.properties");
302        fs::write(&gradle, "VERSION_NAME=1.0.0\nVERSION_CODE=10\n").unwrap();
303
304        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
305
306        assert_eq!(results.len(), 1);
307        assert_eq!(results[0].old_version, "1.0.0");
308        assert_eq!(results[0].name, "gradle.properties");
309        assert_eq!(
310            results[0].extra,
311            Some("VERSION_CODE: 10 \u{2192} 11".to_string()),
312        );
313
314        let on_disk = fs::read_to_string(&gradle).unwrap();
315        assert!(on_disk.contains("VERSION_NAME=2.0.0"));
316        assert!(on_disk.contains("VERSION_CODE=11"));
317    }
318
319    #[test]
320    fn update_version_files_updates_version_file() {
321        let dir = tempfile::tempdir().unwrap();
322        let version = dir.path().join("VERSION");
323        fs::write(&version, "1.0.0\n").unwrap();
324
325        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
326
327        assert_eq!(results.len(), 1);
328        assert_eq!(results[0].old_version, "1.0.0");
329        assert_eq!(results[0].name, "VERSION");
330
331        let on_disk = fs::read_to_string(&version).unwrap();
332        assert_eq!(on_disk, "2.0.0\n");
333    }
334
335    #[test]
336    fn update_version_files_updates_multiple_files() {
337        let dir = tempfile::tempdir().unwrap();
338        fs::write(
339            dir.path().join("Cargo.toml"),
340            "[package]\nname = \"x\"\nversion = \"1.0.0\"\n",
341        )
342        .unwrap();
343        fs::write(dir.path().join("pubspec.yaml"), "name: x\nversion: 1.0.0\n").unwrap();
344        fs::write(dir.path().join("VERSION"), "1.0.0\n").unwrap();
345
346        let results = update_version_files(dir.path(), "2.0.0", &[]).unwrap();
347        assert_eq!(results.len(), 3);
348    }
349
350    #[test]
351    fn error_display() {
352        let err = VersionFileError::NoVersionField;
353        assert_eq!(err.to_string(), "no version field found");
354
355        let err = VersionFileError::FileNotFound(std::path::PathBuf::from("/tmp/gone"));
356        assert!(err.to_string().contains("/tmp/gone"));
357    }
358}