1use std::collections::HashMap;
2use std::time::Duration;
3
4use chrono::{DateTime, Utc};
5
6use crate::countdown;
7use crate::format::{placeholders, substitute, updated_at_hm};
8use crate::pacing::{self, PaceSeverity};
9use crate::pango::{color_span, escape, severity_color, severity_for};
10use crate::theme::Theme;
11use crate::tooltip::{Line as TooltipLine, WindowRow, push_window_with_row, render_bordered};
12use crate::usage::UsageWindow;
13use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
14use crate::waybar::{Class, WaybarOutput};
15
16use super::fetch::FetchOutcome;
17use super::types::{Usage, Window};
18
19pub const DEFAULT_FORMAT: &str = "{ocg_rolling_pct}% · {ocg_rolling_reset}";
20const DEFAULT_PLAN: &str = "OpenCode Go";
21const UNAVAILABLE: &str = "—";
22
23pub const ROLLING_WINDOW: chrono::Duration = chrono::Duration::hours(5);
39pub const WEEKLY_WINDOW: chrono::Duration = chrono::Duration::days(7);
40
41impl From<FetchOutcome> for VendorOutcome {
42 fn from(outcome: FetchOutcome) -> Self {
43 outcome.map(crate::usage::VendorSnapshot::OpenCodeGo)
44 }
45}
46
47pub fn build_placeholders(usage: &Usage, now: DateTime<Utc>) -> HashMap<&'static str, String> {
52 build_placeholders_with_plan(DEFAULT_PLAN, usage, now)
53}
54
55pub fn build_placeholders_with_plan(
56 plan: &str,
57 usage: &Usage,
58 now: DateTime<Utc>,
59) -> HashMap<&'static str, String> {
60 build_placeholders_with_plan_and_tolerance(plan, usage, pacing::DEFAULT_TOLERANCE, now)
61}
62
63fn build_placeholders_with_tolerance(
64 usage: &Usage,
65 pace_tolerance: u32,
66 now: DateTime<Utc>,
67) -> HashMap<&'static str, String> {
68 build_placeholders_with_plan_and_tolerance(DEFAULT_PLAN, usage, pace_tolerance, now)
69}
70
71fn build_placeholders_with_plan_and_tolerance(
72 plan: &str,
73 usage: &Usage,
74 pace_tolerance: u32,
75 now: DateTime<Utc>,
76) -> HashMap<&'static str, String> {
77 let plan = sanitize(plan);
78 let rolling = window_values(usage.rolling.as_ref(), now);
79 let weekly = window_values(usage.weekly.as_ref(), now);
80 let monthly = window_values(usage.monthly.as_ref(), now);
81 let rolling_pace = window_pacing(usage.rolling.as_ref(), ROLLING_WINDOW, pace_tolerance, now);
82 let weekly_pace = window_pacing(usage.weekly.as_ref(), WEEKLY_WINDOW, pace_tolerance, now);
83
84 placeholders([
85 (
86 "vendor_short",
87 VendorId::OpenCodeGo.short_name().to_string(),
88 ),
89 ("plan", plan.clone()),
90 ("ocg_plan", plan),
91 ("session_pct", rolling.percent.clone()),
92 ("session_reset", rolling.reset.clone()),
93 ("session_elapsed", rolling_pace.elapsed.clone()),
94 ("weekly_pct", weekly.percent.clone()),
95 ("weekly_reset", weekly.reset.clone()),
96 ("weekly_elapsed", weekly_pace.elapsed.clone()),
97 ("ocg_rolling_pct", rolling.percent),
98 ("ocg_rolling_reset", rolling.reset),
99 ("ocg_rolling_status", rolling.status),
100 ("ocg_rolling_elapsed", rolling_pace.elapsed),
101 ("ocg_rolling_pace", rolling_pace.ratio_pace),
102 ("ocg_rolling_pace_indicator", rolling_pace.point_pace),
103 ("ocg_weekly_pct", weekly.percent),
104 ("ocg_weekly_reset", weekly.reset),
105 ("ocg_weekly_status", weekly.status),
106 ("ocg_weekly_elapsed", weekly_pace.elapsed),
107 ("ocg_weekly_pace", weekly_pace.ratio_pace),
108 ("ocg_weekly_pace_indicator", weekly_pace.point_pace),
109 ("ocg_monthly_pct", monthly.percent),
110 ("ocg_monthly_reset", monthly.reset),
111 ("ocg_monthly_status", monthly.status),
112 ("ocg_monthly_elapsed", String::new()),
117 ("ocg_monthly_pace", String::new()),
118 ("ocg_monthly_pace_indicator", String::new()),
119 ])
120}
121
122#[derive(Default)]
128struct WindowPacing {
129 elapsed: String,
130 ratio_pace: String,
131 point_pace: String,
132}
133
134fn window_pacing(
135 window: Option<&Window>,
136 duration: chrono::Duration,
137 pace_tolerance: u32,
138 now: DateTime<Utc>,
139) -> WindowPacing {
140 let Some(window) = window else {
141 return WindowPacing::default();
142 };
143 let p = pacing::calc(
144 utilization_pct(window),
145 Some(window.resets_at),
146 now,
147 duration,
148 pace_tolerance,
149 );
150 WindowPacing {
151 elapsed: p.elapsed_pct.to_string(),
152 ratio_pace: p.ratio_pace.glyph().to_string(),
153 point_pace: p.point_pace.glyph().to_string(),
154 }
155}
156
157fn as_usage_window(window: &Window, duration: chrono::Duration) -> UsageWindow {
161 UsageWindow {
162 utilization_pct: utilization_pct(window),
163 resets_at: Some(window.resets_at),
164 window_duration: duration,
165 }
166}
167
168fn utilization_pct(window: &Window) -> i32 {
169 window.percent.round().clamp(0.0, 100.0) as i32
170}
171
172#[derive(Debug)]
173struct WindowValues {
174 percent: String,
175 reset: String,
176 status: String,
177}
178
179fn window_values(window: Option<&Window>, now: DateTime<Utc>) -> WindowValues {
180 let Some(window) = window else {
181 return WindowValues {
182 percent: UNAVAILABLE.to_string(),
183 reset: UNAVAILABLE.to_string(),
184 status: UNAVAILABLE.to_string(),
185 };
186 };
187 WindowValues {
188 percent: window.percent.to_string(),
189 reset: countdown::format(Some(window.resets_at), now),
190 status: sanitize(&window.status),
191 }
192}
193
194fn sanitize(value: &str) -> String {
195 crate::display::sanitize_untrusted_field(value)
196}
197
198pub fn severity(usage: &Usage) -> PaceSeverity {
199 usage
200 .rolling
201 .iter()
202 .chain(usage.weekly.iter())
203 .chain(usage.monthly.iter())
204 .map(|window| window.percent as i32)
205 .max()
206 .map(severity_for)
207 .unwrap_or(PaceSeverity::Low)
208}
209
210pub fn render(
213 outcome: &VendorOutcome,
214 snap: &Usage,
215 theme: &Theme,
216 opts: &RenderOpts,
217 now: DateTime<Utc>,
218) -> WaybarOutput {
219 render_with_meta(
220 snap,
221 outcome.stale,
222 outcome.last_error.as_ref(),
223 outcome.cache_age,
224 theme,
225 opts,
226 now,
227 )
228}
229
230fn render_with_meta(
231 snap: &Usage,
232 stale: bool,
233 last_error: Option<&(u16, String)>,
234 cache_age: Option<Duration>,
235 theme: &Theme,
236 opts: &RenderOpts,
237 now: DateTime<Utc>,
238) -> WaybarOutput {
239 let sev = severity(snap);
240 let format = opts.format.as_deref().unwrap_or(DEFAULT_FORMAT);
241 let values = escaped_placeholders_with_tolerance(snap, opts.pace_tolerance, now);
242 let mut text = substitute(format, &values);
243 if stale {
244 text.push_str(" ⏸");
245 }
246 let icon_prefix = match opts.icon.as_deref() {
247 Some(icon) if !icon.is_empty() => format!("{} ", escape(icon)),
248 _ => String::new(),
249 };
250 let bar_text = color_span(severity_color(sev, theme), &format!("{icon_prefix}{text}"));
251 let tooltip = opts
252 .tooltip_format
253 .as_deref()
254 .map(|format| substitute(format, &values))
255 .unwrap_or_else(|| render_tooltip(snap, stale, last_error, cache_age, theme, opts, now));
256
257 WaybarOutput {
258 text: bar_text,
259 tooltip,
260 class: Class::from(sev),
261 }
262}
263
264fn escaped_placeholders_with_tolerance(
265 usage: &Usage,
266 pace_tolerance: u32,
267 now: DateTime<Utc>,
268) -> HashMap<&'static str, String> {
269 let mut values = build_placeholders_with_tolerance(usage, pace_tolerance, now);
270 for key in [
271 "plan",
272 "ocg_plan",
273 "ocg_rolling_status",
274 "ocg_weekly_status",
275 "ocg_monthly_status",
276 ] {
277 if let Some(value) = values.get_mut(key) {
278 *value = escape(value);
279 }
280 }
281 values
282}
283
284fn render_tooltip(
285 snap: &Usage,
286 stale: bool,
287 last_error: Option<&(u16, String)>,
288 cache_age: Option<Duration>,
289 theme: &Theme,
290 opts: &RenderOpts,
291 now: DateTime<Utc>,
292) -> String {
293 let mut lines = vec![TooltipLine::Center(format!(
294 "<span font_weight='bold' foreground='{}'>{}</span>",
295 theme.blue,
296 escape(DEFAULT_PLAN)
297 ))];
298 lines.push(TooltipLine::Sep);
299 lines.push(TooltipLine::Body(String::new()));
300
301 let row =
302 |w: &UsageWindow| WindowRow::paced(w, now, opts.pace_tolerance, opts.tooltip_pace_pts);
303 let mut present = false;
304 for (label, window, duration) in [
305 (" Rolling (5h)", snap.rolling.as_ref(), ROLLING_WINDOW),
306 (" Weekly (7d)", snap.weekly.as_ref(), WEEKLY_WINDOW),
307 ] {
308 let Some(window) = window else {
309 continue;
310 };
311 present = true;
312 let projected = as_usage_window(window, duration);
313 push_window_with_row(&mut lines, label, &projected, theme, now, row(&projected));
314 }
315 if let Some(window) = snap.monthly.as_ref() {
319 present = true;
320 let pct = utilization_pct(window);
321 let color = severity_color(severity_for(pct), theme);
322 let bar = crate::pango::progress_bar(pct, color, theme, None);
323 lines.push(TooltipLine::Body(format!(
324 " <span foreground='{}'> Monthly</span>",
325 theme.fg
326 )));
327 lines.push(TooltipLine::Body(format!(
328 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%</span>"
329 )));
330 lines.push(TooltipLine::Body(format!(
331 " <span foreground='{}'> ⏱ Resets in {}</span>",
332 theme.dim,
333 escape(&countdown::format(Some(window.resets_at), now))
334 )));
335 }
336 if !present {
337 lines.push(TooltipLine::Body(format!(
338 " <span foreground='{}'>no usage windows reported</span>",
339 theme.dim
340 )));
341 }
342 if stale {
343 lines.push(TooltipLine::Body(String::new()));
344 lines.push(TooltipLine::Body(format!(
345 " <span foreground='{}'> ⏸ Showing cached data</span>",
346 theme.orange
347 )));
348 }
349 if let Some((code, message)) = last_error
350 && *code != 0
351 {
352 lines.push(TooltipLine::Body(String::new()));
353 lines.push(TooltipLine::Sep);
354 lines.push(TooltipLine::Body(format!(
355 " <span foreground='{}'> HTTP {code}: {}</span>",
356 theme.orange,
357 escape(message)
358 )));
359 }
360
361 lines.push(TooltipLine::Body(String::new()));
362 lines.push(TooltipLine::Sep);
363 lines.push(TooltipLine::Body(format!(
364 " <span foreground='{}'> Updated {}</span>",
365 theme.dim,
366 updated_at_hm(now, cache_age)
367 )));
368 render_bordered(&lines, theme)
369}
370
371#[cfg(test)]
372mod tests {
373 use chrono::{DateTime, Utc};
374
375 use super::*;
376 use crate::opencode_go::types::{Usage, Window};
377
378 fn at(value: &str) -> DateTime<Utc> {
379 value.parse().expect("RFC3339 timestamp")
380 }
381
382 fn sample_usage() -> Usage {
383 Usage {
384 rolling: Some(Window {
385 status: "ok".into(),
386 percent: 12.3,
387 resets_at: at("2026-08-16T20:00:00Z"),
388 }),
389 weekly: Some(Window {
390 status: "rate-limited".into(),
391 percent: 45.6,
392 resets_at: at("2026-08-20T00:00:00Z"),
393 }),
394 monthly: Some(Window {
395 status: "ok".into(),
396 percent: 78.9,
397 resets_at: at("2026-09-01T00:00:00Z"),
398 }),
399 }
400 }
401
402 #[test]
403 fn exposes_exact_opencode_go_and_generic_placeholders() {
404 let values = build_placeholders(&sample_usage(), at("2026-08-16T18:00:00Z"));
405
406 assert_eq!(values["vendor_short"], "ocg");
407 assert_eq!(values["session_pct"], "12.3");
408 assert_eq!(values["weekly_pct"], "45.6");
409 assert_eq!(values["ocg_rolling_pct"], "12.3");
410 assert_eq!(values["ocg_rolling_status"], "ok");
411 assert_eq!(values["ocg_weekly_pct"], "45.6");
412 assert_eq!(values["ocg_weekly_status"], "rate-limited");
413 assert_eq!(values["ocg_monthly_pct"], "78.9");
414 assert_eq!(values["ocg_monthly_status"], "ok");
415 }
416
417 #[test]
418 fn default_format_is_rolling_percentage_and_reset() {
419 assert_eq!(DEFAULT_FORMAT, "{ocg_rolling_pct}% · {ocg_rolling_reset}");
420 }
421
422 #[test]
423 fn absent_windows_are_unavailable_not_zero() {
424 let values = build_placeholders(
425 &Usage {
426 rolling: None,
427 weekly: None,
428 monthly: None,
429 },
430 at("2026-08-16T18:00:00Z"),
431 );
432
433 for key in [
434 "session_pct",
435 "weekly_pct",
436 "ocg_rolling_pct",
437 "ocg_weekly_pct",
438 "ocg_monthly_pct",
439 ] {
440 assert_eq!(values[key], "—", "{key} should be unavailable");
441 assert_ne!(values[key], "0");
442 }
443 for key in [
444 "session_reset",
445 "weekly_reset",
446 "ocg_rolling_reset",
447 "ocg_weekly_reset",
448 "ocg_monthly_reset",
449 "ocg_rolling_status",
450 "ocg_weekly_status",
451 "ocg_monthly_status",
452 ] {
453 assert_eq!(values[key], "—", "{key} should be unavailable");
454 }
455 }
456
457 fn opts() -> RenderOpts {
458 RenderOpts {
459 format: None,
460 tooltip_format: None,
461 icon: None,
462 pace_tolerance: 5,
463 format_pace_color: false,
464 tooltip_pace_pts: false,
465 }
466 }
467
468 fn outcome_for(snap: &Usage) -> VendorOutcome {
469 VendorOutcome {
470 snapshot: crate::usage::VendorSnapshot::OpenCodeGo(snap.clone()),
471 stale: false,
472 last_error: None,
473 cache_age: Some(std::time::Duration::from_secs(10)),
474 }
475 }
476
477 #[test]
478 fn elapsed_placeholders_follow_window_progress() {
479 let now = at("2026-08-16T18:00:00Z");
481 let usage = Usage {
482 rolling: Some(Window {
483 status: "ok".into(),
484 percent: 42.0,
485 resets_at: at("2026-08-16T20:00:00Z"),
486 }),
487 weekly: Some(Window {
488 status: "ok".into(),
489 percent: 15.0,
490 resets_at: at("2026-08-19T18:00:00Z"),
491 }),
492 monthly: Some(Window {
493 status: "ok".into(),
494 percent: 70.0,
495 resets_at: at("2026-08-31T18:00:00Z"),
496 }),
497 };
498 let values = build_placeholders(&usage, now);
499 assert_eq!(values["session_elapsed"], "60");
500 assert_eq!(values["weekly_elapsed"], "57");
501 assert_eq!(values["ocg_rolling_elapsed"], "60");
502 assert_eq!(values["ocg_weekly_elapsed"], "57");
503 }
504
505 #[test]
506 fn pace_placeholders_follow_usage_vs_elapsed() {
507 let now = at("2026-08-16T18:00:00Z");
509 let usage = Usage {
510 rolling: Some(Window {
511 status: "ok".into(),
512 percent: 80.0,
513 resets_at: at("2026-08-16T20:00:00Z"),
514 }),
515 weekly: Some(Window {
516 status: "ok".into(),
517 percent: 15.0,
518 resets_at: at("2026-08-19T18:00:00Z"),
519 }),
520 monthly: Some(Window {
521 status: "ok".into(),
522 percent: 70.0,
523 resets_at: at("2026-08-31T18:00:00Z"),
524 }),
525 };
526 let values = build_placeholders(&usage, now);
527 assert_eq!(values["ocg_rolling_pace"], "↑");
528 assert_eq!(values["ocg_rolling_pace_indicator"], "↑");
529 assert_eq!(values["ocg_weekly_pace"], "↓");
530 assert_eq!(values["ocg_weekly_pace_indicator"], "↓");
531 }
532
533 #[test]
534 fn monthly_keeps_reset_but_omits_pacing_until_cycle_bounds_are_known() {
535 let now = at("2026-08-16T18:00:00Z");
539 let usage = Usage {
540 rolling: None,
541 weekly: None,
542 monthly: Some(Window {
543 status: "ok".into(),
544 percent: 70.0,
545 resets_at: at("2026-08-31T18:00:00Z"),
546 }),
547 };
548 let values = build_placeholders(&usage, now);
549 assert_eq!(values["ocg_monthly_pct"], "70");
550 assert_eq!(values["ocg_monthly_reset"], "15d 0h");
551 assert_eq!(values["ocg_monthly_status"], "ok");
552 assert_eq!(values["ocg_monthly_elapsed"], "");
553 assert_eq!(values["ocg_monthly_pace"], "");
554 assert_eq!(values["ocg_monthly_pace_indicator"], "");
555 }
556
557 #[test]
558 fn public_builder_keeps_the_default_tolerance_api() {
559 let now = at("2026-08-16T18:00:00Z");
560 let usage = sample_usage();
561 assert_eq!(
562 build_placeholders(&usage, now),
563 build_placeholders_with_tolerance(&usage, pacing::DEFAULT_TOLERANCE, now)
564 );
565 }
566
567 #[test]
568 fn custom_tolerance_changes_the_ratio_pace() {
569 let now = at("2026-08-16T18:00:00Z");
571 let usage = Usage {
572 rolling: Some(Window {
573 status: "ok".into(),
574 percent: 53.0,
575 resets_at: at("2026-08-16T20:30:00Z"),
576 }),
577 weekly: None,
578 monthly: None,
579 };
580 assert_eq!(
581 build_placeholders_with_tolerance(&usage, 5, now)["ocg_rolling_pace"],
582 "↑"
583 );
584 assert_eq!(
585 build_placeholders_with_tolerance(&usage, 10, now)["ocg_rolling_pace"],
586 "→"
587 );
588 }
589
590 #[test]
591 fn elapsed_and_pace_placeholders_are_empty_without_window() {
592 let values = build_placeholders(
593 &Usage {
594 rolling: None,
595 weekly: None,
596 monthly: None,
597 },
598 at("2026-08-16T18:00:00Z"),
599 );
600 for key in [
601 "session_elapsed",
602 "weekly_elapsed",
603 "ocg_rolling_elapsed",
604 "ocg_rolling_pace",
605 "ocg_rolling_pace_indicator",
606 "ocg_weekly_elapsed",
607 "ocg_weekly_pace",
608 "ocg_weekly_pace_indicator",
609 "ocg_monthly_elapsed",
610 "ocg_monthly_pace",
611 "ocg_monthly_pace_indicator",
612 ] {
613 assert_eq!(values[key], "", "{key} should be empty");
614 }
615 }
616
617 #[test]
618 fn tooltip_shows_the_pace_arrow_next_to_each_percentage() {
619 let now = at("2026-08-16T18:00:00Z");
621 let usage = Usage {
622 rolling: Some(Window {
623 status: "ok".into(),
624 percent: 80.0,
625 resets_at: at("2026-08-16T20:00:00Z"),
626 }),
627 weekly: None,
628 monthly: None,
629 };
630 let out = render(
631 &outcome_for(&usage),
632 &usage,
633 &Theme::default(),
634 &opts(),
635 now,
636 );
637 assert!(out.tooltip.contains("80% ↑"), "{}", out.tooltip);
638 }
639
640 #[test]
641 fn tooltip_monthly_row_keeps_reset_without_a_pace_glyph() {
642 let now = at("2026-08-16T18:00:00Z");
643 let usage = Usage {
644 rolling: None,
645 weekly: None,
646 monthly: Some(Window {
647 status: "ok".into(),
648 percent: 70.0,
649 resets_at: at("2026-08-31T18:00:00Z"),
650 }),
651 };
652 let out = render(
653 &outcome_for(&usage),
654 &usage,
655 &Theme::default(),
656 &opts(),
657 now,
658 );
659 assert!(out.tooltip.contains("Monthly"), "{}", out.tooltip);
660 assert!(out.tooltip.contains("70%"), "{}", out.tooltip);
661 assert!(
662 !out.tooltip.contains('↑') && !out.tooltip.contains('→') && !out.tooltip.contains('↓'),
663 "monthly row must not grow a pace glyph: {}",
664 out.tooltip
665 );
666 assert!(out.tooltip.contains("Resets in 15d 0h"), "{}", out.tooltip);
667 }
668
669 #[test]
670 fn plan_and_status_are_sanitized() {
671 let usage = Usage {
672 rolling: Some(Window {
673 status: "ok\u{1b}[31m\u{7}".into(),
674 percent: 1.0,
675 resets_at: at("2026-08-16T20:00:00Z"),
676 }),
677 weekly: None,
678 monthly: None,
679 };
680 let values = build_placeholders_with_plan(
681 "OpenCode\u{1b}[31m Go",
682 &usage,
683 at("2026-08-16T18:00:00Z"),
684 );
685
686 assert!(!values["plan"].contains('\u{1b}'));
687 assert!(!values["ocg_rolling_status"].contains('\u{1b}'));
688 assert!(!values["ocg_rolling_status"].contains('\u{7}'));
689 }
690}