Skip to main content

lean_ctx/core/
stats.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::Mutex;
5use std::time::Instant;
6
7#[derive(Serialize, Deserialize, Default, Clone)]
8pub struct StatsStore {
9    pub total_commands: u64,
10    pub total_input_tokens: u64,
11    pub total_output_tokens: u64,
12    pub first_use: Option<String>,
13    pub last_use: Option<String>,
14    pub commands: HashMap<String, CommandStats>,
15    pub daily: Vec<DayStats>,
16    #[serde(default)]
17    pub cep: CepStats,
18}
19
20#[derive(Serialize, Deserialize, Clone, Default)]
21pub struct CepStats {
22    pub sessions: u64,
23    pub total_cache_hits: u64,
24    pub total_cache_reads: u64,
25    pub total_tokens_original: u64,
26    pub total_tokens_compressed: u64,
27    pub modes: HashMap<String, u64>,
28    pub scores: Vec<CepSessionSnapshot>,
29    #[serde(default)]
30    pub last_session_pid: Option<u32>,
31    #[serde(default)]
32    pub last_session_original: Option<u64>,
33    #[serde(default)]
34    pub last_session_compressed: Option<u64>,
35}
36
37#[derive(Serialize, Deserialize, Clone)]
38pub struct CepSessionSnapshot {
39    pub timestamp: String,
40    pub score: u32,
41    pub cache_hit_rate: u32,
42    pub mode_diversity: u32,
43    pub compression_rate: u32,
44    pub tool_calls: u64,
45    pub tokens_saved: u64,
46    pub complexity: String,
47}
48
49#[derive(Serialize, Deserialize, Clone, Default, Debug)]
50pub struct CommandStats {
51    pub count: u64,
52    pub input_tokens: u64,
53    pub output_tokens: u64,
54}
55
56#[derive(Serialize, Deserialize, Clone)]
57pub struct DayStats {
58    pub date: String,
59    pub commands: u64,
60    pub input_tokens: u64,
61    pub output_tokens: u64,
62}
63
64fn stats_dir() -> Option<PathBuf> {
65    crate::core::data_dir::lean_ctx_data_dir().ok()
66}
67
68fn stats_path() -> Option<PathBuf> {
69    stats_dir().map(|d| d.join("stats.json"))
70}
71
72fn load_from_disk() -> StatsStore {
73    let path = match stats_path() {
74        Some(p) => p,
75        None => return StatsStore::default(),
76    };
77
78    match std::fs::read_to_string(&path) {
79        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
80        Err(_) => StatsStore::default(),
81    }
82}
83
84fn save_to_disk(store: &StatsStore) {
85    let dir = match stats_dir() {
86        Some(d) => d,
87        None => return,
88    };
89
90    if !dir.exists() {
91        let _ = std::fs::create_dir_all(&dir);
92    }
93
94    let path = dir.join("stats.json");
95    if let Ok(json) = serde_json::to_string(store) {
96        let tmp = dir.join(".stats.json.tmp");
97        if std::fs::write(&tmp, &json).is_ok() {
98            let _ = std::fs::rename(&tmp, &path);
99        }
100    }
101}
102
103pub fn load() -> StatsStore {
104    let guard = STATS_BUFFER.lock().unwrap_or_else(|e| e.into_inner());
105    if let Some((ref store, _)) = *guard {
106        return store.clone();
107    }
108    drop(guard);
109    load_from_disk()
110}
111
112pub fn save(store: &StatsStore) {
113    save_to_disk(store);
114}
115
116const FLUSH_INTERVAL_SECS: u64 = 30;
117
118static STATS_BUFFER: Mutex<Option<(StatsStore, Instant)>> = Mutex::new(None);
119
120fn with_buffer<F, R>(f: F) -> R
121where
122    F: FnOnce(&mut StatsStore, &mut Instant) -> R,
123{
124    let mut guard = STATS_BUFFER.lock().unwrap_or_else(|e| e.into_inner());
125    let (store, last_flush) = guard.get_or_insert_with(|| (load_from_disk(), Instant::now()));
126    f(store, last_flush)
127}
128
129fn maybe_flush(store: &StatsStore, last_flush: &mut Instant) {
130    if last_flush.elapsed().as_secs() >= FLUSH_INTERVAL_SECS {
131        save_to_disk(store);
132        *last_flush = Instant::now();
133    }
134}
135
136pub fn flush() {
137    let mut guard = STATS_BUFFER.lock().unwrap_or_else(|e| e.into_inner());
138    if let Some((ref store, ref mut last_flush)) = *guard {
139        save_to_disk(store);
140        *last_flush = Instant::now();
141    }
142}
143
144pub fn record(command: &str, input_tokens: usize, output_tokens: usize) {
145    with_buffer(|store, last_flush| {
146        let is_first_command = store.total_commands == 0;
147        let now = chrono::Local::now();
148        let today = now.format("%Y-%m-%d").to_string();
149        let timestamp = now.to_rfc3339();
150
151        store.total_commands += 1;
152        store.total_input_tokens += input_tokens as u64;
153        store.total_output_tokens += output_tokens as u64;
154
155        if store.first_use.is_none() {
156            store.first_use = Some(timestamp.clone());
157        }
158        store.last_use = Some(timestamp);
159
160        let cmd_key = normalize_command(command);
161        let entry = store.commands.entry(cmd_key).or_default();
162        entry.count += 1;
163        entry.input_tokens += input_tokens as u64;
164        entry.output_tokens += output_tokens as u64;
165
166        if let Some(day) = store.daily.last_mut() {
167            if day.date == today {
168                day.commands += 1;
169                day.input_tokens += input_tokens as u64;
170                day.output_tokens += output_tokens as u64;
171            } else {
172                store.daily.push(DayStats {
173                    date: today,
174                    commands: 1,
175                    input_tokens: input_tokens as u64,
176                    output_tokens: output_tokens as u64,
177                });
178            }
179        } else {
180            store.daily.push(DayStats {
181                date: today,
182                commands: 1,
183                input_tokens: input_tokens as u64,
184                output_tokens: output_tokens as u64,
185            });
186        }
187
188        if store.daily.len() > 90 {
189            store.daily.drain(..store.daily.len() - 90);
190        }
191
192        // First observable interaction should appear on disk immediately.
193        // This avoids confusing "No commands recorded yet" right after the first MCP call.
194        if is_first_command {
195            save_to_disk(store);
196            *last_flush = Instant::now();
197        } else {
198            maybe_flush(store, last_flush);
199        }
200    });
201}
202
203fn normalize_command(command: &str) -> String {
204    let parts: Vec<&str> = command.split_whitespace().collect();
205    if parts.is_empty() {
206        return command.to_string();
207    }
208
209    let base = std::path::Path::new(parts[0])
210        .file_name()
211        .and_then(|n| n.to_str())
212        .unwrap_or(parts[0]);
213
214    match base {
215        "git" => {
216            if parts.len() > 1 {
217                format!("git {}", parts[1])
218            } else {
219                "git".to_string()
220            }
221        }
222        "cargo" => {
223            if parts.len() > 1 {
224                format!("cargo {}", parts[1])
225            } else {
226                "cargo".to_string()
227            }
228        }
229        "npm" | "yarn" | "pnpm" => {
230            if parts.len() > 1 {
231                format!("{} {}", base, parts[1])
232            } else {
233                base.to_string()
234            }
235        }
236        "docker" => {
237            if parts.len() > 1 {
238                format!("docker {}", parts[1])
239            } else {
240                "docker".to_string()
241            }
242        }
243        _ => base.to_string(),
244    }
245}
246
247pub fn reset_cep() {
248    with_buffer(|store, last_flush| {
249        store.cep = CepStats::default();
250        save_to_disk(store);
251        *last_flush = Instant::now();
252    });
253}
254
255pub fn reset_all() {
256    with_buffer(|store, last_flush| {
257        *store = StatsStore::default();
258        save_to_disk(store);
259        *last_flush = Instant::now();
260    });
261}
262
263pub struct GainSummary {
264    pub total_saved: u64,
265    pub total_calls: u64,
266}
267
268pub fn load_stats() -> GainSummary {
269    let store = load();
270    let input_saved = store
271        .total_input_tokens
272        .saturating_sub(store.total_output_tokens);
273    GainSummary {
274        total_saved: input_saved,
275        total_calls: store.total_commands,
276    }
277}
278
279fn cmd_total_saved(s: &CommandStats, _cm: &CostModel) -> u64 {
280    s.input_tokens.saturating_sub(s.output_tokens)
281}
282
283fn day_total_saved(d: &DayStats, _cm: &CostModel) -> u64 {
284    d.input_tokens.saturating_sub(d.output_tokens)
285}
286
287#[allow(clippy::too_many_arguments)]
288pub fn record_cep_session(
289    score: u32,
290    cache_hits: u64,
291    cache_reads: u64,
292    tokens_original: u64,
293    tokens_compressed: u64,
294    modes: &HashMap<String, u64>,
295    tool_calls: u64,
296    complexity: &str,
297) {
298    with_buffer(|store, last_flush| {
299        let cep = &mut store.cep;
300
301        let pid = std::process::id();
302        let prev_original = cep.last_session_original.unwrap_or(0);
303        let prev_compressed = cep.last_session_compressed.unwrap_or(0);
304        let is_same_session = cep.last_session_pid == Some(pid);
305
306        if is_same_session {
307            let delta_original = tokens_original.saturating_sub(prev_original);
308            let delta_compressed = tokens_compressed.saturating_sub(prev_compressed);
309            cep.total_tokens_original += delta_original;
310            cep.total_tokens_compressed += delta_compressed;
311        } else {
312            cep.sessions += 1;
313            cep.total_cache_hits += cache_hits;
314            cep.total_cache_reads += cache_reads;
315            cep.total_tokens_original += tokens_original;
316            cep.total_tokens_compressed += tokens_compressed;
317
318            for (mode, count) in modes {
319                *cep.modes.entry(mode.clone()).or_insert(0) += count;
320            }
321        }
322
323        cep.last_session_pid = Some(pid);
324        cep.last_session_original = Some(tokens_original);
325        cep.last_session_compressed = Some(tokens_compressed);
326
327        let cache_hit_rate = if cache_reads > 0 {
328            (cache_hits as f64 / cache_reads as f64 * 100.0).round() as u32
329        } else {
330            0
331        };
332
333        let compression_rate = if tokens_original > 0 {
334            ((tokens_original - tokens_compressed) as f64 / tokens_original as f64 * 100.0).round()
335                as u32
336        } else {
337            0
338        };
339
340        let total_modes = 6u32;
341        let mode_diversity =
342            ((modes.len() as f64 / total_modes as f64).min(1.0) * 100.0).round() as u32;
343
344        let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
345
346        cep.scores.push(CepSessionSnapshot {
347            timestamp: chrono::Local::now().to_rfc3339(),
348            score,
349            cache_hit_rate,
350            mode_diversity,
351            compression_rate,
352            tool_calls,
353            tokens_saved,
354            complexity: complexity.to_string(),
355        });
356
357        if cep.scores.len() > 100 {
358            cep.scores.drain(..cep.scores.len() - 100);
359        }
360
361        maybe_flush(store, last_flush);
362    });
363}
364
365use super::theme::{self, Theme};
366
367fn active_theme() -> Theme {
368    let cfg = super::config::Config::load();
369    theme::load_theme(&cfg.theme)
370}
371
372/// Average LLM pricing per 1M tokens (blended across Claude, GPT, Gemini).
373pub const DEFAULT_INPUT_PRICE_PER_M: f64 = 2.50;
374pub const DEFAULT_OUTPUT_PRICE_PER_M: f64 = 10.0;
375
376pub struct CostModel {
377    pub input_price_per_m: f64,
378    pub output_price_per_m: f64,
379    pub avg_verbose_output_per_call: u64,
380    pub avg_concise_output_per_call: u64,
381}
382
383impl Default for CostModel {
384    fn default() -> Self {
385        let env_model = std::env::var("LEAN_CTX_MODEL")
386            .or_else(|_| std::env::var("LCTX_MODEL"))
387            .ok();
388        let pricing = crate::core::gain::model_pricing::ModelPricing::load();
389        let quote = pricing.quote(env_model.as_deref());
390        Self {
391            input_price_per_m: quote.cost.input_per_m,
392            output_price_per_m: quote.cost.output_per_m,
393            avg_verbose_output_per_call: 180,
394            avg_concise_output_per_call: 120,
395        }
396    }
397}
398
399pub struct CostBreakdown {
400    pub input_cost_without: f64,
401    pub input_cost_with: f64,
402    pub output_cost_without: f64,
403    pub output_cost_with: f64,
404    pub total_cost_without: f64,
405    pub total_cost_with: f64,
406    pub total_saved: f64,
407    pub estimated_output_tokens_without: u64,
408    pub estimated_output_tokens_with: u64,
409    pub output_tokens_saved: u64,
410}
411
412impl CostModel {
413    pub fn calculate(&self, store: &StatsStore) -> CostBreakdown {
414        let input_cost_without =
415            store.total_input_tokens as f64 / 1_000_000.0 * self.input_price_per_m;
416        let input_cost_with =
417            store.total_output_tokens as f64 / 1_000_000.0 * self.input_price_per_m;
418
419        let input_saved = store
420            .total_input_tokens
421            .saturating_sub(store.total_output_tokens);
422        let compression_rate = if store.total_input_tokens > 0 {
423            input_saved as f64 / store.total_input_tokens as f64
424        } else {
425            0.0
426        };
427        let est_output_without = store.total_commands * self.avg_verbose_output_per_call;
428        let est_output_with = if compression_rate > 0.01 {
429            store.total_commands * self.avg_concise_output_per_call
430        } else {
431            est_output_without
432        };
433        let output_saved = est_output_without.saturating_sub(est_output_with);
434
435        let output_cost_without = est_output_without as f64 / 1_000_000.0 * self.output_price_per_m;
436        let output_cost_with = est_output_with as f64 / 1_000_000.0 * self.output_price_per_m;
437
438        let total_without = input_cost_without + output_cost_without;
439        let total_with = input_cost_with + output_cost_with;
440
441        CostBreakdown {
442            input_cost_without,
443            input_cost_with,
444            output_cost_without,
445            output_cost_with,
446            total_cost_without: total_without,
447            total_cost_with: total_with,
448            total_saved: total_without - total_with,
449            estimated_output_tokens_without: est_output_without,
450            estimated_output_tokens_with: est_output_with,
451            output_tokens_saved: output_saved,
452        }
453    }
454}
455
456fn format_usd(amount: f64) -> String {
457    if amount >= 0.01 {
458        format!("${amount:.2}")
459    } else {
460        format!("${amount:.3}")
461    }
462}
463
464fn usd_estimate(tokens: u64) -> String {
465    let env_model = std::env::var("LEAN_CTX_MODEL")
466        .or_else(|_| std::env::var("LCTX_MODEL"))
467        .ok();
468    let pricing = crate::core::gain::model_pricing::ModelPricing::load();
469    let quote = pricing.quote(env_model.as_deref());
470    let cost = tokens as f64 * quote.cost.input_per_m / 1_000_000.0;
471    format_usd(cost)
472}
473
474fn format_big(n: u64) -> String {
475    if n >= 1_000_000 {
476        format!("{:.1}M", n as f64 / 1_000_000.0)
477    } else if n >= 1_000 {
478        format!("{:.1}K", n as f64 / 1_000.0)
479    } else {
480        format!("{n}")
481    }
482}
483
484fn format_num(n: u64) -> String {
485    if n >= 1_000_000 {
486        format!("{:.1}M", n as f64 / 1_000_000.0)
487    } else if n >= 1_000 {
488        format!("{},{:03}", n / 1_000, n % 1_000)
489    } else {
490        format!("{n}")
491    }
492}
493
494fn truncate_cmd(cmd: &str, max: usize) -> String {
495    if cmd.len() <= max {
496        cmd.to_string()
497    } else {
498        format!("{}…", &cmd[..max - 1])
499    }
500}
501
502fn format_cep_live(lv: &serde_json::Value, t: &Theme) -> String {
503    let mut o = Vec::new();
504    let r = theme::rst();
505    let b = theme::bold();
506    let d = theme::dim();
507
508    let score = lv["cep_score"].as_u64().unwrap_or(0) as u32;
509    let cache_util = lv["cache_utilization"].as_u64().unwrap_or(0);
510    let mode_div = lv["mode_diversity"].as_u64().unwrap_or(0);
511    let comp_rate = lv["compression_rate"].as_u64().unwrap_or(0);
512    let tok_saved = lv["tokens_saved"].as_u64().unwrap_or(0);
513    let tok_orig = lv["tokens_original"].as_u64().unwrap_or(0);
514    let tool_calls = lv["tool_calls"].as_u64().unwrap_or(0);
515    let cache_hits = lv["cache_hits"].as_u64().unwrap_or(0);
516    let total_reads = lv["total_reads"].as_u64().unwrap_or(0);
517    let complexity = lv["task_complexity"].as_str().unwrap_or("Standard");
518
519    o.push(String::new());
520    o.push(format!(
521        "  {icon} {brand} {cep}  {d}Live Session (no historical data yet){r}",
522        icon = t.header_icon(),
523        brand = t.brand_title(),
524        cep = t.section_title("CEP"),
525    ));
526    o.push(format!("  {ln}", ln = t.border_line(56)));
527    o.push(String::new());
528
529    let txt = t.text.fg();
530    let sc = t.success.fg();
531    let sec = t.secondary.fg();
532
533    o.push(format!(
534        "  {b}{txt}CEP Score{r}         {b}{pc}{score:>3}/100{r}",
535        pc = t.pct_color(score as f64),
536    ));
537    o.push(format!(
538        "  {b}{txt}Cache Hit Rate{r}    {b}{pc}{cache_util}%{r}  {d}({cache_hits} hits / {total_reads} reads){r}",
539        pc = t.pct_color(cache_util as f64),
540    ));
541    o.push(format!(
542        "  {b}{txt}Mode Diversity{r}    {b}{pc}{mode_div}%{r}",
543        pc = t.pct_color(mode_div as f64),
544    ));
545    o.push(format!(
546        "  {b}{txt}Compression{r}       {b}{pc}{comp_rate}%{r}  {d}({} → {}){r}",
547        format_big(tok_orig),
548        format_big(tok_orig.saturating_sub(tok_saved)),
549        pc = t.pct_color(comp_rate as f64),
550    ));
551    o.push(format!(
552        "  {b}{txt}Tokens Saved{r}      {b}{sc}{}{r}  {d}(≈ {}){r}",
553        format_big(tok_saved),
554        usd_estimate(tok_saved),
555    ));
556    o.push(format!(
557        "  {b}{txt}Tool Calls{r}        {b}{sec}{tool_calls}{r}"
558    ));
559    o.push(format!("  {b}{txt}Complexity{r}        {d}{complexity}{r}"));
560    o.push(String::new());
561    o.push(format!("  {ln}", ln = t.border_line(56)));
562    o.push(format!(
563        "  {d}This is live data from the current MCP session.{r}"
564    ));
565    o.push(format!(
566        "  {d}Historical CEP trends appear after more sessions.{r}"
567    ));
568    o.push(String::new());
569
570    o.join("\n")
571}
572
573fn load_mcp_live() -> Option<serde_json::Value> {
574    let path = dirs::home_dir()?.join(".lean-ctx/mcp-live.json");
575    let content = std::fs::read_to_string(path).ok()?;
576    serde_json::from_str(&content).ok()
577}
578
579pub fn format_cep_report() -> String {
580    let t = active_theme();
581    let store = load();
582    let cep = &store.cep;
583    let live = load_mcp_live();
584    let mut o = Vec::new();
585    let r = theme::rst();
586    let b = theme::bold();
587    let d = theme::dim();
588
589    if cep.sessions == 0 && live.is_none() {
590        return format!(
591            "{d}No CEP sessions recorded yet.{r}\n\
592             Use lean-ctx as an MCP server in your editor to start tracking.\n\
593             CEP metrics are recorded automatically during MCP sessions."
594        );
595    }
596
597    if cep.sessions == 0 {
598        if let Some(ref lv) = live {
599            return format_cep_live(lv, &t);
600        }
601    }
602
603    let total_saved = cep
604        .total_tokens_original
605        .saturating_sub(cep.total_tokens_compressed);
606    let overall_compression = if cep.total_tokens_original > 0 {
607        total_saved as f64 / cep.total_tokens_original as f64 * 100.0
608    } else {
609        0.0
610    };
611    let cache_hit_rate = if cep.total_cache_reads > 0 {
612        cep.total_cache_hits as f64 / cep.total_cache_reads as f64 * 100.0
613    } else {
614        0.0
615    };
616    let avg_score = if !cep.scores.is_empty() {
617        cep.scores.iter().map(|s| s.score as f64).sum::<f64>() / cep.scores.len() as f64
618    } else {
619        0.0
620    };
621    let latest_score = cep.scores.last().map(|s| s.score).unwrap_or(0);
622
623    let shell_saved = store
624        .total_input_tokens
625        .saturating_sub(store.total_output_tokens)
626        .saturating_sub(total_saved);
627    let total_all_saved = store
628        .total_input_tokens
629        .saturating_sub(store.total_output_tokens);
630    let cep_share = if total_all_saved > 0 {
631        total_saved as f64 / total_all_saved as f64 * 100.0
632    } else {
633        0.0
634    };
635
636    let txt = t.text.fg();
637    let sc = t.success.fg();
638    let sec = t.secondary.fg();
639    let wrn = t.warning.fg();
640
641    o.push(String::new());
642    o.push(format!(
643        "  {icon} {brand} {cep}  {d}Cognitive Efficiency Protocol Report{r}",
644        icon = t.header_icon(),
645        brand = t.brand_title(),
646        cep = t.section_title("CEP"),
647    ));
648    o.push(format!("  {ln}", ln = t.border_line(56)));
649    o.push(String::new());
650
651    o.push(format!(
652        "  {b}{txt}CEP Score{r}         {b}{pc}{:>3}/100{r}  {d}(avg: {avg_score:.0}, latest: {latest_score}){r}",
653        latest_score,
654        pc = t.pct_color(latest_score as f64),
655    ));
656    o.push(format!(
657        "  {b}{txt}Sessions{r}          {b}{sec}{}{r}",
658        cep.sessions
659    ));
660    o.push(format!(
661        "  {b}{txt}Cache Hit Rate{r}    {b}{pc}{:.1}%{r}  {d}({} hits / {} reads){r}",
662        cache_hit_rate,
663        cep.total_cache_hits,
664        cep.total_cache_reads,
665        pc = t.pct_color(cache_hit_rate),
666    ));
667    o.push(format!(
668        "  {b}{txt}MCP Compression{r}   {b}{pc}{:.1}%{r}  {d}({} → {}){r}",
669        overall_compression,
670        format_big(cep.total_tokens_original),
671        format_big(cep.total_tokens_compressed),
672        pc = t.pct_color(overall_compression),
673    ));
674    o.push(format!(
675        "  {b}{txt}Tokens Saved{r}      {b}{sc}{}{r}  {d}(≈ {}){r}",
676        format_big(total_saved),
677        usd_estimate(total_saved),
678    ));
679    o.push(String::new());
680
681    o.push(format!("  {}", t.section_title("Savings Breakdown")));
682    o.push(format!("  {ln}", ln = t.border_line(56)));
683
684    let bar_w = 30;
685    let shell_ratio = if total_all_saved > 0 {
686        shell_saved as f64 / total_all_saved as f64
687    } else {
688        0.0
689    };
690    let cep_ratio = if total_all_saved > 0 {
691        total_saved as f64 / total_all_saved as f64
692    } else {
693        0.0
694    };
695    let m = t.muted.fg();
696    let shell_bar = theme::pad_right(&t.gradient_bar(shell_ratio, bar_w), bar_w);
697    o.push(format!(
698        "  {m}Shell Hook{r}   {shell_bar} {b}{:>6}{r} {d}({:.0}%){r}",
699        format_big(shell_saved),
700        (1.0 - cep_share) * 100.0 / 100.0 * 100.0,
701    ));
702    let cep_bar = theme::pad_right(&t.gradient_bar(cep_ratio, bar_w), bar_w);
703    o.push(format!(
704        "  {m}MCP/CEP{r}      {cep_bar} {b}{:>6}{r} {d}({cep_share:.0}%){r}",
705        format_big(total_saved),
706    ));
707    o.push(String::new());
708
709    if total_saved == 0 && cep.modes.is_empty() {
710        if store.total_commands > 20 {
711            o.push(format!(
712                "  {wrn}⚠  MCP tools configured but not being used by your AI client.{r}"
713            ));
714            o.push(
715                "     Your AI client may be using native Read/Shell instead of ctx_read/ctx_shell."
716                    .to_string(),
717            );
718            o.push(format!(
719                "     Run {sec}lean-ctx init{r} to update rules, then restart your AI session."
720            ));
721            o.push(format!(
722                "     Run {sec}lean-ctx doctor{r} for detailed adoption diagnostics."
723            ));
724        } else {
725            o.push(format!(
726                "  {wrn}⚠  MCP server not configured.{r} Shell hook compresses output, but"
727            ));
728            o.push(
729                "     full token savings require MCP tools (ctx_read, ctx_shell, ctx_search)."
730                    .to_string(),
731            );
732            o.push(format!(
733                "     Run {sec}lean-ctx setup{r} to auto-configure your editors."
734            ));
735        }
736        o.push(String::new());
737    }
738
739    if !cep.modes.is_empty() {
740        o.push(format!("  {}", t.section_title("Read Modes Used")));
741        o.push(format!("  {ln}", ln = t.border_line(56)));
742
743        let mut sorted_modes: Vec<_> = cep.modes.iter().collect();
744        sorted_modes.sort_by(|a, b2| b2.1.cmp(a.1));
745        let max_mode = *sorted_modes.first().map(|(_, c)| *c).unwrap_or(&1);
746        let max_mode = max_mode.max(1);
747
748        for (mode, count) in &sorted_modes {
749            let ratio = **count as f64 / max_mode as f64;
750            let bar = theme::pad_right(&t.gradient_bar(ratio, 20), 20);
751            o.push(format!("  {sec}{:<14}{r} {:>4}x  {bar}", mode, count,));
752        }
753
754        let total_mode_calls: u64 = sorted_modes.iter().map(|(_, c)| **c).sum();
755        let full_count = cep.modes.get("full").copied().unwrap_or(0);
756        let optimized = total_mode_calls.saturating_sub(full_count);
757        let opt_pct = if total_mode_calls > 0 {
758            optimized as f64 / total_mode_calls as f64 * 100.0
759        } else {
760            0.0
761        };
762        o.push(format!(
763            "  {d}{optimized}/{total_mode_calls} reads used optimized modes ({opt_pct:.0}% non-full){r}"
764        ));
765    }
766
767    if cep.scores.len() >= 2 {
768        o.push(String::new());
769        o.push(format!("  {}", t.section_title("CEP Score Trend")));
770        o.push(format!("  {ln}", ln = t.border_line(56)));
771
772        let score_values: Vec<u64> = cep.scores.iter().map(|s| s.score as u64).collect();
773        let spark = t.gradient_sparkline(&score_values);
774        o.push(format!("  {spark}"));
775
776        let recent: Vec<_> = cep.scores.iter().rev().take(5).collect();
777        for snap in recent.iter().rev() {
778            let ts = snap.timestamp.get(..16).unwrap_or(&snap.timestamp);
779            let pc = t.pct_color(snap.score as f64);
780            o.push(format!(
781                "  {m}{ts}{r}  {pc}{b}{:>3}{r}/100  cache:{:>3}%  modes:{:>3}%  {d}{}{r}",
782                snap.score, snap.cache_hit_rate, snap.mode_diversity, snap.complexity,
783            ));
784        }
785    }
786
787    o.push(String::new());
788    o.push(format!("  {ln}", ln = t.border_line(56)));
789    o.push(format!("  {d}Improve your CEP score:{r}"));
790    if cache_hit_rate < 50.0 {
791        o.push(format!(
792            "    {wrn}↑{r} Re-read files with ctx_read to leverage caching"
793        ));
794    }
795    let modes_count = cep.modes.len();
796    if modes_count < 3 {
797        o.push(format!(
798            "    {wrn}↑{r} Use map/signatures modes for context-only files"
799        ));
800    }
801    if avg_score >= 70.0 {
802        o.push(format!(
803            "    {sc}✓{r} Great score! You're using lean-ctx effectively"
804        ));
805    }
806    o.push(String::new());
807
808    o.join("\n")
809}
810
811pub fn format_gain() -> String {
812    format_gain_themed(&active_theme())
813}
814
815pub fn format_gain_themed(t: &Theme) -> String {
816    format_gain_themed_at(t, None)
817}
818
819pub fn format_gain_themed_at(t: &Theme, tick: Option<u64>) -> String {
820    let store = load();
821    let mut o = Vec::new();
822    let r = theme::rst();
823    let b = theme::bold();
824    let d = theme::dim();
825
826    if store.total_commands == 0 {
827        return format!(
828            "{d}No commands recorded yet.{r} Use {cmd}lean-ctx -c \"command\"{r} to start tracking.",
829            cmd = t.secondary.fg(),
830        );
831    }
832
833    let input_saved = store
834        .total_input_tokens
835        .saturating_sub(store.total_output_tokens);
836    let pct = if store.total_input_tokens > 0 {
837        input_saved as f64 / store.total_input_tokens as f64 * 100.0
838    } else {
839        0.0
840    };
841    let cost_model = CostModel::default();
842    let cost = cost_model.calculate(&store);
843    let total_saved = input_saved;
844    let days_active = store.daily.len();
845
846    let w = 62;
847    let side = t.box_side();
848
849    let box_line = |content: &str| -> String {
850        let padded = theme::pad_right(content, w);
851        format!("  {side}{padded}{side}")
852    };
853
854    o.push(String::new());
855    o.push(format!("  {}", t.box_top(w)));
856    o.push(box_line(""));
857
858    let header = format!(
859        "    {icon}  {b}{title}{r}   {d}Token Savings Dashboard{r}",
860        icon = t.header_icon(),
861        title = t.brand_title(),
862    );
863    o.push(box_line(&header));
864    o.push(box_line(""));
865    o.push(format!("  {}", t.box_mid(w)));
866    o.push(box_line(""));
867
868    let tok_val = format_big(total_saved);
869    let pct_val = format!("{pct:.1}%");
870    let cmd_val = format_num(store.total_commands);
871    let usd_val = format_usd(cost.total_saved);
872
873    let c1 = t.success.fg();
874    let c2 = t.secondary.fg();
875    let c3 = t.warning.fg();
876    let c4 = t.accent.fg();
877
878    let kw = 14;
879    let v1 = theme::pad_right(&format!("{c1}{b}{tok_val}{r}"), kw);
880    let v2 = theme::pad_right(&format!("{c2}{b}{pct_val}{r}"), kw);
881    let v3 = theme::pad_right(&format!("{c3}{b}{cmd_val}{r}"), kw);
882    let v4 = theme::pad_right(&format!("{c4}{b}{usd_val}{r}"), kw);
883    o.push(box_line(&format!("    {v1}{v2}{v3}{v4}")));
884
885    let l1 = theme::pad_right(&format!("{d}tokens saved{r}"), kw);
886    let l2 = theme::pad_right(&format!("{d}compression{r}"), kw);
887    let l3 = theme::pad_right(&format!("{d}commands{r}"), kw);
888    let l4 = theme::pad_right(&format!("{d}USD saved{r}"), kw);
889    o.push(box_line(&format!("    {l1}{l2}{l3}{l4}")));
890    o.push(box_line(""));
891    o.push(format!("  {}", t.box_bottom(w)));
892
893    // Token Guardian Buddy
894    {
895        let cfg = crate::core::config::Config::load();
896        if cfg.buddy_enabled {
897            let buddy = crate::core::buddy::BuddyState::compute();
898            o.push(crate::core::buddy::format_buddy_block_at(&buddy, t, tick));
899        }
900    }
901
902    o.push(String::new());
903
904    let cost_title = t.section_title("Cost Breakdown");
905    o.push(format!(
906        "  {cost_title}  {d}@ ${}/M input · ${}/M output{r}",
907        DEFAULT_INPUT_PRICE_PER_M, DEFAULT_OUTPUT_PRICE_PER_M,
908    ));
909    o.push(format!("  {ln}", ln = t.border_line(w)));
910    o.push(String::new());
911    let lbl_w = 20;
912    let lbl_without = theme::pad_right(&format!("{m}Without lean-ctx{r}", m = t.muted.fg()), lbl_w);
913    let lbl_with = theme::pad_right(&format!("{m}With lean-ctx{r}", m = t.muted.fg()), lbl_w);
914    let lbl_saved = theme::pad_right(&format!("{c}{b}You saved{r}", c = t.success.fg()), lbl_w);
915
916    o.push(format!(
917        "    {lbl_without} {:>8}   {d}{} input + {} output{r}",
918        format_usd(cost.total_cost_without),
919        format_usd(cost.input_cost_without),
920        format_usd(cost.output_cost_without),
921    ));
922    o.push(format!(
923        "    {lbl_with} {:>8}   {d}{} input + {} output{r}",
924        format_usd(cost.total_cost_with),
925        format_usd(cost.input_cost_with),
926        format_usd(cost.output_cost_with),
927    ));
928    o.push(String::new());
929    o.push(format!(
930        "    {lbl_saved} {c}{b}{:>8}{r}   {d}input {} + output {}{r}",
931        format_usd(cost.total_saved),
932        format_usd(cost.input_cost_without - cost.input_cost_with),
933        format_usd(cost.output_cost_without - cost.output_cost_with),
934        c = t.success.fg(),
935    ));
936
937    o.push(String::new());
938
939    if let (Some(first), Some(_last)) = (&store.first_use, &store.last_use) {
940        let first_short = first.get(..10).unwrap_or(first);
941        let daily_savings: Vec<u64> = store
942            .daily
943            .iter()
944            .map(|d2| day_total_saved(d2, &cost_model))
945            .collect();
946        let spark = t.gradient_sparkline(&daily_savings);
947        o.push(format!(
948            "    {d}Since {first_short} · {days_active} day{plural}{r}   {spark}",
949            plural = if days_active != 1 { "s" } else { "" }
950        ));
951        o.push(String::new());
952    }
953
954    o.push(String::new());
955
956    if !store.commands.is_empty() {
957        o.push(format!("  {}", t.section_title("Top Commands")));
958        o.push(format!("  {ln}", ln = t.border_line(w)));
959        o.push(String::new());
960
961        let mut sorted: Vec<_> = store.commands.iter().collect();
962        sorted.sort_by(|a, b2| {
963            let sa = cmd_total_saved(a.1, &cost_model);
964            let sb = cmd_total_saved(b2.1, &cost_model);
965            sb.cmp(&sa)
966        });
967
968        let max_cmd_saved = sorted
969            .first()
970            .map(|(_, s)| cmd_total_saved(s, &cost_model))
971            .unwrap_or(1)
972            .max(1);
973
974        for (cmd, stats) in sorted.iter().take(10) {
975            let cmd_saved = cmd_total_saved(stats, &cost_model);
976            let cmd_input_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
977            let cmd_pct = if stats.input_tokens > 0 {
978                cmd_input_saved as f64 / stats.input_tokens as f64 * 100.0
979            } else {
980                0.0
981            };
982            let ratio = cmd_saved as f64 / max_cmd_saved as f64;
983            let bar = theme::pad_right(&t.gradient_bar(ratio, 22), 22);
984            let pc = t.pct_color(cmd_pct);
985            let cmd_col = theme::pad_right(
986                &format!("{m}{}{r}", truncate_cmd(cmd, 16), m = t.muted.fg()),
987                18,
988            );
989            let saved_col = theme::pad_right(&format!("{b}{pc}{}{r}", format_big(cmd_saved)), 8);
990            o.push(format!(
991                "    {cmd_col} {:>5}x   {bar}  {saved_col} {d}{cmd_pct:>3.0}%{r}",
992                stats.count,
993            ));
994        }
995
996        if sorted.len() > 10 {
997            o.push(format!(
998                "    {d}... +{} more commands{r}",
999                sorted.len() - 10
1000            ));
1001        }
1002    }
1003
1004    if store.daily.len() >= 2 {
1005        o.push(String::new());
1006        o.push(String::new());
1007        o.push(format!("  {}", t.section_title("Recent Days")));
1008        o.push(format!("  {ln}", ln = t.border_line(w)));
1009        o.push(String::new());
1010
1011        let recent: Vec<_> = store.daily.iter().rev().take(7).collect();
1012        for day in recent.iter().rev() {
1013            let day_saved = day_total_saved(day, &cost_model);
1014            let day_input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1015            let day_pct = if day.input_tokens > 0 {
1016                day_input_saved as f64 / day.input_tokens as f64 * 100.0
1017            } else {
1018                0.0
1019            };
1020            let pc = t.pct_color(day_pct);
1021            let date_short = day.date.get(5..).unwrap_or(&day.date);
1022            let date_col = theme::pad_right(&format!("{m}{date_short}{r}", m = t.muted.fg()), 7);
1023            let saved_col = theme::pad_right(&format!("{pc}{b}{}{r}", format_big(day_saved)), 9);
1024            o.push(format!(
1025                "    {date_col}  {:>5} cmds   {saved_col} saved   {pc}{day_pct:>5.1}%{r}",
1026                day.commands,
1027            ));
1028        }
1029    }
1030
1031    o.push(String::new());
1032    o.push(String::new());
1033
1034    if let Some(tip) = contextual_tip(&store) {
1035        o.push(format!("    {w}💡 {tip}{r}", w = t.warning.fg()));
1036        o.push(String::new());
1037    }
1038
1039    // Bug Memory stats
1040    {
1041        let project_root = std::env::current_dir()
1042            .map(|p| p.to_string_lossy().to_string())
1043            .unwrap_or_default();
1044        if !project_root.is_empty() {
1045            let gotcha_store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
1046            if gotcha_store.stats.total_errors_detected > 0 || !gotcha_store.gotchas.is_empty() {
1047                let a = t.accent.fg();
1048                o.push(format!("    {a}🧠 Bug Memory{r}"));
1049                o.push(format!(
1050                    "    {m}   Active gotchas: {}{r}   Bugs prevented: {}{r}",
1051                    gotcha_store.gotchas.len(),
1052                    gotcha_store.stats.total_prevented,
1053                    m = t.muted.fg(),
1054                ));
1055                o.push(String::new());
1056            }
1057        }
1058    }
1059
1060    let m = t.muted.fg();
1061    o.push(format!(
1062        "    {m}🐛 Found a bug? Run: lean-ctx report-issue{r}"
1063    ));
1064    o.push(format!(
1065        "    {m}📊 Help improve lean-ctx: lean-ctx contribute{r}"
1066    ));
1067    o.push(format!("    {m}🧠 View bug memory: lean-ctx gotchas{r}"));
1068
1069    o.push(String::new());
1070    o.push(String::new());
1071
1072    o.join("\n")
1073}
1074
1075fn contextual_tip(store: &StatsStore) -> Option<String> {
1076    let tips = build_tips(store);
1077    if tips.is_empty() {
1078        return None;
1079    }
1080    let seed = std::time::SystemTime::now()
1081        .duration_since(std::time::UNIX_EPOCH)
1082        .unwrap_or_default()
1083        .as_secs()
1084        / 86400;
1085    Some(tips[(seed as usize) % tips.len()].clone())
1086}
1087
1088fn build_tips(store: &StatsStore) -> Vec<String> {
1089    let mut tips = Vec::new();
1090
1091    if store.cep.modes.get("map").copied().unwrap_or(0) == 0 {
1092        tips.push("Try mode=\"map\" for files you only need as context — shows deps + exports, skips implementation.".into());
1093    }
1094
1095    if store.cep.modes.get("signatures").copied().unwrap_or(0) == 0 {
1096        tips.push("Try mode=\"signatures\" for large files — returns only the API surface.".into());
1097    }
1098
1099    if store.cep.total_cache_reads > 0
1100        && store.cep.total_cache_hits as f64 / store.cep.total_cache_reads as f64 > 0.8
1101    {
1102        tips.push(
1103            "High cache hit rate! Use ctx_compress periodically to keep context compact.".into(),
1104        );
1105    }
1106
1107    if store.total_commands > 50 && store.cep.sessions == 0 {
1108        tips.push("Use ctx_session to track your task — enables cross-session memory.".into());
1109    }
1110
1111    if store.cep.modes.get("entropy").copied().unwrap_or(0) == 0 && store.total_commands > 20 {
1112        tips.push("Try mode=\"entropy\" for maximum compression on large files.".into());
1113    }
1114
1115    if store.daily.len() >= 7 {
1116        tips.push("Run lean-ctx gain --graph for a 30-day sparkline chart.".into());
1117    }
1118
1119    tips.push("Run ctx_overview(task) at session start for a task-aware project map.".into());
1120    tips.push("Run lean-ctx dashboard for a live web UI with all your stats.".into());
1121
1122    let cfg = crate::core::config::Config::load();
1123    if cfg.theme == "default" {
1124        tips.push(
1125            "Customize your dashboard! Try: lean-ctx theme set cyberpunk (or neon, ocean, sunset, monochrome)".into(),
1126        );
1127        tips.push(
1128            "Want a unique look? Run lean-ctx theme list to see all available themes.".into(),
1129        );
1130    } else {
1131        tips.push(format!(
1132            "Current theme: {}. Run lean-ctx theme list to explore others.",
1133            cfg.theme
1134        ));
1135    }
1136
1137    tips.push(
1138        "Create your own theme with lean-ctx theme create <name> and set custom colors!".into(),
1139    );
1140
1141    tips
1142}
1143
1144pub fn gain_live() {
1145    use std::io::Write;
1146
1147    let interval = std::time::Duration::from_secs(1);
1148    let mut line_count = 0usize;
1149    let d = theme::dim();
1150    let r = theme::rst();
1151
1152    eprintln!("  {d}▸ Live mode (1s refresh) · Ctrl+C to exit{r}");
1153
1154    loop {
1155        if line_count > 0 {
1156            print!("\x1B[{line_count}A\x1B[J");
1157        }
1158
1159        let tick = std::time::SystemTime::now()
1160            .duration_since(std::time::UNIX_EPOCH)
1161            .ok()
1162            .map(|d| d.as_millis() as u64);
1163        let output = format_gain_themed_at(&active_theme(), tick);
1164        let footer = format!("\n  {d}▸ Live · updates every 1s · Ctrl+C to exit{r}\n");
1165        let full = format!("{output}{footer}");
1166        line_count = full.lines().count();
1167
1168        print!("{full}");
1169        let _ = std::io::stdout().flush();
1170
1171        std::thread::sleep(interval);
1172    }
1173}
1174
1175pub fn format_gain_graph() -> String {
1176    let t = active_theme();
1177    let store = load();
1178    let r = theme::rst();
1179    let b = theme::bold();
1180    let d = theme::dim();
1181
1182    if store.daily.is_empty() {
1183        return format!("{d}No daily data yet.{r} Use lean-ctx for a few days to see the graph.");
1184    }
1185
1186    let cm = CostModel::default();
1187    let days: Vec<_> = store
1188        .daily
1189        .iter()
1190        .rev()
1191        .take(30)
1192        .collect::<Vec<_>>()
1193        .into_iter()
1194        .rev()
1195        .collect();
1196
1197    let savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
1198
1199    let max_saved = *savings.iter().max().unwrap_or(&1);
1200    let max_saved = max_saved.max(1);
1201
1202    let bar_width = 36;
1203    let mut o = Vec::new();
1204
1205    o.push(String::new());
1206    o.push(format!(
1207        "  {icon} {title}  {d}Token Savings Graph (last 30 days){r}",
1208        icon = t.header_icon(),
1209        title = t.brand_title(),
1210    ));
1211    o.push(format!("  {ln}", ln = t.border_line(58)));
1212    o.push(format!(
1213        "  {d}{:>58}{r}",
1214        format!("peak: {}", format_big(max_saved))
1215    ));
1216    o.push(String::new());
1217
1218    for (i, day) in days.iter().enumerate() {
1219        let saved = savings[i];
1220        let ratio = saved as f64 / max_saved as f64;
1221        let bar = theme::pad_right(&t.gradient_bar(ratio, bar_width), bar_width);
1222
1223        let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1224        let pct = if day.input_tokens > 0 {
1225            input_saved as f64 / day.input_tokens as f64 * 100.0
1226        } else {
1227            0.0
1228        };
1229        let date_short = day.date.get(5..).unwrap_or(&day.date);
1230
1231        o.push(format!(
1232            "  {m}{date_short}{r} {brd}│{r} {bar} {b}{:>6}{r} {d}{pct:.0}%{r}",
1233            format_big(saved),
1234            m = t.muted.fg(),
1235            brd = t.border.fg(),
1236        ));
1237    }
1238
1239    let total_saved: u64 = savings.iter().sum();
1240    let total_cmds: u64 = days.iter().map(|day| day.commands).sum();
1241    let spark = t.gradient_sparkline(&savings);
1242
1243    o.push(String::new());
1244    o.push(format!("  {ln}", ln = t.border_line(58)));
1245    o.push(format!(
1246        "  {spark}  {b}{txt}{}{r} saved across {b}{}{r} commands",
1247        format_big(total_saved),
1248        format_num(total_cmds),
1249        txt = t.text.fg(),
1250    ));
1251    o.push(String::new());
1252
1253    o.join("\n")
1254}
1255
1256pub fn format_gain_daily() -> String {
1257    let t = active_theme();
1258    let store = load();
1259    let r = theme::rst();
1260    let b = theme::bold();
1261    let d = theme::dim();
1262
1263    if store.daily.is_empty() {
1264        return format!("{d}No daily data yet.{r}");
1265    }
1266
1267    let mut o = Vec::new();
1268    let w = 64;
1269
1270    let side = t.box_side();
1271    let daily_box = |content: &str| -> String {
1272        let padded = theme::pad_right(content, w);
1273        format!("  {side}{padded}{side}")
1274    };
1275
1276    o.push(String::new());
1277    o.push(format!(
1278        "  {icon} {title}  {d}Daily Breakdown{r}",
1279        icon = t.header_icon(),
1280        title = t.brand_title(),
1281    ));
1282    o.push(format!("  {}", t.box_top(w)));
1283    let hdr = format!(
1284        " {b}{txt}{:<12} {:>6}  {:>10}  {:>10}  {:>7}  {:>6}{r}",
1285        "Date",
1286        "Cmds",
1287        "Input",
1288        "Saved",
1289        "Rate",
1290        "USD",
1291        txt = t.text.fg(),
1292    );
1293    o.push(daily_box(&hdr));
1294    o.push(format!("  {}", t.box_mid(w)));
1295
1296    let days: Vec<_> = store
1297        .daily
1298        .iter()
1299        .rev()
1300        .take(30)
1301        .collect::<Vec<_>>()
1302        .into_iter()
1303        .rev()
1304        .cloned()
1305        .collect();
1306
1307    let cm = CostModel::default();
1308    for day in &days {
1309        let saved = day_total_saved(day, &cm);
1310        let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1311        let pct = if day.input_tokens > 0 {
1312            input_saved as f64 / day.input_tokens as f64 * 100.0
1313        } else {
1314            0.0
1315        };
1316        let pc = t.pct_color(pct);
1317        let usd = usd_estimate(saved);
1318        let row = format!(
1319            " {m}{:<12}{r} {:>6}  {:>10}  {pc}{b}{:>10}{r}  {pc}{:>6.1}%{r}  {d}{:>6}{r}",
1320            &day.date,
1321            day.commands,
1322            format_big(day.input_tokens),
1323            format_big(saved),
1324            pct,
1325            usd,
1326            m = t.muted.fg(),
1327        );
1328        o.push(daily_box(&row));
1329    }
1330
1331    let total_input: u64 = store.daily.iter().map(|day| day.input_tokens).sum();
1332    let total_saved: u64 = store
1333        .daily
1334        .iter()
1335        .map(|day| day_total_saved(day, &cm))
1336        .sum();
1337    let total_pct = if total_input > 0 {
1338        let input_saved: u64 = store
1339            .daily
1340            .iter()
1341            .map(|day| day.input_tokens.saturating_sub(day.output_tokens))
1342            .sum();
1343        input_saved as f64 / total_input as f64 * 100.0
1344    } else {
1345        0.0
1346    };
1347    let total_usd = usd_estimate(total_saved);
1348    let sc = t.success.fg();
1349
1350    o.push(format!("  {}", t.box_mid(w)));
1351    let total_row = format!(
1352        " {b}{txt}{:<12}{r} {:>6}  {:>10}  {sc}{b}{:>10}{r}  {sc}{b}{:>6.1}%{r}  {b}{:>6}{r}",
1353        "TOTAL",
1354        format_num(store.total_commands),
1355        format_big(total_input),
1356        format_big(total_saved),
1357        total_pct,
1358        total_usd,
1359        txt = t.text.fg(),
1360    );
1361    o.push(daily_box(&total_row));
1362    o.push(format!("  {}", t.box_bottom(w)));
1363
1364    let daily_savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
1365    let spark = t.gradient_sparkline(&daily_savings);
1366    o.push(format!("  {d}Trend:{r} {spark}"));
1367    o.push(String::new());
1368
1369    o.join("\n")
1370}
1371
1372pub fn format_gain_json() -> String {
1373    let store = load();
1374    serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
1375}