1use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6
7use crate::countdown;
8use crate::format::{placeholders, substitute, updated_at_hm};
9use crate::pacing::{self, PaceSeverity};
10use crate::pango::{color_span, escape, severity_color, severity_for};
11use crate::theme::Theme;
12use crate::tooltip::{Line as TooltipLine, WindowRow, push_window_with_row, render_bordered};
13use crate::usage::{OllamaSnapshot, UsageWindow};
14use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
15use crate::waybar::{Class, WaybarOutput};
16
17use super::fetch::FetchOutcome;
18
19pub const DEFAULT_FORMAT: &str = "{oll_session_pct}% · {oll_weekly_pct}%w";
20
21pub fn build_placeholders(
23 snap: &OllamaSnapshot,
24 now: DateTime<Utc>,
25) -> HashMap<&'static str, String> {
26 build_placeholders_with_tolerance(snap, pacing::DEFAULT_TOLERANCE, now)
27}
28
29fn build_placeholders_with_tolerance(
30 snap: &OllamaSnapshot,
31 pace_tolerance: u32,
32 now: DateTime<Utc>,
33) -> HashMap<&'static str, String> {
34 let session_pct = snap
35 .session
36 .as_ref()
37 .map(|w| w.utilization_pct)
38 .unwrap_or(0);
39 let weekly_pct = snap.weekly.as_ref().map(|w| w.utilization_pct).unwrap_or(0);
40 let monthly_pct = snap
41 .monthly
42 .as_ref()
43 .map(|w| w.utilization_pct)
44 .unwrap_or(0);
45 let session = window_pacing(snap.session.as_ref(), pace_tolerance, now);
46 let weekly = window_pacing(snap.weekly.as_ref(), pace_tolerance, now);
47 let monthly = window_pacing(snap.monthly.as_ref(), pace_tolerance, now);
48 let cost = snap.activity_cost.clone().unwrap_or_else(|| "—".into());
49
50 placeholders(vec![
51 ("icon", "🦙".to_string()),
52 ("vendor_short", VendorId::Ollama.short_name().to_string()),
53 ("session_pct", session_pct.to_string()),
55 (
56 "session_reset",
57 countdown::format(window_reset(&snap.session), now),
58 ),
59 ("weekly_pct", weekly_pct.to_string()),
60 (
61 "weekly_reset",
62 countdown::format(window_reset(&snap.weekly), now),
63 ),
64 ("session_elapsed", session.elapsed.clone()),
65 ("weekly_elapsed", weekly.elapsed.clone()),
66 ("plan", snap.plan.clone()),
67 ("oll_plan", snap.plan.clone()),
68 ("oll_session_pct", session_pct.to_string()),
69 (
70 "oll_session_reset",
71 countdown::format(window_reset(&snap.session), now),
72 ),
73 ("oll_weekly_pct", weekly_pct.to_string()),
74 (
75 "oll_weekly_reset",
76 countdown::format(window_reset(&snap.weekly), now),
77 ),
78 ("oll_monthly_pct", monthly_pct.to_string()),
79 (
80 "oll_monthly_reset",
81 countdown::format(window_reset(&snap.monthly), now),
82 ),
83 ("oll_session_elapsed", session.elapsed),
84 ("oll_session_pace", session.ratio_pace),
85 ("oll_session_pace_indicator", session.point_pace),
86 ("oll_weekly_elapsed", weekly.elapsed),
87 ("oll_weekly_pace", weekly.ratio_pace),
88 ("oll_weekly_pace_indicator", weekly.point_pace),
89 ("oll_monthly_elapsed", monthly.elapsed),
90 ("oll_monthly_pace", monthly.ratio_pace),
91 ("oll_monthly_pace_indicator", monthly.point_pace),
92 ("oll_cost", cost),
93 ])
94}
95
96fn window_reset(w: &Option<UsageWindow>) -> Option<DateTime<Utc>> {
97 w.as_ref().and_then(|w| w.resets_at)
98}
99
100#[derive(Default)]
101struct WindowPacing {
102 elapsed: String,
103 ratio_pace: String,
104 point_pace: String,
105}
106
107fn window_pacing(w: Option<&UsageWindow>, pace_tolerance: u32, now: DateTime<Utc>) -> WindowPacing {
108 let Some(w) = w else {
109 return WindowPacing::default();
110 };
111 let p = pacing::calc(
112 w.utilization_pct,
113 w.resets_at,
114 now,
115 w.window_duration,
116 pace_tolerance,
117 );
118 WindowPacing {
119 elapsed: p.elapsed_pct.to_string(),
120 ratio_pace: p.ratio_pace.glyph().to_string(),
121 point_pace: p.point_pace.glyph().to_string(),
122 }
123}
124
125pub fn severity(snap: &OllamaSnapshot) -> PaceSeverity {
126 let session = snap
129 .session
130 .as_ref()
131 .map(|w| w.utilization_pct)
132 .unwrap_or(0);
133 let weekly = snap.weekly.as_ref().map(|w| w.utilization_pct).unwrap_or(0);
134 let monthly = snap
135 .monthly
136 .as_ref()
137 .map(|w| w.utilization_pct)
138 .unwrap_or(0);
139 severity_for(session.max(weekly).max(monthly))
140}
141
142pub fn render(
143 outcome: &VendorOutcome,
144 snap: &OllamaSnapshot,
145 theme: &Theme,
146 opts: &RenderOpts,
147 now: DateTime<Utc>,
148) -> WaybarOutput {
149 let class = Class::from(severity(snap));
150 let format = opts
151 .format
152 .clone()
153 .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
154 let values = build_placeholders_with_tolerance(snap, opts.pace_tolerance, now);
155
156 let mut text = substitute(&format, &values);
157 if outcome.stale {
158 text.push('…');
159 }
160
161 let wrapper_color = severity_color(severity(snap), theme).to_string();
162 let icon_prefix = match opts.icon.as_deref() {
163 Some(ic) if !ic.is_empty() => format!("{ic} "),
164 _ => String::new(),
165 };
166 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
167
168 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
169 substitute(fmt, &values)
170 } else {
171 render_tooltip(outcome, snap, theme, opts.pace_tolerance, now)
172 };
173
174 WaybarOutput {
175 text: bar_text,
176 tooltip,
177 class,
178 }
179}
180
181fn row(w: &UsageWindow) -> WindowRow {
182 let _ = w;
184 WindowRow::default()
185}
186
187fn render_tooltip(
188 outcome: &VendorOutcome,
189 snap: &OllamaSnapshot,
190 theme: &Theme,
191 _pace_tolerance: u32,
192 now: DateTime<Utc>,
193) -> String {
194 let blue = &theme.blue;
195 let dim = &theme.dim;
196
197 let mut lines: Vec<TooltipLine> = Vec::new();
198 lines.push(TooltipLine::Center(format!(
199 "<span font_weight='bold' foreground='{blue}'>Ollama Cloud</span>"
200 )));
201 if !snap.plan.is_empty() {
202 lines.push(TooltipLine::Center(format!(
203 "<span foreground='{dim}'>{}</span>",
204 escape(&snap.plan)
205 )));
206 }
207 lines.push(TooltipLine::Sep);
208 lines.push(TooltipLine::Body("".into()));
209
210 if let Some(w) = snap.session.as_ref() {
211 push_window_with_row(&mut lines, " Session (5h)", w, theme, now, row(w));
212 if !snap.session_models.is_empty() {
213 push_model_rows(&mut lines, &snap.session_models, dim);
214 }
215 lines.push(TooltipLine::Body("".into()));
216 }
217
218 if let Some(w) = snap.weekly.as_ref() {
219 push_window_with_row(&mut lines, " Weekly", w, theme, now, row(w));
220 if !snap.weekly_models.is_empty() {
221 push_model_rows(&mut lines, &snap.weekly_models, dim);
222 }
223 lines.push(TooltipLine::Body("".into()));
224 }
225
226 if let Some(w) = snap.monthly.as_ref() {
227 push_window_with_row(&mut lines, " Monthly", w, theme, now, row(w));
228 if !snap.monthly_models.is_empty() {
229 push_model_rows(&mut lines, &snap.monthly_models, dim);
230 }
231 lines.push(TooltipLine::Body("".into()));
232 }
233
234 if let Some(cost) = snap.activity_cost.as_deref() {
235 let period = snap.activity_period.as_deref().unwrap_or("activity");
236 lines.push(TooltipLine::Body(format!(
237 " <span foreground='{dim}'> $ {period}</span>"
238 )));
239 lines.push(TooltipLine::Body(format!(
240 " <span foreground='{dim}'>${}</span>",
241 escape(cost)
242 )));
243 }
244
245 if let Some((code, msg)) = outcome.last_error.as_ref()
246 && *code != 0
247 {
248 let (icon, ecolor) = if *code >= 500 {
249 ("!", theme.red.as_str())
250 } else {
251 ("!", theme.orange.as_str())
252 };
253 lines.push(TooltipLine::Body("".into()));
254 lines.push(TooltipLine::Sep);
255 lines.push(TooltipLine::Body(format!(
256 " <span foreground='{ecolor}'> {icon} HTTP {code}</span>"
257 )));
258 lines.push(TooltipLine::Body(format!(
259 " <span foreground='{dim}'>{}</span>",
260 escape(msg)
261 )));
262 }
263
264 let updated = updated_at_hm(now, outcome.cache_age);
265 lines.push(TooltipLine::Body("".into()));
266 lines.push(TooltipLine::Sep);
267 lines.push(TooltipLine::Body(format!(
268 " <span foreground='{dim}'> Updated {updated}</span>"
269 )));
270
271 render_bordered(&lines, theme)
272}
273
274fn push_model_rows(
275 lines: &mut Vec<TooltipLine>,
276 models: &[crate::usage::OllamaModelUsage],
277 dim: &str,
278) {
279 for m in models.iter().take(6) {
281 lines.push(TooltipLine::Body(format!(
282 " <span foreground='{dim}'>{} · {} req</span>",
283 escape(&m.name),
284 m.request_count
285 )));
286 }
287 if models.len() > 6 {
288 lines.push(TooltipLine::Body(format!(
289 " <span foreground='{dim}'>… +{} more</span>",
290 models.len() - 6
291 )));
292 }
293}
294
295impl From<FetchOutcome> for VendorOutcome {
296 fn from(o: FetchOutcome) -> Self {
297 o.map(crate::usage::VendorSnapshot::Ollama)
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::usage::{OllamaModelUsage, OllamaSnapshot, UsageWindow};
305
306 fn sample_snap() -> OllamaSnapshot {
307 OllamaSnapshot {
308 plan: "pro".into(),
309 session: Some(UsageWindow {
310 utilization_pct: 82,
311 resets_at: None,
312 window_duration: chrono::Duration::hours(5),
313 }),
314 weekly: Some(UsageWindow {
315 utilization_pct: 23,
316 resets_at: None,
317 window_duration: chrono::Duration::days(7),
318 }),
319 monthly: None,
320 session_models: vec![OllamaModelUsage {
321 name: "kimi-k3".into(),
322 request_count: 180,
323 }],
324 weekly_models: vec![
325 OllamaModelUsage {
326 name: "kimi-k3".into(),
327 request_count: 180,
328 },
329 OllamaModelUsage {
330 name: "minimax-m3".into(),
331 request_count: 554,
332 },
333 ],
334 monthly_models: vec![],
335 activity_cost: Some("0.00000".into()),
336 activity_period: Some("last_4_weeks".into()),
337 }
338 }
339
340 fn sample_outcome(snap: OllamaSnapshot) -> VendorOutcome {
341 VendorOutcome {
342 snapshot: crate::usage::VendorSnapshot::Ollama(snap),
343 stale: false,
344 last_error: None,
345 cache_age: Some(std::time::Duration::from_secs(10)),
346 }
347 }
348
349 fn opts() -> RenderOpts {
350 RenderOpts {
351 format: None,
352 tooltip_format: None,
353 icon: None,
354 pace_tolerance: pacing::DEFAULT_TOLERANCE,
355 format_pace_color: false,
356 tooltip_pace_pts: false,
357 }
358 }
359
360 #[test]
361 fn placeholders_expose_session_and_weekly() {
362 let now = Utc::now();
363 let values = build_placeholders(&sample_snap(), now);
364 assert_eq!(
365 values.get("oll_session_pct").map(String::as_str),
366 Some("82")
367 );
368 assert_eq!(values.get("oll_weekly_pct").map(String::as_str), Some("23"));
369 assert_eq!(values.get("oll_cost").map(String::as_str), Some("0.00000"));
370 assert_eq!(values.get("vendor_short").map(String::as_str), Some("oll"));
371 assert_eq!(values.get("plan").map(String::as_str), Some("pro"));
372 }
373
374 #[test]
375 fn severity_tracks_worst_window() {
376 assert_eq!(severity(&sample_snap()), severity_for(82));
377 }
378
379 #[test]
380 fn render_produces_nonempty_bar_and_tooltip() {
381 let snap = sample_snap();
382 let outcome = sample_outcome(snap.clone());
383 let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
384 assert!(!out.text.is_empty());
385 assert!(out.tooltip.contains("Ollama Cloud"));
386 assert!(out.tooltip.contains("kimi-k3"));
387 assert!(out.tooltip.contains("Session") || out.tooltip.contains("82"));
388 }
389}