1use crate::core::session::SessionState;
2use crate::core::stats;
3
4pub struct WrappedReport {
5 pub period: String,
6 pub tokens_saved: u64,
7 pub tokens_input: u64,
8 pub cost_avoided_usd: f64,
9 pub total_commands: u64,
10 pub sessions_count: usize,
11 pub top_commands: Vec<(String, u64, f64)>,
12 pub compression_rate_pct: f64,
13 pub files_touched: u64,
14 pub daily_savings: Vec<u64>,
15 pub bounce_tokens: u64,
18 pub model_key: String,
20 pub pricing_estimated: bool,
23 pub percentile: Option<u8>,
26}
27
28impl WrappedReport {
29 pub fn generate(period: &str) -> Self {
30 let store = stats::load();
31 let sessions = SessionState::list_sessions();
32
33 let (gross_tokens_saved, tokens_input, total_commands) = match period {
34 "week" => aggregate_recent_stats(&store, 7),
35 "month" => aggregate_recent_stats(&store, 30),
36 _ => (
37 store
38 .total_input_tokens
39 .saturating_sub(store.total_output_tokens),
40 store.total_input_tokens,
41 store.total_commands,
42 ),
43 };
44
45 let period_days = match period {
48 "week" => Some(7),
49 "month" => Some(30),
50 _ => None,
51 };
52 let bounce_tokens = crate::core::savings_ledger::bounce_tokens(period_days);
53 let tokens_saved = gross_tokens_saved.saturating_sub(bounce_tokens);
54
55 let env_model = std::env::var("LEAN_CTX_MODEL")
56 .or_else(|_| std::env::var("LCTX_MODEL"))
57 .ok();
58 let pricing = crate::core::gain::model_pricing::ModelPricing::load();
59 let quote = pricing.quote(env_model.as_deref());
60 let cost_avoided_usd = quote.cost.estimate_usd(tokens_saved, 0, 0, 0);
64 let pricing_estimated = matches!(
65 quote.match_kind,
66 crate::core::gain::model_pricing::PricingMatchKind::Fallback
67 );
68 let model_key = quote.model_key.clone();
69
70 let sessions_count = match period {
71 "week" => count_recent_sessions(&sessions, 7),
72 "month" => count_recent_sessions(&sessions, 30),
73 _ => sessions.len(),
74 };
75
76 let mut top_commands: Vec<(String, u64, f64)> = store
77 .commands
78 .iter()
79 .map(|(cmd, stats)| {
80 let saved = stats.input_tokens.saturating_sub(stats.output_tokens);
81 let pct = if stats.input_tokens > 0 {
82 saved as f64 / stats.input_tokens as f64 * 100.0
83 } else {
84 0.0
85 };
86 (cmd.clone(), saved, pct)
87 })
88 .collect();
89 top_commands.sort_by_key(|x| std::cmp::Reverse(x.1));
90 top_commands.truncate(5);
91
92 let compression_rate_pct = if tokens_input > 0 {
93 tokens_saved as f64 / tokens_input as f64 * 100.0
94 } else {
95 0.0
96 };
97
98 let files_touched: u64 = sessions.iter().map(|s| s.tool_calls as u64).sum();
99
100 let day_saved = |d: &stats::DayStats| d.input_tokens.saturating_sub(d.output_tokens);
101 let take_recent = |n: usize| -> Vec<u64> {
102 store
103 .daily
104 .iter()
105 .rev()
106 .take(n)
107 .collect::<Vec<_>>()
108 .into_iter()
109 .rev()
110 .map(day_saved)
111 .collect()
112 };
113 let daily_savings = match period {
114 "week" => take_recent(7),
115 "month" => take_recent(30),
116 _ => store.daily.iter().map(day_saved).collect(),
117 };
118
119 WrappedReport {
120 period: period.to_string(),
121 tokens_saved,
122 tokens_input,
123 cost_avoided_usd,
124 total_commands,
125 sessions_count,
126 top_commands,
127 compression_rate_pct,
128 files_touched,
129 daily_savings,
130 bounce_tokens,
131 model_key,
132 pricing_estimated,
133 percentile: estimate_percentile(tokens_saved),
134 }
135 }
136
137 pub fn methodology_line(&self) -> String {
141 let price = if self.pricing_estimated {
142 format!(
143 "{} blended fallback price (set LEAN_CTX_MODEL for exact)",
144 self.model_key
145 )
146 } else {
147 format!("{} input price", self.model_key)
148 };
149 let basis = if self.bounce_tokens > 0 {
150 format!(
151 "measured original - compressed - {} bounce tokens",
152 format_tokens(self.bounce_tokens)
153 )
154 } else {
155 "measured original - compressed tokens".to_string()
156 };
157 format!("Savings = {basis}; USD is an upper bound at {price}")
158 }
159
160 #[allow(clippy::many_single_char_names)] pub fn format_ascii(&self) -> String {
165 use crate::core::theme;
166
167 let cfg = crate::core::config::Config::load();
168 let t = theme::load_theme(&cfg.theme);
169 let rst = theme::rst();
170 let bold = theme::bold();
171 let dim = theme::dim();
172
173 let period_label = match self.period.as_str() {
174 "week" => format!("Week of {}", chrono::Utc::now().format("%b %d, %Y")),
175 "month" => format!("Month of {}", chrono::Utc::now().format("%B %Y")),
176 _ => "All Time".to_string(),
177 };
178
179 let w = 52;
180 let side = t.box_side();
181 let box_line = |content: &str| -> String {
182 let padded = theme::pad_right(content, w);
183 format!(" {side}{padded}{side}")
184 };
185
186 let mut out: Vec<String> = Vec::new();
187 out.push(String::new());
188 out.push(format!(" {}", t.box_top(w)));
189 out.push(box_line(""));
190 out.push(box_line(&format!(
191 " {icon} {brand} {accent}Wrapped{rst} {dim}· {period_label}{rst}",
192 icon = t.header_icon(),
193 brand = t.brand_title(),
194 accent = t.accent.fg(),
195 )));
196 out.push(box_line(""));
197 out.push(format!(" {}", t.box_mid(w)));
198 out.push(box_line(""));
199
200 let kw = 16;
202 let sc = t.success.fg();
203 let c2 = t.secondary.fg();
204 let c3 = t.warning.fg();
205 let c4 = t.accent.fg();
206
207 let v1 = theme::pad_right(
208 &format!("{sc}{bold}{}{rst}", format_tokens(self.tokens_saved)),
209 kw,
210 );
211 let v2 = theme::pad_right(&format!("{c4}{bold}${:.2}{rst}", self.cost_avoided_usd), kw);
212 let v3 = theme::pad_right(&format!("{c3}{bold}{}{rst}", self.total_commands), kw);
213 out.push(box_line(&format!(" {v1}{v2}{v3}")));
214 let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
215 let l2 = theme::pad_right(&format!("{dim}cost avoided{rst}"), kw);
216 let l3 = theme::pad_right(&format!("{dim}commands{rst}"), kw);
217 out.push(box_line(&format!(" {l1}{l2}{l3}")));
218 out.push(box_line(""));
219
220 let v4 = theme::pad_right(&format!("{c2}{bold}{}{rst}", self.sessions_count), kw);
223 let v5 = theme::pad_right(
224 &format!(
225 "{pc}{bold}{:.1}%{rst}",
226 self.compression_rate_pct,
227 pc = t.pct_color(self.compression_rate_pct),
228 ),
229 kw,
230 );
231 let energy = crate::core::energy::format_for_tokens(self.tokens_saved);
232 let v6 = theme::pad_right(&format!("{c4}{bold}{energy}{rst}"), kw);
233 out.push(box_line(&format!(" {v4}{v5}{v6}")));
234 let l4 = theme::pad_right(&format!("{dim}sessions{rst}"), kw);
235 let l5 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
236 let l6 = theme::pad_right(&format!("{dim}energy saved{rst}"), kw);
237 out.push(box_line(&format!(" {l4}{l5}{l6}")));
238 out.push(box_line(""));
239
240 if self.daily_savings.iter().filter(|v| **v > 0).count() >= 2 {
242 let spark = t.gradient_sparkline(&self.daily_savings);
243 out.push(box_line(&format!(" {dim}trend{rst} {spark}")));
244 out.push(box_line(""));
245 }
246
247 if !self.top_commands.is_empty() {
249 let prefix_visible = 8; let budget = w.saturating_sub(prefix_visible);
251 let mut top_str = self
252 .top_commands
253 .iter()
254 .take(3)
255 .map(|(cmd, _, pct)| format!("{cmd} {pct:.0}%"))
256 .collect::<Vec<_>>()
257 .join(" · ");
258 if top_str.chars().count() > budget {
259 let truncated: String = top_str.chars().take(budget.saturating_sub(1)).collect();
260 top_str = format!("{truncated}…");
261 }
262 out.push(format!(" {}", t.box_mid(w)));
263 out.push(box_line(&format!(
264 " {m}top{rst} {top_str}",
265 m = t.muted.fg()
266 )));
267 }
268
269 out.push(format!(" {}", t.box_bottom(w)));
270 out.push(format!(
271 " {dim}\"Your AI saw only what mattered.\"{rst} {accent}leanctx.com{rst}",
272 accent = t.accent.fg(),
273 ));
274 let est_marker = if self.pricing_estimated {
275 " (est.)"
276 } else {
277 ""
278 };
279 out.push(format!(
280 " {dim}model {model}{est_marker} · USD = upper bound{rst}",
281 model = self.model_key,
282 ));
283 out.push(String::new());
284
285 out.join("\n")
286 }
287
288 pub fn format_compact(&self) -> String {
289 let saved_str = format_tokens(self.tokens_saved);
290 let cost_str = format!("${:.2}", self.cost_avoided_usd);
291 let top_str = self
292 .top_commands
293 .iter()
294 .take(3)
295 .map(|(cmd, _, pct)| format!("{cmd} {pct:.0}%"))
296 .collect::<Vec<_>>()
297 .join(" | ");
298
299 let est_marker = if self.pricing_estimated {
300 " (est.)"
301 } else {
302 ""
303 };
304 format!(
305 "WRAPPED [{}]: {} tok saved, {} avoided{}, {} sessions, {} cmds | Top: {} | Compression: {:.1}% | Energy: {} | model={}",
306 self.period,
307 saved_str,
308 cost_str,
309 est_marker,
310 self.sessions_count,
311 self.total_commands,
312 top_str,
313 self.compression_rate_pct,
314 crate::core::energy::format_for_tokens(self.tokens_saved),
315 self.model_key,
316 )
317 }
318}
319
320fn aggregate_recent_stats(store: &stats::StatsStore, days: usize) -> (u64, u64, u64) {
321 let recent_days: Vec<&stats::DayStats> = store.daily.iter().rev().take(days).collect();
322
323 let input: u64 = recent_days.iter().map(|d| d.input_tokens).sum();
324 let output: u64 = recent_days.iter().map(|d| d.output_tokens).sum();
325 let commands: u64 = recent_days.iter().map(|d| d.commands).sum();
326 let saved = input.saturating_sub(output);
327
328 (saved, input, commands)
329}
330
331fn count_recent_sessions(sessions: &[crate::core::session::SessionSummary], days: i64) -> usize {
332 let cutoff = chrono::Utc::now() - chrono::Duration::days(days);
333 sessions.iter().filter(|s| s.updated_at > cutoff).count()
334}
335
336pub(crate) fn format_tokens(tokens: u64) -> String {
337 if tokens >= 1_000_000_000_000 {
338 format!("{:.2}T", tokens as f64 / 1_000_000_000_000.0)
339 } else if tokens >= 1_000_000_000 {
340 format!("{:.2}B", tokens as f64 / 1_000_000_000.0)
341 } else if tokens >= 1_000_000 {
342 format!("{:.1}M", tokens as f64 / 1_000_000.0)
343 } else if tokens >= 1_000 {
344 format!("{:.1}K", tokens as f64 / 1_000.0)
345 } else {
346 format!("{tokens}")
347 }
348}
349
350fn estimate_percentile(tokens_saved: u64) -> Option<u8> {
354 if tokens_saved < 1_000 {
355 return None;
356 }
357 let pct = if tokens_saved >= 100_000_000 {
360 99
361 } else if tokens_saved >= 50_000_000 {
362 97
363 } else if tokens_saved >= 10_000_000 {
364 95
365 } else if tokens_saved >= 5_000_000 {
366 90
367 } else if tokens_saved >= 1_000_000 {
368 80
369 } else if tokens_saved >= 500_000 {
370 70
371 } else if tokens_saved >= 100_000 {
372 55
373 } else if tokens_saved >= 50_000 {
374 40
375 } else if tokens_saved >= 10_000 {
376 25
377 } else {
378 10
379 };
380 Some(pct)
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 fn sample() -> WrappedReport {
388 WrappedReport {
389 period: "all".into(),
390 tokens_saved: 348_300_000,
391 tokens_input: 580_000_000,
392 cost_avoided_usd: 870.81,
393 total_commands: 17_055,
394 sessions_count: 67,
395 top_commands: vec![
396 ("ctx_search".into(), 100, 60.0),
397 ("cli_grep".into(), 80, 85.0),
398 ("cli_shell".into(), 50, 37.0),
399 ],
400 compression_rate_pct: 60.2,
401 files_touched: 1_234,
402 daily_savings: vec![10, 50, 30, 30, 80, 80, 20, 5, 5, 40, 60, 40, 5, 50, 15],
403 bounce_tokens: 0,
404 model_key: "claude-3.5-sonnet".into(),
405 pricing_estimated: false,
406 percentile: Some(95),
407 }
408 }
409
410 fn is_box_line(l: &str) -> bool {
411 let trimmed = l.trim_start();
412 ["│", "╭", "├", "╰"].iter().any(|c| trimmed.starts_with(c))
413 }
414
415 #[test]
416 fn wrapped_ascii_box_lines_have_uniform_width() {
417 let out = sample().format_ascii();
419 let widths: Vec<usize> = out
420 .lines()
421 .filter(|l| is_box_line(l))
422 .map(|l| l.chars().count())
423 .collect();
424 assert!(widths.len() >= 4, "expected several box lines:\n{out}");
425 let first = widths[0];
426 for w in &widths {
427 assert_eq!(*w, first, "box line widths must be uniform:\n{out}");
428 }
429 }
430
431 #[test]
432 fn wrapped_ascii_includes_brand_and_metrics() {
433 let out = sample().format_ascii();
434 assert!(out.contains("leanctx.com"), "missing brand footer:\n{out}");
435 assert!(out.contains("Wrapped"));
436 assert!(out.contains("tokens saved"));
437 assert!(out.contains("compression"));
438 }
439
440 #[test]
441 fn wrapped_ascii_truncates_overlong_top_line() {
442 let out = sample().format_ascii();
443 let max = out
445 .lines()
446 .filter(|l| is_box_line(l))
447 .map(|l| l.chars().count())
448 .max()
449 .unwrap_or(0);
450 let min = out
451 .lines()
452 .filter(|l| is_box_line(l))
453 .map(|l| l.chars().count())
454 .min()
455 .unwrap_or(0);
456 assert_eq!(max, min, "top line overflowed the box:\n{out}");
457 }
458
459 #[test]
460 fn wrapped_compact_is_single_line_summary() {
461 let out = sample().format_compact();
462 assert!(out.starts_with("WRAPPED"), "compact summary changed: {out}");
463 assert!(out.contains("Compression:"));
464 assert!(
465 out.contains("model="),
466 "compact must name the pricing model: {out}"
467 );
468 }
469
470 #[test]
471 fn methodology_is_conservative_and_explainable() {
472 let m = sample().methodology_line();
473 assert!(
474 m.contains("upper bound"),
475 "must state it is an upper bound: {m}"
476 );
477 assert!(m.contains("claude-3.5-sonnet"), "must name the model: {m}");
478 }
479
480 #[test]
481 fn ascii_footer_surfaces_model_and_upper_bound() {
482 let out = sample().format_ascii();
483 assert!(
484 out.contains("model claude-3.5-sonnet"),
485 "footer must name model:\n{out}"
486 );
487 assert!(
488 out.contains("USD = upper bound"),
489 "footer must flag upper bound:\n{out}"
490 );
491 }
492
493 #[test]
494 fn estimated_pricing_is_flagged() {
495 let mut r = sample();
496 r.pricing_estimated = true;
497 assert!(
498 r.format_ascii().contains("(est.)"),
499 "estimated price must show (est.)"
500 );
501 assert!(r.format_compact().contains("(est.)"));
502 assert!(r.methodology_line().contains("fallback"));
503 }
504}