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_for_display();
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
350pub(crate) fn edit_token_recovery_message(card_id: &str, nonce: &str) -> String {
353 format!("lean-ctx:wrapped-edit-token-recovery:v1\ncard_id:{card_id}\nnonce:{nonce}")
354}
355
356fn estimate_percentile(tokens_saved: u64) -> Option<u8> {
360 if tokens_saved < 1_000 {
361 return None;
362 }
363 let pct = if tokens_saved >= 100_000_000 {
366 99
367 } else if tokens_saved >= 50_000_000 {
368 97
369 } else if tokens_saved >= 10_000_000 {
370 95
371 } else if tokens_saved >= 5_000_000 {
372 90
373 } else if tokens_saved >= 1_000_000 {
374 80
375 } else if tokens_saved >= 500_000 {
376 70
377 } else if tokens_saved >= 100_000 {
378 55
379 } else if tokens_saved >= 50_000 {
380 40
381 } else if tokens_saved >= 10_000 {
382 25
383 } else {
384 10
385 };
386 Some(pct)
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn sample() -> WrappedReport {
394 WrappedReport {
395 period: "all".into(),
396 tokens_saved: 348_300_000,
397 tokens_input: 580_000_000,
398 cost_avoided_usd: 870.81,
399 total_commands: 17_055,
400 sessions_count: 67,
401 top_commands: vec![
402 ("ctx_search".into(), 100, 60.0),
403 ("cli_grep".into(), 80, 85.0),
404 ("cli_shell".into(), 50, 37.0),
405 ],
406 compression_rate_pct: 60.2,
407 files_touched: 1_234,
408 daily_savings: vec![10, 50, 30, 30, 80, 80, 20, 5, 5, 40, 60, 40, 5, 50, 15],
409 bounce_tokens: 0,
410 model_key: "claude-3.5-sonnet".into(),
411 pricing_estimated: false,
412 percentile: Some(95),
413 }
414 }
415
416 #[test]
417 fn edit_token_recovery_proof_is_domain_separated_and_stable() {
418 assert_eq!(
419 edit_token_recovery_message("card-1", "nonce-1"),
420 "lean-ctx:wrapped-edit-token-recovery:v1\ncard_id:card-1\nnonce:nonce-1"
421 );
422 }
423
424 fn is_box_line(l: &str) -> bool {
425 let trimmed = l.trim_start();
426 ["│", "╭", "├", "╰"].iter().any(|c| trimmed.starts_with(c))
427 }
428
429 #[test]
430 fn wrapped_ascii_box_lines_have_uniform_width() {
431 let _env = crate::core::data_dir::test_env_lock();
436 let out = sample().format_ascii();
438 let widths: Vec<usize> = out
439 .lines()
440 .filter(|l| is_box_line(l))
441 .map(crate::core::theme::visual_len)
442 .collect();
443 assert!(widths.len() >= 4, "expected several box lines:\n{out}");
444 let first = widths[0];
445 for w in &widths {
446 assert_eq!(*w, first, "box line widths must be uniform:\n{out}");
447 }
448 }
449
450 #[test]
451 fn wrapped_ascii_includes_brand_and_metrics() {
452 let out = sample().format_ascii();
453 assert!(out.contains("leanctx.com"), "missing brand footer:\n{out}");
454 assert!(out.contains("Wrapped"));
455 assert!(out.contains("tokens saved"));
456 assert!(out.contains("compression"));
457 }
458
459 #[test]
460 fn wrapped_ascii_truncates_overlong_top_line() {
461 let out = sample().format_ascii();
462 let max = out
464 .lines()
465 .filter(|l| is_box_line(l))
466 .map(crate::core::theme::visual_len)
467 .max()
468 .unwrap_or(0);
469 let min = out
470 .lines()
471 .filter(|l| is_box_line(l))
472 .map(crate::core::theme::visual_len)
473 .min()
474 .unwrap_or(0);
475 assert_eq!(max, min, "top line overflowed the box:\n{out}");
476 }
477
478 #[test]
479 fn wrapped_compact_is_single_line_summary() {
480 let out = sample().format_compact();
481 assert!(out.starts_with("WRAPPED"), "compact summary changed: {out}");
482 assert!(out.contains("Compression:"));
483 assert!(
484 out.contains("model="),
485 "compact must name the pricing model: {out}"
486 );
487 }
488
489 #[test]
490 fn methodology_is_conservative_and_explainable() {
491 let m = sample().methodology_line();
492 assert!(
493 m.contains("upper bound"),
494 "must state it is an upper bound: {m}"
495 );
496 assert!(m.contains("claude-3.5-sonnet"), "must name the model: {m}");
497 }
498
499 #[test]
500 fn ascii_footer_surfaces_model_and_upper_bound() {
501 let out = sample().format_ascii();
502 assert!(
503 out.contains("model claude-3.5-sonnet"),
504 "footer must name model:\n{out}"
505 );
506 assert!(
507 out.contains("USD = upper bound"),
508 "footer must flag upper bound:\n{out}"
509 );
510 }
511
512 #[test]
513 fn estimated_pricing_is_flagged() {
514 let mut r = sample();
515 r.pricing_estimated = true;
516 assert!(
517 r.format_ascii().contains("(est.)"),
518 "estimated price must show (est.)"
519 );
520 assert!(r.format_compact().contains("(est.)"));
521 assert!(r.methodology_line().contains("fallback"));
522 }
523}