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