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