Skip to main content

lean_ctx/core/
wrapped.rs

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    /// Tokens netted out of `tokens_saved` because a compressed read later bounced to a
16    /// full re-read (G7). Sourced from the persistent savings ledger for the period.
17    pub bounce_tokens: u64,
18    /// Resolved pricing model key used to value the saved tokens (e.g. "claude-3.5-sonnet").
19    pub model_key: String,
20    /// True when no model could be resolved and a blended fallback price was used.
21    /// Surfaced everywhere so an estimate is never presented as a precise figure.
22    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        // G7: net out compressed->full bounce recorded in the persistent ledger for this
43        // period, so the headline is the *realized* saving, not a gross upper bound.
44        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        // Saved tokens would have been *input* tokens, so value them at the input rate.
58        // Still an upper bound on the bounce-adjusted figure: it ignores prompt-cache
59        // discounts. We never inflate beyond this.
60        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    /// One-line, conservative explanation of how the headline numbers were derived.
134    /// Reused by the ASCII footer, the compact summary, and the SVG share card so the
135    /// figure is always explainable and never over-claimed.
136    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    /// Renders a premium, shareable "Wrapped" card. Colors are emitted only when
157    /// stdout is a TTY (see `theme::no_color`), so piping to a file or social post
158    /// yields clean copy-pasteable ASCII.
159    #[allow(clippy::many_single_char_names)] // ANSI formatting helpers: t/r/b/d
160    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        // Primary metric row: tokens saved + cost avoided + commands.
197        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        // Secondary metric row: sessions + compression + energy saved (estimate, same
217        // methodology as the community /metrics page so local & shared figures reconcile).
218        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        // Trend sparkline (only when there is at least a little history).
237        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        // Top commands (truncated to fit the inner box width).
244        if !self.top_commands.is_empty() {
245            let prefix_visible = 8; // "   top  "
246            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        // In the test runner, stdout is not a TTY, so colors are auto-disabled.
369        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        // No box line may exceed the others (top row must be truncated to fit).
395        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}