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/// Read the full [`Version`] (major/minor/patch) out of whatever version file
157/// `detect_version_file` resolves, mirroring [`write_version`]'s format
158/// handling (including `[workspace.package]`).
159///
160/// Unlike [`compute_version`], this never touches git — it reports exactly
161/// what was last written to the version file, not a freshly recomputed
162/// minor/patch. Callers that need the version a prior [`write_version`] call
163/// actually wrote (e.g. after a tag was just cut) must use this instead of
164/// `compute_version`, which would see the new tag and return a different,
165/// larger version.
166pub fn read_version(project_root: &Path) -> Result<Version, VersionError> {
167    let path = detect_version_file(project_root)
168        .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
169    let contents = std::fs::read_to_string(&path)?;
170    let field = field_for(&path, &contents);
171    let version_str = find_version_in_contents(&contents, field)
172        .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
173    parse_version_str(&version_str)
174}
175
176/// Parse a `MAJOR.MINOR.PATCH` string (optionally followed by `-`/`+`
177/// metadata) into a [`Version`].
178fn parse_version_str(version: &str) -> Result<Version, VersionError> {
179    let mut parts = version.split(['.', '+', '-']);
180    let mut next =
181        |label: &str| -> Result<u32, VersionError> {
182            parts.next().unwrap_or("0").parse::<u32>().map_err(|err| {
183                VersionError::Parse(format!("invalid {label} in `{version}`: {err}"))
184            })
185        };
186    let major = next("major")?;
187    let minor = next("minor")?;
188    let patch = next("patch")?;
189    Ok(Version {
190        major,
191        minor,
192        patch,
193    })
194}
195
196/// Write `version` into the project's auto-detected version file.
197pub fn write_version(project_root: &Path, version: &Version) -> Result<PathBuf, VersionError> {
198    let path = detect_version_file(project_root)
199        .ok_or_else(|| VersionError::Parse("no version file found".into()))?;
200    let contents = std::fs::read_to_string(&path)?;
201    let field = field_for(&path, &contents);
202    let replaced = replace_version_in_contents(&contents, field, &version.to_string())
203        .ok_or_else(|| VersionError::Parse(format!("field `{field}` not found")))?;
204    std::fs::write(&path, replaced)?;
205    Ok(path)
206}
207
208/// Split a dotted field path into its TOML section path and the final key.
209fn split_field(field: &str) -> (&str, &str) {
210    match field.rsplit_once('.') {
211        Some((section, key)) => (section, key),
212        None => ("", field),
213    }
214}
215
216/// Return the dotted table path for a TOML section header line, if any.
217fn parse_section_header(trimmed: &str) -> Option<&str> {
218    let inner = if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
219        trimmed.strip_prefix("[[")?.strip_suffix("]]")?
220    } else {
221        trimmed.strip_prefix('[')?.strip_suffix(']')?
222    };
223    Some(inner.trim())
224}
225
226fn find_version_in_contents(contents: &str, field: &str) -> Option<String> {
227    let (section, key) = split_field(field);
228    let mut current = "";
229    for line in contents.lines() {
230        let trimmed = line.trim();
231        if let Some(header) = parse_section_header(trimmed) {
232            current = header;
233            continue;
234        }
235        if current != section {
236            continue;
237        }
238        if let Some((lhs, value)) = trimmed.split_once(['=', ':']) {
239            let lhs_key = lhs.trim().trim_matches('"').trim_matches('\'');
240            if lhs_key != key {
241                continue;
242            }
243            let value = value.trim();
244            if value.starts_with('{') {
245                continue;
246            }
247            return Some(value.trim_matches(['"', '\'']).to_string());
248        }
249    }
250    None
251}
252
253fn replace_version_in_contents(contents: &str, field: &str, new_version: &str) -> Option<String> {
254    let (section, key) = split_field(field);
255    let mut current = "";
256    let mut changed = false;
257    let mut output = String::new();
258    for line in contents.lines() {
259        let trimmed = line.trim();
260        if let Some(header) = parse_section_header(trimmed) {
261            current = header;
262            output.push_str(line);
263            output.push('\n');
264            continue;
265        }
266        if !changed
267            && current == section
268            && let Some((left, value)) = line.split_once(['=', ':'])
269        {
270            let left_key = left.trim().trim_matches('"').trim_matches('\'');
271            if left_key == key && !value.trim().starts_with('{') {
272                let separator: &str = if trimmed.contains('=') { " = " } else { ": " };
273                let trimmed_value = value.trim();
274                let needs_quote = trimmed_value.starts_with('"') || trimmed_value.starts_with('\'');
275                let quote_char: &str = if trimmed_value.starts_with('\'') {
276                    "'"
277                } else {
278                    "\""
279                };
280                // Capture whatever follows the version token itself (a
281                // trailing `,` in JSON, a trailing `# comment` in TOML) so it
282                // survives the rewrite instead of being silently dropped
283                // (GAP-6).
284                let remainder = if needs_quote {
285                    // Token ends at the closing quote; skip the opening
286                    // quote and scan for the matching close.
287                    trimmed_value[1..]
288                        .find(quote_char)
289                        .map(|end| &trimmed_value[end + 2..])
290                        .unwrap_or("")
291                } else {
292                    // Unquoted: token ends at the first whitespace, `,`, or `#`.
293                    let end = trimmed_value
294                        .find([' ', '\t', ',', '#'])
295                        .unwrap_or(trimmed_value.len());
296                    &trimmed_value[end..]
297                };
298                output.push_str(left.trim_end());
299                output.push_str(separator);
300                if needs_quote {
301                    output.push_str(quote_char);
302                    output.push_str(new_version);
303                    output.push_str(quote_char);
304                } else {
305                    output.push_str(new_version);
306                }
307                output.push_str(remainder.trim_end());
308                output.push('\n');
309                changed = true;
310                continue;
311            }
312        }
313        output.push_str(line);
314        output.push('\n');
315    }
316    changed.then_some(output)
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use std::process::Command;
323
324    fn git(root: &Path, args: &[&str]) {
325        let ok = Command::new("git")
326            .args(args)
327            .current_dir(root)
328            .output()
329            .unwrap()
330            .status
331            .success();
332        assert!(ok, "git {args:?} failed");
333    }
334
335    fn init_repo(root: &Path) {
336        git(root, &["init", "-q"]);
337        git(root, &["config", "user.email", "test@example.com"]);
338        git(root, &["config", "user.name", "Test"]);
339        git(root, &["config", "commit.gpgsign", "false"]);
340        git(root, &["config", "tag.gpgsign", "false"]);
341        git(root, &["config", "core.hooksPath", "/dev/null"]);
342    }
343
344    fn commit(root: &Path, name: &str) {
345        std::fs::write(root.join(name), name).unwrap();
346        git(root, &["add", "."]);
347        git(root, &["commit", "-q", "-m", &format!("add {name}")]);
348    }
349
350    #[test]
351    fn detect_prefers_cargo_then_pyproject_then_package_json() {
352        let dir = tempfile::tempdir().unwrap();
353        assert!(detect_version_file(dir.path()).is_none());
354        std::fs::write(dir.path().join("package.json"), "{\"version\":\"1.0.0\"}").unwrap();
355        assert!(
356            detect_version_file(dir.path())
357                .unwrap()
358                .ends_with("package.json")
359        );
360        std::fs::write(
361            dir.path().join("Cargo.toml"),
362            "[package]\nversion=\"1.0.0\"",
363        )
364        .unwrap();
365        assert!(
366            detect_version_file(dir.path())
367                .unwrap()
368                .ends_with("Cargo.toml")
369        );
370    }
371
372    #[test]
373    fn read_major_from_workspace_package() {
374        let dir = tempfile::tempdir().unwrap();
375        let file = dir.path().join("Cargo.toml");
376        std::fs::write(
377            &file,
378            "[workspace.package]\nversion = \"2.5.7\"\nedition = \"2024\"\n",
379        )
380        .unwrap();
381        assert_eq!(read_major_version(&file).unwrap(), 2);
382    }
383
384    #[test]
385    fn inline_table_version_does_not_shadow_workspace_package() {
386        assert_eq!(parse_section_header("[[bin]]"), Some("bin"));
387
388        let dir = tempfile::tempdir().unwrap();
389        let file = dir.path().join("Cargo.toml");
390        std::fs::write(
391            &file,
392            "[[bin]]\nname = \"devflow\"\n\
393             [workspace.dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n\
394             [workspace.package]\nversion = \"1.2.0\"\n",
395        )
396        .unwrap();
397
398        assert_eq!(read_major_version(&file).unwrap(), 1);
399        write_version(
400            dir.path(),
401            &Version {
402                major: 2,
403                minor: 3,
404                patch: 4,
405            },
406        )
407        .unwrap();
408        let contents = std::fs::read_to_string(file).unwrap();
409        assert!(contents.contains("serde = { version = \"1\""));
410        assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
411    }
412
413    #[test]
414    fn read_major_from_package_json() {
415        let dir = tempfile::tempdir().unwrap();
416        let file = dir.path().join("package.json");
417        std::fs::write(&file, "{\n  \"version\": \"3.1.0\"\n}\n").unwrap();
418        assert_eq!(read_major_version(&file).unwrap(), 3);
419    }
420
421    #[test]
422    fn count_tags_and_commits_drive_minor_and_patch() {
423        let dir = tempfile::tempdir().unwrap();
424        let root = dir.path();
425        init_repo(root);
426        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
427        commit(root, "a.txt");
428        // No tags yet → minor 0, patch counts all commits.
429        assert_eq!(count_git_tags(root).unwrap(), 0);
430        let v = compute_version(root).unwrap();
431        assert_eq!(v.major, 2);
432        assert_eq!(v.minor, 0);
433        assert!(v.patch >= 1);
434
435        git(root, &["tag", "v2.0.0"]);
436        commit(root, "b.txt");
437        commit(root, "c.txt");
438        assert_eq!(count_git_tags(root).unwrap(), 1);
439        assert_eq!(commits_since_last_minor_tag(root).unwrap(), 2);
440
441        let v = compute_version(root).unwrap();
442        assert_eq!(
443            v,
444            Version {
445                major: 2,
446                minor: 1,
447                patch: 2
448            }
449        );
450        assert_eq!(v.to_string(), "2.1.2");
451    }
452
453    #[test]
454    fn write_version_replaces_in_cargo_toml() {
455        let dir = tempfile::tempdir().unwrap();
456        std::fs::write(
457            dir.path().join("Cargo.toml"),
458            "[package]\nversion = \"0.1.0\"\n",
459        )
460        .unwrap();
461        let path = write_version(
462            dir.path(),
463            &Version {
464                major: 2,
465                minor: 3,
466                patch: 4,
467            },
468        )
469        .unwrap();
470        let contents = std::fs::read_to_string(&path).unwrap();
471        assert!(contents.contains("version = \"2.3.4\""));
472    }
473
474    #[test]
475    fn write_version_replaces_in_workspace_cargo_toml() {
476        let dir = tempfile::tempdir().unwrap();
477        std::fs::write(
478            dir.path().join("Cargo.toml"),
479            "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
480        )
481        .unwrap();
482        let path = write_version(
483            dir.path(),
484            &Version {
485                major: 2,
486                minor: 3,
487                patch: 4,
488            },
489        )
490        .unwrap();
491        let contents = std::fs::read_to_string(&path).unwrap();
492        assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
493    }
494
495    #[test]
496    fn write_version_errors_without_version_file() {
497        let dir = tempfile::tempdir().unwrap();
498        assert!(matches!(
499            write_version(
500                dir.path(),
501                &Version {
502                    major: 1,
503                    minor: 0,
504                    patch: 0
505                }
506            ),
507            Err(VersionError::Parse(_))
508        ));
509    }
510
511    #[test]
512    fn read_version_round_trips_through_write_version_in_plain_cargo_toml() {
513        let dir = tempfile::tempdir().unwrap();
514        std::fs::write(
515            dir.path().join("Cargo.toml"),
516            "[package]\nversion = \"0.1.0\"\n",
517        )
518        .unwrap();
519        let written = Version {
520            major: 2,
521            minor: 3,
522            patch: 4,
523        };
524        write_version(dir.path(), &written).unwrap();
525        assert_eq!(read_version(dir.path()).unwrap(), written);
526    }
527
528    #[test]
529    fn read_version_round_trips_through_write_version_in_workspace_cargo_toml() {
530        let dir = tempfile::tempdir().unwrap();
531        std::fs::write(
532            dir.path().join("Cargo.toml"),
533            "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
534        )
535        .unwrap();
536        let written = Version {
537            major: 5,
538            minor: 6,
539            patch: 7,
540        };
541        write_version(dir.path(), &written).unwrap();
542        assert_eq!(read_version(dir.path()).unwrap(), written);
543    }
544
545    #[test]
546    fn read_version_round_trips_through_write_version_in_package_json() {
547        let dir = tempfile::tempdir().unwrap();
548        std::fs::write(
549            dir.path().join("package.json"),
550            "{\n  \"version\": \"0.1.0\"\n}\n",
551        )
552        .unwrap();
553        let written = Version {
554            major: 1,
555            minor: 9,
556            patch: 12,
557        };
558        write_version(dir.path(), &written).unwrap();
559        assert_eq!(read_version(dir.path()).unwrap(), written);
560    }
561
562    #[test]
563    fn read_version_errors_without_version_file() {
564        let dir = tempfile::tempdir().unwrap();
565        assert!(matches!(
566            read_version(dir.path()),
567            Err(VersionError::Parse(_))
568        ));
569    }
570
571    #[test]
572    fn write_version_preserves_trailing_comma_in_package_json() {
573        // GAP-6: replace_version_in_contents reassembles the matched line as
574        // `left.trim_end() + separator + quoted_version + '\n'`, discarding
575        // everything in `value` after the version token. For a real
576        // package.json where `version` is not the last key, that eats the
577        // mandatory trailing comma and produces invalid JSON. Parsing is the
578        // assertion that matters here — a substring check would be a
579        // vacuous fixture that can't reach this defect.
580        let dir = tempfile::tempdir().unwrap();
581        std::fs::write(
582            dir.path().join("package.json"),
583            "{\n  \"name\": \"x\",\n  \"version\": \"0.1.0\",\n  \"private\": true\n}\n",
584        )
585        .unwrap();
586        write_version(
587            dir.path(),
588            &Version {
589                major: 2,
590                minor: 3,
591                patch: 4,
592            },
593        )
594        .unwrap();
595        let contents = std::fs::read_to_string(dir.path().join("package.json")).unwrap();
596        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|err| {
597            panic!("package.json no longer parses as JSON: {err}\n{contents}")
598        });
599        assert_eq!(parsed["name"], "x");
600        assert_eq!(parsed["private"], true);
601        assert_eq!(parsed["version"], "2.3.4");
602    }
603
604    #[test]
605    fn write_version_preserves_trailing_comment_in_toml() {
606        // GAP-6, TOML variant: a trailing `# comment` after the quoted
607        // version is discarded by the same line-reassembly defect.
608        let dir = tempfile::tempdir().unwrap();
609        std::fs::write(
610            dir.path().join("Cargo.toml"),
611            "[package]\nversion = \"0.1.0\"  # pinned\n",
612        )
613        .unwrap();
614        write_version(
615            dir.path(),
616            &Version {
617                major: 2,
618                minor: 3,
619                patch: 4,
620            },
621        )
622        .unwrap();
623        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
624        assert!(
625            contents.contains("version = \"2.3.4\"  # pinned"),
626            "expected trailing comment to survive, got: {contents}"
627        );
628    }
629
630    #[test]
631    fn write_version_preserves_trailing_comment_in_single_quoted_toml() {
632        // GAP-6, TOML literal-string variant (17-13 review IN-03): the
633        // remainder scan keys off the OPENING quote character, so the
634        // single-quote branch is a distinct path from the double-quote case
635        // above and needs its own fixture.
636        let dir = tempfile::tempdir().unwrap();
637        std::fs::write(
638            dir.path().join("Cargo.toml"),
639            "[package]\nversion = '0.1.0'  # pinned\n",
640        )
641        .unwrap();
642        write_version(
643            dir.path(),
644            &Version {
645                major: 2,
646                minor: 3,
647                patch: 4,
648            },
649        )
650        .unwrap();
651        let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
652        assert!(
653            contents.contains("version = '2.3.4'  # pinned"),
654            "expected single-quoted value and trailing comment to survive, got: {contents}"
655        );
656    }
657
658    #[test]
659    fn read_version_does_not_recompute_from_git_tags() {
660        // read_version must report exactly what's on disk, not a freshly
661        // computed minor/patch — this is the property VersionBump/
662        // ChangelogAppend ordering depends on (version.rs must never see a
663        // tag VersionBump just created and derive a different number).
664        let dir = tempfile::tempdir().unwrap();
665        let root = dir.path();
666        init_repo(root);
667        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
668        commit(root, "a.txt");
669        write_version(
670            root,
671            &Version {
672                major: 2,
673                minor: 0,
674                patch: 0,
675            },
676        )
677        .unwrap();
678        git(root, &["tag", "v2.0.0"]);
679        commit(root, "b.txt");
680        commit(root, "c.txt");
681        // compute_version would see 1 tag + 2 commits since => 2.1.2.
682        // read_version must still report exactly what's on disk: 2.0.0.
683        assert_eq!(
684            read_version(root).unwrap(),
685            Version {
686                major: 2,
687                minor: 0,
688                patch: 0
689            }
690        );
691    }
692}