agent_doc_log_time/
lib.rs1use std::{
35 process::Command,
36 time::{SystemTime, UNIX_EPOCH},
37};
38
39pub fn current_epoch_secs() -> u64 {
42 SystemTime::now()
43 .duration_since(UNIX_EPOCH)
44 .map(|d| d.as_secs())
45 .unwrap_or_default()
46}
47
48pub fn current_log_timestamp() -> String {
50 format_log_timestamp(current_epoch_secs())
51}
52
53pub fn current_human_timestamp() -> String {
56 format_human_timestamp(current_epoch_secs())
57}
58
59pub fn current_local_date_ymd() -> String {
65 Command::new("date")
66 .args(["+%Y-%m-%d"])
67 .output()
68 .ok()
69 .and_then(|o| trimmed_date_stdout(&o.stdout))
70 .unwrap_or_else(|| "unknown-date".to_string())
71}
72
73fn trimmed_date_stdout(stdout: &[u8]) -> Option<String> {
74 let trimmed = String::from_utf8_lossy(stdout).trim().to_string();
75 (!trimmed.is_empty()).then_some(trimmed)
76}
77
78pub fn format_log_timestamp(secs: u64) -> String {
81 let days = (secs / 86_400) as i64;
82 let tod = secs % 86_400;
83 let (hh, mm, ss) = (tod / 3_600, (tod % 3_600) / 60, tod % 60);
84
85 let z = days + 719_468;
87 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
88 let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
91 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if m <= 2 { y + 1 } else { y };
96
97 format!("{year:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
98}
99
100pub fn format_human_timestamp(secs: u64) -> String {
102 let iso = format_log_timestamp(secs);
103 format!("{} {}", &iso[..10], &iso[11..19])
104}
105
106pub fn format_ops_log_tracking_suffix(
111 doc_stem: Option<&str>,
112 session: Option<&str>,
113 turn: Option<&str>,
114) -> String {
115 let mut suffix = String::new();
116 if let Some(stem) = doc_stem {
117 suffix.push_str(" doc=");
118 suffix.push_str(stem);
119 }
120 if let Some(session) = session.filter(|session| !session.is_empty()) {
121 suffix.push_str(" session=");
122 suffix.push_str(session);
123 }
124 if let Some(turn) = turn.filter(|turn| !turn.is_empty()) {
125 suffix.push_str(" turn=");
126 suffix.push_str(turn);
127 }
128 suffix
129}
130
131pub fn format_ops_log_line(timestamp_secs: u64, message: &str, tracking_suffix: &str) -> String {
133 format!(
134 "[{}] {}{}",
135 format_log_timestamp(timestamp_secs),
136 message,
137 tracking_suffix
138 )
139}
140
141pub fn parse_log_timestamp(raw: &str) -> Option<u64> {
146 let s = raw.trim();
147 if let Ok(epoch) = s.parse::<u64>() {
148 return Some(epoch);
149 }
150 let s = s.strip_suffix('Z').unwrap_or(s);
151 let (date, time) = s.split_once('T')?;
152 let mut dp = date.split('-');
153 let year: i64 = dp.next()?.parse().ok()?;
154 let month: u64 = dp.next()?.parse().ok()?;
155 let day: u64 = dp.next()?.parse().ok()?;
156 if dp.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
157 return None;
158 }
159 let mut tp = time.split(':');
160 let hh: u64 = tp.next()?.parse().ok()?;
161 let mm: u64 = tp.next()?.parse().ok()?;
162 let ss: u64 = tp.next()?.parse().ok()?;
163 if tp.next().is_some() || hh > 23 || mm > 59 || ss > 60 {
164 return None;
165 }
166
167 let y = if month <= 2 { year - 1 } else { year };
169 let era = if y >= 0 { y } else { y - 399 } / 400;
170 let yoe = (y - era * 400) as u64; let mp = if month > 2 { month - 3 } else { month + 9 }; let doy = (153 * mp + 2) / 5 + day - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; let days = era * 146_097 + doe as i64 - 719_468;
175 let secs = days * 86_400 + (hh * 3_600 + mm * 60 + ss) as i64;
176 u64::try_from(secs).ok()
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn format_matches_known_iso8601_utc() {
185 assert_eq!(format_log_timestamp(1_781_771_180), "2026-06-18T08:26:20Z");
187 assert_eq!(format_log_timestamp(0), "1970-01-01T00:00:00Z");
188 assert_eq!(format_log_timestamp(1_709_164_800), "2024-02-29T00:00:00Z");
189 }
190
191 #[test]
192 fn human_timestamp_preserves_space_separated_shape() {
193 assert_eq!(format_human_timestamp(1_781_771_180), "2026-06-18 08:26:20");
194 assert_eq!(format_human_timestamp(0), "1970-01-01 00:00:00");
195 }
196
197 #[test]
198 fn local_date_stdout_trims_and_rejects_empty_output() {
199 assert_eq!(
200 trimmed_date_stdout(b"2026-07-02\n"),
201 Some("2026-07-02".to_string())
202 );
203 assert_eq!(trimmed_date_stdout(b"\n"), None);
204 }
205
206 #[test]
207 fn current_local_date_ymd_has_archive_shape() {
208 let date = current_local_date_ymd();
209 assert!(
210 date == "unknown-date"
211 || (date.len() == 10 && date.as_bytes()[4] == b'-' && date.as_bytes()[7] == b'-'),
212 "unexpected archive date shape: {date:?}"
213 );
214 }
215
216 #[test]
217 fn parse_accepts_epoch_and_iso_and_round_trips() {
218 assert_eq!(parse_log_timestamp("1781771180"), Some(1_781_771_180));
220 assert_eq!(
222 parse_log_timestamp("2026-06-18T08:26:20Z"),
223 Some(1_781_771_180)
224 );
225 for secs in [0_u64, 1, 1_709_164_800, 1_781_915_847, 4_102_444_800] {
227 assert_eq!(
228 parse_log_timestamp(&format_log_timestamp(secs)),
229 Some(secs),
230 "round-trip failed for epoch {secs}"
231 );
232 }
233 assert_eq!(parse_log_timestamp("not-a-timestamp"), None);
235 assert_eq!(parse_log_timestamp("2026-13-01T00:00:00Z"), None);
236 }
237
238 #[test]
239 fn current_log_timestamp_is_parseable() {
240 let timestamp = current_log_timestamp();
241 assert!(
242 parse_log_timestamp(×tamp).is_some(),
243 "current timestamp should parse: {timestamp:?}"
244 );
245 }
246
247 #[test]
248 fn tracking_suffix_preserves_ops_log_wire_format() {
249 assert_eq!(
250 format_ops_log_tracking_suffix(Some("plan"), Some("sess-1"), Some("turn-2")),
251 " doc=plan session=sess-1 turn=turn-2"
252 );
253 assert_eq!(
254 format_ops_log_tracking_suffix(Some("plan"), None, Some("")),
255 " doc=plan"
256 );
257 }
258
259 #[test]
260 fn ops_log_line_uses_bracketed_iso_timestamp() {
261 assert_eq!(
262 format_ops_log_line(0, "event happened", " doc=plan"),
263 "[1970-01-01T00:00:00Z] event happened doc=plan"
264 );
265 }
266}