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}