Skip to main content

linesmith_core/segments/rate_limit/
window.rs

1//! [`UsageWindow`] and the per-window `resolve_*_reset` functions
2//! collapse the (Endpoint vs JSONL) match dance for the four
3//! rate-limit segments into typed calls. Without this seam, each
4//! segment open-codes the same nested match against [`UsageData`],
5//! differing only in window selector (`five_hour` vs `seven_day`) and
6//! hide-reason wording.
7//!
8//! Reset resolution is split per-window because the return types are
9//! asymmetric: 5h has two sources (endpoint `resets_at` or the JSONL
10//! block's derived `ends_at()`), while 7d has only the endpoint
11//! source. A unified return shape would force an `unreachable!()`
12//! arm at the 7d caller; per-window functions express the asymmetry
13//! in the type system instead.
14//!
15//! Returns `Result<_, &'static str>` for every resolution path: the
16//! `Err` arm carries the body of the segment's `lsm_debug!` line so
17//! the hide-reason wording sits at one site (here), while the
18//! segment-name prefix stays at the segment's call site (preserves
19//! per-segment ops grep).
20
21use crate::data_context::{UsageBucket, UsageData};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub(crate) enum UsageWindow {
25    FiveHour,
26    SevenDay,
27}
28
29/// Outcome of [`UsageWindow::resolve_percent`] when a percent segment
30/// has data to render. Callers route `Endpoint` through
31/// `format_percent` and `JsonlTokens` through `format_jsonl_tokens`.
32pub(crate) enum WindowResolution<'a> {
33    Endpoint(&'a UsageBucket),
34    JsonlTokens(u64),
35}
36
37/// Outcome of [`resolve_five_hour_reset`]. `Endpoint` carries the
38/// bucket-sourced `resets_at`; `JsonlBlockEnd` carries the JSONL
39/// block's derived `ends_at()` (= block.start + 5h) and signals
40/// stale-marker rendering at the segment.
41pub(crate) enum ResetSource {
42    Endpoint(jiff::Timestamp),
43    JsonlBlockEnd(jiff::Timestamp),
44}
45
46impl UsageWindow {
47    /// Resolve a percent-segment render path for this window. `Err`
48    /// carries the hide-reason body for `lsm_debug!`; callers prepend
49    /// the segment name and append `"; hiding"`.
50    pub(crate) fn resolve_percent<'a>(
51        self,
52        data: &'a UsageData,
53    ) -> Result<WindowResolution<'a>, &'static str> {
54        match (self, data) {
55            (Self::FiveHour, UsageData::Endpoint(e)) => e
56                .five_hour
57                .as_ref()
58                .map(WindowResolution::Endpoint)
59                .ok_or("endpoint usage.five_hour absent"),
60            (Self::SevenDay, UsageData::Endpoint(e)) => e
61                .seven_day
62                .as_ref()
63                .map(WindowResolution::Endpoint)
64                .ok_or("endpoint usage.seven_day absent"),
65            (Self::FiveHour, UsageData::Jsonl(j)) => j
66                .five_hour
67                .as_ref()
68                .map(|w| WindowResolution::JsonlTokens(w.tokens.total()))
69                .ok_or("jsonl five_hour block inactive"),
70            // 7d JSONL is always populated (zero-valued on empty
71            // transcripts per `docs/specs/jsonl-aggregation.md`), so
72            // this arm never hides.
73            (Self::SevenDay, UsageData::Jsonl(j)) => {
74                Ok(WindowResolution::JsonlTokens(j.seven_day.tokens.total()))
75            }
76        }
77    }
78}
79
80/// Resolve the 5h reset path. Returns either the endpoint-sourced
81/// `resets_at` or the JSONL block's derived `ends_at()`. Callers gate
82/// the resulting timestamp with `remaining > 0`; the `remaining` check
83/// would couple this function to wall-clock time, so it lives at the
84/// segment.
85pub(crate) fn resolve_five_hour_reset(data: &UsageData) -> Result<ResetSource, &'static str> {
86    match data {
87        UsageData::Endpoint(e) => {
88            let bucket = e
89                .five_hour
90                .as_ref()
91                .ok_or("endpoint usage.five_hour absent")?;
92            bucket
93                .resets_at
94                .map(ResetSource::Endpoint)
95                .ok_or("five_hour.resets_at absent")
96        }
97        UsageData::Jsonl(j) => {
98            let window = j
99                .five_hour
100                .as_ref()
101                .ok_or("jsonl five_hour block inactive")?;
102            Ok(ResetSource::JsonlBlockEnd(window.ends_at()))
103        }
104    }
105}
106
107/// Resolve the 7d reset path. Always returns the endpoint-sourced
108/// `resets_at` on success; the return type is `jiff::Timestamp` (not
109/// `ResetSource`) because ADR-0013 rejects synthesizing a 7d reset
110/// timestamp under JSONL — the rolling 7d window has no hard reset,
111/// and the signature encodes that. JSONL data always returns `Err`.
112pub(crate) fn resolve_seven_day_reset(data: &UsageData) -> Result<jiff::Timestamp, &'static str> {
113    match data {
114        UsageData::Endpoint(e) => {
115            let bucket = e
116                .seven_day
117                .as_ref()
118                .ok_or("endpoint usage.seven_day absent")?;
119            bucket.resets_at.ok_or("seven_day.resets_at absent")
120        }
121        UsageData::Jsonl(_) => Err("jsonl fallback has no hard reset"),
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::data_context::{EndpointUsage, JsonlUsage, SevenDayWindow, TokenCounts, UsageData};
129
130    #[test]
131    fn seven_day_reset_returns_err_under_jsonl() {
132        // Pins ADR-0013's invariant: the 7d rolling window has no hard
133        // reset under JSONL. If a future contributor adds a JSONL arm
134        // that synthesizes a timestamp, this test fails before the
135        // signature's `Result<jiff::Timestamp, _>` shape can be silently
136        // relaxed back into a sum type.
137        let data = UsageData::Jsonl(JsonlUsage::new(
138            None,
139            SevenDayWindow::new(TokenCounts::default()),
140        ));
141        assert_eq!(
142            resolve_seven_day_reset(&data),
143            Err("jsonl fallback has no hard reset"),
144        );
145    }
146
147    #[test]
148    fn seven_day_reset_returns_err_when_endpoint_bucket_absent() {
149        let data = UsageData::Endpoint(EndpointUsage {
150            five_hour: None,
151            seven_day: None,
152            seven_day_opus: None,
153            seven_day_sonnet: None,
154            seven_day_oauth_apps: None,
155            extra_usage: None,
156            unknown_buckets: std::collections::HashMap::new(),
157        });
158        assert_eq!(
159            resolve_seven_day_reset(&data),
160            Err("endpoint usage.seven_day absent"),
161        );
162    }
163}