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 EventKind::ProfileChanged { from, to } => {
462 ("->", "profile", format!("{from} -> {to}"), BLUE)
463 }
464 EventKind::SloViolation {
465 slo_name, action, ..
466 } => ("!!", "slo", format!("{slo_name} violated → {action}"), RED),
467 EventKind::Anomaly {
468 metric,
469 deviation_factor,
470 ..
471 } => (
472 "??",
473 "anomaly",
474 format!("{metric} {deviation_factor:.1}x StdDev"),
475 YELLOW,
476 ),
477 EventKind::VerificationWarning {
478 warning_kind,
479 detail,
480 ..
481 } => (
482 "!?",
483 "verify",
484 format!(
485 "{warning_kind}: {}",
486 detail.chars().take(40).collect::<String>()
487 ),
488 YELLOW,
489 ),
490 EventKind::ThresholdAdapted { language, arm, .. } => (
491 "~>",
492 "adapt",
493 format!("{language}/{arm} threshold adapted"),
494 BLUE,
495 ),
496 };
497 let ts = &ev.timestamp[11..19.min(ev.timestamp.len())];
498 ListItem::new(Line::from(vec![
499 Span::styled(format!("{ts} "), Style::default().fg(MUTED)),
500 Span::styled(format!("{icon} "), Style::default().fg(color)),
501 Span::styled(
502 format!("{tool:14}"),
503 Style::default().fg(color).add_modifier(Modifier::BOLD),
504 ),
505 Span::styled(detail, Style::default().fg(MUTED)),
506 ]))
507 })
508 .collect();
509
510 let list = List::new(items).block(block);
511 f.render_widget(list, area);
512}
513
514fn draw_heatmap(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
515 let block = Block::default()
516 .title(Span::styled(
517 " File Heatmap ",
518 Style::default().fg(YELLOW).add_modifier(Modifier::BOLD),
519 ))
520 .borders(Borders::ALL)
521 .border_style(Style::default().fg(if state.focus == 2 {
522 GREEN
523 } else {
524 Color::Rgb(30, 30, 50)
525 }))
526 .style(Style::default().bg(SURFACE));
527
528 let mut files: Vec<_> = state.files.iter().collect();
529 files.sort_by_key(|x| std::cmp::Reverse(x.1.access_count));
530 if files.is_empty() {
531 let msg = Paragraph::new("Waiting for file activity...")
532 .style(Style::default().fg(MUTED))
533 .block(block);
534 f.render_widget(msg, area);
535 return;
536 }
537 let max_access = files.first().map_or(1, |f| f.1.access_count).max(1);
538
539 let visible = (area.height.saturating_sub(2)) as usize;
540 let rows: Vec<Row> = files
541 .iter()
542 .take(visible)
543 .map(|(path, heat)| {
544 let short = path.rsplit('/').next().unwrap_or(path);
545 let bar_len = (heat.access_count as f64 / max_access as f64 * 12.0) as usize;
546 let bar: String = "█".repeat(bar_len) + &"░".repeat(12 - bar_len);
547 Row::new(vec![
548 ratatui::widgets::Cell::from(Span::styled(
549 format!("{short:20}"),
550 Style::default().fg(Color::White),
551 )),
552 ratatui::widgets::Cell::from(Span::styled(bar, Style::default().fg(YELLOW))),
553 ratatui::widgets::Cell::from(Span::styled(
554 format!("{}x", heat.access_count),
555 Style::default().fg(MUTED),
556 )),
557 ratatui::widgets::Cell::from(Span::styled(
558 format!("{}t", format_tokens(heat.tokens_saved)),
559 Style::default().fg(GREEN),
560 )),
561 ])
562 })
563 .collect();
564
565 let table = Table::new(
566 rows,
567 [
568 Constraint::Length(22),
569 Constraint::Length(14),
570 Constraint::Length(6),
571 Constraint::Length(10),
572 ],
573 )
574 .block(block);
575 f.render_widget(table, area);
576}
577
578fn draw_savings(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
579 let block = Block::default()
580 .title(Span::styled(
581 " Token Savings ",
582 Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
583 ))
584 .borders(Borders::ALL)
585 .border_style(Style::default().fg(if state.focus == 1 {
586 GREEN
587 } else {
588 Color::Rgb(30, 30, 50)
589 }))
590 .style(Style::default().bg(SURFACE));
591
592 let inner = block.inner(area);
593 f.render_widget(block, area);
594
595 let chunks = Layout::default()
596 .direction(Direction::Vertical)
597 .constraints([
598 Constraint::Length(2),
599 Constraint::Length(3),
600 Constraint::Length(1),
601 Constraint::Length(2),
602 Constraint::Length(3),
603 Constraint::Min(0),
604 ])
605 .split(inner);
606
607 let pct = state.savings_pct();
608 f.render_widget(
609 Paragraph::new(Line::from(vec![
610 Span::styled(
611 format!(" {} saved ", format_tokens(state.total_saved)),
612 Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
613 ),
614 Span::styled(format!("({pct:.0}%)"), Style::default().fg(MUTED)),
615 ])),
616 chunks[0],
617 );
618
619 let ratio = (pct / 100.0).min(1.0);
620 f.render_widget(
621 Gauge::default()
622 .ratio(ratio)
623 .gauge_style(Style::default().fg(GREEN).bg(BG))
624 .label(format!("{pct:.0}%")),
625 chunks[1],
626 );
627
628 f.render_widget(Paragraph::new(""), chunks[2]);
629
630 let cache_pct = state.cache_rate();
631 f.render_widget(
632 Paragraph::new(Line::from(vec![
633 Span::styled(" Cache Hit Rate ", Style::default().fg(PURPLE)),
634 Span::styled(format!("{cache_pct:.0}%"), Style::default().fg(MUTED)),
635 ])),
636 chunks[3],
637 );
638
639 let cache_ratio = (cache_pct / 100.0).min(1.0);
640 f.render_widget(
641 Gauge::default()
642 .ratio(cache_ratio)
643 .gauge_style(Style::default().fg(PURPLE).bg(BG))
644 .label(format!("{cache_pct:.0}%")),
645 chunks[4],
646 );
647}
648
649fn draw_session(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
650 let block = Block::default()
651 .title(Span::styled(
652 " Session ",
653 Style::default().fg(BLUE).add_modifier(Modifier::BOLD),
654 ))
655 .borders(Borders::ALL)
656 .border_style(Style::default().fg(if state.focus == 3 {
657 GREEN
658 } else {
659 Color::Rgb(30, 30, 50)
660 }))
661 .style(Style::default().bg(SURFACE));
662
663 let cost = state.total_saved as f64 * 2.5 / 1_000_000.0;
664
665 let lines = vec![
666 Line::from(vec![
667 Span::styled(" Calls ", Style::default().fg(MUTED)),
668 Span::styled(
669 format!("{}", state.total_calls),
670 Style::default().fg(Color::White),
671 ),
672 ]),
673 Line::from(vec![
674 Span::styled(" Files ", Style::default().fg(MUTED)),
675 Span::styled(
676 format!("{}", state.files.len()),
677 Style::default().fg(Color::White),
678 ),
679 ]),
680 Line::from(vec![
681 Span::styled(" Original ", Style::default().fg(MUTED)),
682 Span::styled(
683 format_tokens(state.total_original),
684 Style::default().fg(Color::White),
685 ),
686 ]),
687 Line::from(vec![
688 Span::styled(" Sent ", Style::default().fg(MUTED)),
689 Span::styled(
690 format_tokens(state.total_original.saturating_sub(state.total_saved)),
691 Style::default().fg(Color::White),
692 ),
693 ]),
694 Line::from(vec![
695 Span::styled(" Saved ", Style::default().fg(MUTED)),
696 Span::styled(format!("${cost:.3}"), Style::default().fg(GREEN)),
697 ]),
698 Line::from(""),
699 Line::from(Span::styled(
700 " q=quit Tab=focus 1-4=panel",
701 Style::default().fg(Color::Rgb(50, 50, 70)),
702 )),
703 ];
704
705 let paragraph = Paragraph::new(lines).block(block);
706 f.render_widget(paragraph, area);
707}
708
709fn format_tokens(n: u64) -> String {
710 if n >= 1_000_000 {
711 format!("{:.1}M", n as f64 / 1_000_000.0)
712 } else if n >= 1_000 {
713 format!("{:.1}K", n as f64 / 1_000.0)
714 } else {
715 format!("{n}")
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 fn mk_state() -> AppState {
724 AppState {
725 events: Vec::new(),
726 total_saved: 0,
727 total_original: 0,
728 cache_hits: 0,
729 total_calls: 0,
730 files: std::collections::HashMap::new(),
731 gain_score: None,
732 last_gain_refresh: Instant::now(),
733 quit: false,
734 focus: 0,
735 }
736 }
737
738 #[test]
739 fn ingest_toolcall_with_path_populates_heatmap() {
740 let mut s = mk_state();
741 s.ingest(vec![LeanCtxEvent {
742 id: 1,
743 timestamp: "t".to_string(),
744 kind: EventKind::ToolCall {
745 tool: "ctx_read".to_string(),
746 tokens_original: 100,
747 tokens_saved: 80,
748 mode: Some("full".to_string()),
749 duration_ms: 1,
750 path: Some("src/main.rs".to_string()),
751 },
752 }]);
753
754 let entry = s.files.get("src/main.rs").expect("file entry missing");
755 assert_eq!(entry.access_count, 1);
756 assert_eq!(entry.tokens_saved, 80);
757 }
758
759 #[test]
760 fn ingest_compression_counts_access_without_fake_tokens() {
761 let mut s = mk_state();
762 s.ingest(vec![LeanCtxEvent {
763 id: 1,
764 timestamp: "t".to_string(),
765 kind: EventKind::Compression {
766 path: "src/lib.rs".to_string(),
767 before_lines: 100,
768 after_lines: 10,
769 strategy: "entropy".to_string(),
770 kept_line_count: 10,
771 removed_line_count: 90,
772 },
773 }]);
774
775 let entry = s.files.get("src/lib.rs").expect("file entry missing");
776 assert_eq!(entry.access_count, 1);
777 assert_eq!(entry.tokens_saved, 0);
778 }
779}