agent_runtime/render/
time.rs1use anyhow::{Context, Result, anyhow};
17use std::path::Path;
18use std::process::Command;
19
20pub fn source_commit_timestamp(source_root: &Path) -> Result<String> {
30 let output = Command::new("git")
31 .arg("-C")
32 .arg(source_root)
33 .args(["log", "-1", "--format=%cI", "HEAD"])
34 .output()
35 .with_context(|| {
36 format!(
37 "spawn `git -C {} log -1 --format=%cI HEAD`",
38 source_root.display()
39 )
40 })?;
41 if !output.status.success() {
42 let stderr = String::from_utf8_lossy(&output.stderr);
43 return Err(anyhow!(
44 "git log -1 --format=%cI HEAD exited {status:?} at {root}: {stderr}",
45 status = output.status.code(),
46 root = source_root.display(),
47 stderr = stderr.trim(),
48 ));
49 }
50 let raw = String::from_utf8(output.stdout)
51 .context("git log -1 --format=%cI HEAD produced non-UTF8 output")?;
52 let trimmed = raw.trim();
53 if trimmed.is_empty() {
54 return Err(anyhow!(
55 "git log -1 --format=%cI HEAD returned empty stdout at {}",
56 source_root.display(),
57 ));
58 }
59 Ok(trimmed.to_string())
60}
61
62#[cfg(test)]
63mod tests {
64 use super::*;
65 use std::fs;
66 use std::path::PathBuf;
67 use tempfile::TempDir;
68
69 fn run(cmd: &str, args: &[&str], cwd: &Path) {
70 let status = Command::new(cmd)
71 .args(args)
72 .current_dir(cwd)
73 .status()
74 .unwrap();
75 assert!(
76 status.success(),
77 "{cmd} {args:?} failed at {}",
78 cwd.display()
79 );
80 }
81
82 fn init_git_repo() -> (TempDir, PathBuf) {
83 let tmp = TempDir::new().unwrap();
84 let root = tmp.path().to_path_buf();
85 run("git", &["init", "--quiet", "--initial-branch=main"], &root);
86 run("git", &["config", "user.email", "test@example.com"], &root);
87 run("git", &["config", "user.name", "Test"], &root);
88 run("git", &["config", "commit.gpgsign", "false"], &root);
89 run("git", &["config", "tag.gpgsign", "false"], &root);
90 fs::write(root.join("README"), "seed\n").unwrap();
91 run("git", &["add", "README"], &root);
92 let env_args = [
94 "-c",
95 "user.email=test@example.com",
96 "-c",
97 "user.name=Test",
98 "-c",
99 "commit.gpgsign=false",
100 "commit",
101 "--quiet",
102 "--allow-empty-message",
103 "-m",
104 "seed",
105 "--date=2026-05-21T00:00:00+00:00",
106 ];
107 let status = Command::new("git")
108 .args(env_args)
109 .env("GIT_COMMITTER_DATE", "2026-05-21T00:00:00+00:00")
110 .env("GIT_AUTHOR_DATE", "2026-05-21T00:00:00+00:00")
111 .current_dir(&root)
112 .status()
113 .unwrap();
114 assert!(status.success());
115 (tmp, root)
116 }
117
118 fn is_strict_iso8601(s: &str) -> bool {
122 let bytes = s.as_bytes();
123 if bytes.len() < 20 {
125 return false;
126 }
127 let digit = |i: usize| bytes[i].is_ascii_digit();
128 let sep = |i: usize, c: u8| bytes[i] == c;
129 if !(digit(0)
130 && digit(1)
131 && digit(2)
132 && digit(3)
133 && sep(4, b'-')
134 && digit(5)
135 && digit(6)
136 && sep(7, b'-')
137 && digit(8)
138 && digit(9)
139 && sep(10, b'T')
140 && digit(11)
141 && digit(12)
142 && sep(13, b':')
143 && digit(14)
144 && digit(15)
145 && sep(16, b':')
146 && digit(17)
147 && digit(18))
148 {
149 return false;
150 }
151 let tz = &bytes[19..];
152 match tz {
153 [b'Z'] => true,
154 [sign, h1, h2, b':', m1, m2]
155 if (*sign == b'+' || *sign == b'-')
156 && h1.is_ascii_digit()
157 && h2.is_ascii_digit()
158 && m1.is_ascii_digit()
159 && m2.is_ascii_digit() =>
160 {
161 true
162 }
163 _ => false,
164 }
165 }
166
167 #[test]
168 fn returns_iso8601_timestamp_for_head() {
169 let (_tmp, root) = init_git_repo();
170 let ts = source_commit_timestamp(&root).unwrap();
171 assert!(
172 is_strict_iso8601(&ts),
173 "expected strict ISO-8601 (YYYY-MM-DDTHH:MM:SS[Z|±HH:MM]), got {ts:?}",
174 );
175 assert!(ts.starts_with("2026-05-21T00:00:00"), "got {ts:?}");
177 }
178
179 #[test]
180 fn is_stable_across_calls_for_same_head() {
181 let (_tmp, root) = init_git_repo();
182 let first = source_commit_timestamp(&root).unwrap();
183 let second = source_commit_timestamp(&root).unwrap();
184 assert_eq!(first, second);
185 }
186
187 #[test]
188 fn errors_when_source_root_is_not_a_git_repo() {
189 let tmp = TempDir::new().unwrap();
190 let err = source_commit_timestamp(tmp.path()).unwrap_err();
191 let msg = format!("{err:#}");
192 assert!(
195 msg.contains("git log") || msg.contains("not a git"),
196 "{msg}"
197 );
198 }
199
200 #[test]
201 fn errors_when_repo_has_no_head_yet() {
202 let tmp = TempDir::new().unwrap();
203 let root = tmp.path().to_path_buf();
204 run("git", &["init", "--quiet", "--initial-branch=main"], &root);
205 let err = source_commit_timestamp(&root).unwrap_err();
207 let msg = format!("{err:#}");
208 assert!(
209 msg.contains("git log") && !msg.is_empty(),
210 "expected git log error for unborn HEAD, got {msg:?}"
211 );
212 }
213
214 #[test]
215 fn iso8601_strictness_rejects_obvious_garbage() {
216 assert!(is_strict_iso8601("2026-05-21T00:00:00Z"));
220 assert!(is_strict_iso8601("2026-05-21T00:00:00+08:00"));
221 assert!(is_strict_iso8601("2026-05-21T00:00:00-05:30"));
222 assert!(!is_strict_iso8601(""));
223 assert!(!is_strict_iso8601("2026-05-21"));
224 assert!(!is_strict_iso8601("2026-05-21 00:00:00Z"));
225 assert!(!is_strict_iso8601("2026/05/21T00:00:00Z"));
226 assert!(!is_strict_iso8601("2026-05-21T00:00:00"));
227 assert!(!is_strict_iso8601("2026-05-21T00:00:00+0000"));
228 }
229}