Skip to main content

omni_dev/
build_info.rs

1//! Build-time git provenance captured by [`build.rs`](../build.rs) and exposed
2//! as compile-time constants.
3//!
4//! Two binaries built from different commits of the *same* crate version are
5//! otherwise indistinguishable by [`crate::VERSION`] alone; this module carries
6//! the commit SHA, dirty flag, commit date, and build timestamp so a resident
7//! daemon can report exactly which code it is running (#1374). Every git-derived
8//! value is optional: a build made outside a git checkout (a crates.io download
9//! or release tarball) simply reports `None`, and every consumer degrades
10//! gracefully. See [`Provenance`].
11
12use serde::{Deserialize, Serialize};
13
14/// Full 40-character commit SHA the binary was built from, or `None` when built
15/// outside a git checkout.
16pub const GIT_SHA: Option<&str> = option_env!("OMNI_DEV_GIT_SHA");
17
18/// Abbreviated commit SHA (e.g. `a6d304fd`), or `None` outside a checkout.
19pub const GIT_SHA_SHORT: Option<&str> = option_env!("OMNI_DEV_GIT_SHA_SHORT");
20
21/// Committer date of the built commit in strict ISO-8601, or `None` outside a
22/// checkout.
23pub const GIT_COMMIT_DATE: Option<&str> = option_env!("OMNI_DEV_GIT_COMMIT_DATE");
24
25/// Raw `"true"`/`"false"` working-tree dirty flag, or `None` when built outside
26/// a git work tree. Interpreted by [`git_dirty`].
27const GIT_DIRTY: Option<&str> = option_env!("OMNI_DEV_GIT_DIRTY");
28
29/// Build time as integer seconds since the Unix epoch, or `None` if the build
30/// script could not read a clock. Formatted by [`build_timestamp`].
31const BUILD_EPOCH: Option<&str> = option_env!("OMNI_DEV_BUILD_EPOCH");
32
33/// Whether the working tree had uncommitted changes at build time.
34///
35/// `None` when the binary was built outside a git work tree.
36#[must_use]
37pub fn git_dirty() -> Option<bool> {
38    match GIT_DIRTY {
39        Some("true") => Some(true),
40        Some("false") => Some(false),
41        _ => None,
42    }
43}
44
45/// Build timestamp formatted as an RFC3339 (UTC) string, or `None` when the
46/// build script recorded no clock reading.
47#[must_use]
48pub fn build_timestamp() -> Option<String> {
49    let secs: i64 = BUILD_EPOCH?.parse().ok()?;
50    chrono::DateTime::from_timestamp(secs, 0).map(|dt| dt.to_rfc3339())
51}
52
53/// Git provenance of the running binary, surfaced on the daemon `status`/`ping`
54/// wire and in `omni-dev --version`.
55///
56/// Every field is optional and additive: each is omitted from the wire when
57/// absent, so a daemon built without git metadata stays byte-identical to a
58/// pre-#1374 one and older clients ignore the extra keys.
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct Provenance {
61    /// Abbreviated commit SHA (e.g. `a6d304fd`).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub commit: Option<String>,
64    /// Full 40-character commit SHA.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub commit_long: Option<String>,
67    /// Committer date of the built commit, strict ISO-8601.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub commit_date: Option<String>,
70    /// Whether the working tree was dirty at build time.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub dirty: Option<bool>,
73    /// Build timestamp, RFC3339 (UTC).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub build_timestamp: Option<String>,
76}
77
78/// Snapshots this binary's compile-time provenance into an owned [`Provenance`].
79///
80/// All fields are `None` when the binary was built outside a git checkout.
81#[must_use]
82pub fn provenance() -> Provenance {
83    Provenance {
84        commit: GIT_SHA_SHORT.map(str::to_string),
85        commit_long: GIT_SHA.map(str::to_string),
86        commit_date: GIT_COMMIT_DATE.map(str::to_string),
87        dirty: git_dirty(),
88        build_timestamp: build_timestamp(),
89    }
90}
91
92/// Long version string for `omni-dev --version`: the crate version plus git
93/// provenance when available, e.g. `0.36.0 (a6d304fd 2026-07-20, dirty)`.
94///
95/// Degrades to the bare crate version when built outside a git checkout.
96/// Returns a `&'static str` (computed once, then cached) because that is what
97/// clap's `long_version` builder requires.
98#[must_use]
99pub fn long_version() -> &'static str {
100    static LONG_VERSION: std::sync::LazyLock<String> =
101        std::sync::LazyLock::new(compute_long_version);
102    LONG_VERSION.as_str()
103}
104
105/// Assembles the [`long_version`] string from the compile-time provenance.
106fn compute_long_version() -> String {
107    let mut suffix = String::new();
108    if let Some(commit) = GIT_SHA_SHORT {
109        suffix.push_str(commit);
110    }
111    if let Some(date) = GIT_COMMIT_DATE {
112        // Keep just the calendar day (`YYYY-MM-DD`) for a compact banner.
113        let day = date.split('T').next().unwrap_or(date);
114        if !suffix.is_empty() {
115            suffix.push(' ');
116        }
117        suffix.push_str(day);
118    }
119    if git_dirty() == Some(true) {
120        if !suffix.is_empty() {
121            suffix.push_str(", ");
122        }
123        suffix.push_str("dirty");
124    }
125    if suffix.is_empty() {
126        crate::VERSION.to_string()
127    } else {
128        format!("{} ({suffix})", crate::VERSION)
129    }
130}
131
132#[cfg(test)]
133#[allow(clippy::unwrap_used, clippy::expect_used)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn git_dirty_maps_the_raw_flag() {
139        // The public accessor only ever yields the interpreted tri-state; the raw
140        // const is whatever this build captured, so assert the mapping via the
141        // pure match instead of the environment-dependent const.
142        assert_eq!(interpret_dirty(Some("true")), Some(true));
143        assert_eq!(interpret_dirty(Some("false")), Some(false));
144        assert_eq!(interpret_dirty(None), None);
145        assert_eq!(interpret_dirty(Some("garbage")), None);
146    }
147
148    // Mirror of `git_dirty`'s mapping over an explicit input, so the test does
149    // not depend on how *this* binary happened to be built.
150    fn interpret_dirty(raw: Option<&str>) -> Option<bool> {
151        match raw {
152            Some("true") => Some(true),
153            Some("false") => Some(false),
154            _ => None,
155        }
156    }
157
158    #[test]
159    fn build_timestamp_formats_epoch_seconds() {
160        // `build_timestamp` reads a const, so validate the formatting on a fixed
161        // epoch directly: 1_700_000_000 == 2023-11-14T22:13:20Z.
162        let dt = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
163        assert_eq!(dt.to_rfc3339(), "2023-11-14T22:13:20+00:00");
164    }
165
166    #[test]
167    fn long_version_always_starts_with_the_crate_version() {
168        let v = long_version();
169        assert!(v.starts_with(crate::VERSION), "{v}");
170    }
171
172    #[test]
173    fn provenance_round_trips_and_omits_absent_fields() {
174        // An empty provenance serializes to `{}` — no keys — so a daemon with no
175        // git metadata adds nothing to the wire.
176        let empty = serde_json::to_string(&Provenance::default()).unwrap();
177        assert_eq!(empty, "{}");
178
179        let full = Provenance {
180            commit: Some("a6d304fd".to_string()),
181            commit_long: Some("a6d304fddeadbeef".to_string()),
182            commit_date: Some("2026-07-20T15:33:17+10:00".to_string()),
183            dirty: Some(true),
184            build_timestamp: Some("2026-07-20T05:33:17+00:00".to_string()),
185        };
186        let line = serde_json::to_string(&full).unwrap();
187        let back: Provenance = serde_json::from_str(&line).unwrap();
188        assert_eq!(back.commit.as_deref(), Some("a6d304fd"));
189        assert_eq!(back.dirty, Some(true));
190    }
191}