Skip to main content

khive_runtime/
build_info.rs

1//! Compile-time identity for the source and build that produced this runtime.
2
3/// Package version shared by runtime diagnostics and lightweight identity probes.
4pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
5
6#[cfg(test)]
7#[path = "build_info_support.rs"]
8mod build_info_support;
9
10/// Explicit source identity used when the build cannot inspect a Git checkout.
11pub const UNSTAMPED_REVISION: &str = "unstamped";
12
13/// Explicit build-time fallback used when no compile-time timestamp is available.
14pub const UNKNOWN_BUILD_TIME: &str = "unknown";
15
16/// Immutable provenance stamped into the binary at compile time.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub struct BuildInfo {
19    pub source_revision: &'static str,
20    pub build_time: &'static str,
21}
22
23impl BuildInfo {
24    /// Construct build information while preserving an explicit unstamped state.
25    pub const fn new(
26        source_revision: Option<&'static str>,
27        build_time: Option<&'static str>,
28    ) -> Self {
29        Self {
30            source_revision: match source_revision {
31                Some(revision) => revision,
32                None => UNSTAMPED_REVISION,
33            },
34            build_time: match build_time {
35                Some(build_time) => build_time,
36                None => UNKNOWN_BUILD_TIME,
37            },
38        }
39    }
40
41    pub fn is_stamped(&self) -> bool {
42        self.source_revision != UNSTAMPED_REVISION
43    }
44}
45
46/// Provenance for the currently compiled runtime.
47pub const BUILD_INFO: BuildInfo = BuildInfo::new(
48    option_env!("KHIVE_SOURCE_REVISION"),
49    option_env!("KHIVE_BUILD_TIME"),
50);
51
52/// Rich version string used by `kkernel --version`.
53pub const BUILD_VERSION: &str = concat!(
54    env!("CARGO_PKG_VERSION"),
55    " (revision ",
56    env!("KHIVE_SOURCE_REVISION"),
57    ", built ",
58    env!("KHIVE_BUILD_TIME"),
59    ")"
60);
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use std::path::Path;
66    use std::process::Command;
67
68    #[test]
69    fn stamped_build_preserves_full_dirty_revision_and_time() {
70        let info = BuildInfo::new(
71            Some("45131c27b615f641c579046513d7c0ddd15c0bfb-dirty"),
72            Some("2026-07-31T20:00:00Z"),
73        );
74
75        assert_eq!(
76            info.source_revision,
77            "45131c27b615f641c579046513d7c0ddd15c0bfb-dirty"
78        );
79        assert_eq!(info.build_time, "2026-07-31T20:00:00Z");
80        assert!(info.is_stamped());
81    }
82
83    #[test]
84    fn unstamped_build_is_explicit() {
85        let info = BuildInfo::new(None, None);
86
87        assert_eq!(info.source_revision, UNSTAMPED_REVISION);
88        assert_eq!(info.build_time, UNKNOWN_BUILD_TIME);
89        assert!(!info.is_stamped());
90    }
91
92    #[test]
93    fn compiled_version_uses_the_compiled_provenance() {
94        assert!(BUILD_VERSION.contains(BUILD_INFO.source_revision));
95        assert!(BUILD_VERSION.contains(BUILD_INFO.build_time));
96
97        if BUILD_INFO.is_stamped() {
98            let revision = BUILD_INFO
99                .source_revision
100                .strip_suffix("-dirty")
101                .unwrap_or(BUILD_INFO.source_revision);
102            assert_eq!(revision.len(), 40);
103            assert!(revision.bytes().all(|byte| byte.is_ascii_hexdigit()));
104        }
105    }
106
107    #[test]
108    fn revision_derivation_distinguishes_clean_dirty_and_unstamped() {
109        let repo = tempfile::tempdir().unwrap();
110        run_git(repo.path(), &["init", "--quiet"]);
111        run_git(repo.path(), &["config", "user.email", "test@example.com"]);
112        run_git(repo.path(), &["config", "user.name", "khive test"]);
113        run_git(repo.path(), &["config", "commit.gpgsign", "false"]);
114        std::fs::write(repo.path().join("tracked.txt"), "clean\n").unwrap();
115        run_git(repo.path(), &["add", "tracked.txt"]);
116        run_git(repo.path(), &["commit", "--quiet", "-m", "baseline"]);
117
118        let revision = build_info_support::git_output(repo.path(), &["rev-parse", "HEAD"])
119            .expect("temp repository must have a revision");
120        assert_eq!(
121            build_info_support::source_revision(repo.path()).as_deref(),
122            Some(revision.as_str())
123        );
124
125        std::fs::write(repo.path().join("untracked.txt"), "not compiled\n").unwrap();
126        assert_eq!(
127            build_info_support::source_revision(repo.path()).as_deref(),
128            Some(revision.as_str())
129        );
130
131        std::fs::write(repo.path().join("tracked.txt"), "dirty\n").unwrap();
132        assert_eq!(
133            build_info_support::source_revision(repo.path()),
134            Some(format!("{revision}-dirty"))
135        );
136
137        let non_git = tempfile::tempdir().unwrap();
138        assert_eq!(build_info_support::source_revision(non_git.path()), None);
139    }
140
141    /// A `rerun-if-changed` path that does not exist is not "unchanged" to cargo, it is
142    /// permanently stale, so the unit rebuilds on every invocation forever. `git rev-parse
143    /// --git-path refs/heads/<branch>` answers with the loose ref path whether or not the ref is
144    /// loose, and packing prunes that file.
145    #[test]
146    fn git_rerun_inputs_drop_the_loose_ref_path_once_the_ref_is_packed() {
147        let repo = tempfile::tempdir().unwrap();
148        init_repo_with_one_commit(repo.path());
149        run_git(repo.path(), &["pack-refs", "--all"]);
150
151        let branch = build_info_support::git_output(repo.path(), &["symbolic-ref", "-q", "HEAD"])
152            .expect("a fresh repository is on a branch");
153        let loose = repo.path().join(".git").join(&branch);
154
155        // THE PRECONDITION IS ASSERTED, NOT ASSUMED. If a git version stopped pruning the
156        // loose ref here the arm would otherwise pass while testing nothing.
157        assert!(
158            !loose.exists(),
159            "packing did not prune {loose:?}; this arm no longer reproduces the condition"
160        );
161
162        let inputs = build_info_support::git_rerun_inputs(repo.path());
163        let missing: Vec<_> = inputs.iter().filter(|path| !path.exists()).collect();
164        assert!(
165            missing.is_empty(),
166            "these registered paths do not exist and would make the unit permanently stale: \
167             {missing:?}"
168        );
169        assert!(!inputs.contains(&loose));
170
171        // And the packed case is still observed, which is what makes dropping the loose entry
172        // free rather than a loss of signal. Without this a fix that returned an empty vector
173        // would satisfy the arm above.
174        assert!(
175            inputs.iter().any(|path| path.ends_with("packed-refs")),
176            "packed-refs must stay registered: {inputs:?}"
177        );
178    }
179
180    #[test]
181    fn git_rerun_inputs_keep_the_loose_ref_path_while_it_exists() {
182        let repo = tempfile::tempdir().unwrap();
183        init_repo_with_one_commit(repo.path());
184
185        let branch = build_info_support::git_output(repo.path(), &["symbolic-ref", "-q", "HEAD"])
186            .expect("a fresh repository is on a branch");
187        let loose = repo.path().join(".git").join(&branch);
188        assert!(loose.exists(), "an unpacked ref must be a file: {loose:?}");
189
190        let inputs = build_info_support::git_rerun_inputs(repo.path());
191        assert!(
192            inputs.contains(&loose),
193            "the branch ref must stay registered while it exists: {inputs:?}"
194        );
195    }
196
197    fn init_repo_with_one_commit(repo: &Path) {
198        run_git(repo, &["init", "--quiet"]);
199        run_git(repo, &["config", "user.email", "test@example.com"]);
200        run_git(repo, &["config", "user.name", "khive test"]);
201        run_git(repo, &["config", "commit.gpgsign", "false"]);
202        std::fs::write(repo.join("tracked.txt"), "clean\n").unwrap();
203        run_git(repo, &["add", "tracked.txt"]);
204        run_git(repo, &["commit", "--quiet", "-m", "baseline"]);
205    }
206
207    fn run_git(repo: &Path, args: &[&str]) {
208        let output = Command::new("git")
209            .env_remove("GIT_DIR")
210            .env_remove("GIT_WORK_TREE")
211            .arg("-C")
212            .arg(repo)
213            .args(args)
214            .output()
215            .expect("git must be available for build-provenance tests");
216        assert!(
217            output.status.success(),
218            "git {:?} failed: {}",
219            args,
220            String::from_utf8_lossy(&output.stderr)
221        );
222    }
223}