Skip to main content

lean_ctx/core/
wrapped.rs

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    /// Tokens netted out of `tokens_saved` because a compressed read later bounced to a
17    /// full re-read (G7). Sourced from the persistent savings ledger for the period.
18    pub bounce_tokens: u64,
19    /// Resolved pricing model key used to value the saved tokens (e.g. "claude-3.5-sonnet").
20    pub model_key: String,
21    /// True when no model could be resolved and a blended fallback price was used.
22    /// Surfaced everywhere so an estimate is never presented as a precise figure.
23    pub pricing_estimated: bool,
24    /// Estimated percentile rank among lean-ctx users (0-100). Based on tokens saved.
25    /// None if insufficient data or user hasn't opted into community metrics.
26    pub percentile: Option<u8>,
27    /// ✓ measured security events of the period, re-counted from the signed
28    /// audit trail. `None` when nothing happened or the chain fails
29    /// verification — an unproven count is never shown.
30    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        // G7: net out compressed->full bounce recorded in the persistent ledger for this
51        // period, so the headline is the *realized* saving, not a gross upper bound.
52        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        // Saved tokens would have been *input* tokens, so value them at the input rate.
70        // Still an upper bound on the bounce-adjusted figure: it ignores prompt-cache
71        // discounts. We never inflate beyond this.
72        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    /// The period's security events in the contract's wording, one phrase per
155    /// kind; empty when there is nothing verified to show.
156    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    /// One-line, conservative explanation of how the headline numbers were derived.
164    /// Reused by the ASCII footer, the compact summary, and the SVG share card so the
165    /// figure is always explainable and never over-claimed.
166    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    /// Renders a premium, shareable "Wrapped" card. Colors are emitted only when
187    /// stdout is a TTY (see `theme::no_color`), so piping to a file or social post
188    /// yields clean copy-pasteable ASCII.
189    #[allow(clippy::many_single_char_names)] // ANSI formatting helpers: t/r/b/d
190    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        // Primary metric row: tokens saved + cost avoided + commands.
227        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        // Secondary metric row: sessions + compression + energy saved (estimate, same
247        // methodology as the community /metrics page so local & shared figures reconcile).
248        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        // Trend sparkline (only when there is at least a little history).
267        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        // Security: only counts re-derived from the verified audit trail.
274        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        // Top commands (truncated to fit the inner box width).
286        if !self.top_commands.is_empty() {
287            let prefix_visible = 8; // "   top  "
288            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
398/// Estimate the user's percentile rank among lean-ctx users based on tokens saved.
399/// Uses a rough distribution model derived from community metrics data.
400/// Returns None if insufficient data (< 1000 tokens saved).
401fn estimate_percentile(tokens_saved: u64) -> Option<u8> {
402    if tokens_saved < 1_000 {
403        return None;
404    }
405    // Rough percentile thresholds based on community data distribution
406    // (log-normal distribution of savings across users)
407    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        // `format_ascii` calls `Config::load()` for the theme; serialize against
467        // tests that mutate the data-dir / env (they hold the same lock via
468        // `isolated_data_dir`) so a parallel run can't swap the config — and thus
469        // the theme's box glyphs / brand width — out from under us mid-format.
470        let _env = crate::core::data_dir::test_env_lock();
471        // In the test runner, stdout is not a TTY, so colors are auto-disabled.
472        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        // No box line may exceed the others (top row must be truncated to fit).
498        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}