1use std::collections::BTreeMap;
8
9pub(crate) const PRIORITY: u8 = 96;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum PercentFormat {
20 Percent,
21 Progress,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ResetFormat {
30 Duration,
31 Absolute(AbsoluteFormat),
32 Progress,
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct AbsoluteFormat {
41 pub timezone: Timezone,
42 pub hour: HourFormat,
43 pub locale: Locale,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub enum Timezone {
53 #[default]
54 SystemLocal,
55 Iana(jiff::tz::TimeZone),
56}
57
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
60pub enum HourFormat {
61 Hour12,
62 #[default]
63 Hour24,
64}
65
66#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69#[non_exhaustive]
70pub enum Locale {
71 #[default]
72 EnUs,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ExtraUsageFormat {
78 Currency,
79 Percent,
80}
81
82#[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 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
113pub(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 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#[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#[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#[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 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 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 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}