fix: suppress unused direction field warning in snapshot
This commit is contained in:
574
src/snapshot.rs
Normal file
574
src/snapshot.rs
Normal file
@@ -0,0 +1,574 @@
|
||||
//! HTTP/2 traffic snapshot parser.
|
||||
//!
|
||||
//! Parses Go's `GODEBUG=http2debug=2` output into structured,
|
||||
//! human-readable traffic snapshots. Can also be used to parse
|
||||
//! MITM proxy raw data dumps.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Read};
|
||||
|
||||
// ── Domain metadata ──────────────────────────────────────────────────────────
|
||||
|
||||
const DOMAIN_INFO: &[(&str, &str, &str)] = &[
|
||||
("antigravity-unleash.goog", "Feature Flags", "Unleash SDK — controls A/B tests and feature rollouts"),
|
||||
("daily-cloudcode-pa.googleapis.com", "LLM API (gRPC)", "Primary Gemini/Claude API endpoint"),
|
||||
("cloudcode-pa.googleapis.com", "LLM API (gRPC)", "Production Gemini/Claude API endpoint"),
|
||||
("api.anthropic.com", "Claude API", "Direct Anthropic API calls"),
|
||||
("lh3.googleusercontent.com", "Profile Picture", "User avatar"),
|
||||
("play.googleapis.com", "Telemetry", "Google Play telemetry"),
|
||||
("firebaseinstallations.googleapis.com", "Firebase", "Installation tracking"),
|
||||
("oauth2.googleapis.com", "OAuth", "Token refresh/exchange"),
|
||||
("speech.googleapis.com", "Speech", "Voice input processing"),
|
||||
("modelarmor.googleapis.com", "Safety", "Content safety/filtering"),
|
||||
];
|
||||
|
||||
fn domain_label(domain: &str) -> (&str, &str) {
|
||||
for &(d, label, desc) in DOMAIN_INFO {
|
||||
if domain == d || domain.ends_with(d) {
|
||||
return (label, desc);
|
||||
}
|
||||
}
|
||||
("External", "")
|
||||
}
|
||||
|
||||
// ── ANSI colors ──────────────────────────────────────────────────────────────
|
||||
|
||||
const BOLD: &str = "\x1b[1m";
|
||||
const DIM: &str = "\x1b[2m";
|
||||
#[allow(dead_code)]
|
||||
const RED: &str = "\x1b[91m";
|
||||
const GREEN: &str = "\x1b[92m";
|
||||
const YELLOW: &str = "\x1b[93m";
|
||||
const CYAN: &str = "\x1b[96m";
|
||||
const MAGENTA: &str = "\x1b[95m";
|
||||
const NC: &str = "\x1b[0m";
|
||||
|
||||
// ── Parsed types ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct HttpExchange {
|
||||
authority: String,
|
||||
method: String,
|
||||
path: String,
|
||||
headers: Vec<(String, String)>,
|
||||
body: Vec<u8>,
|
||||
body_total_len: usize,
|
||||
stream_id: Option<String>,
|
||||
_direction: Direction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Direction {
|
||||
Outgoing, // LS → upstream
|
||||
Incoming, // external → LS (our curl calls)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Snapshot {
|
||||
connections: Vec<String>,
|
||||
exchanges: Vec<HttpExchange>,
|
||||
ls_logs: Vec<String>,
|
||||
}
|
||||
|
||||
// ── Interesting headers to show prominently ──────────────────────────────────
|
||||
|
||||
const INTERESTING_HEADERS: &[&str] = &[
|
||||
"authorization",
|
||||
"content-type",
|
||||
"user-agent",
|
||||
"unleash-appname",
|
||||
"unleash-instanceid",
|
||||
"unleash-sdk",
|
||||
"x-goog-api-key",
|
||||
"x-goog-api-client",
|
||||
"grpc-encoding",
|
||||
"te",
|
||||
];
|
||||
|
||||
// ── Parser ───────────────────────────────────────────────────────────────────
|
||||
|
||||
impl Snapshot {
|
||||
fn parse(input: &str) -> Self {
|
||||
let mut snap = Snapshot::default();
|
||||
let mut current_headers: Vec<(String, String)> = Vec::new();
|
||||
let mut current_pseudo: HashMap<String, String> = HashMap::new();
|
||||
let mut current_direction = Direction::Outgoing;
|
||||
let mut current_stream: Option<String> = None;
|
||||
|
||||
for line in input.lines() {
|
||||
let line = line.trim_end();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// LS process logs
|
||||
if line.starts_with('I') || line.starts_with('W') || line.starts_with('E') {
|
||||
if line.len() > 4 && line.chars().nth(1).map_or(false, |c| c.is_ascii_digit()) {
|
||||
snap.ls_logs.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if line.contains("maxprocs:") {
|
||||
snap.ls_logs.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
// New connection
|
||||
if let Some(target) = extract_after(line, "Transport creating client conn") {
|
||||
let target = target.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
if let Some(to) = target.strip_prefix("to ") {
|
||||
snap.connections.push(to.to_string());
|
||||
} else {
|
||||
// e.g. "0x... to host:port"
|
||||
if let Some(pos) = target.find(" to ") {
|
||||
snap.connections.push(target[pos + 4..].to_string());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Outgoing request headers (Transport encoding header)
|
||||
if let Some((key, val)) = extract_header(line, "Transport encoding header") {
|
||||
if key == ":method" {
|
||||
// Finalize previous exchange
|
||||
if current_pseudo.contains_key(":path") || current_pseudo.contains_key(":method") {
|
||||
snap.finalize_exchange(¤t_pseudo, ¤t_headers, current_direction, current_stream.clone());
|
||||
}
|
||||
current_headers.clear();
|
||||
current_pseudo.clear();
|
||||
current_direction = Direction::Outgoing;
|
||||
current_stream = None;
|
||||
}
|
||||
if key.starts_with(':') {
|
||||
current_pseudo.insert(key, val);
|
||||
} else {
|
||||
current_headers.push((key, val));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Incoming / server-received headers
|
||||
if let Some((key, val)) = extract_header(line, "decoded hpack field header field") {
|
||||
if key == ":authority" && !line.contains("server read frame") {
|
||||
if current_pseudo.contains_key(":path") || current_pseudo.contains_key(":method") {
|
||||
snap.finalize_exchange(¤t_pseudo, ¤t_headers, current_direction, current_stream.clone());
|
||||
}
|
||||
current_headers.clear();
|
||||
current_pseudo.clear();
|
||||
current_direction = Direction::Incoming;
|
||||
current_stream = None;
|
||||
}
|
||||
if key.starts_with(':') {
|
||||
current_pseudo.insert(key, val);
|
||||
} else {
|
||||
current_headers.push((key, val));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// HEADERS frame (extracts stream ID)
|
||||
if line.contains("wrote HEADERS") {
|
||||
if let Some(stream) = extract_stream_id(line) {
|
||||
current_stream = Some(stream.clone());
|
||||
if current_pseudo.contains_key(":path") || current_pseudo.contains_key(":method") {
|
||||
let ex = snap.finalize_exchange(¤t_pseudo, ¤t_headers, current_direction, Some(stream));
|
||||
if ex.is_some() {
|
||||
current_headers.clear();
|
||||
current_pseudo.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// DATA frames
|
||||
if (line.contains("wrote DATA") || line.contains("read DATA") || line.contains("server read frame DATA"))
|
||||
&& line.contains("data=\"")
|
||||
{
|
||||
let is_outgoing = line.contains("wrote DATA") || line.contains("server read frame DATA");
|
||||
if let Some(stream) = extract_stream_id(line) {
|
||||
if let Some(data_str) = extract_data(line) {
|
||||
let raw = decode_go_escaped(&data_str);
|
||||
let len = extract_data_len(line).unwrap_or(raw.len());
|
||||
// Find matching exchange by stream
|
||||
for ex in snap.exchanges.iter_mut().rev() {
|
||||
if ex.stream_id.as_deref() == Some(&stream) && is_outgoing {
|
||||
ex.body.extend_from_slice(&raw);
|
||||
ex.body_total_len = ex.body_total_len.max(len);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize remaining
|
||||
if current_pseudo.contains_key(":path") || current_pseudo.contains_key(":method") {
|
||||
snap.finalize_exchange(¤t_pseudo, ¤t_headers, current_direction, current_stream);
|
||||
}
|
||||
|
||||
snap
|
||||
}
|
||||
|
||||
fn finalize_exchange(
|
||||
&mut self,
|
||||
pseudo: &HashMap<String, String>,
|
||||
headers: &[(String, String)],
|
||||
direction: Direction,
|
||||
stream_id: Option<String>,
|
||||
) -> Option<usize> {
|
||||
let method = pseudo.get(":method").cloned().unwrap_or_default();
|
||||
let path = pseudo.get(":path").cloned().unwrap_or_default();
|
||||
let authority = pseudo.get(":authority").cloned().unwrap_or_default();
|
||||
|
||||
if method.is_empty() && path.is_empty() && authority.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.exchanges.push(HttpExchange {
|
||||
authority,
|
||||
method: if method.is_empty() { "GET".into() } else { method },
|
||||
path,
|
||||
headers: headers.to_vec(),
|
||||
body: Vec::new(),
|
||||
body_total_len: 0,
|
||||
stream_id,
|
||||
_direction: direction,
|
||||
});
|
||||
Some(self.exchanges.len() - 1)
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
fn render(&self) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
let sep = "═".repeat(70);
|
||||
let sep_thin = "─".repeat(60);
|
||||
out.push_str(&format!("\n{BOLD}{CYAN}{sep}{NC}\n"));
|
||||
out.push_str(&format!("{BOLD}{CYAN} STANDALONE LS TRAFFIC SNAPSHOT{NC}\n"));
|
||||
out.push_str(&format!("{BOLD}{CYAN}{sep}{NC}\n\n"));
|
||||
|
||||
// LS Logs
|
||||
if !self.ls_logs.is_empty() {
|
||||
out.push_str(&format!("{BOLD}▸ Language Server Logs{NC}\n"));
|
||||
out.push_str(&format!("{DIM}{sep_thin}{NC}\n"));
|
||||
for log in &self.ls_logs {
|
||||
out.push_str(&format!(" {DIM}{log}{NC}\n"));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Connections
|
||||
if !self.connections.is_empty() {
|
||||
out.push_str(&format!("{BOLD}▸ Outbound Connections{NC}\n"));
|
||||
out.push_str(&format!("{DIM}{sep_thin}{NC}\n"));
|
||||
for target in &self.connections {
|
||||
let domain = target.split(':').next().unwrap_or(target);
|
||||
let (label, desc) = domain_label(domain);
|
||||
out.push_str(&format!(" {GREEN}→{NC} {BOLD}{target}{NC} {DIM}({label}){NC}\n"));
|
||||
if !desc.is_empty() {
|
||||
out.push_str(&format!(" {DIM}{desc}{NC}\n"));
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Group by domain
|
||||
let mut by_domain: Vec<(&str, Vec<&HttpExchange>)> = Vec::new();
|
||||
for ex in &self.exchanges {
|
||||
if let Some(entry) = by_domain.iter_mut().find(|(d, _)| *d == ex.authority.as_str()) {
|
||||
entry.1.push(ex);
|
||||
} else {
|
||||
by_domain.push((&ex.authority, vec![ex]));
|
||||
}
|
||||
}
|
||||
|
||||
for (domain, exchanges) in &by_domain {
|
||||
if domain.starts_with("127.0.0.1") {
|
||||
// Skip local requests (our own curl calls to the LS)
|
||||
continue;
|
||||
}
|
||||
|
||||
let (label, _desc) = domain_label(domain);
|
||||
let color = if label.contains("API") { YELLOW } else { CYAN };
|
||||
|
||||
out.push_str(&format!("\n{BOLD}{sep}{NC}\n"));
|
||||
out.push_str(&format!("{BOLD}{color} {domain}{NC} {DIM}— {label}{NC}\n"));
|
||||
out.push_str(&format!("{BOLD}{sep}{NC}\n"));
|
||||
|
||||
for ex in exchanges {
|
||||
let method_color = if ex.method == "GET" { GREEN } else { YELLOW };
|
||||
out.push_str(&format!("\n {BOLD}→ {method_color}{}{NC} {}\n", ex.method, ex.path));
|
||||
|
||||
// Interesting headers
|
||||
for (key, val) in &ex.headers {
|
||||
let key_lower = key.to_lowercase();
|
||||
if INTERESTING_HEADERS.contains(&key_lower.as_str()) {
|
||||
let display_val = mask_token(&key_lower, val);
|
||||
out.push_str(&format!(" {DIM}{key}:{NC} {display_val}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// Other headers
|
||||
for (key, val) in &ex.headers {
|
||||
let key_lower = key.to_lowercase();
|
||||
if !INTERESTING_HEADERS.contains(&key_lower.as_str()) {
|
||||
out.push_str(&format!(" {DIM}{key}:{NC} {val}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
if !ex.body.is_empty() {
|
||||
out.push_str(&render_body(&ex.body, ex.body_total_len));
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ── Body rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
fn render_body(data: &[u8], total_len: usize) -> String {
|
||||
let mut out = String::new();
|
||||
let len = total_len.max(data.len());
|
||||
|
||||
// Try JSON
|
||||
if let Ok(text) = std::str::from_utf8(data) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(text) {
|
||||
let pretty = serde_json::to_string_pretty(&val).unwrap_or_default();
|
||||
out.push_str(&format!(" {BOLD}Body ({len} bytes, JSON):{NC}\n"));
|
||||
for (i, line) in pretty.lines().enumerate() {
|
||||
if i >= 40 {
|
||||
out.push_str(&format!(" {DIM}... ({} more lines){NC}\n", pretty.lines().count() - 40));
|
||||
break;
|
||||
}
|
||||
out.push_str(&format!(" {GREEN}{line}{NC}\n"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// Try gzip → JSON
|
||||
if data.len() >= 2 && data[0] == 0x1f && data[1] == 0x8b {
|
||||
if let Ok(decompressed) = decompress_gzip(data) {
|
||||
if let Ok(text) = std::str::from_utf8(&decompressed) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(text) {
|
||||
let pretty = serde_json::to_string_pretty(&val).unwrap_or_default();
|
||||
out.push_str(&format!(" {BOLD}Body ({len} bytes gzip → {} bytes, JSON):{NC}\n", decompressed.len()));
|
||||
for (i, line) in pretty.lines().enumerate() {
|
||||
if i >= 50 {
|
||||
out.push_str(&format!(" {DIM}... ({} more lines){NC}\n", pretty.lines().count() - 50));
|
||||
break;
|
||||
}
|
||||
out.push_str(&format!(" {GREEN}{line}{NC}\n"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Plain text
|
||||
out.push_str(&format!(" {BOLD}Body ({len} bytes gzip → {} bytes, text):{NC}\n", decompressed.len()));
|
||||
for line in text.lines().take(20) {
|
||||
out.push_str(&format!(" {line}\n"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Binary gzip
|
||||
out.push_str(&format!(" {BOLD}Body ({len} bytes gzip → {} bytes, binary):{NC}\n", decompressed.len()));
|
||||
let strings = extract_strings(&decompressed);
|
||||
for s in strings.iter().take(15) {
|
||||
out.push_str(&format!(" {MAGENTA}{s}{NC}\n"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// PNG
|
||||
if data.len() >= 4 && &data[..4] == b"\x89PNG" {
|
||||
out.push_str(&format!(" {BOLD}Body ({len} bytes, PNG image){NC}\n"));
|
||||
return out;
|
||||
}
|
||||
|
||||
// Protobuf / binary with string extraction
|
||||
let strings = extract_strings(data);
|
||||
if !strings.is_empty() {
|
||||
let kind = if !data.is_empty() && matches!(data[0], 0x08 | 0x0a | 0x10 | 0x12 | 0x18 | 0x1a | 0x20 | 0x22) {
|
||||
"protobuf"
|
||||
} else {
|
||||
"binary"
|
||||
};
|
||||
out.push_str(&format!(" {BOLD}Body ({len} bytes, {kind}):{NC}\n"));
|
||||
out.push_str(&format!(" {DIM}Extracted strings:{NC}\n"));
|
||||
for s in strings.iter().take(20) {
|
||||
out.push_str(&format!(" {MAGENTA}{s}{NC}\n"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Plain text fallback
|
||||
if let Ok(text) = std::str::from_utf8(data) {
|
||||
out.push_str(&format!(" {BOLD}Body ({len} bytes, text):{NC}\n"));
|
||||
for line in text.lines().take(10) {
|
||||
out.push_str(&format!(" {line}\n"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
out.push_str(&format!(" {BOLD}Body ({len} bytes, binary){NC}\n"));
|
||||
out
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn extract_after<'a>(line: &'a str, pattern: &str) -> Option<&'a str> {
|
||||
line.find(pattern).map(|pos| &line[pos + pattern.len()..])
|
||||
}
|
||||
|
||||
fn extract_header(line: &str, pattern: &str) -> Option<(String, String)> {
|
||||
if !line.contains(pattern) {
|
||||
return None;
|
||||
}
|
||||
// Pattern: ... "key" = "value"
|
||||
let after = extract_after(line, pattern)?;
|
||||
let first_quote = after.find('"')?;
|
||||
let rest = &after[first_quote + 1..];
|
||||
let end_key = rest.find('"')?;
|
||||
let key = &rest[..end_key];
|
||||
|
||||
let after_eq = &rest[end_key + 1..];
|
||||
let val_start = after_eq.find('"')?;
|
||||
let val_rest = &after_eq[val_start + 1..];
|
||||
let end_val = val_rest.rfind('"').unwrap_or(val_rest.len());
|
||||
let val = &val_rest[..end_val];
|
||||
|
||||
Some((key.to_string(), val.to_string()))
|
||||
}
|
||||
|
||||
fn extract_stream_id(line: &str) -> Option<String> {
|
||||
let pos = line.find("stream=")?;
|
||||
let rest = &line[pos + 7..];
|
||||
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
fn extract_data(line: &str) -> Option<String> {
|
||||
let pos = line.find("data=\"")?;
|
||||
let rest = &line[pos + 6..];
|
||||
// Find the closing quote — but beware of escaped quotes
|
||||
let mut end = 0;
|
||||
let chars: Vec<char> = rest.chars().collect();
|
||||
while end < chars.len() {
|
||||
if chars[end] == '"' && (end == 0 || chars[end - 1] != '\\') {
|
||||
break;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
fn extract_data_len(line: &str) -> Option<usize> {
|
||||
let pos = line.find("len=")?;
|
||||
let rest = &line[pos + 4..];
|
||||
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
|
||||
rest[..end].parse().ok()
|
||||
}
|
||||
|
||||
fn decode_go_escaped(s: &str) -> Vec<u8> {
|
||||
let mut result = Vec::new();
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'\\' && i + 1 < bytes.len() {
|
||||
match bytes[i + 1] {
|
||||
b'x' if i + 3 < bytes.len() => {
|
||||
if let Ok(b) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 2..i + 4]).unwrap_or(""), 16) {
|
||||
result.push(b);
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
b'n' => { result.push(b'\n'); i += 2; continue; }
|
||||
b'r' => { result.push(b'\r'); i += 2; continue; }
|
||||
b't' => { result.push(b'\t'); i += 2; continue; }
|
||||
b'\\' => { result.push(b'\\'); i += 2; continue; }
|
||||
b'"' => { result.push(b'"'); i += 2; continue; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn extract_strings(data: &[u8]) -> Vec<String> {
|
||||
let mut strings = Vec::new();
|
||||
let mut current = Vec::new();
|
||||
for &b in data {
|
||||
if (32..=126).contains(&b) {
|
||||
current.push(b);
|
||||
} else {
|
||||
if current.len() >= 4 {
|
||||
if let Ok(s) = std::str::from_utf8(¤t) {
|
||||
if !strings.contains(&s.to_string()) {
|
||||
strings.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
if current.len() >= 4 {
|
||||
if let Ok(s) = std::str::from_utf8(¤t) {
|
||||
if !strings.contains(&s.to_string()) {
|
||||
strings.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
strings.truncate(30);
|
||||
strings
|
||||
}
|
||||
|
||||
fn mask_token(key: &str, val: &str) -> String {
|
||||
if key == "authorization" && val.len() > 30 {
|
||||
if let Some(rest) = val.strip_prefix("Bearer ") {
|
||||
if rest.len() > 20 {
|
||||
return format!("Bearer {}...{}", &rest[..12], &rest[rest.len() - 8..]);
|
||||
}
|
||||
}
|
||||
if val.len() > 40 {
|
||||
return format!("{}...{}", &val[..25], &val[val.len() - 8..]);
|
||||
}
|
||||
}
|
||||
val.to_string()
|
||||
}
|
||||
|
||||
fn decompress_gzip(data: &[u8]) -> Result<Vec<u8>, io::Error> {
|
||||
use flate2::read::GzDecoder;
|
||||
let mut decoder = GzDecoder::new(data);
|
||||
let mut out = Vec::new();
|
||||
decoder.read_to_end(&mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ── CLI entry point ──────────────────────────────────────────────────────────
|
||||
|
||||
pub fn run_cli() {
|
||||
let input = if let Some(path) = std::env::args().nth(1) {
|
||||
std::fs::read_to_string(&path).unwrap_or_else(|e| {
|
||||
eprintln!("Failed to read {path}: {e}");
|
||||
std::process::exit(1);
|
||||
})
|
||||
} else {
|
||||
let mut buf = String::new();
|
||||
io::stdin().lock().read_to_string(&mut buf).expect("Failed to read stdin");
|
||||
buf
|
||||
};
|
||||
|
||||
let snap = Snapshot::parse(&input);
|
||||
print!("{}", snap.render());
|
||||
}
|
||||
Reference in New Issue
Block a user