rainier_support/build_info.rs
1//! What is actually running — [`BuildInfo`] and [`build_info!`](macro@crate::build_info).
2//!
3//! ```ignore
4//! router.get("/health/version", || async { Response::json(&build_info!()) });
5//! ```
6//!
7//! ```json
8//! { "name": "identity", "version": "2.4.1", "commit": "9f3c2ab", "profile": "release" }
9//! ```
10//!
11//! The first question of every incident is *which build is this?* — and the
12//! usual answer is somebody reading a deploy pipeline backwards. A version
13//! endpoint answers it in a second, and a log line at boot answers it for the
14//! machine nobody can reach any more.
15//!
16//! # Where the values come from
17//!
18//! The macro expands **in your crate**, so the name and version are your
19//! package's, not Rainier's. The commit and build time come from the
20//! environment at compile time, so they are present when a build sets them and
21//! absent when it does not:
22//!
23//! | Field | Read from |
24//! |---|---|
25//! | `commit` | `GIT_SHA`, `GITHUB_SHA`, or `VERGEN_GIT_SHA` |
26//! | `built_at` | `BUILD_TIMESTAMP` or `SOURCE_DATE_EPOCH` |
27//!
28//! `GITHUB_SHA` is set by GitHub Actions already, so a CI build gets its
29//! commit with nothing added. Anywhere else it is one line:
30//!
31//! ```dockerfile
32//! ARG GIT_SHA
33//! ENV GIT_SHA=$GIT_SHA
34//! RUN cargo build --release
35//! ```
36//!
37//! A local `cargo run` has neither, and reports `None` rather than guessing —
38//! a commit inferred from a dirty working tree is worse than no commit.
39
40use serde::Serialize;
41
42/// The identity of a running binary.
43///
44/// Build it with [`build_info!`](macro@crate::build_info), which fills it in from the crate being
45/// compiled.
46///
47/// Every field is `&'static str`, because every value is known when the binary
48/// is — so this costs nothing to hold and nothing to clone. That is also why
49/// it is `Serialize` and not `Deserialize`: a service reads its *own* build
50/// info, and a client reading somebody else's wants its own owned type.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct BuildInfo {
53 /// The package name.
54 pub name: &'static str,
55 /// The package version.
56 pub version: &'static str,
57 /// The commit this was built from, if the build was told.
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub commit: Option<&'static str>,
60 /// When it was built, if the build was told.
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub built_at: Option<&'static str>,
63 /// `debug` or `release`.
64 pub profile: &'static str,
65}
66
67impl BuildInfo {
68 /// The commit, shortened to the first seven characters.
69 ///
70 /// What everybody actually pastes into a chat message.
71 pub fn short_commit(&self) -> Option<&str> {
72 self.commit.map(|commit| &commit[..commit.len().min(7)])
73 }
74
75 /// Whether this is a debug build.
76 ///
77 /// Worth asserting at boot in production: a debug binary is several times
78 /// slower, and the difference is easy to deploy by accident.
79 pub fn is_debug(&self) -> bool {
80 self.profile == "debug"
81 }
82
83 /// One line, for a boot log.
84 ///
85 /// ```text
86 /// identity 2.4.1 (9f3c2ab, release)
87 /// ```
88 pub fn summary(&self) -> String {
89 let mut parts = Vec::new();
90 if let Some(commit) = self.short_commit() {
91 parts.push(commit.to_string());
92 }
93 parts.push(self.profile.to_string());
94
95 format!("{} {} ({})", self.name, self.version, parts.join(", "))
96 }
97}
98
99impl std::fmt::Display for BuildInfo {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.write_str(&self.summary())
102 }
103}
104
105/// The [`BuildInfo`] of the crate this is written in.
106///
107/// ```
108/// use rainier_support::build_info;
109///
110/// let info = build_info!();
111///
112/// assert_eq!(info.name, "rainier-support");
113/// assert!(!info.version.is_empty());
114/// ```
115#[macro_export]
116macro_rules! build_info {
117 () => {
118 $crate::build_info::BuildInfo {
119 name: env!("CARGO_PKG_NAME"),
120 version: env!("CARGO_PKG_VERSION"),
121 commit: option_env!("GIT_SHA")
122 .or(option_env!("GITHUB_SHA"))
123 .or(option_env!("VERGEN_GIT_SHA")),
124 built_at: option_env!("BUILD_TIMESTAMP").or(option_env!("SOURCE_DATE_EPOCH")),
125 profile: if cfg!(debug_assertions) { "debug" } else { "release" },
126 }
127 };
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 fn info() -> BuildInfo {
135 BuildInfo {
136 name: "identity",
137 version: "2.4.1",
138 commit: Some("9f3c2ab1d4e5f60718293a4b5c6d7e8f90123456"),
139 built_at: Some("2026-07-25T09:14:00Z"),
140 profile: "release",
141 }
142 }
143
144 #[test]
145 fn the_macro_reads_the_crate_it_is_written_in() {
146 let info = build_info!();
147
148 assert_eq!(info.name, "rainier-support");
149 assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
150 // Tests are a debug build.
151 assert_eq!(info.profile, "debug");
152 assert!(info.is_debug());
153 }
154
155 #[test]
156 fn a_commit_is_shortened_to_what_people_paste() {
157 assert_eq!(info().short_commit(), Some("9f3c2ab"));
158 }
159
160 #[test]
161 fn a_short_commit_is_not_truncated_past_its_end() {
162 // A tag or a branch name could be shorter than seven characters, and
163 // slicing past the end of a string is a panic in a health endpoint.
164 let mut info = info();
165 info.commit = Some("ab1");
166
167 assert_eq!(info.short_commit(), Some("ab1"));
168 }
169
170 #[test]
171 fn no_commit_is_none_rather_than_unknown() {
172 let mut info = info();
173 info.commit = None;
174
175 assert_eq!(info.short_commit(), None);
176 assert_eq!(info.summary(), "identity 2.4.1 (release)");
177 }
178
179 #[test]
180 fn the_summary_is_one_line_worth_reading() {
181 assert_eq!(info().summary(), "identity 2.4.1 (9f3c2ab, release)");
182 assert_eq!(info().to_string(), info().summary());
183 }
184
185 #[test]
186 fn absent_fields_are_left_out_of_the_json_entirely() {
187 // `"commit": null` on a version endpoint reads as "there is no
188 // commit"; omitting it reads as "this build was not told", which is
189 // what actually happened.
190 let mut info = info();
191 info.commit = None;
192 info.built_at = None;
193
194 let json = serde_json::to_value(&info).unwrap();
195 assert_eq!(
196 json,
197 serde_json::json!({
198 "name": "identity",
199 "version": "2.4.1",
200 "profile": "release",
201 })
202 );
203 }
204
205 #[test]
206 fn the_whole_thing_serialises() {
207 assert_eq!(
208 serde_json::to_value(info()).unwrap(),
209 serde_json::json!({
210 "name": "identity",
211 "version": "2.4.1",
212 "commit": "9f3c2ab1d4e5f60718293a4b5c6d7e8f90123456",
213 "built_at": "2026-07-25T09:14:00Z",
214 "profile": "release",
215 })
216 );
217 }
218}