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
158fn env_epoch_secs() -> Option<u64> {
159    let raw = std::env::var(CLOCK_EPOCH_ENV).ok()?;
160    let trimmed = raw.trim();
161    if trimmed.is_empty() {
162        return None;
163    }
164    match trimmed.parse::<u64>() {
165        Ok(epoch_secs) => Some(epoch_secs),
166        Err(e) => {
167            tracing::warn!("ignoring {CLOCK_EPOCH_ENV}={raw}: {e}");
168            None
169        }
170    }
171}
172
173fn head_commit_epoch_secs(root: &Path) -> Option<u64> {
174    let output = crate::git_env::git_command()
175        .args(["log", "-1", "--format=%ct", "HEAD"])
176        .current_dir(root)
177        .stderr(Stdio::null())
178        .output()
179        .ok()?;
180    if !output.status.success() {
181        return None;
182    }
183    String::from_utf8_lossy(&output.stdout).trim().parse().ok()
184}
185
186fn wall_clock_secs() -> u64 {
187    std::time::SystemTime::now()
188        .duration_since(std::time::UNIX_EPOCH)
189        .unwrap_or_default()
190        .as_secs()
191}
192
193/// Days since 1970-01-01 for a proleptic-Gregorian date.
194///
195/// Howard Hinnant's `days_from_civil`, so month and year windows land on real
196/// calendar boundaries instead of on a 30-day approximation.
197fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
198    let year = if month <= 2 { year - 1 } else { year };
199    let era = if year >= 0 { year } else { year - 399 } / 400;
200    let year_of_era = year - era * 400;
201    let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
202    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
203    era * 146_097 + day_of_era - 719_468
204}
205
206/// Inverse of [`days_from_civil`], returning `(year, month, day)`.
207fn civil_from_days(days: i64) -> (i64, i64, i64) {
208    let shifted = days + 719_468;
209    let era = if shifted >= 0 {
210        shifted
211    } else {
212        shifted - 146_096
213    } / 146_097;
214    let day_of_era = shifted - era * 146_097;
215    let year_of_era =
216        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
217    let year = year_of_era + era * 400;
218    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
219    let month_prime = (5 * day_of_year + 2) / 153;
220    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
221    let month = if month_prime < 10 {
222        month_prime + 3
223    } else {
224        month_prime - 9
225    };
226    (if month <= 2 { year + 1 } else { year }, month, day)
227}
228
229fn days_in_month(year: i64, month: i64) -> i64 {
230    match month {
231        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
232        2 if is_leap_year(year) => 29,
233        2 => 28,
234        // 4, 6, 9 and 11 have 30 days; callers only ever pass 1..=12, so the
235        // wildcard covers them and nothing else.
236        _ => 30,
237    }
238}
239
240const fn is_leap_year(year: i64) -> bool {
241    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::{
247        AnalysisClock, AnalysisClockSource, civil_from_days, days_from_civil, utc_midnight_epoch,
248    };
249
250    /// 2026-09-07T12:00:00Z.
251    const NOON: u64 = 1_788_782_400;
252
253    #[test]
254    fn civil_conversions_round_trip() {
255        for days in [-719_468, -1, 0, 1, 19_000, 20_338, 100_000] {
256            let (year, month, day) = civil_from_days(days);
257            assert_eq!(days_from_civil(year, month, day), days);
258        }
259    }
260
261    #[test]
262    fn utc_midnight_epoch_parses_and_rejects() {
263        assert_eq!(utc_midnight_epoch("1970-01-01"), Some(0));
264        assert_eq!(utc_midnight_epoch("2025-06-01"), Some(1_748_736_000));
265        assert_eq!(utc_midnight_epoch("2024-02-29"), Some(1_709_164_800));
266        assert_eq!(utc_midnight_epoch("2025-02-29"), None);
267        assert_eq!(utc_midnight_epoch("2025-13-01"), None);
268        assert_eq!(utc_midnight_epoch("2025-06"), None);
269        assert_eq!(utc_midnight_epoch("2025-06-01-01"), None);
270    }
271
272    #[test]
273    fn relative_windows_land_on_calendar_boundaries() {
274        let clock = AnalysisClock::pinned(NOON);
275        assert_eq!(clock.minus_days(0), NOON);
276        assert_eq!(clock.minus_days(7), NOON - 7 * 86_400);
277        // 2026-09-07 minus six months is 2026-03-07, not 180 days.
278        assert_eq!(clock.minus_months(6), NOON - 184 * 86_400);
279        // 2026-09-07 minus one year is 2025-09-07.
280        assert_eq!(clock.minus_years(1), NOON - 365 * 86_400);
281    }
282
283    #[test]
284    fn month_shift_clamps_a_missing_day_of_month() {
285        // 2026-03-31T00:00:00Z minus one month clamps to 2026-02-28.
286        let clock = AnalysisClock::pinned(1_774_915_200);
287        assert_eq!(clock.minus_months(1), 1_772_236_800);
288    }
289
290    #[test]
291    fn pinned_clock_is_reproducible() {
292        let clock = AnalysisClock::pinned(NOON);
293        assert_eq!(clock.epoch_secs(), NOON);
294        assert_eq!(clock.source(), AnalysisClockSource::Environment);
295        assert!(clock.is_reproducible());
296    }
297}