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}