Skip to main content

devflow_core/
version.rs

1//! Hybrid Git-based SemVer.
2//!
3//! DevFlow derives the version from a mix of the project's version file and git
4//! history rather than a config-driven scheme:
5//!
6//! - **MAJOR** — read from the auto-detected version file (`Cargo.toml`,
7//!   `pyproject.toml`, or `package.json`). This is the one component a human
8//!   bumps deliberately.
9//! - **MINOR** — the number of git tags (one tag per shipped milestone).
10//! - **PATCH** — commits since the most recent tag.
11
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15/// A computed semantic version.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct Version {
18    /// Major component, from the version file.
19    pub major: u32,
20    /// Minor component, from the git tag count.
21    pub minor: u32,
22    /// Patch component, from commits since the last tag.
23    pub patch: u32,
24}
25
26impl std::fmt::Display for Version {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
29    }
30}
31
32/// Errors produced by version operations.
33#[derive(Debug, thiserror::Error)]
34pub enum VersionError {
35    /// Filesystem operation failed.
36    #[error("version file I/O failed: {0}")]
37    Io(#[from] std::io::Error),
38    /// Version field could not be found or parsed.
39    #[error("version parse failed: {0}")]
40    Parse(String),
41    /// A git command failed.
42    #[error("git command failed: {0}")]
43    Git(String),
44}
45
46/// Detect the project's version file, checking Cargo.toml, then pyproject.toml,
47/// then package.json. Returns the first that exists.
48pub fn detect_version_file(project_root: &Path) -> Option<PathBuf> {
49    for name in ["Cargo.toml", "pyproject.toml", "package.json"] {
50        let path = project_root.join(name);
51        if path.exists() {
52            return Some(path);
53        }
54    }
55    None
56}
57
58/// The dotted field path that holds the version in a given file.
59fn field_for(path: &Path, contents: &str) -> &'static str {
60    match path.file_name().and_then(|n| n.to_str()) {
61        Some("Cargo.toml") => {
62            if contents.contains("[workspace.package]") {
63                "workspace.package.version"
64            } else {
65                "package.version"
66            }
67        }
68        Some("pyproject.toml") => "project.version",
69        Some("package.json") => "version",
70        _ => "version",
71    }
72}
73
74/// Read the MAJOR version component from a version file.
75pub fn read_major_version(path: &Path) -> Result<u32, VersionError> {
76    let contents = std::fs::read_to_string(path)?;
77    let field = field_for(path, &contents);
78    let version = find_version_in_contents(&contents, field)
79        .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
80    let major = version
81        .split(['.', '+', '-'])
82        .next()
83        .unwrap_or("0")
84        .parse::<u32>()
85        .map_err(|err| VersionError::Parse(format!("invalid major in `{version}`: {err}")))?;
86    Ok(major)
87}
88
89/// Count all git tags (the MINOR component).
90pub fn count_git_tags(project_root: &Path) -> Result<u32, VersionError> {
91    let output = Command::new("git")
92        .arg("tag")
93        .current_dir(project_root)
94        .output()
95        .map_err(|err| VersionError::Git(err.to_string()))?;
96    if !output.status.success() {
97        return Err(VersionError::Git(
98            String::from_utf8_lossy(&output.stderr).trim().to_string(),
99        ));
100    }
101    let count = String::from_utf8_lossy(&output.stdout)
102        .lines()
103        .filter(|l| !l.trim().is_empty())
104        .count();
105    Ok(count as u32)
106}
107
108/// Count commits since the most recent tag (the PATCH component). If there are
109/// no tags yet, counts all commits reachable from HEAD.
110pub fn commits_since_last_minor_tag(project_root: &Path) -> Result<u32, VersionError> {
111    let last_tag = Command::new("git")
112        .args(["describe", "--tags", "--abbrev=0"])
113        .current_dir(project_root)
114        .output()
115        .map_err(|err| VersionError::Git(err.to_string()))?;
116
117    let range = if last_tag.status.success() {
118        let tag = String::from_utf8_lossy(&last_tag.stdout).trim().to_string();
119        format!("{tag}..HEAD")
120    } else {
121        "HEAD".to_string()
122    };
123
124    let output = Command::new("git")
125        .args(["rev-list", "--count", &range])
126        .current_dir(project_root)
127        .output()
128        .map_err(|err| VersionError::Git(err.to_string()))?;
129    if !output.status.success() {
130        // No commits yet (e.g. empty repo) → zero patch.
131        return Ok(0);
132    }
133    let count = String::from_utf8_lossy(&output.stdout)
134        .trim()
135        .parse::<u32>()
136        .unwrap_or(0);
137    Ok(count)
138}
139
140/// Compute the full version: MAJOR from the version file, MINOR from the tag
141/// count, PATCH from commits since the last tag.
142pub fn compute_version(project_root: &Path) -> Result<Version, VersionError> {
143    let major = match detect_version_file(project_root) {
144        Some(path) => read_major_version(&path)?,
145        None => 0,
146    };
147    let minor = count_git_tags(project_root)?;
148    let patch = commits_since_last_minor_tag(project_root)?;
149    Ok(Version {
150        major,
151        minor,
152        patch,
153    })
154}
155
156/// Write `version` into the project's auto-detected version file.
157pub fn write_version(project_root: &Path, version: &Version) -> Result<PathBuf, VersionError> {
158    let path = detect_version_file(project_root)
159        .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
160    let contents = std::fs::read_to_string(&path)?;
161    let field = field_for(&path, &contents);
162    let replaced = replace_version_in_contents(&contents, field, &version.to_string())
163        .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found")))?;
164    std::fs::write(&path, replaced)?;
165    Ok(path)
166}
167
168/// Split a dotted field path into its TOML section path and the final key.
169fn split_field(field: &str) -> (&str, &str) {
170    match field.rsplit_once('.') {
171        Some((section, key)) => (section, key),
172        None => ("", field),
173    }
174}
175
176/// Return the dotted table path for a TOML section header line, if any.
177fn parse_section_header(trimmed: &str) -> Option<&str> {
178    let inner = if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
179        trimmed.strip_prefix("[[")?.strip_suffix("]]")?
180    } else {
181        trimmed.strip_prefix('[')?.strip_suffix(']')?
182    };
183    Some(inner.trim())
184}
185
186fn find_version_in_contents(contents: &str, field: &str) -> Option<String> {
187    let (section, key) = split_field(field);
188    let mut current = "";
189    for line in contents.lines() {
190        let trimmed = line.trim();
191        if let Some(header) = parse_section_header(trimmed) {
192            current = header;
193            continue;
194        }
195        if current != section {
196            continue;
197        }
198        if let Some((lhs, value)) = trimmed.split_once(['=', ':']) {
199            let lhs_key = lhs.trim().trim_matches('"').trim_matches('\'');
200            if lhs_key != key {
201                continue;
202            }
203            let value = value.trim();
204            if value.starts_with('{') {
205                continue;
206            }
207            return Some(value.trim_matches(['"', '\'']).to_string());
208        }
209    }
210    None
211}
212
213fn replace_version_in_contents(contents: &str, field: &str, new_version: &str) -> Option<String> {
214    let (section, key) = split_field(field);
215    let mut current = "";
216    let mut changed = false;
217    let mut output = String::new();
218    for line in contents.lines() {
219        let trimmed = line.trim();
220        if let Some(header) = parse_section_header(trimmed) {
221            current = header;
222            output.push_str(line);
223            output.push('\n');
224            continue;
225        }
226        if !changed
227            && current == section
228            && let Some((left, value)) = line.split_once(['=', ':'])
229        {
230            let left_key = left.trim().trim_matches('"').trim_matches('\'');
231            if left_key == key && !value.trim().starts_with('{') {
232                let separator: &str = if trimmed.contains('=') { " = " } else { ": " };
233                let quote_char: &str = if value.trim().starts_with('\'') {
234                    "'"
235                } else {
236                    "\""
237                };
238                let needs_quote = value.trim().starts_with('"') || value.trim().starts_with('\'');
239                output.push_str(left.trim_end());
240                output.push_str(separator);
241                if needs_quote {
242                    output.push_str(quote_char);
243                    output.push_str(new_version);
244                    output.push_str(quote_char);
245                } else {
246                    output.push_str(new_version);
247                }
248                output.push('\n');
249                changed = true;
250                continue;
251            }
252        }
253        output.push_str(line);
254        output.push('\n');
255    }
256    changed.then_some(output)
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use std::process::Command;
263
264    fn git(root: &Path, args: &[&str]) {
265        let ok = Command::new("git")
266            .args(args)
267            .current_dir(root)
268            .output()
269            .unwrap()
270            .status
271            .success();
272        assert!(ok, "git {args:?} failed");
273    }
274
275    fn init_repo(root: &Path) {
276        git(root, &["init", "-q"]);
277        git(root, &["config", "user.email", "test@example.com"]);
278        git(root, &["config", "user.name", "Test"]);
279        git(root, &["config", "commit.gpgsign", "false"]);
280        git(root, &["config", "tag.gpgsign", "false"]);
281        git(root, &["config", "core.hooksPath", "/dev/null"]);
282    }
283
284    fn commit(root: &Path, name: &str) {
285        std::fs::write(root.join(name), name).unwrap();
286        git(root, &["add", "."]);
287        git(root, &["commit", "-q", "-m", &format!("add {name}")]);
288    }
289
290    #[test]
291    fn detect_prefers_cargo_then_pyproject_then_package_json() {
292        let dir = tempfile::tempdir().unwrap();
293        assert!(detect_version_file(dir.path()).is_none());
294        std::fs::write(dir.path().join("package.json"), "{\"version\":\"1.0.0\"}").unwrap();
295        assert!(
296            detect_version_file(dir.path())
297                .unwrap()
298                .ends_with("package.json")
299        );
300        std::fs::write(
301            dir.path().join("Cargo.toml"),
302            "[package]\nversion=\"1.0.0\"",
303        )
304        .unwrap();
305        assert!(
306            detect_version_file(dir.path())
307                .unwrap()
308                .ends_with("Cargo.toml")
309        );
310    }
311
312    #[test]
313    fn read_major_from_workspace_package() {
314        let dir = tempfile::tempdir().unwrap();
315        let file = dir.path().join("Cargo.toml");
316        std::fs::write(
317            &file,
318            "[workspace.package]\nversion = \"2.5.7\"\nedition = \"2024\"\n",
319        )
320        .unwrap();
321        assert_eq!(read_major_version(&file).unwrap(), 2);
322    }
323
324    #[test]
325    fn inline_table_version_does_not_shadow_workspace_package() {
326        assert_eq!(parse_section_header("[[bin]]"), Some("bin"));
327
328        let dir = tempfile::tempdir().unwrap();
329        let file = dir.path().join("Cargo.toml");
330        std::fs::write(
331            &file,
332            "[[bin]]\nname = \"devflow\"\n\
333             [workspace.dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n\
334             [workspace.package]\nversion = \"1.2.0\"\n",
335        )
336        .unwrap();
337
338        assert_eq!(read_major_version(&file).unwrap(), 1);
339        write_version(
340            dir.path(),
341            &Version {
342                major: 2,
343                minor: 3,
344                patch: 4,
345            },
346        )
347        .unwrap();
348        let contents = std::fs::read_to_string(file).unwrap();
349        assert!(contents.contains("serde = { version = \"1\""));
350        assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
351    }
352
353    #[test]
354    fn read_major_from_package_json() {
355        let dir = tempfile::tempdir().unwrap();
356        let file = dir.path().join("package.json");
357        std::fs::write(&file, "{\n  \"version\": \"3.1.0\"\n}\n").unwrap();
358        assert_eq!(read_major_version(&file).unwrap(), 3);
359    }
360
361    #[test]
362    fn count_tags_and_commits_drive_minor_and_patch() {
363        let dir = tempfile::tempdir().unwrap();
364        let root = dir.path();
365        init_repo(root);
366        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
367        commit(root, "a.txt");
368        // No tags yet → minor 0, patch counts all commits.
369        assert_eq!(count_git_tags(root).unwrap(), 0);
370        let v = compute_version(root).unwrap();
371        assert_eq!(v.major, 2);
372        assert_eq!(v.minor, 0);
373        assert!(v.patch >= 1);
374
375        git(root, &["tag", "v2.0.0"]);
376        commit(root, "b.txt");
377        commit(root, "c.txt");
378        assert_eq!(count_git_tags(root).unwrap(), 1);
379        assert_eq!(commits_since_last_minor_tag(root).unwrap(), 2);
380
381        let v = compute_version(root).unwrap();
382        assert_eq!(
383            v,
384            Version {
385                major: 2,
386                minor: 1,
387                patch: 2
388            }
389        );
390        assert_eq!(v.to_string(), "2.1.2");
391    }
392
393    #[test]
394    fn write_version_replaces_in_cargo_toml() {
395        let dir = tempfile::tempdir().unwrap();
396        std::fs::write(
397            dir.path().join("Cargo.toml"),
398            "[package]\nversion = \"0.1.0\"\n",
399        )
400        .unwrap();
401        let path = write_version(
402            dir.path(),
403            &Version {
404                major: 2,
405                minor: 3,
406                patch: 4,
407            },
408        )
409        .unwrap();
410        let contents = std::fs::read_to_string(&path).unwrap();
411        assert!(contents.contains("version = \"2.3.4\""));
412    }
413
414    #[test]
415    fn write_version_replaces_in_workspace_cargo_toml() {
416        let dir = tempfile::tempdir().unwrap();
417        std::fs::write(
418            dir.path().join("Cargo.toml"),
419            "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
420        )
421        .unwrap();
422        let path = write_version(
423            dir.path(),
424            &Version {
425                major: 2,
426                minor: 3,
427                patch: 4,
428            },
429        )
430        .unwrap();
431        let contents = std::fs::read_to_string(&path).unwrap();
432        assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
433    }
434
435    #[test]
436    fn write_version_errors_without_version_file() {
437        let dir = tempfile::tempdir().unwrap();
438        assert!(matches!(
439            write_version(
440                dir.path(),
441                &Version {
442                    major: 1,
443                    minor: 0,
444                    patch: 0
445                }
446            ),
447            Err(VersionError::Parse(_))
448        ));
449    }
450}