Skip to main content

mcp_compressor_core/app/
banner.rs

1//! Terminal startup banner with compression statistics.
2
3use crate::compression::engine::Tool;
4use crate::compression::{CompressionEngine, CompressionLevel};
5
6const TITLE: &str = "\
7\x1b[32m█▀▄▀█ █▀▀ █▀█   █▀▀ █▀█ █▀▄▀█ █▀█ █▀█ █▀▀ █▀▀ █▀▀ █▀█ █▀█\x1b[0m
8\x1b[32m█ ▀ █ █▄▄ █▀▀   █▄▄ █▄█ █ ▀ █ █▀▀ █▀▄ ██▄ ▄▄█ ▄▄█ █▄█ █▀▄\x1b[0m";
9
10/// Compute compression statistics for all levels given a set of backend tools.
11pub fn compression_stats(tools: &[Tool]) -> CompressionStats {
12    let original: usize = tools
13        .iter()
14        .map(|t| {
15            let name_len = t.name.len();
16            let desc_len = t.description.as_deref().unwrap_or("").len();
17            let schema_len = t
18                .input_schema
19                .get("properties")
20                .and_then(|p| serde_json::to_string(p).ok())
21                .map(|s| s.len())
22                .unwrap_or(0);
23            name_len + desc_len + schema_len
24        })
25        .sum();
26
27    let levels = [
28        CompressionLevel::Low,
29        CompressionLevel::Medium,
30        CompressionLevel::High,
31        CompressionLevel::Max,
32    ];
33
34    let compressed: Vec<(CompressionLevel, usize)> = levels
35        .iter()
36        .map(|level| (level.clone(), compressed_frontend_size(tools, level)))
37        .collect();
38
39    CompressionStats {
40        original_size: original,
41        compressed,
42    }
43}
44
45pub struct CompressionStats {
46    pub original_size: usize,
47    pub compressed: Vec<(CompressionLevel, usize)>,
48}
49
50fn compressed_frontend_size(tools: &[Tool], level: &CompressionLevel) -> usize {
51    let engine = CompressionEngine::new(level.clone());
52    let listing = engine.format_listing(tools);
53
54    let get_tool_schema_description = format!(
55        "Get the complete schema and description for one backend tool. Available tools:\n{listing}"
56    );
57    let invoke_tool_description = "Invoke one backend tool by name with JSON input.";
58    let list_tools_description = "List backend tools available through this compressed MCP server.";
59
60    let schema_wrapper = serde_json::json!({
61        "type": "object",
62        "properties": {
63            "tool_name": {"type": "string", "description": "Name of the backend tool"}
64        },
65        "required": ["tool_name"]
66    });
67    let invoke_wrapper = serde_json::json!({
68        "type": "object",
69        "properties": {
70            "tool_name": {"type": "string", "description": "Name of the backend tool"},
71            "tool_input": {"type": "object", "description": "JSON input for the backend tool"}
72        },
73        "required": ["tool_name", "tool_input"]
74    });
75    let list_wrapper = serde_json::json!({
76        "type": "object",
77        "properties": {}
78    });
79
80    let mut size = get_tool_schema_description.len()
81        + invoke_tool_description.len()
82        + schema_wrapper.to_string().len()
83        + invoke_wrapper.to_string().len();
84
85    if *level == CompressionLevel::Max {
86        size += list_tools_description.len() + list_wrapper.to_string().len();
87    }
88
89    size
90}
91
92/// Print the startup banner with compression chart to stderr.
93pub fn print_banner(
94    server_name: Option<&str>,
95    transport_type: &str,
96    active_level: &CompressionLevel,
97    tools: &[Tool],
98    cli_info: Option<CliInfo<'_>>,
99) {
100    let columns = terminal_width().min(80);
101    if columns < 63 {
102        return;
103    }
104
105    let content_width = columns - 6;
106    let header = format!("╭{}╮", "─".repeat(columns - 2));
107    let footer = format!("╰{}╯", "─".repeat(columns - 2));
108    let separator = format!("├{}┤", "─".repeat(columns - 2));
109    let blank = format!("│{}│", " ".repeat(columns - 2));
110
111    let stats = compression_stats(tools);
112
113    let mut lines = vec![header.clone(), blank.clone()];
114    for title_line in TITLE.lines() {
115        lines.push(pad_line(title_line, content_width, true));
116    }
117    lines.push(blank.clone());
118    lines.push(pad_line(
119        "https://atlassian-labs.github.io/mcp-compressor/",
120        content_width,
121        true,
122    ));
123    if let Some(name) = server_name {
124        lines.push(blank.clone());
125        lines.push(pad_line(
126            &format!("\x1b[32m●\x1b[0m Backend server name: {name}"),
127            content_width,
128            false,
129        ));
130    }
131    lines.push(pad_line(
132        &format!(
133            "\x1b[32m●\x1b[0m Backend server transport: {}",
134            transport_type.to_uppercase()
135        ),
136        content_width,
137        false,
138    ));
139    lines.push(blank.clone());
140    lines.push(separator.clone());
141    lines.push(blank.clone());
142
143    lines.push(pad_line(
144        &format!(
145            "📊 Compression Statistics (current = {}):",
146            capitalize(active_level)
147        ),
148        content_width - 1,
149        false,
150    ));
151    lines.push(blank.clone());
152    lines.extend(format_chart(&stats, content_width, active_level));
153
154    if let Some(info) = cli_info {
155        lines.push(blank.clone());
156        lines.push(separator.clone());
157        lines.push(blank.clone());
158        if let Some(script) = info.script_path {
159            lines.push(pad_line(
160                &format!("Script:  {script}"),
161                content_width,
162                false,
163            ));
164        }
165        if let Some(bridge) = info.bridge_url {
166            lines.push(pad_line(
167                &format!("Bridge:  {bridge}"),
168                content_width,
169                false,
170            ));
171        }
172        if let Some(invoke) = info.invoke_prefix {
173            lines.push(pad_line(
174                &format!("Run:     {invoke} --help"),
175                content_width,
176                false,
177            ));
178        }
179    }
180
181    lines.push(blank.clone());
182    lines.push(footer);
183
184    eprintln!("{}", lines.join("\n"));
185}
186
187pub struct CliInfo<'a> {
188    pub script_path: Option<&'a str>,
189    pub bridge_url: Option<&'a str>,
190    pub invoke_prefix: Option<&'a str>,
191}
192
193fn format_chart(
194    stats: &CompressionStats,
195    width: usize,
196    active_level: &CompressionLevel,
197) -> Vec<String> {
198    let chart_width = width.saturating_sub(16);
199    let original = stats.original_size;
200    let mut lines = Vec::new();
201
202    // Original bar (100%)
203    let bar = "█".repeat(chart_width);
204    lines.push(pad_line(&format!("Original {bar} 100.0%"), width, false));
205
206    // Each compression level
207    let levels = [
208        CompressionLevel::Low,
209        CompressionLevel::Medium,
210        CompressionLevel::High,
211        CompressionLevel::Max,
212    ];
213    for level in &levels {
214        let size = stats
215            .compressed
216            .iter()
217            .find(|(l, _)| l == level)
218            .map(|(_, s)| *s)
219            .unwrap_or(0);
220        let ratio = if original > 0 {
221            size as f64 / original as f64
222        } else {
223            0.0
224        };
225        let filled = (ratio * chart_width as f64).round() as usize;
226        let filled = filled.min(chart_width);
227        let bar = format!("{}{}", "█".repeat(filled), "░".repeat(chart_width - filled));
228        let pct = ratio * 100.0;
229        let label = format!("{:<8}", capitalize(level));
230        let mut line = pad_line(&format!("{label} {bar} {pct:5.1}%"), width, false);
231
232        if level == active_level {
233            line = highlight_bar(&line);
234        }
235        lines.push(line);
236    }
237    lines
238}
239
240/// Highlight the filled █ portion of a bar line in green using char-safe splits.
241fn highlight_bar(line: &str) -> String {
242    // Find the first '░' (dim block) char position using char indices
243    if let Some(fade_byte) = line.char_indices().find(|(_, c)| *c == '░').map(|(i, _)| i) {
244        // Find the last '│' before the bar content — the prefix up to and including "│  "
245        // We work purely with byte positions from char_indices, so this is safe.
246        let prefix_end = line
247            .char_indices()
248            .take_while(|(_, c)| *c != '█' && *c != '░')
249            .last()
250            .map(|(i, c)| i + c.len_utf8())
251            .unwrap_or(0);
252        format!(
253            "{}\x1b[1;32m{}\x1b[0m{}",
254            &line[..prefix_end],
255            &line[prefix_end..fade_byte],
256            &line[fade_byte..]
257        )
258    } else {
259        // No dim blocks — whole bar is filled, highlight entirely
260        format!("\x1b[1;32m{line}\x1b[0m")
261    }
262}
263
264fn capitalize(level: &CompressionLevel) -> String {
265    let s = level.to_string();
266    let mut chars = s.chars();
267    match chars.next() {
268        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
269        None => String::new(),
270    }
271}
272
273fn pad_line(line: &str, total_width: usize, center: bool) -> String {
274    // Strip ANSI codes for width calculation
275    let clean: String = strip_ansi(line);
276    let clean_width = clean.chars().count();
277
278    if center {
279        let padding_total = total_width.saturating_sub(clean_width);
280        let padding_left = padding_total / 2;
281        let padding_right = padding_total - padding_left;
282        format!(
283            "│  {}{}{}  │",
284            " ".repeat(padding_left),
285            line,
286            " ".repeat(padding_right)
287        )
288    } else {
289        let padding_right = total_width.saturating_sub(clean_width);
290        format!("│  {}{}  │", line, " ".repeat(padding_right))
291    }
292}
293
294fn strip_ansi(s: &str) -> String {
295    let mut result = String::with_capacity(s.len());
296    let mut in_escape = false;
297    for c in s.chars() {
298        if in_escape {
299            if c.is_ascii_alphabetic() {
300                in_escape = false;
301            }
302        } else if c == '\x1b' {
303            in_escape = true;
304        } else {
305            result.push(c);
306        }
307    }
308    result
309}
310
311fn terminal_width() -> usize {
312    // Try to get terminal width; fall back to 80
313    #[cfg(unix)]
314    {
315        use std::mem::MaybeUninit;
316        unsafe {
317            let mut ws = MaybeUninit::<libc::winsize>::zeroed();
318            if libc::ioctl(2, libc::TIOCGWINSZ, ws.as_mut_ptr()) == 0 {
319                let ws = ws.assume_init();
320                if ws.ws_col > 0 {
321                    return ws.ws_col as usize;
322                }
323            }
324        }
325    }
326    80
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn highlight_bar_handles_unicode_box_border() {
335        let line =
336            "│  Medium   █████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  15.5%  │";
337        let highlighted = highlight_bar(line);
338        assert!(highlighted.contains("\x1b[1;32m"));
339        assert!(highlighted.contains("Medium"));
340        assert_eq!(strip_ansi(&highlighted), line);
341    }
342
343    #[test]
344    fn format_chart_only_shows_compression_levels() {
345        let stats = CompressionStats {
346            original_size: 129,
347            compressed: vec![
348                (CompressionLevel::Low, 80),
349                (CompressionLevel::Medium, 20),
350                (CompressionLevel::High, 10),
351                (CompressionLevel::Max, 5),
352            ],
353        };
354
355        let lines = format_chart(&stats, 80, &CompressionLevel::Medium);
356        assert_eq!(lines.len(), 5);
357        assert!(lines.iter().any(|line| line.contains("Original")));
358        assert!(lines.iter().any(|line| line.contains("Low")));
359        assert!(lines.iter().any(|line| line.contains("Medium")));
360        assert!(lines.iter().any(|line| line.contains("High")));
361        assert!(lines.iter().any(|line| line.contains("Max")));
362        assert!(!lines.iter().any(|line| line.contains("CLI mode")));
363        let medium = lines.iter().find(|line| line.contains("Medium")).unwrap();
364        assert!(medium.contains("\x1b[1;32m"));
365    }
366
367    #[test]
368    fn max_compression_stat_includes_wrapper_schema_surface() {
369        let tools = vec![Tool::new(
370            "echo",
371            Some("Echo a message".to_string()),
372            serde_json::json!({
373                "type": "object",
374                "properties": {"message": {"type": "string"}},
375                "required": ["message"]
376            }),
377        )];
378        let stats = compression_stats(&tools);
379        let max = stats
380            .compressed
381            .iter()
382            .find(|(level, _)| *level == CompressionLevel::Max)
383            .map(|(_, size)| *size)
384            .unwrap();
385        assert!(max > 0);
386    }
387}