//! LogCompressor — Build log compression using M3.7.7 patterns //! //! Reuses failure signature extraction from lesson.rs: //! - markers() for error line detection //! - is_cascade() for noise suppression //! - strip_ansi() for ANSI code cleanup use anyhow::Result; pub struct LogCompressor; impl LogCompressor { pub fn new() -> Self { Self } /// Compress logs by keeping errors, stack traces, and dropping noise pub fn compress(&self, content: &str) -> Result { // Step 1: Strip ANSI codes let cleaned = strip_ansi(content); // Step 2: Filter to keep only meaningful lines let lines: Vec<&str> = cleaned.lines().collect(); let mut result_lines = Vec::new(); for (i, line) in lines.iter().enumerate() { let trimmed = line.trim(); // Skip empty lines if trimmed.is_empty() { continue; } // Always keep error lines if is_error_line(line) { result_lines.push(*line); // Look ahead for stack trace lines if i + 1 < lines.len() { let next = lines[i + 1]; if is_stack_line(next) { result_lines.push(next); } } continue; } // Skip noisy patterns if is_noise_line(trimmed) { continue; } // Keep important lines if is_important_line(line) { result_lines.push(*line); } } // Step 3: Remove cascade duplicates let deduped = remove_cascade(result_lines); Ok(deduped.join("\n")) } } impl Default for LogCompressor { fn default() -> Self { Self::new() } } /// Strip ANSI color/style codes fn strip_ansi(s: &str) -> String { // Simple ANSI code removal: \x1b[...m patterns let ansi_re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap_or_else(|_| { regex::Regex::new(r"\[0m").unwrap() }); ansi_re.replace_all(s, "").to_string() } /// Check if line is an error marker fn is_error_line(line: &str) -> bool { let line_lower = line.to_lowercase(); line_lower.contains("error:") || line_lower.contains("err!") || line_lower.contains("panic:") || line_lower.contains("fatal:") || line_lower.contains("exception") || line.contains("FAIL") || line.contains("Error") || line.contains("ERROR") || line.contains("panic") || line.contains("error[") || line.contains("at line") } /// Check if line is part of a stack trace (indented) fn is_stack_line(line: &str) -> bool { // Stack trace lines are usually indented and contain location info (line.starts_with('\t') || line.starts_with(" ")) && (line.contains("at ") || line.contains("in file ") || line.contains("line ") || line.contains(".rs:") // Rust || line.contains(".py:") // Python || line.contains(".js:") // JavaScript || line.contains(".go:") // Go || line.contains(".java:")) // Java } /// Check if line is noisy and should be dropped fn is_noise_line(line: &str) -> bool { let line_lower = line.to_lowercase(); // Drop common noise patterns line_lower.contains("warn") || line_lower.contains("notice") || line_lower.contains("optional dependency") || line_lower.contains("skipping") || line_lower.contains("deprecated") || line_lower.contains("compiling") || line_lower.contains("finished") || line_lower.contains("compiling") || line_lower.contains("warning:") || line_lower.contains("created a lockfile") || line_lower.contains("you should commit") || line.contains(">>>") } /// Check if line contains important exit info fn is_important_line(line: &str) -> bool { let line_lower = line.to_lowercase(); line_lower.contains("exit code:") || line_lower.contains("exited with") || line_lower.contains("failed with") || line_lower.contains("status:") || line_lower.contains("error found") || line_lower.contains("aborting due to") } /// Remove cascade (repeated identical lines) fn remove_cascade(lines: Vec<&str>) -> Vec { let mut result = Vec::new(); let mut prev = ""; for line in lines { if !line.trim().is_empty() && line != prev { result.push(line.to_string()); prev = line; } } result } #[cfg(test)] mod tests { use super::*; #[test] fn test_strip_ansi() { let input = "Normal text \x1b[31mRED\x1b[0m normal"; let cleaned = strip_ansi(input); assert!(!cleaned.contains("\x1b")); } #[test] fn test_error_line_detection() { assert!(is_error_line("ERROR: connection timeout")); assert!(is_error_line("panic: index out of bounds")); assert!(is_error_line("npm ERR! 404 not found")); assert!(!is_error_line("Info: compiling module")); } #[test] fn test_stack_line_detection() { assert!(is_stack_line(" at main.rs:42")); assert!(is_stack_line(" in file test.py line 10")); assert!(is_stack_line("\tat String.js:5")); assert!(!is_stack_line("Normal text")); } #[test] fn test_cascade_removal() { let lines = vec!["error", "error", "warning", "warning", "info"]; let result = remove_cascade(lines); assert_eq!(result, vec!["error", "warning", "info"]); } #[test] fn test_compress_npm_error_log() { let log = r#" npm WARN deprecated info@0.8.0: package no longer supported npm notice created a lockfile as package-lock.json. You should commit this file. npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.3.2 (node_modules/fsevents): npm ERR! 404 Not Found - GET https://registry.npmjs.org/react@99.0.0 npm ERR! 404 npm ERR! 404 Not Found - GET https://registry.npmjs.org/react@99.0.0 npm ERR! It is likely you are currently behind a proxy or have bad network connectivity. npm ERR! If you are behind a proxy, please make sure that the proxy url is correct. npm ERR! A complete log of this error can be found in: npm ERR! /home/user/.npm/_logs/2026-08-28T09_15_00_000Z-debug-0.log "#; let compressor = LogCompressor::new(); let result = compressor.compress(log).unwrap(); // Should keep error lines assert!(result.to_lowercase().contains("npm err!")); assert!(result.contains("404")); // Should drop warning/info noise assert!(!result.contains("WARN deprecated")); assert!(!result.contains("notice created")); // Result should be smaller (some lines dropped) assert!(result.len() < log.len()); } #[test] fn test_compress_rust_error_log() { let log = r#" Compiling myapp v0.1.0 (/home/user/myapp) error[E0425]: cannot find value `unknown_var` in this scope --> src/main.rs:42:5 | 42 | unknown_var = 42; | ^^^^^^^^^^^ not found in this scope error: aborting due to 1 previous error warning: build succeeded with warnings "#; let compressor = LogCompressor::new(); let result = compressor.compress(log).unwrap(); // Should keep error lines assert!(result.contains("error[E0425]")); assert!(result.contains("src/main.rs:42")); // Should drop compilation success noise assert!(!result.contains("Compiling myapp")); } #[test] fn test_compression_ratio() { let log = "INFO: Starting process\n".repeat(100) + "ERROR: failed to connect\n" + "ERROR: retry attempt 1\n" + "ERROR: retry attempt 2\n"; let compressor = LogCompressor::new(); let compressed = compressor.compress(&log).unwrap(); // Should be much smaller (dropped 100 INFO lines) assert!(compressed.len() < log.len() / 10); } }