1pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
5
6#[cfg(test)]
7#[path = "build_info_support.rs"]
8mod build_info_support;
9
10pub const UNSTAMPED_REVISION: &str = "unstamped";
12
13pub const UNKNOWN_BUILD_TIME: &str = "unknown";
15
16#[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 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
46pub const BUILD_INFO: BuildInfo = BuildInfo::new(
48 option_env!("KHIVE_SOURCE_REVISION"),
49 option_env!("KHIVE_BUILD_TIME"),
50);
51
52pub 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 #[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 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 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}