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}
16
17impl WrappedReport {
18    pub fn generate(period: &str) -> Self {
19        let store = stats::load();
20        let sessions = SessionState::list_sessions();
21
22        let (tokens_saved, tokens_input, total_commands) = match period {
23            "week" => aggregate_recent_stats(&store, 7),
24            "month" => aggregate_recent_stats(&store, 30),
25            _ => (
26                store
27                    .total_input_tokens
28                    .saturating_sub(store.total_output_tokens),
29                store.total_input_tokens,
30                store.total_commands,
31            ),
32        };
33
34        let env_model = std::env::var("LEAN_CTX_MODEL")
35            .or_else(|_| std::env::var("LCTX_MODEL"))
36            .ok();
37        let pricing = crate::core::gain::model_pricing::ModelPricing::load();
38        let quote = pricing.quote(env_model.as_deref());
39        let cost_avoided_usd = quote.cost.estimate_usd(tokens_saved, 0, 0, 0);
40
41        let sessions_count = match period {
42            "week" => count_recent_sessions(&sessions, 7),
43            "month" => count_recent_sessions(&sessions, 30),
44            _ => sessions.len(),
45        };
46
47        let mut top_commands: Vec<(String, u64, f64)> = store
48            .commands
49            .iter()
50            .map(|(cmd, stats)| {
51                let saved = stats.input_tokens.saturating_sub(stats.output_tokens);
52                let pct = if stats.input_tokens > 0 {
53                    saved as f64 / stats.input_tokens as f64 * 100.0
54                } else {
55                    0.0
56                };
57                (cmd.clone(), saved, pct)
58            })
59            .collect();
60        top_commands.sort_by_key(|x| std::cmp::Reverse(x.1));
61        top_commands.truncate(5);
62
63        let compression_rate_pct = if tokens_input > 0 {
64            tokens_saved as f64 / tokens_input as f64 * 100.0
65        } else {
66            0.0
67        };
68
69        let files_touched: u64 = sessions.iter().map(|s| s.tool_calls as u64).sum();
70
71        let day_saved = |d: &stats::DayStats| d.input_tokens.saturating_sub(d.output_tokens);
72        let take_recent = |n: usize| -> Vec<u64> {
73            store
74                .daily
75                .iter()
76                .rev()
77                .take(n)
78                .collect::<Vec<_>>()
79                .into_iter()
80                .rev()
81                .map(day_saved)
82                .collect()
83        };
84        let daily_savings = match period {
85            "week" => take_recent(7),
86            "month" => take_recent(30),
87            _ => store.daily.iter().map(day_saved).collect(),
88        };
89
90        WrappedReport {
91            period: period.to_string(),
92            tokens_saved,
93            tokens_input,
94            cost_avoided_usd,
95            total_commands,
96            sessions_count,
97            top_commands,
98            compression_rate_pct,
99            files_touched,
100            daily_savings,
101        }
102    }
103
104    /// Renders a premium, shareable "Wrapped" card. Colors are emitted only when
105    /// stdout is a TTY (see `theme::no_color`), so piping to a file or social post
106    /// yields clean copy-pasteable ASCII.
107    #[allow(clippy::many_single_char_names)] // ANSI formatting helpers: t/r/b/d
108    pub fn format_ascii(&self) -> String {
109        use crate::core::theme;
110
111        let cfg = crate::core::config::Config::load();
112        let t = theme::load_theme(&cfg.theme);
113        let rst = theme::rst();
114        let bold = theme::bold();
115        let dim = theme::dim();
116
117        let period_label = match self.period.as_str() {
118            "week" => format!("Week of {}", chrono::Utc::now().format("%b %d, %Y")),
119            "month" => format!("Month of {}", chrono::Utc::now().format("%B %Y")),
120            _ => "All Time".to_string(),
121        };
122
123        let w = 52;
124        let side = t.box_side();
125        let box_line = |content: &str| -> String {
126            let padded = theme::pad_right(content, w);
127            format!("  {side}{padded}{side}")
128        };
129
130        let mut out: Vec<String> = Vec::new();
131        out.push(String::new());
132        out.push(format!("  {}", t.box_top(w)));
133        out.push(box_line(""));
134        out.push(box_line(&format!(
135            "   {icon}  {brand} {accent}Wrapped{rst}  {dim}· {period_label}{rst}",
136            icon = t.header_icon(),
137            brand = t.brand_title(),
138            accent = t.accent.fg(),
139        )));
140        out.push(box_line(""));
141        out.push(format!("  {}", t.box_mid(w)));
142        out.push(box_line(""));
143
144        // Primary metric row: tokens saved + cost avoided + commands.
145        let kw = 16;
146        let sc = t.success.fg();
147        let c2 = t.secondary.fg();
148        let c3 = t.warning.fg();
149        let c4 = t.accent.fg();
150
151        let v1 = theme::pad_right(
152            &format!("{sc}{bold}{}{rst}", format_tokens(self.tokens_saved)),
153            kw,
154        );
155        let v2 = theme::pad_right(&format!("{c4}{bold}${:.2}{rst}", self.cost_avoided_usd), kw);
156        let v3 = theme::pad_right(&format!("{c3}{bold}{}{rst}", self.total_commands), kw);
157        out.push(box_line(&format!("   {v1}{v2}{v3}")));
158        let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
159        let l2 = theme::pad_right(&format!("{dim}cost avoided{rst}"), kw);
160        let l3 = theme::pad_right(&format!("{dim}commands{rst}"), kw);
161        out.push(box_line(&format!("   {l1}{l2}{l3}")));
162        out.push(box_line(""));
163
164        // Secondary metric row: sessions + compression rate.
165        let v4 = theme::pad_right(&format!("{c2}{bold}{}{rst}", self.sessions_count), kw);
166        let v5 = theme::pad_right(
167            &format!(
168                "{pc}{bold}{:.1}%{rst}",
169                self.compression_rate_pct,
170                pc = t.pct_color(self.compression_rate_pct),
171            ),
172            kw,
173        );
174        out.push(box_line(&format!("   {v4}{v5}")));
175        let l4 = theme::pad_right(&format!("{dim}sessions{rst}"), kw);
176        let l5 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
177        out.push(box_line(&format!("   {l4}{l5}")));
178        out.push(box_line(""));
179
180        // Trend sparkline (only when there is at least a little history).
181        if self.daily_savings.iter().filter(|v| **v > 0).count() >= 2 {
182            let spark = t.gradient_sparkline(&self.daily_savings);
183            out.push(box_line(&format!("   {dim}trend{rst}  {spark}")));
184            out.push(box_line(""));
185        }
186
187        // Top commands (truncated to fit the inner box width).
188        if !self.top_commands.is_empty() {
189            let prefix_visible = 8; // "   top  "
190            let budget = w.saturating_sub(prefix_visible);
191            let mut top_str = self
192                .top_commands
193                .iter()
194                .take(3)
195                .map(|(cmd, _, pct)| format!("{cmd} {pct:.0}%"))
196                .collect::<Vec<_>>()
197                .join("  ·  ");
198            if top_str.chars().count() > budget {
199                let truncated: String = top_str.chars().take(budget.saturating_sub(1)).collect();
200                top_str = format!("{truncated}…");
201            }
202            out.push(format!("  {}", t.box_mid(w)));
203            out.push(box_line(&format!(
204                "   {m}top{rst}  {top_str}",
205                m = t.muted.fg()
206            )));
207        }
208
209        out.push(format!("  {}", t.box_bottom(w)));
210        out.push(format!(
211            "    {dim}\"Your AI saw only what mattered.\"{rst}   {accent}leanctx.com{rst}",
212            accent = t.accent.fg(),
213        ));
214        out.push(String::new());
215
216        out.join("\n")
217    }
218
219    pub fn format_compact(&self) -> String {
220        let saved_str = format_tokens(self.tokens_saved);
221        let cost_str = format!("${:.2}", self.cost_avoided_usd);
222        let top_str = self
223            .top_commands
224            .iter()
225            .take(3)
226            .map(|(cmd, _, pct)| format!("{cmd} {pct:.0}%"))
227            .collect::<Vec<_>>()
228            .join(" | ");
229
230        format!(
231            "WRAPPED [{}]: {} tok saved, {} avoided, {} sessions, {} cmds | Top: {} | Compression: {:.1}%",
232            self.period, saved_str, cost_str, self.sessions_count,
233            self.total_commands, top_str, self.compression_rate_pct,
234        )
235    }
236}
237
238fn aggregate_recent_stats(store: &stats::StatsStore, days: usize) -> (u64, u64, u64) {
239    let recent_days: Vec<&stats::DayStats> = store.daily.iter().rev().take(days).collect();
240
241    let input: u64 = recent_days.iter().map(|d| d.input_tokens).sum();
242    let output: u64 = recent_days.iter().map(|d| d.output_tokens).sum();
243    let commands: u64 = recent_days.iter().map(|d| d.commands).sum();
244    let saved = input.saturating_sub(output);
245
246    (saved, input, commands)
247}
248
249fn count_recent_sessions(sessions: &[crate::core::session::SessionSummary], days: i64) -> usize {
250    let cutoff = chrono::Utc::now() - chrono::Duration::days(days);
251    sessions.iter().filter(|s| s.updated_at > cutoff).count()
252}
253
254fn format_tokens(tokens: u64) -> String {
255    if tokens >= 1_000_000 {
256        format!("{:.1}M", tokens as f64 / 1_000_000.0)
257    } else if tokens >= 1_000 {
258        format!("{:.1}K", tokens as f64 / 1_000.0)
259    } else {
260        format!("{tokens}")
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn sample() -> WrappedReport {
269        WrappedReport {
270            period: "all".into(),
271            tokens_saved: 348_300_000,
272            tokens_input: 580_000_000,
273            cost_avoided_usd: 870.81,
274            total_commands: 17_055,
275            sessions_count: 67,
276            top_commands: vec![
277                ("ctx_search".into(), 100, 60.0),
278                ("cli_grep".into(), 80, 85.0),
279                ("cli_shell".into(), 50, 37.0),
280            ],
281            compression_rate_pct: 60.2,
282            files_touched: 1_234,
283            daily_savings: vec![10, 50, 30, 30, 80, 80, 20, 5, 5, 40, 60, 40, 5, 50, 15],
284        }
285    }
286
287    fn is_box_line(l: &str) -> bool {
288        let trimmed = l.trim_start();
289        ["│", "╭", "├", "╰"].iter().any(|c| trimmed.starts_with(c))
290    }
291
292    #[test]
293    fn wrapped_ascii_box_lines_have_uniform_width() {
294        // In the test runner, stdout is not a TTY, so colors are auto-disabled.
295        let out = sample().format_ascii();
296        let widths: Vec<usize> = out
297            .lines()
298            .filter(|l| is_box_line(l))
299            .map(|l| l.chars().count())
300            .collect();
301        assert!(widths.len() >= 4, "expected several box lines:\n{out}");
302        let first = widths[0];
303        for w in &widths {
304            assert_eq!(*w, first, "box line widths must be uniform:\n{out}");
305        }
306    }
307
308    #[test]
309    fn wrapped_ascii_includes_brand_and_metrics() {
310        let out = sample().format_ascii();
311        assert!(out.contains("leanctx.com"), "missing brand footer:\n{out}");
312        assert!(out.contains("Wrapped"));
313        assert!(out.contains("tokens saved"));
314        assert!(out.contains("compression"));
315    }
316
317    #[test]
318    fn wrapped_ascii_truncates_overlong_top_line() {
319        let out = sample().format_ascii();
320        // No box line may exceed the others (top row must be truncated to fit).
321        let max = out
322            .lines()
323            .filter(|l| is_box_line(l))
324            .map(|l| l.chars().count())
325            .max()
326            .unwrap_or(0);
327        let min = out
328            .lines()
329            .filter(|l| is_box_line(l))
330            .map(|l| l.chars().count())
331            .min()
332            .unwrap_or(0);
333        assert_eq!(max, min, "top line overflowed the box:\n{out}");
334    }
335
336    #[test]
337    fn wrapped_compact_is_single_line_summary() {
338        let out = sample().format_compact();
339        assert!(out.starts_with("WRAPPED"), "compact summary changed: {out}");
340        assert!(out.contains("Compression:"));
341    }
342}