Skip to main content

lean_ctx/core/
savings_footer.rs

1use std::cell::RefCell;
2use std::sync::atomic::{AtomicUsize, Ordering};
3
4static SESSION_ORIGINAL: AtomicUsize = AtomicUsize::new(0);
5static SESSION_SAVED: AtomicUsize = AtomicUsize::new(0);
6static SESSION_CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
7
8const SESSION_TOTAL_INTERVAL: usize = 10;
9
10thread_local! {
11    static CURRENT_MODE: RefCell<Option<String>> = const { RefCell::new(None) };
12    static CURRENT_DETAIL: RefCell<Option<String>> = const { RefCell::new(None) };
13}
14
15pub struct SavingsInfo<'a> {
16    pub original: usize,
17    pub compressed: usize,
18    pub mode: Option<&'a str>,
19    pub detail: Option<&'a str>,
20}
21
22pub struct ModeGuard;
23
24impl ModeGuard {
25    pub fn new(mode: &str) -> Self {
26        CURRENT_MODE.with(|m| *m.borrow_mut() = Some(mode.to_string()));
27        Self
28    }
29
30    pub fn with_detail(mode: &str, detail: &str) -> Self {
31        CURRENT_MODE.with(|m| *m.borrow_mut() = Some(mode.to_string()));
32        CURRENT_DETAIL.with(|d| *d.borrow_mut() = Some(detail.to_string()));
33        Self
34    }
35}
36
37impl Drop for ModeGuard {
38    fn drop(&mut self) {
39        // Must be panic-free: a `borrow_mut` panic while the thread is already
40        // unwinding another panic would escalate to a process abort (#378). Use
41        // `try_borrow_mut` and silently skip if the slot is somehow in use.
42        CURRENT_MODE.with(|m| {
43            if let Ok(mut slot) = m.try_borrow_mut() {
44                *slot = None;
45            }
46        });
47        CURRENT_DETAIL.with(|d| {
48            if let Ok(mut slot) = d.try_borrow_mut() {
49                *slot = None;
50            }
51        });
52    }
53}
54
55fn current_mode() -> Option<String> {
56    CURRENT_MODE.with(|m| m.borrow().clone())
57}
58
59fn current_detail() -> Option<String> {
60    CURRENT_DETAIL.with(|d| d.borrow().clone())
61}
62
63pub fn record_savings(original: usize, saved: usize) {
64    SESSION_ORIGINAL.fetch_add(original, Ordering::Relaxed);
65    SESSION_SAVED.fetch_add(saved, Ordering::Relaxed);
66    SESSION_CALL_COUNT.fetch_add(1, Ordering::Relaxed);
67}
68
69pub fn session_totals() -> (usize, usize, usize) {
70    (
71        SESSION_ORIGINAL.load(Ordering::Relaxed),
72        SESSION_SAVED.load(Ordering::Relaxed),
73        SESSION_CALL_COUNT.load(Ordering::Relaxed),
74    )
75}
76
77pub fn reset_session() {
78    SESSION_ORIGINAL.store(0, Ordering::Relaxed);
79    SESSION_SAVED.store(0, Ordering::Relaxed);
80    SESSION_CALL_COUNT.store(0, Ordering::Relaxed);
81}
82
83fn format_number(n: usize) -> String {
84    if n >= 1_000_000 {
85        let m = n as f64 / 1_000_000.0;
86        format!("{m:.1}M")
87    } else if n >= 10_000 {
88        let k = n as f64 / 1_000.0;
89        format!("{k:.1}k")
90    } else if n >= 1_000 {
91        let whole = n / 1_000;
92        format!("{whole},{:03}", n % 1_000)
93    } else {
94        n.to_string()
95    }
96}
97
98fn is_explicitly_enabled() -> bool {
99    matches!(std::env::var("LEAN_CTX_SHOW_SAVINGS"), Ok(v) if v.trim() == "1")
100}
101
102fn is_ultra_suppressed() -> bool {
103    if is_explicitly_enabled() {
104        return false;
105    }
106    let level = super::config::CompressionLevel::effective(&super::config::Config::load());
107    matches!(level, super::config::CompressionLevel::Max)
108}
109
110pub fn format_footer(info: &SavingsInfo<'_>) -> String {
111    if !super::protocol::savings_footer_visible() {
112        return String::new();
113    }
114    if is_ultra_suppressed() {
115        return String::new();
116    }
117    format_footer_inner(info)
118}
119
120fn format_footer_inner(info: &SavingsInfo<'_>) -> String {
121    if info.original == 0 {
122        return String::new();
123    }
124    let saved = info.original.saturating_sub(info.compressed);
125    if saved == 0 {
126        return String::new();
127    }
128    let pct = (saved as f64 / info.original as f64 * 100.0).round() as usize;
129
130    let orig_str = format_number(info.original);
131    let comp_str = format_number(info.compressed);
132
133    let mut parts = vec![format!(
134        "{orig_str} \u{2192} {comp_str} tok (\u{2193}{pct}%)"
135    )];
136
137    if let Some(mode) = info.mode {
138        parts.push(format!("mode: {mode}"));
139    }
140    if let Some(detail) = info.detail {
141        parts.push(detail.to_string());
142    }
143
144    record_savings(info.original, saved);
145
146    let call_count = SESSION_CALL_COUNT.load(Ordering::Relaxed);
147    if call_count > 0 && call_count.is_multiple_of(SESSION_TOTAL_INTERVAL) {
148        let (_, total_saved, _) = session_totals();
149        let total_str = format_number(total_saved);
150        parts.push(format!("session: {total_str} saved"));
151    }
152
153    let body = parts.join(" | ");
154    format!("\u{2500}\u{2500}\u{2500} {body} \u{2500}\u{2500}\u{2500}")
155}
156
157pub fn format_footer_basic(original: usize, compressed: usize) -> String {
158    let mode = current_mode();
159    let detail = current_detail();
160    format_footer(&SavingsInfo {
161        original,
162        compressed,
163        mode: mode.as_deref(),
164        detail: detail.as_deref(),
165    })
166}
167
168pub fn append_footer(output: &str, info: &SavingsInfo<'_>) -> String {
169    let footer = format_footer(info);
170    if footer.is_empty() {
171        output.to_string()
172    } else {
173        format!("{output}\n{footer}")
174    }
175}
176
177pub fn append_footer_basic(output: &str, original: usize, compressed: usize) -> String {
178    let footer = format_footer_basic(original, compressed);
179    if footer.is_empty() {
180        output.to_string()
181    } else {
182        format!("{output}\n{footer}")
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn format_number_small() {
192        assert_eq!(format_number(42), "42");
193        assert_eq!(format_number(999), "999");
194    }
195
196    #[test]
197    fn format_number_thousands() {
198        assert_eq!(format_number(1_000), "1,000");
199        assert_eq!(format_number(4_200), "4,200");
200        assert_eq!(format_number(9_999), "9,999");
201    }
202
203    #[test]
204    fn format_number_large() {
205        assert_eq!(format_number(12_300), "12.3k");
206        assert_eq!(format_number(45_200), "45.2k");
207    }
208
209    #[test]
210    fn format_number_millions() {
211        assert_eq!(format_number(1_500_000), "1.5M");
212    }
213
214    #[test]
215    fn basic_footer_format() {
216        let info = SavingsInfo {
217            original: 4200,
218            compressed: 840,
219            mode: Some("map"),
220            detail: None,
221        };
222        let result = format_footer_inner(&info);
223        assert!(
224            result.starts_with("\u{2500}\u{2500}\u{2500} "),
225            "should start with box-drawing: {result}"
226        );
227        assert!(
228            result.ends_with(" \u{2500}\u{2500}\u{2500}"),
229            "should end with box-drawing: {result}"
230        );
231        assert!(
232            result.contains("4,200"),
233            "should contain formatted original: {result}"
234        );
235        assert!(
236            result.contains("840"),
237            "should contain compressed: {result}"
238        );
239        assert!(
240            result.contains("\u{2193}80%"),
241            "should contain percentage: {result}"
242        );
243        assert!(
244            result.contains("mode: map"),
245            "should contain mode: {result}"
246        );
247    }
248
249    #[test]
250    fn footer_with_detail() {
251        let info = SavingsInfo {
252            original: 12300,
253            compressed: 620,
254            mode: None,
255            detail: Some("3 patterns matched"),
256        };
257        let result = format_footer_inner(&info);
258        assert!(
259            result.contains("3 patterns matched"),
260            "detail missing: {result}"
261        );
262        assert!(
263            result.contains("12.3k"),
264            "should format large numbers: {result}"
265        );
266    }
267
268    #[test]
269    fn footer_returns_empty_when_no_savings() {
270        let result = format_footer_inner(&SavingsInfo {
271            original: 100,
272            compressed: 100,
273            mode: None,
274            detail: None,
275        });
276        assert!(
277            result.is_empty(),
278            "should be empty with 0 savings: {result}"
279        );
280    }
281
282    #[test]
283    fn footer_returns_empty_when_zero_original() {
284        let result = format_footer_inner(&SavingsInfo {
285            original: 0,
286            compressed: 0,
287            mode: None,
288            detail: None,
289        });
290        assert!(
291            result.is_empty(),
292            "should be empty with 0 original: {result}"
293        );
294    }
295
296    #[test]
297    fn visibility_gated_tests() {
298        let _lock = crate::core::data_dir::test_env_lock();
299
300        crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
301        crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
302        let result = format_footer_basic(100, 50);
303        assert!(
304            result.is_empty(),
305            "should be empty with never mode: {result}"
306        );
307
308        let result = append_footer_basic("hello", 100, 50);
309        assert_eq!(result, "hello");
310
311        crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
312        crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "always");
313        crate::test_env::remove_var("LEAN_CTX_QUIET");
314        super::super::protocol::set_mcp_context(false);
315
316        let result = append_footer_basic("hello", 100, 50);
317        assert!(
318            result.starts_with("hello\n"),
319            "should start with original: {result}"
320        );
321        assert!(
322            result.contains("\u{2500}\u{2500}\u{2500}"),
323            "should contain box-drawing: {result}"
324        );
325
326        // Restore ALL touched env — leaking LEAN_CTX_SAVINGS_FOOTER=always
327        // made footers visible in unrelated tests (GL #556 flakiness).
328        crate::test_env::remove_var("LEAN_CTX_SHOW_SAVINGS");
329        crate::test_env::remove_var("LEAN_CTX_SAVINGS_FOOTER");
330    }
331
332    #[test]
333    fn session_accumulator_tracks() {
334        reset_session();
335        record_savings(100, 50);
336        record_savings(200, 80);
337        let (orig, saved, calls) = session_totals();
338        assert_eq!(orig, 300);
339        assert_eq!(saved, 130);
340        assert_eq!(calls, 2);
341        reset_session();
342    }
343
344    #[test]
345    fn session_total_shown_at_interval() {
346        reset_session();
347        for _ in 0..(SESSION_TOTAL_INTERVAL - 1) {
348            record_savings(100, 50);
349        }
350        let info = SavingsInfo {
351            original: 100,
352            compressed: 50,
353            mode: None,
354            detail: None,
355        };
356        let result = format_footer_inner(&info);
357        assert!(
358            result.contains("session:"),
359            "should contain session total at interval: {result}"
360        );
361        reset_session();
362    }
363
364    #[test]
365    fn mode_guard_sets_and_clears() {
366        assert!(current_mode().is_none());
367        {
368            let _guard = ModeGuard::new("map");
369            assert_eq!(current_mode().as_deref(), Some("map"));
370        }
371        assert!(current_mode().is_none());
372    }
373
374    #[test]
375    fn mode_guard_with_detail() {
376        {
377            let _guard = ModeGuard::with_detail("shell", "3 patterns");
378            assert_eq!(current_mode().as_deref(), Some("shell"));
379            assert_eq!(current_detail().as_deref(), Some("3 patterns"));
380        }
381        assert!(current_mode().is_none());
382        assert!(current_detail().is_none());
383    }
384}