Skip to main content

agent_runtime/render/
time.rs

1//! The only sanctioned time value the render engine is allowed to use.
2//!
3//! Per Resolved Decision #9 in
4//! `agent-runtime-kit/docs/source/inventory-target-architecture.md`,
5//! render output must be a pure function of the source-root contents.
6//! `std::time::SystemTime::now()` and `chrono::Utc::now()` are
7//! clippy-banned inside `agent-runtime` and `nils-common`. The
8//! single escape hatch is [`source_commit_timestamp`], which returns
9//! the ISO-8601 commit timestamp of the source-root's `HEAD` — a value
10//! that changes only when the source tree itself changes, so two cold
11//! processes rendering the same source produce identical output.
12//!
13//! See `crates/agent-runtime/docs/determinism.md` for the full
14//! contract.
15
16use anyhow::{Context, Result, anyhow};
17use std::path::Path;
18use std::process::Command;
19
20/// Return the ISO-8601 commit timestamp of `HEAD` in the git repository
21/// at `source_root`. Equivalent to `git -C <source_root> log -1
22/// --format=%cI HEAD`. The output is the only time-shaped value
23/// permitted to land in rendered output.
24///
25/// Returns `Err` when:
26/// - `git` is missing from `PATH`,
27/// - `source_root` is not a git repository,
28/// - `HEAD` cannot be resolved (empty repo).
29pub 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        // Force a fixed committer date so the assertion below is stable.
93        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    /// Strict RFC 3339 / ISO 8601 check. Matches `YYYY-MM-DDTHH:MM:SS`
119    /// followed by either `Z` or `±HH:MM`. This is what `--format=%cI`
120    /// produces and what downstream Tera helpers will assume.
121    fn is_strict_iso8601(s: &str) -> bool {
122        let bytes = s.as_bytes();
123        // Minimum length: "YYYY-MM-DDTHH:MM:SSZ" = 20.
124        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        // The fixed committer date pins this exact value.
176        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        // git's error message contains "not a git repository"; we
193        // surface whatever git stderr said with the source root.
194        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        // No commits — `HEAD` is unborn.
206        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        // Self-test for the matcher — protects against the matcher
217        // silently accepting non-ISO output if git ever changes its
218        // `%cI` format.
219        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}