Skip to main content

lean_ctx/tui/
app.rs

1use crate::core::events::{EventKind, LeanCtxEvent};
2use crate::core::gain::gain_score::GainScore;
3use crate::core::gain::model_pricing::ModelPricing;
4use crate::core::gain::task_classifier::{TaskCategory, TaskClassifier};
5use crate::tui::event_reader::EventTail;
6use crossterm::event::{self, Event, KeyCode, KeyEventKind};
7use crossterm::terminal::{
8    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
9};
10use crossterm::ExecutableCommand;
11use ratatui::layout::{Constraint, Direction, Layout, Rect};
12use ratatui::style::{Color, Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Row, Table};
15use ratatui::Terminal;
16use std::io::stdout;
17use std::time::{Duration, Instant};
18
19const GREEN: Color = Color::Rgb(52, 211, 153);
20const PURPLE: Color = Color::Rgb(129, 140, 248);
21const BLUE: Color = Color::Rgb(56, 189, 248);
22const YELLOW: Color = Color::Rgb(251, 191, 36);
23const RED: Color = Color::Rgb(248, 113, 113);
24const MUTED: Color = Color::Rgb(107, 107, 136);
25const SURFACE: Color = Color::Rgb(10, 10, 18);
26const BG: Color = Color::Rgb(6, 6, 10);
27
28struct AppState {
29    events: Vec<LeanCtxEvent>,
30    total_saved: u64,
31    total_original: u64,
32    cache_hits: u64,
33    total_calls: u64,
34    files: std::collections::HashMap<String, FileHeat>,
35    gain_score: Option<GainScore>,
36    last_gain_refresh: Instant,
37    quit: bool,
38    focus: usize,
39}
40
41struct FileHeat {
42    access_count: u32,
43    tokens_saved: u64,
44}
45
46impl AppState {
47    fn new() -> Self {
48        let store = crate::core::stats::load();
49        let heatmap = crate::core::heatmap::HeatMap::load();
50        let files = heatmap
51            .entries
52            .values()
53            .map(|e| {
54                (
55                    e.path.clone(),
56                    FileHeat {
57                        access_count: e.access_count,
58                        tokens_saved: e.total_tokens_saved,
59                    },
60                )
61            })
62            .collect();
63        Self {
64            events: Vec::new(),
65            total_saved: store
66                .total_input_tokens
67                .saturating_sub(store.total_output_tokens),
68            total_original: store.total_input_tokens,
69            cache_hits: store.cep.total_cache_hits,
70            total_calls: store.total_commands,
71            files,
72            gain_score: None,
73            last_gain_refresh: Instant::now(),
74            quit: false,
75            focus: 0,
76        }
77    }
78
79    fn ingest(&mut self, new_events: Vec<LeanCtxEvent>) {
80        for ev in &new_events {
81            match &ev.kind {
82                EventKind::ToolCall {
83                    tool: _,
84                    tokens_original,
85                    tokens_saved,
86                    path,
87                    ..
88                } => {
89                    self.total_saved += tokens_saved;
90                    self.total_original += tokens_original;
91                    self.total_calls += 1;
92                    if let Some(p) = path {
93                        let entry = self.files.entry(p.clone()).or_insert(FileHeat {
94                            access_count: 0,
95                            tokens_saved: 0,
96                        });
97                        entry.access_count += 1;
98                        entry.tokens_saved += tokens_saved;
99                    }
100                }
101                EventKind::CacheHit { path, saved_tokens } => {
102                    self.cache_hits += 1;
103                    self.total_saved += saved_tokens;
104                    let entry = self.files.entry(path.clone()).or_insert(FileHeat {
105                        access_count: 0,
106                        tokens_saved: 0,
107                    });
108                    entry.access_count += 1;
109                    entry.tokens_saved += saved_tokens;
110                }
111                EventKind::Compression { path, .. } => {
112                    let entry = self.files.entry(path.clone()).or_insert(FileHeat {
113                        access_count: 0,
114                        tokens_saved: 0,
115                    });
116                    entry.access_count += 1;
117                }
118                _ => {}
119            }
120        }
121        self.events.extend(new_events);
122        if self.events.len() > 200 {
123            let drain = self.events.len() - 200;
124            self.events.drain(..drain);
125        }
126    }
127
128    fn savings_pct(&self) -> f64 {
129        if self.total_original == 0 {
130            return 0.0;
131        }
132        self.total_saved as f64 / self.total_original as f64 * 100.0
133    }
134
135    fn cache_rate(&self) -> f64 {
136        if self.total_calls == 0 {
137            return 0.0;
138        }
139        self.cache_hits as f64 / self.total_calls as f64 * 100.0
140    }
141
142    fn refresh_gain_score(&mut self) {
143        if self.last_gain_refresh.elapsed() < Duration::from_secs(2) {
144            return;
145        }
146        let engine = crate::core::gain::GainEngine::load();
147        self.gain_score = Some(engine.gain_score(None));
148        self.last_gain_refresh = Instant::now();
149    }
150}
151
152pub fn run() -> anyhow::Result<()> {
153    enable_raw_mode()?;
154    stdout().execute(EnterAlternateScreen)?;
155    let backend = ratatui::backend::CrosstermBackend::new(stdout());
156    let mut terminal = Terminal::new(backend)?;
157
158    let mut state = AppState::new();
159    let mut tail = EventTail::new();
160    let tick_rate = Duration::from_millis(200);
161    let mut last_tick = Instant::now();
162
163    loop {
164        terminal.draw(|f| draw(f, &state))?;
165
166        let timeout = tick_rate.saturating_sub(last_tick.elapsed());
167        if event::poll(timeout)? {
168            if let Event::Key(key) = event::read()? {
169                if key.kind == KeyEventKind::Press {
170                    match key.code {
171                        KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
172                        KeyCode::Tab => state.focus = (state.focus + 1) % 5,
173                        KeyCode::Char('1') => state.focus = 0,
174                        KeyCode::Char('2') => state.focus = 1,
175                        KeyCode::Char('3') => state.focus = 2,
176                        KeyCode::Char('4') => state.focus = 3,
177                        KeyCode::Char('5') => state.focus = 4,
178                        _ => {}
179                    }
180                }
181            }
182        }
183
184        if last_tick.elapsed() >= tick_rate {
185            let new = tail.poll();
186            if !new.is_empty() {
187                state.ingest(new);
188            }
189            state.refresh_gain_score();
190            last_tick = Instant::now();
191        }
192
193        if state.quit {
194            break;
195        }
196    }
197
198    disable_raw_mode()?;
199    stdout().execute(LeaveAlternateScreen)?;
200    Ok(())
201}
202
203fn draw(f: &mut ratatui::Frame, state: &AppState) {
204    let size = f.area();
205
206    let header_body = Layout::default()
207        .direction(Direction::Vertical)
208        .constraints([Constraint::Length(3), Constraint::Min(0)])
209        .split(size);
210
211    draw_header(f, header_body[0], state);
212
213    let columns = Layout::default()
214        .direction(Direction::Horizontal)
215        .constraints([Constraint::Percentage(65), Constraint::Percentage(35)])
216        .split(header_body[1]);
217
218    let left = Layout::default()
219        .direction(Direction::Vertical)
220        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
221        .split(columns[0]);
222
223    let right = Layout::default()
224        .direction(Direction::Vertical)
225        .constraints([
226            Constraint::Percentage(38),
227            Constraint::Percentage(37),
228            Constraint::Percentage(25),
229        ])
230        .split(columns[1]);
231
232    draw_live_feed(f, left[0], state);
233    draw_heatmap(f, left[1], state);
234    draw_savings(f, right[0], state);
235    draw_session(f, right[1], state);
236    draw_task_activity(f, right[2], state);
237}
238
239fn draw_header(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
240    let saved = format_tokens(state.total_saved);
241    let pct = format!("{:.0}%", state.savings_pct());
242    let env_model = std::env::var("LEAN_CTX_MODEL")
243        .or_else(|_| std::env::var("LCTX_MODEL"))
244        .ok();
245    let pricing = ModelPricing::load();
246    let quote = pricing.quote(env_model.as_deref());
247    let cost = format!(
248        "${:.3}",
249        state.total_saved as f64 * quote.cost.input_per_m / 1_000_000.0
250    );
251    let gain_score = state.gain_score.as_ref().map_or(0, |s| s.total);
252    let trend_icon = state.gain_score.as_ref().map_or("─", |s| match s.trend {
253        crate::core::gain::gain_score::Trend::Rising => "▲",
254        crate::core::gain::gain_score::Trend::Stable => "─",
255        crate::core::gain::gain_score::Trend::Declining => "▼",
256    });
257    let trend_color = state.gain_score.as_ref().map_or(MUTED, |s| match s.trend {
258        crate::core::gain::gain_score::Trend::Rising => GREEN,
259        crate::core::gain::gain_score::Trend::Stable => MUTED,
260        crate::core::gain::gain_score::Trend::Declining => YELLOW,
261    });
262
263    let spans = vec![
264        Span::styled(
265            " LeanCTX ",
266            Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
267        ),
268        Span::styled("Observatory ", Style::default().fg(MUTED)),
269        Span::raw("   "),
270        Span::styled(format!("{saved} saved"), Style::default().fg(GREEN)),
271        Span::raw("  "),
272        Span::styled(format!("{pct} compression"), Style::default().fg(PURPLE)),
273        Span::raw("  "),
274        Span::styled(format!("{cost} avoided"), Style::default().fg(BLUE)),
275        Span::raw("  "),
276        Span::styled(format!("{gain_score}/100 gain"), Style::default().fg(GREEN)),
277        Span::styled(format!(" {trend_icon}"), Style::default().fg(trend_color)),
278        Span::raw("  "),
279        Span::styled(
280            format!("{} events", state.events.len()),
281            Style::default().fg(MUTED),
282        ),
283    ];
284
285    let header = Paragraph::new(Line::from(spans)).block(
286        Block::default()
287            .borders(Borders::BOTTOM)
288            .border_style(Style::default().fg(Color::Rgb(30, 30, 50))),
289    );
290    f.render_widget(header, area);
291}
292
293fn draw_task_activity(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
294    let block = Block::default()
295        .title(Span::styled(
296            " Task Activity ",
297            Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
298        ))
299        .borders(Borders::ALL)
300        .border_style(Style::default().fg(if state.focus == 4 {
301            GREEN
302        } else {
303            Color::Rgb(30, 30, 50)
304        }))
305        .style(Style::default().bg(SURFACE));
306
307    let mut counts: std::collections::HashMap<TaskCategory, u64> = std::collections::HashMap::new();
308    for ev in state.events.iter().rev().take(120) {
309        if let EventKind::ToolCall { tool, .. } = &ev.kind {
310            let cat = TaskClassifier::classify_tool(tool);
311            *counts.entry(cat).or_insert(0) += 1;
312        }
313    }
314
315    let mut rows: Vec<(TaskCategory, u64)> = counts.into_iter().collect();
316    rows.sort_by_key(|x| std::cmp::Reverse(x.1));
317
318    let max_items = area.height.saturating_sub(2) as usize;
319    let items: Vec<ListItem> = if rows.is_empty() {
320        vec![ListItem::new(Line::from(vec![Span::styled(
321            "No tool calls yet.",
322            Style::default().fg(MUTED),
323        )]))]
324    } else {
325        rows.into_iter()
326            .take(max_items)
327            .map(|(cat, n)| {
328                ListItem::new(Line::from(vec![
329                    Span::styled(
330                        format!("{:<14}", cat.label()),
331                        Style::default().fg(Color::Rgb(220, 220, 240)),
332                    ),
333                    Span::styled(format!("{n:>4}"), Style::default().fg(MUTED)),
334                ]))
335            })
336            .collect()
337    };
338
339    let list = List::new(items).block(block);
340    f.render_widget(list, area);
341}
342
343fn draw_live_feed(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
344    let block = Block::default()
345        .title(Span::styled(
346            " Live Feed ",
347            Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
348        ))
349        .borders(Borders::ALL)
350        .border_style(Style::default().fg(if state.focus == 0 {
351            GREEN
352        } else {
353            Color::Rgb(30, 30, 50)
354        }))
355        .style(Style::default().bg(SURFACE));
356
357    let visible = area.height.saturating_sub(2) as usize;
358    let start = state.events.len().saturating_sub(visible);
359    let items: Vec<ListItem> = state.events[start..]
360        .iter()
361        .rev()
362        .map(|ev| {
363            let (icon, tool, detail, color) = match &ev.kind {
364                EventKind::ToolCall {
365                    tool,
366                    tokens_original,
367                    tokens_saved,
368                    mode,
369                    ..
370                } => {
371                    let pct = if *tokens_original > 0 {
372                        format!("-{}%", tokens_saved * 100 / tokens_original)
373                    } else {
374                        String::new()
375                    };
376                    let m = mode.as_deref().unwrap_or("");
377                    (
378                        ">>",
379                        tool.as_str(),
380                        format!(
381                            "{} {}t->{}t {}",
382                            m,
383                            tokens_original,
384                            tokens_original - tokens_saved,
385                            pct
386                        ),
387                        GREEN,
388                    )
389                }
390                EventKind::CacheHit { path, saved_tokens } => {
391                    let short = path.rsplit('/').next().unwrap_or(path);
392                    (
393                        "**",
394                        "cache",
395                        format!("{short} {saved_tokens}t saved"),
396                        PURPLE,
397                    )
398                }
399                EventKind::Compression {
400                    path,
401                    strategy,
402                    before_lines,
403                    after_lines,
404                    ..
405                } => {
406                    let short = path.rsplit('/').next().unwrap_or(path);
407                    (
408                        "~~",
409                        "compress",
410                        format!("{short} {strategy} {before_lines}L->{after_lines}L"),
411                        BLUE,
412                    )
413                }
414                EventKind::AgentAction {
415                    agent_id, action, ..
416                } => ("@@", "agent", format!("{agent_id} {action}"), YELLOW),
417                EventKind::KnowledgeUpdate {
418                    category,
419                    key,
420                    action,
421                } => (
422                    "!!",
423                    "knowledge",
424                    format!("{action} {category}/{key}"),
425                    PURPLE,
426                ),
427                EventKind::ThresholdShift {
428                    language,
429                    new_entropy,
430                    new_jaccard,
431                    ..
432                } => (
433                    "~~",
434                    "threshold",
435                    format!("{language} e={new_entropy:.2} j={new_jaccard:.2}"),
436                    MUTED,
437                ),
438                EventKind::BudgetWarning {
439                    role,
440                    dimension,
441                    percent,
442                    ..
443                } => (
444                    "$$",
445                    "budget",
446                    format!("{role} {dimension} {percent}% WARNING"),
447                    YELLOW,
448                ),
449                EventKind::BudgetExhausted {
450                    role, dimension, ..
451                } => ("!!", "budget", format!("{role} {dimension} EXHAUSTED"), RED),
452                EventKind::PolicyViolation { role, tool, reason } => (
453                    "XX",
454                    "policy",
455                    format!("{role} blocked {tool}: {reason}"),
456                    RED,
457                ),
458                EventKind::RoleChanged { from, to } => {
459                    ("->", "role", format!("{from} -> {to}"), BLUE)
460                }
461            };
462            let ts = &ev.timestamp[11..19.min(ev.timestamp.len())];
463            ListItem::new(Line::from(vec![
464                Span::styled(format!("{ts} "), Style::default().fg(MUTED)),
465                Span::styled(format!("{icon} "), Style::default().fg(color)),
466                Span::styled(
467                    format!("{tool:14}"),
468                    Style::default().fg(color).add_modifier(Modifier::BOLD),
469                ),
470                Span::styled(detail, Style::default().fg(MUTED)),
471            ]))
472        })
473        .collect();
474
475    let list = List::new(items).block(block);
476    f.render_widget(list, area);
477}
478
479fn draw_heatmap(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
480    let block = Block::default()
481        .title(Span::styled(
482            " File Heatmap ",
483            Style::default().fg(YELLOW).add_modifier(Modifier::BOLD),
484        ))
485        .borders(Borders::ALL)
486        .border_style(Style::default().fg(if state.focus == 2 {
487            GREEN
488        } else {
489            Color::Rgb(30, 30, 50)
490        }))
491        .style(Style::default().bg(SURFACE));
492
493    let mut files: Vec<_> = state.files.iter().collect();
494    files.sort_by_key(|x| std::cmp::Reverse(x.1.access_count));
495    if files.is_empty() {
496        let msg = Paragraph::new("Waiting for file activity...")
497            .style(Style::default().fg(MUTED))
498            .block(block);
499        f.render_widget(msg, area);
500        return;
501    }
502    let max_access = files.first().map_or(1, |f| f.1.access_count).max(1);
503
504    let visible = (area.height.saturating_sub(2)) as usize;
505    let rows: Vec<Row> = files
506        .iter()
507        .take(visible)
508        .map(|(path, heat)| {
509            let short = path.rsplit('/').next().unwrap_or(path);
510            let bar_len = (heat.access_count as f64 / max_access as f64 * 12.0) as usize;
511            let bar: String = "█".repeat(bar_len) + &"░".repeat(12 - bar_len);
512            Row::new(vec![
513                ratatui::widgets::Cell::from(Span::styled(
514                    format!("{short:20}"),
515                    Style::default().fg(Color::White),
516                )),
517                ratatui::widgets::Cell::from(Span::styled(bar, Style::default().fg(YELLOW))),
518                ratatui::widgets::Cell::from(Span::styled(
519                    format!("{}x", heat.access_count),
520                    Style::default().fg(MUTED),
521                )),
522                ratatui::widgets::Cell::from(Span::styled(
523                    format!("{}t", format_tokens(heat.tokens_saved)),
524                    Style::default().fg(GREEN),
525                )),
526            ])
527        })
528        .collect();
529
530    let table = Table::new(
531        rows,
532        [
533            Constraint::Length(22),
534            Constraint::Length(14),
535            Constraint::Length(6),
536            Constraint::Length(10),
537        ],
538    )
539    .block(block);
540    f.render_widget(table, area);
541}
542
543fn draw_savings(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
544    let block = Block::default()
545        .title(Span::styled(
546            " Token Savings ",
547            Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
548        ))
549        .borders(Borders::ALL)
550        .border_style(Style::default().fg(if state.focus == 1 {
551            GREEN
552        } else {
553            Color::Rgb(30, 30, 50)
554        }))
555        .style(Style::default().bg(SURFACE));
556
557    let inner = block.inner(area);
558    f.render_widget(block, area);
559
560    let chunks = Layout::default()
561        .direction(Direction::Vertical)
562        .constraints([
563            Constraint::Length(2),
564            Constraint::Length(3),
565            Constraint::Length(1),
566            Constraint::Length(2),
567            Constraint::Length(3),
568            Constraint::Min(0),
569        ])
570        .split(inner);
571
572    let pct = state.savings_pct();
573    f.render_widget(
574        Paragraph::new(Line::from(vec![
575            Span::styled(
576                format!(" {} saved ", format_tokens(state.total_saved)),
577                Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
578            ),
579            Span::styled(format!("({pct:.0}%)"), Style::default().fg(MUTED)),
580        ])),
581        chunks[0],
582    );
583
584    let ratio = (pct / 100.0).min(1.0);
585    f.render_widget(
586        Gauge::default()
587            .ratio(ratio)
588            .gauge_style(Style::default().fg(GREEN).bg(BG))
589            .label(format!("{pct:.0}%")),
590        chunks[1],
591    );
592
593    f.render_widget(Paragraph::new(""), chunks[2]);
594
595    let cache_pct = state.cache_rate();
596    f.render_widget(
597        Paragraph::new(Line::from(vec![
598            Span::styled(" Cache Hit Rate ", Style::default().fg(PURPLE)),
599            Span::styled(format!("{cache_pct:.0}%"), Style::default().fg(MUTED)),
600        ])),
601        chunks[3],
602    );
603
604    let cache_ratio = (cache_pct / 100.0).min(1.0);
605    f.render_widget(
606        Gauge::default()
607            .ratio(cache_ratio)
608            .gauge_style(Style::default().fg(PURPLE).bg(BG))
609            .label(format!("{cache_pct:.0}%")),
610        chunks[4],
611    );
612}
613
614fn draw_session(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
615    let block = Block::default()
616        .title(Span::styled(
617            " Session ",
618            Style::default().fg(BLUE).add_modifier(Modifier::BOLD),
619        ))
620        .borders(Borders::ALL)
621        .border_style(Style::default().fg(if state.focus == 3 {
622            GREEN
623        } else {
624            Color::Rgb(30, 30, 50)
625        }))
626        .style(Style::default().bg(SURFACE));
627
628    let cost = state.total_saved as f64 * 2.5 / 1_000_000.0;
629
630    let lines = vec![
631        Line::from(vec![
632            Span::styled("  Calls     ", Style::default().fg(MUTED)),
633            Span::styled(
634                format!("{}", state.total_calls),
635                Style::default().fg(Color::White),
636            ),
637        ]),
638        Line::from(vec![
639            Span::styled("  Files     ", Style::default().fg(MUTED)),
640            Span::styled(
641                format!("{}", state.files.len()),
642                Style::default().fg(Color::White),
643            ),
644        ]),
645        Line::from(vec![
646            Span::styled("  Original  ", Style::default().fg(MUTED)),
647            Span::styled(
648                format_tokens(state.total_original),
649                Style::default().fg(Color::White),
650            ),
651        ]),
652        Line::from(vec![
653            Span::styled("  Sent      ", Style::default().fg(MUTED)),
654            Span::styled(
655                format_tokens(state.total_original.saturating_sub(state.total_saved)),
656                Style::default().fg(Color::White),
657            ),
658        ]),
659        Line::from(vec![
660            Span::styled("  Saved     ", Style::default().fg(MUTED)),
661            Span::styled(format!("${cost:.3}"), Style::default().fg(GREEN)),
662        ]),
663        Line::from(""),
664        Line::from(Span::styled(
665            "  q=quit Tab=focus 1-4=panel",
666            Style::default().fg(Color::Rgb(50, 50, 70)),
667        )),
668    ];
669
670    let paragraph = Paragraph::new(lines).block(block);
671    f.render_widget(paragraph, area);
672}
673
674fn format_tokens(n: u64) -> String {
675    if n >= 1_000_000 {
676        format!("{:.1}M", n as f64 / 1_000_000.0)
677    } else if n >= 1_000 {
678        format!("{:.1}K", n as f64 / 1_000.0)
679    } else {
680        format!("{n}")
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    fn mk_state() -> AppState {
689        AppState {
690            events: Vec::new(),
691            total_saved: 0,
692            total_original: 0,
693            cache_hits: 0,
694            total_calls: 0,
695            files: std::collections::HashMap::new(),
696            gain_score: None,
697            last_gain_refresh: Instant::now(),
698            quit: false,
699            focus: 0,
700        }
701    }
702
703    #[test]
704    fn ingest_toolcall_with_path_populates_heatmap() {
705        let mut s = mk_state();
706        s.ingest(vec![LeanCtxEvent {
707            id: 1,
708            timestamp: "t".to_string(),
709            kind: EventKind::ToolCall {
710                tool: "ctx_read".to_string(),
711                tokens_original: 100,
712                tokens_saved: 80,
713                mode: Some("full".to_string()),
714                duration_ms: 1,
715                path: Some("src/main.rs".to_string()),
716            },
717        }]);
718
719        let entry = s.files.get("src/main.rs").expect("file entry missing");
720        assert_eq!(entry.access_count, 1);
721        assert_eq!(entry.tokens_saved, 80);
722    }
723
724    #[test]
725    fn ingest_compression_counts_access_without_fake_tokens() {
726        let mut s = mk_state();
727        s.ingest(vec![LeanCtxEvent {
728            id: 1,
729            timestamp: "t".to_string(),
730            kind: EventKind::Compression {
731                path: "src/lib.rs".to_string(),
732                before_lines: 100,
733                after_lines: 10,
734                strategy: "entropy".to_string(),
735                kept_line_count: 10,
736                removed_line_count: 90,
737            },
738        }]);
739
740        let entry = s.files.get("src/lib.rs").expect("file entry missing");
741        assert_eq!(entry.access_count, 1);
742        assert_eq!(entry.tokens_saved, 0);
743    }
744}