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    /// Estimated percentile rank among lean-ctx users (0-100). Based on tokens saved.
24    /// None if insufficient data or user hasn't opted into community metrics.
25    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        // G7: net out compressed->full bounce recorded in the persistent ledger for this
46        // period, so the headline is the *realized* saving, not a gross upper bound.
47        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        // Saved tokens would have been *input* tokens, so value them at the input rate.
61        // Still an upper bound on the bounce-adjusted figure: it ignores prompt-cache
62        // discounts. We never inflate beyond this.
63        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    /// One-line, conservative explanation of how the headline numbers were derived.
138    /// Reused by the ASCII footer, the compact summary, and the SVG share card so the
139    /// figure is always explainable and never over-claimed.
140    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    /// Renders a premium, shareable "Wrapped" card. Colors are emitted only when
161    /// stdout is a TTY (see `theme::no_color`), so piping to a file or social post
162    /// yields clean copy-pasteable ASCII.
163    #[allow(clippy::many_single_char_names)] // ANSI formatting helpers: t/r/b/d
164    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        // Primary metric row: tokens saved + cost avoided + commands.
201        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        // Secondary metric row: sessions + compression + energy saved (estimate, same
221        // methodology as the community /metrics page so local & shared figures reconcile).
222        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        // Trend sparkline (only when there is at least a little history).
241        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        // Top commands (truncated to fit the inner box width).
248        if !self.top_commands.is_empty() {
249            let prefix_visible = 8; // "   top  "
250            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
350/// Estimate the user's percentile rank among lean-ctx users based on tokens saved.
351/// Uses a rough distribution model derived from community metrics data.
352/// Returns None if insufficient data (< 1000 tokens saved).
353fn estimate_percentile(tokens_saved: u64) -> Option<u8> {
354    if tokens_saved < 1_000 {
355        return None;
356    }
357    // Rough percentile thresholds based on community data distribution
358    // (log-normal distribution of savings across users)
359    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        // In the test runner, stdout is not a TTY, so colors are auto-disabled.
418        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        // No box line may exceed the others (top row must be truncated to fit).
444        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}