Skip to main content

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}