Skip to main content

majordomus_cli/
build_identity.rs

1//! Which commit an executable was built from, and whether that tree was dirty: the decision
2//! `build.rs` makes once, at build time, and compiles in as `MAJORDOMUS_COMMIT` and
3//! `MAJORDOMUS_DIRTY` (read back as [`crate::COMMIT`] and [`crate::DIRTY`]).
4//!
5//! It lives here rather than inline in the build script so that it can be tested: a build
6//! script has no test harness, and the constants it emits can only be compared with
7//! themselves. `build.rs` compiles this file through `#[path]`, the way it compiles
8//! `generation.rs`, so there is one definition of the decision and the tests below exercise
9//! the one the build runs. For the same reason nothing outside `std` may be used here, and
10//! every item must be reached by the build script or it is dead code in that compilation.
11//!
12//! The dirty flag describes the crate's own directory (the build script asks git with the
13//! pathspec `.` from `CARGO_MANIFEST_DIR`), as it stood when the build script last ran: an
14//! edit to documentation elsewhere in the repository does not mark the executable dirty, and
15//! a commit that touches no crate source does not rerun the build script, so the flag says
16//! what the build saw and not what the tree says now.
17//!
18//! ```
19//! use majordomus_cli::build_identity::{identify, HEAD_ARGS, STATUS_ARGS};
20//!
21//! // a plain `cargo build` in a checkout: git names the commit and reports a clean crate
22//! let sha = "0123456789abcdef0123456789abcdef01234567";
23//! let git = |args: &[&str]| match args {
24//!     a if a == HEAD_ARGS => Some(format!("{sha}\n")),
25//!     a if a == STATUS_ARGS => Some(String::new()),
26//!     _ => None,
27//! };
28//! assert_eq!(identify(None, None, git), (sha.to_string(), "false"));
29//!
30//! // a release build hands the commit and the flag in, and git is never asked
31//! let never = |args: &[&str]| -> Option<String> { panic!("git asked {args:?}") };
32//! assert_eq!(identify(Some(sha), Some("0"), never), (sha.to_string(), "false"));
33//!
34//! // a source tarball has neither git nor a declaration: both halves are `unknown`
35//! assert_eq!(identify(None, None, |_| None), ("unknown".to_string(), "unknown"));
36//! ```
37
38/// The arguments the build script hands git to learn the commit.
39pub const HEAD_ARGS: &[&str] = &["rev-parse", "HEAD"];
40/// The arguments the build script hands git to learn whether the crate's tree is dirty:
41/// tracked changes only, under the build script's working directory (the crate), and without
42/// the optional index refresh — a build is a read and must not rewrite the index of a
43/// repository another session may be staging into.
44pub const STATUS_ARGS: &[&str] = &[
45    "--no-optional-locks",
46    "status",
47    "--porcelain",
48    "--untracked-files=no",
49    "--",
50    ".",
51];
52
53/// Decide the commit and the dirty flag from what the build was handed and what git says.
54///
55/// `declared_commit` is `MAJORDOMUS_BUILD_COMMIT` and `declared_dirty` is
56/// `MAJORDOMUS_BUILD_DIRTY`; `git` runs git with the given arguments and returns its standard
57/// output on success. The commit is the declared one when it names something (neither empty
58/// nor `unknown`), else git's `HEAD`, else `unknown`. The flag is `true`/`false` when it was
59/// declared (`true`/`1`, `false`/`0`); otherwise it is read from git only when git also named
60/// the commit — a commit handed in from outside says nothing about the tree it came from —
61/// and is `unknown` when neither said.
62///
63/// ```
64/// use majordomus_cli::build_identity::{identify, STATUS_ARGS};
65///
66/// // git names the commit and finds a tracked change in the crate: the build is dirty
67/// let git = |args: &[&str]| {
68///     Some(if args == STATUS_ARGS { " M src/lib.rs\n" } else { "abc123\n" }.to_string())
69/// };
70/// assert_eq!(identify(None, None, git), ("abc123".to_string(), "true"));
71///
72/// // a declared commit says nothing about the tree, so its dirtiness stays `unknown`
73/// let never = |args: &[&str]| -> Option<String> { panic!("git asked {args:?}") };
74/// assert_eq!(identify(Some(" def456 "), None, never), ("def456".to_string(), "unknown"));
75/// ```
76pub fn identify(
77    declared_commit: Option<&str>,
78    declared_dirty: Option<&str>,
79    mut git: impl FnMut(&[&str]) -> Option<String>,
80) -> (String, &'static str) {
81    let declared = declared_commit
82        .map(str::trim)
83        .filter(|c| !c.is_empty() && *c != "unknown")
84        .map(str::to_string);
85    let from_git = declared.is_none();
86    let commit = declared
87        .or_else(|| {
88            git(HEAD_ARGS)
89                .map(|s| s.trim().to_string())
90                .filter(|s| !s.is_empty())
91        })
92        .unwrap_or_else(|| "unknown".into());
93    let dirty = match declared_dirty.map(str::trim) {
94        Some("true" | "1") => "true",
95        Some("false" | "0") => "false",
96        _ if from_git && commit != "unknown" => match git(STATUS_ARGS) {
97            Some(out) if out.trim().is_empty() => "false",
98            Some(_) => "true",
99            None => "unknown",
100        },
101        _ => "unknown",
102    };
103    (commit, dirty)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    const SHA: &str = "0123456789abcdef0123456789abcdef01234567";
111    const OTHER: &str = "fedcba9876543210fedcba9876543210fedcba98";
112
113    /// A git that answers `HEAD` with [`SHA`] and `status` with `status`, and records what
114    /// it was asked.
115    fn git(status: Option<&'static str>) -> impl FnMut(&[&str]) -> Option<String> {
116        move |args: &[&str]| match args {
117            a if a == HEAD_ARGS => Some(format!("{SHA}\n")),
118            a if a == STATUS_ARGS => status.map(str::to_string),
119            other => panic!("git asked something the build does not ask: {other:?}"),
120        }
121    }
122
123    fn no_git(_: &[&str]) -> Option<String> {
124        None
125    }
126
127    #[test]
128    fn git_names_the_commit_and_a_clean_tree() {
129        assert_eq!(identify(None, None, git(Some(""))), (SHA.into(), "false"));
130    }
131
132    #[test]
133    fn git_names_a_dirty_tree() {
134        assert_eq!(
135            identify(None, None, git(Some(" M src/lib.rs\n"))),
136            (SHA.into(), "true")
137        );
138    }
139
140    #[test]
141    fn a_status_git_cannot_answer_is_unknown() {
142        assert_eq!(identify(None, None, git(None)), (SHA.into(), "unknown"));
143    }
144
145    #[test]
146    fn a_declared_commit_without_a_dirty_flag_is_unknown_dirtiness() {
147        // git is not asked about a tree the declared commit may not be
148        let asked = |args: &[&str]| -> Option<String> { panic!("git asked {args:?}") };
149        assert_eq!(
150            identify(Some(OTHER), None, asked),
151            (OTHER.into(), "unknown")
152        );
153    }
154
155    #[test]
156    fn a_declared_dirty_flag_is_taken_as_declared() {
157        for (v, want) in [
158            ("true", "true"),
159            ("1", "true"),
160            ("false", "false"),
161            ("0", "false"),
162        ] {
163            assert_eq!(
164                identify(Some(OTHER), Some(v), no_git),
165                (OTHER.into(), want),
166                "{v}"
167            );
168            assert_eq!(
169                identify(None, Some(v), git(Some(" M x\n"))),
170                (SHA.into(), want),
171                "{v}"
172            );
173        }
174    }
175
176    #[test]
177    fn a_declared_flag_that_is_neither_falls_back() {
178        assert_eq!(
179            identify(None, Some("maybe"), git(Some(""))),
180            (SHA.into(), "false")
181        );
182        assert_eq!(
183            identify(Some(OTHER), Some("unknown"), no_git),
184            (OTHER.into(), "unknown")
185        );
186    }
187
188    #[test]
189    fn a_declared_unknown_or_empty_commit_falls_back_to_git() {
190        for declared in ["unknown", "", "  "] {
191            assert_eq!(
192                identify(Some(declared), None, git(Some(""))),
193                (SHA.into(), "false")
194            );
195        }
196    }
197
198    #[test]
199    fn a_declared_commit_is_trimmed() {
200        let padded = format!(" {OTHER}\n");
201        assert_eq!(
202            identify(Some(&padded), Some("0"), no_git),
203            (OTHER.into(), "false")
204        );
205    }
206
207    #[test]
208    fn no_git_and_nothing_declared_is_unknown() {
209        assert_eq!(identify(None, None, no_git), ("unknown".into(), "unknown"));
210        let empty = |_: &[&str]| Some("\n".to_string());
211        assert_eq!(identify(None, None, empty), ("unknown".into(), "unknown"));
212    }
213}