1use std::path::Path;
15use std::process::Stdio;
16
17pub const CLOCK_EPOCH_ENV: &str = "FALLOW_CLOCK_EPOCH";
19
20const SECS_PER_DAY: u64 = 86_400;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum AnalysisClockSource {
26 Environment,
28 HeadCommit,
30 WallClock,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct AnalysisClock {
37 epoch_secs: u64,
38 source: AnalysisClockSource,
39}
40
41impl AnalysisClock {
42 #[must_use]
45 pub fn for_repo(root: &Path) -> Self {
46 if let Some(epoch_secs) = env_epoch_secs() {
47 return Self {
48 epoch_secs,
49 source: AnalysisClockSource::Environment,
50 };
51 }
52 if let Some(epoch_secs) = head_commit_epoch_secs(root) {
53 return Self {
54 epoch_secs,
55 source: AnalysisClockSource::HeadCommit,
56 };
57 }
58 Self {
59 epoch_secs: wall_clock_secs(),
60 source: AnalysisClockSource::WallClock,
61 }
62 }
63
64 #[must_use]
67 pub const fn pinned(epoch_secs: u64) -> Self {
68 Self {
69 epoch_secs,
70 source: AnalysisClockSource::Environment,
71 }
72 }
73
74 #[must_use]
76 pub const fn epoch_secs(self) -> u64 {
77 self.epoch_secs
78 }
79
80 #[must_use]
82 pub const fn source(self) -> AnalysisClockSource {
83 self.source
84 }
85
86 #[must_use]
88 pub const fn is_reproducible(self) -> bool {
89 !matches!(self.source, AnalysisClockSource::WallClock)
90 }
91
92 #[must_use]
94 pub const fn minus_days(self, days: u64) -> u64 {
95 self.epoch_secs
96 .saturating_sub(days.saturating_mul(SECS_PER_DAY))
97 }
98
99 #[must_use]
103 pub fn minus_months(self, months: u64) -> u64 {
104 self.shift_months(i64::try_from(months).unwrap_or(i64::MAX))
105 }
106
107 #[must_use]
110 pub fn minus_years(self, years: u64) -> u64 {
111 self.shift_months(
112 i64::try_from(years)
113 .unwrap_or(i64::MAX / 12)
114 .saturating_mul(12),
115 )
116 }
117
118 fn shift_months(self, months: i64) -> u64 {
119 let days = i64::try_from(self.epoch_secs / SECS_PER_DAY).unwrap_or(0);
120 let time_of_day = self.epoch_secs % SECS_PER_DAY;
121 let (year, month, day) = civil_from_days(days);
122
123 let total = year * 12 + (month - 1) - months;
124 let shifted_year = total.div_euclid(12);
125 let shifted_month = total.rem_euclid(12) + 1;
126 let shifted_day = day.min(days_in_month(shifted_year, shifted_month));
127
128 let shifted_days = days_from_civil(shifted_year, shifted_month, shifted_day);
129 u64::try_from(shifted_days)
130 .unwrap_or(0)
131 .saturating_mul(SECS_PER_DAY)
132 .saturating_add(time_of_day)
133 }
134}
135
136#[must_use]
142pub fn utc_midnight_epoch(iso_date: &str) -> Option<u64> {
143 let mut parts = iso_date.split('-');
144 let year: i64 = parts.next()?.parse().ok()?;
145 let month: i64 = parts.next()?.parse().ok()?;
146 let day: i64 = parts.next()?.parse().ok()?;
147 if parts.next().is_some() || !(1..=12).contains(&month) {
148 return None;
149 }
150 if day < 1 || day > days_in_month(year, month) {
151 return None;
152 }
153 u64::try_from(days_from_civil(year, month, day))
154 .ok()
155 .map(|days| days * SECS_PER_DAY)
156}
157
158#[must_use]
160pub fn utc_date(epoch_secs: u64) -> String {
161 let days = i64::try_from(epoch_secs / SECS_PER_DAY).unwrap_or(0);
162 let (year, month, day) = civil_from_days(days);
163 format!("{year:04}-{month:02}-{day:02}")
164}
165
166#[must_use]
168pub fn utc_timestamp(epoch_secs: u64) -> String {
169 let time_of_day = epoch_secs % SECS_PER_DAY;
170 format!(
171 "{}T{:02}:{:02}:{:02}Z",
172 utc_date(epoch_secs),
173 time_of_day / 3600,
174 (time_of_day % 3600) / 60,
175 time_of_day % 60
176 )
177}
178
179fn env_epoch_secs() -> Option<u64> {
180 let raw = std::env::var(CLOCK_EPOCH_ENV).ok()?;
181 let trimmed = raw.trim();
182 if trimmed.is_empty() {
183 return None;
184 }
185 match trimmed.parse::<u64>() {
186 Ok(epoch_secs) => Some(epoch_secs),
187 Err(e) => {
188 tracing::warn!("ignoring {CLOCK_EPOCH_ENV}={raw}: {e}");
189 None
190 }
191 }
192}
193
194fn head_commit_epoch_secs(root: &Path) -> Option<u64> {
195 let output = crate::git_env::git_command()
196 .args(["log", "-1", "--format=%ct", "HEAD"])
197 .current_dir(root)
198 .stderr(Stdio::null())
199 .output()
200 .ok()?;
201 if !output.status.success() {
202 return None;
203 }
204 String::from_utf8_lossy(&output.stdout).trim().parse().ok()
205}
206
207fn wall_clock_secs() -> u64 {
208 std::time::SystemTime::now()
209 .duration_since(std::time::UNIX_EPOCH)
210 .unwrap_or_default()
211 .as_secs()
212}
213
214fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
219 let year = if month <= 2 { year - 1 } else { year };
220 let era = if year >= 0 { year } else { year - 399 } / 400;
221 let year_of_era = year - era * 400;
222 let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
223 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
224 era * 146_097 + day_of_era - 719_468
225}
226
227fn civil_from_days(days: i64) -> (i64, i64, i64) {
229 let shifted = days + 719_468;
230 let era = if shifted >= 0 {
231 shifted
232 } else {
233 shifted - 146_096
234 } / 146_097;
235 let day_of_era = shifted - era * 146_097;
236 let year_of_era =
237 (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
238 let year = year_of_era + era * 400;
239 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
240 let month_prime = (5 * day_of_year + 2) / 153;
241 let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
242 let month = if month_prime < 10 {
243 month_prime + 3
244 } else {
245 month_prime - 9
246 };
247 (if month <= 2 { year + 1 } else { year }, month, day)
248}
249
250fn days_in_month(year: i64, month: i64) -> i64 {
251 match month {
252 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
253 2 if is_leap_year(year) => 29,
254 2 => 28,
255 _ => 30,
258 }
259}
260
261const fn is_leap_year(year: i64) -> bool {
262 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
263}
264
265#[cfg(test)]
266mod tests {
267 use super::{
268 AnalysisClock, AnalysisClockSource, civil_from_days, days_from_civil, utc_date,
269 utc_midnight_epoch, utc_timestamp,
270 };
271
272 const NOON: u64 = 1_788_782_400;
274
275 #[test]
276 fn utc_date_and_timestamp_format_the_epoch() {
277 assert_eq!(utc_date(0), "1970-01-01");
278 assert_eq!(utc_timestamp(1_758_758_400), "2025-09-25T00:00:00Z");
279 assert_eq!(utc_timestamp(1_709_210_096), "2024-02-29T12:34:56Z");
280 assert_eq!(utc_date(1_709_210_096), "2024-02-29");
281 }
282
283 #[test]
284 fn civil_conversions_round_trip() {
285 for days in [-719_468, -1, 0, 1, 19_000, 20_338, 100_000] {
286 let (year, month, day) = civil_from_days(days);
287 assert_eq!(days_from_civil(year, month, day), days);
288 }
289 }
290
291 #[test]
292 fn utc_midnight_epoch_parses_and_rejects() {
293 assert_eq!(utc_midnight_epoch("1970-01-01"), Some(0));
294 assert_eq!(utc_midnight_epoch("2025-06-01"), Some(1_748_736_000));
295 assert_eq!(utc_midnight_epoch("2024-02-29"), Some(1_709_164_800));
296 assert_eq!(utc_midnight_epoch("2025-02-29"), None);
297 assert_eq!(utc_midnight_epoch("2025-13-01"), None);
298 assert_eq!(utc_midnight_epoch("2025-06"), None);
299 assert_eq!(utc_midnight_epoch("2025-06-01-01"), None);
300 }
301
302 #[test]
303 fn relative_windows_land_on_calendar_boundaries() {
304 let clock = AnalysisClock::pinned(NOON);
305 assert_eq!(clock.minus_days(0), NOON);
306 assert_eq!(clock.minus_days(7), NOON - 7 * 86_400);
307 assert_eq!(clock.minus_months(6), NOON - 184 * 86_400);
309 assert_eq!(clock.minus_years(1), NOON - 365 * 86_400);
311 }
312
313 #[test]
314 fn month_shift_clamps_a_missing_day_of_month() {
315 let clock = AnalysisClock::pinned(1_774_915_200);
317 assert_eq!(clock.minus_months(1), 1_772_236_800);
318 }
319
320 #[test]
321 fn pinned_clock_is_reproducible() {
322 let clock = AnalysisClock::pinned(NOON);
323 assert_eq!(clock.epoch_secs(), NOON);
324 assert_eq!(clock.source(), AnalysisClockSource::Environment);
325 assert!(clock.is_reproducible());
326 }
327}