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 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 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}