Skip to main content

repon_core/
cell.rs

1//! The Cell vocabulary: every displayed value carries its whole provenance, and the
2//! only way to it is a match, so an absent value can never be read as a default.
3//!
4//! See [ADR 0001](https://github.com/paulchiu/repon/blob/main/docs/adr/0001-per-cell-provenance.md),
5//! [ADR 0015](https://github.com/paulchiu/repon/blob/main/docs/adr/0015-the-core-owns-the-table.md)
6//! and `docs/spec/core-api.md`.
7
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use crate::git::ProbeError;
11
12/// One refresh, so a newer one can be recognised over an older one still draining.
13///
14/// Ordered so a [`Cell`] can tell a superseded write from a current one. Minting one
15/// is `Core::refresh`'s job elsewhere; this crate only compares them.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize))]
18pub struct Generation(u64);
19
20impl Generation {
21    /// Wraps a raw counter value. `Core::refresh` is the one minter of a real one.
22    pub(crate) fn new(value: u64) -> Self {
23        Generation(value)
24    }
25
26    /// The raw counter this wraps, for the one place that keys by it: the table's
27    /// own per-Generation start times and in-flight records.
28    pub(crate) fn value(self) -> u64 {
29        self.0
30    }
31
32    /// The Generation immediately after this one.
33    ///
34    /// What lets a supersession test name the Generation it means by its order after a
35    /// Generation it holds, rather than by a counter value that shifts the moment
36    /// something else in the crate mints one earlier.
37    #[cfg(test)]
38    pub(crate) fn successor(self) -> Self {
39        Generation(self.0 + 1)
40    }
41}
42
43/// A wall-clock moment, RFC 3339 on request via [`std::fmt::Display`].
44///
45/// Never a monotonic instant and never a raw `SystemTime` on the surface.
46/// Supersession arbitrates entirely on [`Generation`], never on this, so a clock
47/// that jumps backwards is not guarded against here.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct Timestamp(SystemTime);
50
51impl Timestamp {
52    /// The current wall-clock time.
53    pub fn now() -> Self {
54        Timestamp(SystemTime::now())
55    }
56
57    /// Wraps an arbitrary point in time. `now` is every real caller's constructor;
58    /// this exists so a consumer's test can build a `Timestamp` in the future, the
59    /// only way to exercise a backward clock jump deterministically.
60    ///
61    /// Gated behind `test-util` (on by default under `cfg(test)` for this crate's own
62    /// tests) so a test-only affordance never ships on the default published surface,
63    /// per [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md).
64    #[cfg(any(test, feature = "test-util"))]
65    pub fn at(instant: SystemTime) -> Self {
66        Timestamp(instant)
67    }
68
69    /// How long ago this timestamp was, against the wall clock right now.
70    ///
71    /// A future `self`, the shape a backward clock jump leaves behind, has no
72    /// negative `Duration` to report, so this reads zero rather than erring: a
73    /// reader sees "just now" with no defensive clamp layered on top.
74    pub fn elapsed(&self) -> Duration {
75        SystemTime::now()
76            .duration_since(self.0)
77            .unwrap_or(Duration::ZERO)
78    }
79}
80
81impl std::fmt::Display for Timestamp {
82    /// Formats as RFC 3339 (`2026-08-30T12:34:56Z`), computed by hand: the
83    /// dependency allowlist has no time crate to reach for.
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        let secs = self
86            .0
87            .duration_since(UNIX_EPOCH)
88            .unwrap_or(Duration::ZERO)
89            .as_secs() as i64;
90        let days = secs.div_euclid(86_400);
91        let secs_of_day = secs.rem_euclid(86_400);
92        let (year, month, day) = civil_from_days(days);
93        let hour = secs_of_day / 3_600;
94        let minute = (secs_of_day % 3_600) / 60;
95        let second = secs_of_day % 60;
96        write!(
97            f,
98            "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
99        )
100    }
101}
102
103/// RFC 3339 on the wire (`docs/spec/core-api.md`'s "The timestamp"), reusing [`Display`](std::fmt::Display)
104/// rather than deriving on the private `SystemTime`: `SystemTime`'s own `Serialize` impl
105/// writes a `{"secs_since_epoch":...,"nanos_since_epoch":...}` pair, the exact shape ADR 0015
106/// rejects as something "no consumer wants".
107#[cfg(feature = "serde")]
108impl serde::Serialize for Timestamp {
109    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
110    where
111        S: serde::Serializer,
112    {
113        serializer.collect_str(self)
114    }
115}
116
117/// Days since the Unix epoch to a proleptic Gregorian (year, month, day).
118///
119/// Howard Hinnant's `civil_from_days` (<https://howardhinnant.github.io/date_algorithms.html>),
120/// chosen so this crate never needs a calendar dependency for one field's display.
121fn civil_from_days(days: i64) -> (i64, u32, u32) {
122    let z = days + 719_468;
123    let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
124    let day_of_era = z - era * 146_097; // [0, 146096]
125    let year_of_era =
126        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; // [0, 399]
127    let year = year_of_era + era * 400;
128    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); // [0, 365]
129    let month_prime = (5 * day_of_year + 2) / 153; // [0, 11]
130    let day = (day_of_year - (153 * month_prime + 2) / 5 + 1) as u32; // [1, 31]
131    let month = (if month_prime < 10 {
132        month_prime + 3
133    } else {
134        month_prime - 9
135    }) as u32; // [1, 12]
136    let year = if month <= 2 { year + 1 } else { year };
137    (year, month, day)
138}
139
140/// Why a [`Cell`] is [`Settled::Unknown`]. Closed at exactly these three: every other
141/// absence this design once modelled as Unknown turned out to be a settled value
142/// rendered elsewhere (a branch with no upstream renders `-`, a Repo with no remote
143/// renders `∅`), so no `NoUpstream` or `NoRemote` reason exists here.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145#[cfg_attr(feature = "serde", derive(serde::Serialize))]
146pub enum Unknown {
147    /// The Generation hit its deadline while this cell was still being probed.
148    TimedOut,
149    /// The default branch resolution chain reached its last rung with no answer.
150    NoDefaultBranch,
151    /// A Submodule has never been `git submodule update --init`-ed, so opening it
152    /// found nothing there rather than something broken
153    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
154    /// "The Submodule row").
155    SubmoduleUninitialized,
156}
157
158/// What a [`Cell`] has settled to. Never a bare `Option<T>`, so an absent value can
159/// never be read as a default.
160#[derive(Debug, Clone)]
161#[cfg_attr(feature = "serde", derive(serde::Serialize))]
162pub enum Settled<T> {
163    /// Asked and got nothing back, for one of [`Unknown`]'s closed reasons.
164    Unknown(Unknown),
165    /// A value as of `at`; `stale` means known to be old with nothing currently
166    /// fixing it.
167    Known {
168        value: T,
169        at: Timestamp,
170        stale: bool,
171    },
172    /// The probe itself failed.
173    Failed(ProbeError),
174    /// A settled fact rather than a missing value: this column has no meaning for
175    /// this row. Its three producers are Worktree state on a Repo row, `base` on a
176    /// row whose branch is itself the default branch, and `base` on a Repo with no
177    /// remote.
178    NotApplicable,
179}
180
181/// A displayed value together with its whole provenance.
182///
183/// Every field is private; the only way out is [`Cell::settled`] plus a match, so an
184/// absent value can never be read as a default. `in_flight` is orthogonal to
185/// `settled` rather than a fifth [`Settled`] arm, which is what lets a re-probing
186/// cell keep its previous value instead of blanking. `settled` being `None` while
187/// `in_flight` is `false` is a cell nothing has looked at yet, only reachable before
188/// the first Generation covers the entity.
189#[derive(Debug, Clone)]
190#[cfg_attr(feature = "serde", derive(serde::Serialize))]
191pub struct Cell<T> {
192    settled: Option<Settled<T>>,
193    in_flight: bool,
194    // Internal supersession bookkeeping, not a fact about the value: skipped on the wire
195    // rather than leaking a Generation number a consumer never needs.
196    #[cfg_attr(feature = "serde", serde(skip))]
197    #[allow(dead_code)] // read only by settle's own supersession check for now
198    generation: Generation,
199}
200
201impl<T> Default for Cell<T> {
202    fn default() -> Self {
203        Cell {
204            settled: None,
205            in_flight: false,
206            generation: Generation::default(),
207        }
208    }
209}
210
211impl<T> Cell<T> {
212    /// A Cell already in `settled`, with nothing in flight against it.
213    ///
214    /// `settle` is every real writer's way in, and it is `pub(crate)` because only a
215    /// probe's result may reach a Cell in production. This exists so a consumer's own
216    /// test can build a table of settled values without running a probe against a real
217    /// repository, and is gated behind `test-util` (on by default under `cfg(test)` for
218    /// this crate's own tests) so it never ships on the default published surface, per
219    /// [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md).
220    #[cfg(any(test, feature = "test-util"))]
221    pub fn already_settled(settled: Settled<T>) -> Self {
222        Cell {
223            settled: Some(settled),
224            in_flight: false,
225            generation: Generation::default(),
226        }
227    }
228
229    /// A Cell already in `settled`, with a re-probe running against it right now: the shape a
230    /// consumer's own test needs to exercise a reprobe of an already-`Known` cell without
231    /// running a real probe. Gated behind `test-util` on the same terms as
232    /// [`Cell::already_settled`].
233    #[cfg(any(test, feature = "test-util"))]
234    pub fn already_settled_and_in_flight(settled: Settled<T>) -> Self {
235        Cell {
236            settled: Some(settled),
237            in_flight: true,
238            generation: Generation::default(),
239        }
240    }
241
242    /// The settled state, or `None` while absent (loading, or never yet probed).
243    /// The only way to a `T`.
244    pub fn settled(&self) -> Option<&Settled<T>> {
245        self.settled.as_ref()
246    }
247
248    /// Whether a probe is running against this cell right now. A row's summary
249    /// treats in-flight as a property that outranks its least-settled Cell.
250    pub fn is_in_flight(&self) -> bool {
251        self.in_flight
252    }
253
254    /// Marks a probe as started, leaving any previous settled value untouched.
255    pub(crate) fn begin_probe(&mut self) {
256        self.in_flight = true;
257    }
258
259    /// Records one probe's result for `generation`; dropped without effect if a
260    /// later Generation has already written this cell, which is the write-time half
261    /// of supersession. Returns whether the write was applied, which is what lets a
262    /// caller update entity-level diagnostics (not themselves a Cell, so not
263    /// self-superseding) only on the write that actually won.
264    pub(crate) fn settle(&mut self, generation: Generation, settled: Settled<T>) -> bool {
265        if generation < self.generation {
266            return false;
267        }
268        self.generation = generation;
269        self.settled = Some(settled);
270        self.in_flight = false;
271        true
272    }
273
274    /// Marks a `Known` value stale in place, keeping its value and timestamp. A
275    /// no-op on every other shape: `Unknown`, `Failed` and `NotApplicable` carry
276    /// no staleness of their own, and a cell nothing has looked at yet has no
277    /// value to mark old. This is what a Vanished Entity forces on every cell it
278    /// holds, never blanking the last known values, and what the metadata poll
279    /// forces on a row's status cells the moment it sees movement it cannot
280    /// cheaply attribute to a fresh probe.
281    pub(crate) fn force_stale(&mut self) {
282        if let Some(Settled::Known {
283            stale,
284            value: _,
285            at: _,
286        }) = &mut self.settled
287        {
288            *stale = true;
289        }
290    }
291
292    /// Marks a `Known` value stale in place once it is at least `threshold` old.
293    /// The elapsed-age writer of the same `stale` field [`Self::force_stale`]
294    /// writes on evidence, for a cell with no cheap detector of its own
295    /// (`docs/spec/core-api.md`'s "Staleness"). A no-op on every other shape, for
296    /// the same reasons as `force_stale`.
297    pub(crate) fn age_into_stale(&mut self, threshold: Duration) {
298        if let Some(Settled::Known {
299            at,
300            stale,
301            value: _,
302        }) = &mut self.settled
303            && at.elapsed() >= threshold
304        {
305            *stale = true;
306        }
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use std::sync::Arc;
313
314    use super::*;
315
316    /// Pins [`Unknown`] to the table in `docs/spec/core-api.md`, the document that calls
317    /// this set closed. Without it the enum and the document drift apart silently, which is
318    /// how the third reason arrived with both the document and ADR 0013 still saying two.
319    #[test]
320    fn unknown_reasons_match_this_documents_own_table() {
321        let spec_path =
322            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/core-api.md");
323        let spec = std::fs::read_to_string(&spec_path)
324            .unwrap_or_else(|error| panic!("read {}: {error}", spec_path.display()));
325
326        let declaration = spec
327            .lines()
328            .find(|line| line.starts_with("pub enum Unknown {"))
329            .unwrap_or_else(|| panic!("no `pub enum Unknown` line in {}", spec_path.display()));
330        let documented: Vec<&str> = declaration
331            .trim_start_matches("pub enum Unknown {")
332            .trim_end_matches('}')
333            .split(',')
334            .map(str::trim)
335            .filter(|name| !name.is_empty())
336            .collect();
337
338        // Exhaustive rather than a list: a new variant fails to compile here, so this test
339        // cannot fall behind the enum the way the document did.
340        let in_code: Vec<&str> = [
341            Unknown::TimedOut,
342            Unknown::NoDefaultBranch,
343            Unknown::SubmoduleUninitialized,
344        ]
345        .iter()
346        .map(|reason| match reason {
347            Unknown::TimedOut => "TimedOut",
348            Unknown::NoDefaultBranch => "NoDefaultBranch",
349            Unknown::SubmoduleUninitialized => "SubmoduleUninitialized",
350        })
351        .collect();
352
353        assert_eq!(
354            in_code, documented,
355            "`Unknown`'s variants and core-api.md's own enum line disagree; amend the \
356             document's table and its closed-set sentence in the same change as the enum"
357        );
358        for reason in &in_code {
359            assert!(
360                spec.contains(&format!("| `{reason}` |")),
361                "core-api.md's reason table has no row for `{reason}`"
362            );
363        }
364    }
365
366    #[test]
367    fn re_probing_keeps_the_previous_value_instead_of_blanking() {
368        let mut cell: Cell<u32> = Cell::default();
369        cell.settle(
370            Generation::new(1),
371            Settled::Known {
372                value: 7,
373                at: Timestamp::now(),
374                stale: false,
375            },
376        );
377
378        cell.begin_probe();
379
380        match cell.settled() {
381            Some(Settled::Known {
382                value,
383                at: _,
384                stale: _,
385            }) => assert_eq!(*value, 7),
386            other => {
387                panic!("expected the previous Known value to survive a re-probe, got {other:?}")
388            }
389        }
390    }
391
392    #[test]
393    fn absent_before_any_probe_is_distinct_from_absent_while_loading() {
394        let never_probed: Cell<u32> = Cell::default();
395        assert!(never_probed.settled().is_none());
396        assert!(!never_probed.in_flight);
397
398        let mut loading: Cell<u32> = Cell::default();
399        loading.begin_probe();
400        assert!(loading.settled().is_none());
401        assert!(loading.in_flight);
402    }
403
404    #[test]
405    fn a_lower_generation_write_does_not_overwrite_a_higher_one() {
406        let mut cell: Cell<u32> = Cell::default();
407        cell.settle(
408            Generation::new(2),
409            Settled::Known {
410                value: 9,
411                at: Timestamp::now(),
412                stale: false,
413            },
414        );
415
416        cell.settle(
417            Generation::new(1),
418            Settled::Known {
419                value: 1,
420                at: Timestamp::now(),
421                stale: false,
422            },
423        );
424
425        match cell.settled() {
426            Some(Settled::Known {
427                value,
428                at: _,
429                stale: _,
430            }) => assert_eq!(*value, 9),
431            other => panic!("expected the higher Generation's value to survive, got {other:?}"),
432        }
433    }
434
435    #[test]
436    fn every_settled_shape_round_trips_through_settle_and_settled() {
437        let mut unknown_cell: Cell<u32> = Cell::default();
438        unknown_cell.settle(Generation::new(1), Settled::Unknown(Unknown::TimedOut));
439        assert!(matches!(
440            unknown_cell.settled(),
441            Some(Settled::Unknown(Unknown::TimedOut))
442        ));
443
444        let mut failed_cell: Cell<u32> = Cell::default();
445        failed_cell.settle(
446            Generation::new(1),
447            Settled::Failed(ProbeError::Open(Arc::from("boom"))),
448        );
449        assert!(matches!(
450            failed_cell.settled(),
451            Some(Settled::Failed(ProbeError::Open(_)))
452        ));
453
454        let mut not_applicable_cell: Cell<u32> = Cell::default();
455        not_applicable_cell.settle(Generation::new(1), Settled::NotApplicable);
456        assert!(matches!(
457            not_applicable_cell.settled(),
458            Some(Settled::NotApplicable)
459        ));
460    }
461
462    #[test]
463    fn force_stale_marks_a_known_value_stale_without_changing_it() {
464        let mut cell: Cell<u32> = Cell::default();
465        cell.settle(
466            Generation::new(1),
467            Settled::Known {
468                value: 42,
469                at: Timestamp::now(),
470                stale: false,
471            },
472        );
473
474        cell.force_stale();
475
476        match cell.settled() {
477            Some(Settled::Known {
478                value,
479                stale,
480                at: _,
481            }) => {
482                assert_eq!(*value, 42, "the value must survive being forced stale");
483                assert!(*stale, "the cell must be marked stale");
484            }
485            other => panic!("expected the Known value to survive, got {other:?}"),
486        }
487    }
488
489    #[test]
490    fn age_into_stale_marks_a_known_value_stale_once_it_is_old_enough() {
491        let mut cell: Cell<u32> = Cell::default();
492        cell.settle(
493            Generation::new(1),
494            Settled::Known {
495                value: 42,
496                at: Timestamp::at(SystemTime::now() - Duration::from_secs(3600)),
497                stale: false,
498            },
499        );
500
501        cell.age_into_stale(Duration::from_secs(300));
502
503        match cell.settled() {
504            Some(Settled::Known {
505                value,
506                stale,
507                at: _,
508            }) => {
509                assert_eq!(*value, 42, "the value must survive ageing into stale");
510                assert!(
511                    *stale,
512                    "an hour-old value past a five-minute threshold must go stale"
513                );
514            }
515            other => panic!("expected the Known value to survive, got {other:?}"),
516        }
517    }
518
519    #[test]
520    fn age_into_stale_leaves_a_known_value_fresh_before_the_threshold() {
521        let mut cell: Cell<u32> = Cell::default();
522        cell.settle(
523            Generation::new(1),
524            Settled::Known {
525                value: 7,
526                at: Timestamp::now(),
527                stale: false,
528            },
529        );
530
531        cell.age_into_stale(Duration::from_secs(300));
532
533        match cell.settled() {
534            Some(Settled::Known {
535                stale,
536                value: _,
537                at: _,
538            }) => {
539                assert!(
540                    !*stale,
541                    "a value settled moments ago must not age into stale yet"
542                )
543            }
544            other => panic!("expected a fresh Known value, got {other:?}"),
545        }
546    }
547
548    #[test]
549    fn age_into_stale_on_a_cell_with_no_known_value_is_a_no_op() {
550        let mut unknown_cell: Cell<u32> = Cell::default();
551        unknown_cell.settle(Generation::new(1), Settled::Unknown(Unknown::TimedOut));
552        unknown_cell.age_into_stale(Duration::ZERO);
553        assert!(matches!(
554            unknown_cell.settled(),
555            Some(Settled::Unknown(Unknown::TimedOut))
556        ));
557
558        let mut never_probed: Cell<u32> = Cell::default();
559        never_probed.age_into_stale(Duration::ZERO);
560        assert!(never_probed.settled().is_none());
561    }
562
563    #[test]
564    fn force_stale_on_a_cell_with_no_known_value_is_a_no_op() {
565        let mut unknown_cell: Cell<u32> = Cell::default();
566        unknown_cell.settle(Generation::new(1), Settled::Unknown(Unknown::TimedOut));
567        unknown_cell.force_stale();
568        assert!(matches!(
569            unknown_cell.settled(),
570            Some(Settled::Unknown(Unknown::TimedOut))
571        ));
572
573        let mut never_probed: Cell<u32> = Cell::default();
574        never_probed.force_stale();
575        assert!(never_probed.settled().is_none());
576    }
577
578    #[test]
579    fn a_settled_cell_clones() {
580        let mut cell: Cell<u32> = Cell::default();
581        cell.settle(
582            Generation::new(1),
583            Settled::Known {
584                value: 3,
585                at: Timestamp::now(),
586                stale: false,
587            },
588        );
589
590        let cloned = cell.clone();
591
592        match cloned.settled() {
593            Some(Settled::Known {
594                value,
595                at: _,
596                stale: _,
597            }) => assert_eq!(*value, 3),
598            other => panic!("expected the clone to carry the same Known value, got {other:?}"),
599        }
600    }
601
602    #[test]
603    fn elapsed_reads_zero_for_a_timestamp_in_the_future_rather_than_a_negative_duration() {
604        let future = Timestamp::at(SystemTime::now() + Duration::from_secs(3600));
605
606        assert_eq!(future.elapsed(), Duration::ZERO);
607    }
608
609    #[test]
610    fn elapsed_reads_a_positive_duration_for_a_timestamp_in_the_past() {
611        let past = Timestamp::at(SystemTime::now() - Duration::from_secs(90));
612
613        assert!(past.elapsed() >= Duration::from_secs(90));
614    }
615
616    #[test]
617    fn timestamp_formats_as_rfc3339() {
618        let cases: [(u64, &str); 6] = [
619            (0, "1970-01-01T00:00:00Z"),
620            (1, "1970-01-01T00:00:01Z"),
621            (86_399, "1970-01-01T23:59:59Z"),
622            (86_400, "1970-01-02T00:00:00Z"),
623            (951_782_400, "2000-02-29T00:00:00Z"),
624            (1_700_000_000, "2023-11-14T22:13:20Z"),
625        ];
626
627        for (epoch_secs, expected) in cases {
628            let timestamp = Timestamp(UNIX_EPOCH + Duration::from_secs(epoch_secs));
629            assert_eq!(timestamp.to_string(), expected);
630        }
631    }
632}