Skip to main content

proc_cli/
version.rs

1//! The version string `proc --version` prints.
2//!
3//! `main` keeps the last released version number until the next release, so a
4//! build from `main` would print the same `1.14.5` as the real 1.14.5. This
5//! module names the commit for any build that is not the release tag itself.
6//!
7//! `build.rs` compiles this file too (through `#[path]`): it gathers the git
8//! facts, calls [`format_version`] and hands the result to the crate as
9//! `PROC_VERSION`. The logic lives here, in the crate, so it is tested and
10//! counted by the coverage gate; `build.rs` only runs git.
11
12/// What git says about the checkout a build is made from.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct GitFacts {
15    /// Current branch, or `None` when HEAD is detached.
16    pub branch: Option<String>,
17    /// Abbreviated hash of HEAD.
18    pub commit: String,
19    /// Output of `git describe --tags --long --match 'v*'`, if a `v*` tag is reachable.
20    pub describe: Option<String>,
21    /// Whether tracked files have uncommitted changes.
22    pub modified: bool,
23}
24
25/// Split `git describe --long` output (`v2.0.0-3-g5e94647`) into the tag and
26/// the number of commits after it.
27fn parse_describe(describe: &str) -> Option<(&str, u32)> {
28    let (rest, _hash) = describe.rsplit_once('-')?;
29    let (tag, count) = rest.rsplit_once('-')?;
30    Some((tag, count.parse().ok()?))
31}
32
33/// The version to show. `package_version` is Cargo's version; `facts` is
34/// `None` outside a git checkout (crates.io packages, Docker contexts without
35/// `.git`), which gives the plain version.
36pub fn format_version(package_version: &str, facts: Option<&GitFacts>) -> String {
37    let Some(facts) = facts else {
38        return package_version.to_string();
39    };
40    let since = facts.describe.as_deref().and_then(parse_describe);
41    if !facts.modified && since == Some((format!("v{package_version}").as_str(), 0)) {
42        return package_version.to_string();
43    }
44    let branch = facts.branch.as_deref().unwrap_or("detached");
45    let mut details = format!("{branch}@{}", facts.commit);
46    if let Some((tag, count)) = since {
47        let noun = if count == 1 { "commit" } else { "commits" };
48        details.push_str(&format!(", {count} {noun} after {tag}"));
49    }
50    if facts.modified {
51        details.push_str(", modified");
52    }
53    format!("{package_version}-dev ({details})")
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    fn facts(describe: Option<&str>, modified: bool) -> GitFacts {
61        GitFacts {
62            branch: Some("main".into()),
63            commit: "5e94647".into(),
64            describe: describe.map(String::from),
65            modified,
66        }
67    }
68
69    #[test]
70    fn no_git_is_the_plain_version() {
71        assert_eq!(format_version("2.0.0", None), "2.0.0");
72    }
73
74    #[test]
75    fn the_clean_release_tag_is_the_plain_version() {
76        let f = facts(Some("v2.0.0-0-g5e94647"), false);
77        assert_eq!(format_version("2.0.0", Some(&f)), "2.0.0");
78    }
79
80    #[test]
81    fn commits_after_the_tag_are_counted() {
82        let f = facts(Some("v2.0.0-3-g5e94647"), false);
83        assert_eq!(
84            format_version("2.0.0", Some(&f)),
85            "2.0.0-dev (main@5e94647, 3 commits after v2.0.0)"
86        );
87        let f = facts(Some("v2.0.0-1-g5e94647"), false);
88        assert_eq!(
89            format_version("2.0.0", Some(&f)),
90            "2.0.0-dev (main@5e94647, 1 commit after v2.0.0)"
91        );
92    }
93
94    #[test]
95    fn main_before_the_release_counts_from_the_last_tag() {
96        let f = facts(Some("v1.14.5-12-g5e94647"), false);
97        assert_eq!(
98            format_version("1.14.5", Some(&f)),
99            "1.14.5-dev (main@5e94647, 12 commits after v1.14.5)"
100        );
101    }
102
103    #[test]
104    fn a_modified_tree_says_so_even_on_the_tag() {
105        let f = facts(Some("v2.0.0-0-g5e94647"), true);
106        assert_eq!(
107            format_version("2.0.0", Some(&f)),
108            "2.0.0-dev (main@5e94647, 0 commits after v2.0.0, modified)"
109        );
110        let f = facts(Some("v2.0.0-3-g5e94647"), true);
111        assert_eq!(
112            format_version("2.0.0", Some(&f)),
113            "2.0.0-dev (main@5e94647, 3 commits after v2.0.0, modified)"
114        );
115    }
116
117    #[test]
118    fn a_detached_head_is_named() {
119        let mut f = facts(Some("v2.0.0-3-g5e94647"), false);
120        f.branch = None;
121        assert_eq!(
122            format_version("2.0.0", Some(&f)),
123            "2.0.0-dev (detached@5e94647, 3 commits after v2.0.0)"
124        );
125    }
126
127    #[test]
128    fn a_tag_for_another_version_is_not_this_release() {
129        let f = facts(Some("v1.14.5-0-g5e94647"), false);
130        assert_eq!(
131            format_version("2.0.0", Some(&f)),
132            "2.0.0-dev (main@5e94647, 0 commits after v1.14.5)"
133        );
134    }
135
136    #[test]
137    fn no_tag_reachable_leaves_out_the_count() {
138        let f = facts(None, false);
139        assert_eq!(
140            format_version("2.0.0", Some(&f)),
141            "2.0.0-dev (main@5e94647)"
142        );
143        let f = facts(Some("not-a-describe"), false);
144        assert_eq!(
145            format_version("2.0.0", Some(&f)),
146            "2.0.0-dev (main@5e94647)"
147        );
148        let f = facts(Some("nodash"), true);
149        assert_eq!(
150            format_version("2.0.0", Some(&f)),
151            "2.0.0-dev (main@5e94647, modified)"
152        );
153    }
154}