Skip to main content

fallow_engine/
clock.rs

1//! Run-scoped analysis clock.
2//!
3//! Churn recency weighting, ownership staleness, and the churn window all need
4//! a "now". Reading the system clock separately at each of those sites makes
5//! two runs over the same commit disagree: the recency decay is continuous, so
6//! `weighted_commits` moves every run, and `stale_days` flips fixed thresholds
7//! (owner-active, drift minimum file age) as the day rolls over. The clock here
8//! resolves once per churn analysis from HEAD's committer timestamp, so one
9//! commit always yields the same churn-derived numbers on any machine.
10//!
11//! `FALLOW_CLOCK_EPOCH` pins the reference epoch explicitly, for reproducible
12//! builds and for comparing two checkouts against a fixed instant.
13
14use std::path::Path;
15use std::process::Stdio;
16
17/// Environment override for the run's reference epoch, in unix seconds.
18pub const CLOCK_EPOCH_ENV: &str = "FALLOW_CLOCK_EPOCH";
19
20/// Seconds in one day.
21const SECS_PER_DAY: u64 = 86_400;
22
23/// Where a run's reference epoch came from.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum AnalysisClockSource {
26    /// Pinned by [`CLOCK_EPOCH_ENV`].
27    Environment,
28    /// HEAD's committer timestamp: identical for every run over one commit.
29    HeadCommit,
30    /// The system wall clock, when HEAD has no readable committer timestamp.
31    WallClock,
32}
33
34/// The single instant a run measures commit ages and staleness against.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct AnalysisClock {
37    epoch_secs: u64,
38    source: AnalysisClockSource,
39}
40
41impl AnalysisClock {
42    /// Resolve the clock for `root`: the environment override first, then
43    /// HEAD's committer timestamp, then the system wall clock.
44    #[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    /// A clock pinned to an explicit epoch. Used by tests and by embedders that
65    /// already know the instant they want the analysis measured against.
66    #[must_use]
67    pub const fn pinned(epoch_secs: u64) -> Self {
68        Self {
69            epoch_secs,
70            source: AnalysisClockSource::Environment,
71        }
72    }
73
74    /// The reference epoch, in unix seconds.
75    #[must_use]
76    pub const fn epoch_secs(self) -> u64 {
77        self.epoch_secs
78    }
79
80    /// Where the reference epoch came from.
81    #[must_use]
82    pub const fn source(self) -> AnalysisClockSource {
83        self.source
84    }
85
86    /// True when two runs over the same commit resolve the same epoch.
87    #[must_use]
88    pub const fn is_reproducible(self) -> bool {
89        !matches!(self.source, AnalysisClockSource::WallClock)
90    }
91
92    /// The epoch `days` days before the clock.
93    #[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    /// The epoch `months` calendar months before the clock, in UTC. A
100    /// day-of-month that the earlier month does not have clamps to its last
101    /// day, so "1 month before March 31" is February 28 rather than March 3.
102    #[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    /// The epoch `years` years before the clock, in UTC. February 29 clamps to
108    /// February 28 in a non-leap year.
109    #[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/// Parse an ISO `YYYY-MM-DD` date into the epoch of its UTC midnight.
137///
138/// Git's own date parser reads a bare date in the machine's local time zone, so
139/// the same `--since 2025-06-01` covered a different span on two machines. UTC
140/// makes the window mean one thing everywhere.
141#[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/// The UTC calendar date of a unix epoch, as `YYYY-MM-DD`.
159#[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/// A unix epoch as an RFC 3339 UTC timestamp, `YYYY-MM-DDTHH:MM:SSZ`.
167#[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
214/// Days since 1970-01-01 for a proleptic-Gregorian date.
215///
216/// Howard Hinnant's `days_from_civil`, so month and year windows land on real
217/// calendar boundaries instead of on a 30-day approximation.
218fn 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
227/// Inverse of [`days_from_civil`], returning `(year, month, day)`.
228fn 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        // 4, 6, 9 and 11 have 30 days; callers only ever pass 1..=12, so the
256        // wildcard covers them and nothing else.
257        _ => 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    /// 2026-09-07T12:00:00Z.
273    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        // 2026-09-07 minus six months is 2026-03-07, not 180 days.
308        assert_eq!(clock.minus_months(6), NOON - 184 * 86_400);
309        // 2026-09-07 minus one year is 2025-09-07.
310        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        // 2026-03-31T00:00:00Z minus one month clamps to 2026-02-28.
316        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}