feat(package/prettify-log): colored output for log keywords

This commit is contained in:
2025-02-01 00:08:57 +00:00
parent 4d900a5f23
commit 46ab7d9e52
5 changed files with 242 additions and 20 deletions

View File

@@ -33,6 +33,17 @@ dependencies = [
"wait-timeout", "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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.4.0" version = "1.4.0"
@@ -122,6 +133,15 @@ dependencies = [
"windows-targets", "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]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.14" version = "1.0.14"
@@ -202,9 +222,11 @@ name = "prettify-log"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"assert_cmd", "assert_cmd",
"atty",
"colored_json", "colored_json",
"float-cmp", "float-cmp",
"predicates", "predicates",
"regex",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
@@ -363,6 +385,28 @@ dependencies = [
"wit-bindgen-rt", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"

View File

@@ -4,7 +4,9 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
atty = "0.2.14"
colored_json = "5.0.0" colored_json = "5.0.0"
regex = "1.11.1"
serde = "1.0.217" serde = "1.0.217"
serde_json = "1.0.138" serde_json = "1.0.138"

View File

@@ -1,45 +1,102 @@
use std::io::{self, BufRead}; use std::io::{self, BufRead};
use std::env;
use serde_json::Value; 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 /// Finds the first '{' and tries to match nested braces until the corresponding '}'.
/// corresponding '}'. Returns (start, end) byte offsets if found.
fn find_json_block(line: &str) -> Option<(usize, usize)> { fn find_json_block(line: &str) -> Option<(usize, usize)> {
let start = line.find('{')?; let start = line.find('{')?;
let mut brace_count = 0; let mut brace_count = 0;
for (i, ch) in line[start..].char_indices() { for (i, ch) in line[start..].char_indices() {
if ch == '{' { match ch {
brace_count += 1; '{' => brace_count += 1,
} else if ch == '}' { '}' => {
brace_count -= 1; brace_count -= 1;
if brace_count == 0 { if brace_count == 0 {
// Return the byte-range of the entire JSON block
return Some((start, start + i + 1)); return Some((start, start + i + 1));
} }
} }
_ => {}
}
} }
None None
} }
fn main() -> io::Result<()> { /// Applies color to known log keywords (e.g. ERROR, DEBUG) without coloring the colon.
let stdin = io::stdin(); /// 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() { for line_result in stdin.lock().lines() {
let line = line_result?; let line = line_result?;
if let Some((start, end)) = find_json_block(&line) { if let Some((start, end)) = find_json_block(&line) {
let candidate = &line[start..end]; let candidate = &line[start..end];
if let Ok(json) = serde_json::from_str::<Value>(candidate) { if let Ok(json) = serde_json::from_str::<Value>(candidate) {
// Prettify and reconstruct the line let pretty = serde_json::to_string_pretty(&json).unwrap();
let pretty = serde_json::to_string_pretty(&json).unwrap().to_colored_json_auto().unwrap();
let prefix = &line[..start]; let colorized = if force_color {
let suffix = &line[end..]; pretty.to_colored_json(ColorMode::On).unwrap()
println!("{}{}{}", prefix, pretty, suffix); } 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; 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(()) Ok(())
} }

View File

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

View File

@@ -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()
);
}
}