diff --git a/package/prettify-log/Cargo.lock b/package/prettify-log/Cargo.lock index 8e531c2..24c06c2 100644 --- a/package/prettify-log/Cargo.lock +++ b/package/prettify-log/Cargo.lock @@ -33,6 +33,17 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -122,6 +133,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "itoa" version = "1.0.14" @@ -202,9 +222,11 @@ name = "prettify-log" version = "0.1.0" dependencies = [ "assert_cmd", + "atty", "colored_json", "float-cmp", "predicates", + "regex", "serde", "serde_json", "tempfile", @@ -363,6 +385,28 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/package/prettify-log/Cargo.toml b/package/prettify-log/Cargo.toml index f463475..14be183 100644 --- a/package/prettify-log/Cargo.toml +++ b/package/prettify-log/Cargo.toml @@ -4,7 +4,9 @@ version = "0.1.0" edition = "2021" [dependencies] +atty = "0.2.14" colored_json = "5.0.0" +regex = "1.11.1" serde = "1.0.217" serde_json = "1.0.138" diff --git a/package/prettify-log/src/main.rs b/package/prettify-log/src/main.rs index 536299b..4011fb0 100644 --- a/package/prettify-log/src/main.rs +++ b/package/prettify-log/src/main.rs @@ -1,45 +1,102 @@ use std::io::{self, BufRead}; +use std::env; use serde_json::Value; -use colored_json::ToColoredJson; +use colored_json::{ColorMode, ToColoredJson}; +use regex::Regex; -/// Finds the first '{' and tries to match nested braces until the -/// corresponding '}'. Returns (start, end) byte offsets if found. +/// Finds the first '{' and tries to match nested braces until the corresponding '}'. fn find_json_block(line: &str) -> Option<(usize, usize)> { let start = line.find('{')?; let mut brace_count = 0; for (i, ch) in line[start..].char_indices() { - if ch == '{' { - brace_count += 1; - } else if ch == '}' { - brace_count -= 1; - if brace_count == 0 { - // Return the byte-range of the entire JSON block - return Some((start, start + i + 1)); + match ch { + '{' => brace_count += 1, + '}' => { + brace_count -= 1; + if brace_count == 0 { + return Some((start, start + i + 1)); + } } + _ => {} } } None } -fn main() -> io::Result<()> { - let stdin = io::stdin(); +/// Applies color to known log keywords (e.g. ERROR, DEBUG) without coloring the colon. +/// Captures the preceding boundary, the keyword, and the colon, then colors only the keyword. +fn colorize_keywords(line: &str) -> String { + let red = "\x1b[31m"; + let blue = "\x1b[34m"; + let green = "\x1b[32m"; + let yellow = "\x1b[33m"; + let magenta = "\x1b[35m"; + let cyan = "\x1b[36m"; + let reset = "\x1b[0m"; + let patterns = vec![ + (Regex::new(r"(?i)(^|[^A-Za-z])(ERROR)(:)").unwrap(), format!("$1{red}$2{reset}$3", red=red, reset=reset)), + (Regex::new(r"(?i)(^|[^A-Za-z])(DEBUG)(:)").unwrap(), format!("$1{blue}$2{reset}$3", blue=blue, reset=reset)), + (Regex::new(r"(?i)(^|[^A-Za-z])(INFO)(:)").unwrap(), format!("$1{green}$2{reset}$3", green=green, reset=reset)), + (Regex::new(r"(?i)(^|[^A-Za-z])(LOG)(:)").unwrap(), format!("$1{green}$2{reset}$3", green=green, reset=reset)), + (Regex::new(r"(?i)(^|[^A-Za-z])(EXCEPTION)(:)").unwrap(), format!("$1{magenta}$2{reset}$3", magenta=magenta, reset=reset)), + (Regex::new(r"(?i)(^|[^A-Za-z])(WARNING)(:)").unwrap(), format!("$1{yellow}$2{reset}$3", yellow=yellow, reset=reset)), + (Regex::new(r"(?i)(^|[^A-Za-z])(NOTICE)(:)").unwrap(), format!("$1{cyan}$2{reset}$3", cyan=cyan, reset=reset)), + (Regex::new(r"(?i)(^|[^A-Za-z])(HINT)(:)").unwrap(), format!("$1{cyan}$2{reset}$3", cyan=cyan, reset=reset)), + (Regex::new(r"(?i)(^|[^A-Za-z])(FATAL)(:)").unwrap(), format!("$1{magenta}$2{reset}$3", magenta=magenta, reset=reset)), + (Regex::new(r"(?i)(^|[^A-Za-z])(DETAIL)(:)").unwrap(), format!("$1{cyan}$2{reset}$3", cyan=cyan, reset=reset)), + (Regex::new(r"(?i)(^|[^A-Za-z])(STATEMENT)(:)").unwrap(), format!("$1{cyan}$2{reset}$3", cyan=cyan, reset=reset)), + ]; + + let mut out = String::from(line); + for (re, replacement) in patterns { + // Replace all occurrences + out = re.replace_all(&out, replacement.as_str()).to_string(); + } + out +} + +fn conditionally_colorize_keywords<'a>(line: &'a str, force_color: bool) -> String { + if force_color { + colorize_keywords(&line) + } else if atty::is(atty::Stream::Stdout) { + colorize_keywords(&line) + } else { + line.to_string() + } +} + +fn main() -> io::Result<()> { + let mut force_color = false; + for arg in env::args().skip(1) { + if arg == "--color-output" { + force_color = true; + } + } + + let stdin = io::stdin(); for line_result in stdin.lock().lines() { let line = line_result?; if let Some((start, end)) = find_json_block(&line) { let candidate = &line[start..end]; if let Ok(json) = serde_json::from_str::(candidate) { - // Prettify and reconstruct the line - let pretty = serde_json::to_string_pretty(&json).unwrap().to_colored_json_auto().unwrap(); - let prefix = &line[..start]; - let suffix = &line[end..]; - println!("{}{}{}", prefix, pretty, suffix); + let pretty = serde_json::to_string_pretty(&json).unwrap(); + + let colorized = if force_color { + pretty.to_colored_json(ColorMode::On).unwrap() + } else { + pretty.to_colored_json_auto().unwrap() + }; + + let prefix = conditionally_colorize_keywords(&line[..start], force_color); + let suffix = conditionally_colorize_keywords(&line[end..], force_color); + println!("{}{}{}", prefix, colorized, suffix); continue; } } - // If no valid JSON found, print as-is - println!("{}", line); - } + // If no valid JSON found or parsing fails, print unchanged + println!("{}", conditionally_colorize_keywords(&line, force_color)); + } Ok(()) } diff --git a/package/prettify-log/test/fixture/expected-colored.log b/package/prettify-log/test/fixture/expected-colored.log new file mode 100644 index 0000000..c138834 --- /dev/null +++ b/package/prettify-log/test/fixture/expected-colored.log @@ -0,0 +1,87 @@ +Debug: respons { + "some": "name", + "value": 12 +} is received +{timespan} Info: some log without json +Error: { + "code": 500, + "error": "Something went wrong" +} +Warning: Invalid data format detected +{ + "data": { + "id": 1, + "name": "test" + }, + "status": "ok" +} +User log: { + "action": "login", + "timestamp": "2025-01-31T12:00:00Z", + "user": "john_doe" +} +Random text without json +Debug: Payload sent: { + "request": { + "body": { + "key": "value" + }, + "type": "POST" + } +} +Another line with no json +{meta} Log: {"action": "update", "success": true} +Normal message with { + "json": "inside" +} text +{ + "array": [ + 1, + 2, + 3 + ], + "nested": { + "deep": { + "value": "found" + } + } +} +{2025-01-31} Event: {"event": "created", "user": "admin"} +Plain text that should not be modified +Info: { + "details": "this is a test log", + "level": "info" +} +{ + "array": [ + { + "id": 1 + }, + { + "id": 2 + }, + { + "id": 3 + } + ] +} +An unformatted json log { + "name": "example", + "valid": true +} +Debug: { + "data": "test", + "items": [ + { + "x": 10, + "y": 20 + } + ] +} +Non-json message with curly braces {like this} +{ + "key1": "value1", + "key2": { + "subkey": "subvalue" + } +} diff --git a/package/prettify-log/test/prettify.rs b/package/prettify-log/test/prettify.rs index 505c1a6..667e3f9 100644 --- a/package/prettify-log/test/prettify.rs +++ b/package/prettify-log/test/prettify.rs @@ -34,3 +34,35 @@ fn test_prettify() { ); } } + +#[test] +fn test_prettify_colored() { + let input_file = "test/fixture/test.log"; + let expected_output = fs::read_to_string("test/fixture/expected-colored.log") + .expect("Failed to read expected.log"); + + let mut actual_output_file = NamedTempFile::new().expect("Failed to create temp file"); + + let output = Command::new("cargo") + .args(&["run", "--quiet", "--", "--color-output"]) + .stdin(fs::File::open(input_file).expect("Failed to open test.log")) + .output() + .expect("Failed to run prettify_logs"); + + assert!(output.status.success()); + + actual_output_file + .write_all(&output.stdout) + .expect("Failed to write output"); + + let actual_output = String::from_utf8_lossy(&output.stdout); + + assert_eq!(actual_output.trim(), expected_output.trim(), "Output does not match expected.log"); + + if actual_output.trim() != expected_output.trim() { + eprintln!( + "Test failed! Actual output saved to: {}", + actual_output_file.path().display() + ); + } +}