Skip to main content

linesmith_core/segments/rate_limit/
config.rs

1//! Config types and TOML-extras parsers for the rate-limit segment
2//! family. The [`CommonRateLimitConfig`] struct, family-shared format
3//! enums, and `apply_common_extras` / `parse_*_format` helpers all
4//! live here. Render-time helpers (`format_percent`, `format_duration`,
5//! `render_error`, etc.) live in `format`.
6
7use std::collections::BTreeMap;
8
9/// Sits between `model` (priority 64) and `effort` (priority 160) on
10/// the layout-engine priority scale. Layout drops numerically-largest
11/// priorities first under width pressure, so rate-limit segments
12/// survive longer than effort but drop before the model name when
13/// terminal width is tight. Shared by all four rate-limit segments
14/// and `extra_usage`.
15pub(crate) const PRIORITY: u8 = 96;
16
17/// Config-driven rendering format for the percent/progress segments.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum PercentFormat {
20    Percent,
21    Progress,
22}
23
24/// Config-driven rendering format for the reset segments. `Duration`
25/// is the existing `"4h12m"` countdown; `Absolute` is the
26/// ccstatusline-parity `"7:00 PM PT"` wall-clock variant; `Progress`
27/// is the existing progress bar.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ResetFormat {
30    Duration,
31    Absolute(AbsoluteFormat),
32    Progress,
33}
34
35/// Wall-clock formatting knobs for `ResetFormat::Absolute`. Invalid
36/// timezone/locale strings fall back to the default
37/// (`Timezone::SystemLocal` + `Locale::EnUs`) with a structured warning
38/// each.
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct AbsoluteFormat {
41    pub timezone: Timezone,
42    pub hour: HourFormat,
43    pub locale: Locale,
44}
45
46/// Timezone selector for `AbsoluteFormat`. `SystemLocal` resolves at
47/// render time via jiff's auto-detection. `Iana(_)` carries a
48/// pre-resolved jiff zone; jiff is exposed only inside this payload
49/// so future variants (`Utc`, `Fixed(offset)`, etc.) can land without
50/// changing the field type.
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub enum Timezone {
53    #[default]
54    SystemLocal,
55    Iana(jiff::tz::TimeZone),
56}
57
58/// 12-hour vs 24-hour clock face for `AbsoluteFormat`.
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
60pub enum HourFormat {
61    Hour12,
62    #[default]
63    Hour24,
64}
65
66/// Locale for absolute-time formatting. v0.1 ships English-only; v0.2
67/// locale support is planned.
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69#[non_exhaustive]
70pub enum Locale {
71    #[default]
72    EnUs,
73}
74
75/// Config-driven rendering format for `extra_usage`.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ExtraUsageFormat {
78    Currency,
79    Percent,
80}
81
82/// Common config shared by every rate-limit segment per the spec's
83/// §Config schema. Each concrete segment adds its own type-specific
84/// knobs (`format`, `invert`, `compact`, `use_days`) on top.
85#[derive(Debug, Clone, PartialEq, Eq)]
86#[non_exhaustive]
87pub struct CommonRateLimitConfig {
88    pub icon: String,
89    pub label: String,
90    pub stale_marker: String,
91    pub progress_width: u16,
92    /// Set by `apply_common_extras` when the user supplied an
93    /// out-of-range `progress_width`. Segments use it to flip
94    /// `format = Progress` back to the per-family default so the
95    /// spec's "fall back to percent format" rule at
96    /// `rate-limit-segments.md` §Edge cases is honored, not just warned.
97    pub invalid_progress_width: bool,
98}
99
100impl CommonRateLimitConfig {
101    #[must_use]
102    pub fn new(label: impl Into<String>) -> Self {
103        Self {
104            icon: String::new(),
105            label: label.into(),
106            stale_marker: "~".into(),
107            progress_width: 20,
108            invalid_progress_width: false,
109        }
110    }
111}
112
113/// Apply `[segments.<id>]` common overrides (`icon`, `label`,
114/// `stale_marker`, `progress_width`) onto `cfg`. Wrong-type values
115/// warn and leave the default; unknown keys are silently skipped
116/// here because `config::validate_keys` owns that diagnostic.
117pub(crate) fn apply_common_extras(
118    cfg: &mut CommonRateLimitConfig,
119    extras: &BTreeMap<String, toml::Value>,
120    id: &str,
121    warn: &mut impl FnMut(&str),
122) {
123    if let Some(v) = extras.get("icon") {
124        if let Some(s) = v.as_str() {
125            cfg.icon = s.to_string();
126        } else {
127            warn(&format!("segments.{id}.icon: expected string; ignoring"));
128        }
129    }
130    if let Some(v) = extras.get("label") {
131        if let Some(s) = v.as_str() {
132            cfg.label = s.to_string();
133        } else {
134            warn(&format!("segments.{id}.label: expected string; ignoring"));
135        }
136    }
137    if let Some(v) = extras.get("stale_marker") {
138        if let Some(s) = v.as_str() {
139            cfg.stale_marker = s.to_string();
140        } else {
141            warn(&format!(
142                "segments.{id}.stale_marker: expected string; ignoring"
143            ));
144        }
145    }
146    if let Some(v) = extras.get("progress_width") {
147        match v.as_integer() {
148            Some(n) if (1..=i64::from(u16::MAX)).contains(&n) => {
149                cfg.progress_width = n as u16;
150            }
151            _ => {
152                // Spec §Edge cases: 0/negative is invalid and forces
153                // a fallback to percent/duration format at the
154                // segment layer. Flag here; the segment flips its
155                // `format` field after parsing.
156                warn(&format!(
157                    "segments.{id}.progress_width: expected 1..={}; ignoring",
158                    u16::MAX,
159                ));
160                cfg.invalid_progress_width = true;
161            }
162        }
163    }
164}
165
166/// Read `format` from `[segments.<id>]` as `"percent"` or
167/// `"progress"`. Unknown values warn and return `None` so callers
168/// keep their default.
169#[must_use]
170pub(crate) fn parse_percent_format(
171    extras: &BTreeMap<String, toml::Value>,
172    id: &str,
173    warn: &mut impl FnMut(&str),
174) -> Option<PercentFormat> {
175    match extras.get("format")?.as_str() {
176        Some("percent") => Some(PercentFormat::Percent),
177        Some("progress") => Some(PercentFormat::Progress),
178        _ => {
179            warn(&format!(
180                "segments.{id}.format: expected \"percent\" or \"progress\"; ignoring"
181            ));
182            None
183        }
184    }
185}
186
187/// Read `format` from `[segments.<id>]` as "duration" | "absolute" |
188/// "progress". For `"absolute"`, also reads the sibling `timezone` /
189/// `hour_format` / `locale` keys; tz/locale parse errors fall back to
190/// system-local + 24h + en-US with a structured warning each.
191#[must_use]
192pub(crate) fn parse_reset_format(
193    extras: &BTreeMap<String, toml::Value>,
194    id: &str,
195    warn: &mut impl FnMut(&str),
196) -> Option<ResetFormat> {
197    match extras.get("format")?.as_str() {
198        Some("duration") => Some(ResetFormat::Duration),
199        Some("progress") => Some(ResetFormat::Progress),
200        Some("absolute") => Some(ResetFormat::Absolute(parse_absolute_format(
201            extras, id, warn,
202        ))),
203        _ => {
204            warn(&format!(
205                "segments.{id}.format: expected \"duration\", \"absolute\", or \"progress\"; ignoring"
206            ));
207            None
208        }
209    }
210}
211
212fn parse_absolute_format(
213    extras: &BTreeMap<String, toml::Value>,
214    id: &str,
215    warn: &mut impl FnMut(&str),
216) -> AbsoluteFormat {
217    AbsoluteFormat {
218        timezone: parse_timezone(extras, id, warn).unwrap_or_default(),
219        hour: parse_hour_format(extras, id, warn).unwrap_or_default(),
220        locale: parse_locale(extras, id, warn).unwrap_or_default(),
221    }
222}
223
224fn parse_timezone(
225    extras: &BTreeMap<String, toml::Value>,
226    id: &str,
227    warn: &mut impl FnMut(&str),
228) -> Option<Timezone> {
229    let raw = extras.get("timezone")?;
230    let Some(s) = raw.as_str() else {
231        warn(&format!(
232            "segments.{id}.timezone: expected string IANA name (e.g. \"America/Los_Angeles\"); falling back to system local"
233        ));
234        return None;
235    };
236    match jiff::tz::TimeZone::get(s) {
237        Ok(tz) => Some(Timezone::Iana(tz)),
238        Err(e) => {
239            warn(&format!(
240                "segments.{id}.timezone: \"{s}\" not found in tzdb ({e}); falling back to system local"
241            ));
242            None
243        }
244    }
245}
246
247fn parse_hour_format(
248    extras: &BTreeMap<String, toml::Value>,
249    id: &str,
250    warn: &mut impl FnMut(&str),
251) -> Option<HourFormat> {
252    match extras.get("hour_format")?.as_str() {
253        Some("12h") => Some(HourFormat::Hour12),
254        Some("24h") => Some(HourFormat::Hour24),
255        _ => {
256            warn(&format!(
257                "segments.{id}.hour_format: expected \"12h\" or \"24h\"; using 24h"
258            ));
259            None
260        }
261    }
262}
263
264fn parse_locale(
265    extras: &BTreeMap<String, toml::Value>,
266    id: &str,
267    warn: &mut impl FnMut(&str),
268) -> Option<Locale> {
269    let raw = extras.get("locale")?;
270    let Some(s) = raw.as_str() else {
271        warn(&format!(
272            "segments.{id}.locale: expected string (e.g. \"en-US\"); using en-US"
273        ));
274        return None;
275    };
276    match s {
277        "en" | "en-US" => Some(Locale::EnUs),
278        other => {
279            warn(&format!(
280                "segments.{id}.locale: \"{other}\" not yet supported in v0.1; using en-US"
281            ));
282            None
283        }
284    }
285}
286
287/// Read `format` from `[segments.<id>]` as "currency" | "percent".
288#[must_use]
289pub(crate) fn parse_extra_usage_format(
290    extras: &BTreeMap<String, toml::Value>,
291    id: &str,
292    warn: &mut impl FnMut(&str),
293) -> Option<ExtraUsageFormat> {
294    match extras.get("format")?.as_str() {
295        Some("currency") => Some(ExtraUsageFormat::Currency),
296        Some("percent") => Some(ExtraUsageFormat::Percent),
297        _ => {
298            warn(&format!(
299                "segments.{id}.format: expected \"currency\" or \"percent\"; ignoring"
300            ));
301            None
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    fn extras(pairs: &[(&str, toml::Value)]) -> BTreeMap<String, toml::Value> {
311        pairs
312            .iter()
313            .map(|(k, v)| ((*k).to_string(), v.clone()))
314            .collect()
315    }
316
317    struct CapturedWarns {
318        msgs: Vec<String>,
319    }
320    impl CapturedWarns {
321        fn new() -> Self {
322            Self { msgs: Vec::new() }
323        }
324        fn push(&mut self, m: &str) {
325            self.msgs.push(m.to_string());
326        }
327        fn any_contains(&self, needle: &str) -> bool {
328            self.msgs.iter().any(|m| m.contains(needle))
329        }
330    }
331
332    #[test]
333    fn parse_reset_format_absolute_with_full_knobs() {
334        let e = extras(&[
335            ("format", toml::Value::String("absolute".into())),
336            (
337                "timezone",
338                toml::Value::String("America/Los_Angeles".into()),
339            ),
340            ("hour_format", toml::Value::String("12h".into())),
341            ("locale", toml::Value::String("en-US".into())),
342        ]);
343        let mut w = CapturedWarns::new();
344        let f = parse_reset_format(&e, "rate_limit_5h_reset", &mut |m| w.push(m));
345        let Some(ResetFormat::Absolute(abs)) = f else {
346            panic!("expected ResetFormat::Absolute, got {f:?}");
347        };
348        assert_eq!(abs.hour, HourFormat::Hour12);
349        assert_eq!(abs.locale, Locale::EnUs);
350        assert!(matches!(abs.timezone, Timezone::Iana(_)));
351        assert!(w.msgs.is_empty(), "no warnings expected: {:?}", w.msgs);
352    }
353
354    #[test]
355    fn parse_reset_format_absolute_defaults_apply_when_knobs_missing() {
356        // Bare `format = "absolute"` defaults to system-local tz +
357        // 24h + en-US per the bead's "Default tz = system local;
358        // default hour = 24" line.
359        let e = extras(&[("format", toml::Value::String("absolute".into()))]);
360        let mut w = CapturedWarns::new();
361        let f = parse_reset_format(&e, "rate_limit_5h_reset", &mut |m| w.push(m));
362        let Some(ResetFormat::Absolute(abs)) = f else {
363            panic!("expected ResetFormat::Absolute");
364        };
365        assert!(matches!(abs.timezone, Timezone::SystemLocal));
366        assert_eq!(abs.hour, HourFormat::Hour24);
367        assert_eq!(abs.locale, Locale::EnUs);
368    }
369
370    #[test]
371    fn parse_reset_format_unknown_tz_warns_and_falls_back_to_system_local() {
372        let e = extras(&[
373            ("format", toml::Value::String("absolute".into())),
374            ("timezone", toml::Value::String("Mars/Olympus_Mons".into())),
375        ]);
376        let mut w = CapturedWarns::new();
377        let f = parse_reset_format(&e, "rate_limit_5h_reset", &mut |m| w.push(m));
378        let Some(ResetFormat::Absolute(abs)) = f else {
379            panic!("expected ResetFormat::Absolute");
380        };
381        assert!(matches!(abs.timezone, Timezone::SystemLocal));
382        assert!(
383            w.any_contains("Mars/Olympus_Mons"),
384            "warn must mention bad tz: {:?}",
385            w.msgs
386        );
387    }
388
389    #[test]
390    fn parse_reset_format_unknown_hour_format_warns_and_uses_24h() {
391        let e = extras(&[
392            ("format", toml::Value::String("absolute".into())),
393            ("hour_format", toml::Value::String("48h".into())),
394        ]);
395        let mut w = CapturedWarns::new();
396        let f = parse_reset_format(&e, "rate_limit_5h_reset", &mut |m| w.push(m));
397        let Some(ResetFormat::Absolute(abs)) = f else {
398            panic!("expected ResetFormat::Absolute");
399        };
400        assert_eq!(abs.hour, HourFormat::Hour24);
401        assert!(w.any_contains("hour_format"));
402    }
403
404    #[test]
405    fn parse_reset_format_unsupported_locale_warns_and_uses_en_us() {
406        // Forward-compat plumbing: v0.1 ships English only, but a
407        // user config setting `locale = "fr-FR"` must not error —
408        // warn-and-fallback so v0.2 locale support drops in without
409        // breaking existing configs.
410        let e = extras(&[
411            ("format", toml::Value::String("absolute".into())),
412            ("locale", toml::Value::String("fr-FR".into())),
413        ]);
414        let mut w = CapturedWarns::new();
415        let f = parse_reset_format(&e, "rate_limit_5h_reset", &mut |m| w.push(m));
416        let Some(ResetFormat::Absolute(abs)) = f else {
417            panic!("expected ResetFormat::Absolute");
418        };
419        assert_eq!(abs.locale, Locale::EnUs);
420        assert!(w.any_contains("fr-FR"));
421    }
422
423    #[test]
424    fn parse_reset_format_duration_value_parses() {
425        // The `format = "duration"` TOML key continues to parse
426        // cleanly with no warnings.
427        let e = extras(&[("format", toml::Value::String("duration".into()))]);
428        let mut w = CapturedWarns::new();
429        let f = parse_reset_format(&e, "rate_limit_5h_reset", &mut |m| w.push(m));
430        assert!(matches!(f, Some(ResetFormat::Duration)));
431        assert!(w.msgs.is_empty());
432    }
433}