feat(package/prettify-log): colored output for log keywords
This commit is contained in:
44
package/prettify-log/Cargo.lock
generated
44
package/prettify-log/Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
87
package/prettify-log/test/fixture/expected-colored.log
Normal file
87
package/prettify-log/test/fixture/expected-colored.log
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
[34mDebug[0m: respons [1m{[0m
|
||||||
|
[1;34m"[0m[1;34msome[0m[1;34m"[0m: [32m"[0m[32mname[0m[32m"[0m,
|
||||||
|
[1;34m"[0m[1;34mvalue[0m[1;34m"[0m: 12[1m
|
||||||
|
}[0m is received
|
||||||
|
{timespan} [32mInfo[0m: some log without json
|
||||||
|
[31mError[0m: [1m{[0m
|
||||||
|
[1;34m"[0m[1;34mcode[0m[1;34m"[0m: 500,
|
||||||
|
[1;34m"[0m[1;34merror[0m[1;34m"[0m: [32m"[0m[32mSomething went wrong[0m[32m"[0m[1m
|
||||||
|
}[0m
|
||||||
|
[33mWarning[0m: Invalid data format detected
|
||||||
|
[1m{[0m
|
||||||
|
[1;34m"[0m[1;34mdata[0m[1;34m"[0m: [1m{[0m
|
||||||
|
[1;34m"[0m[1;34mid[0m[1;34m"[0m: 1,
|
||||||
|
[1;34m"[0m[1;34mname[0m[1;34m"[0m: [32m"[0m[32mtest[0m[32m"[0m[1m
|
||||||
|
}[0m,
|
||||||
|
[1;34m"[0m[1;34mstatus[0m[1;34m"[0m: [32m"[0m[32mok[0m[32m"[0m[1m
|
||||||
|
}[0m
|
||||||
|
User [32mlog[0m: [1m{[0m
|
||||||
|
[1;34m"[0m[1;34maction[0m[1;34m"[0m: [32m"[0m[32mlogin[0m[32m"[0m,
|
||||||
|
[1;34m"[0m[1;34mtimestamp[0m[1;34m"[0m: [32m"[0m[32m2025-01-31T12:00:00Z[0m[32m"[0m,
|
||||||
|
[1;34m"[0m[1;34muser[0m[1;34m"[0m: [32m"[0m[32mjohn_doe[0m[32m"[0m[1m
|
||||||
|
}[0m
|
||||||
|
Random text without json
|
||||||
|
[34mDebug[0m: Payload sent: [1m{[0m
|
||||||
|
[1;34m"[0m[1;34mrequest[0m[1;34m"[0m: [1m{[0m
|
||||||
|
[1;34m"[0m[1;34mbody[0m[1;34m"[0m: [1m{[0m
|
||||||
|
[1;34m"[0m[1;34mkey[0m[1;34m"[0m: [32m"[0m[32mvalue[0m[32m"[0m[1m
|
||||||
|
}[0m,
|
||||||
|
[1;34m"[0m[1;34mtype[0m[1;34m"[0m: [32m"[0m[32mPOST[0m[32m"[0m[1m
|
||||||
|
}[0m[1m
|
||||||
|
}[0m
|
||||||
|
Another line with no json
|
||||||
|
{meta} [32mLog[0m: {"action": "update", "success": true}
|
||||||
|
Normal message with [1m{[0m
|
||||||
|
[1;34m"[0m[1;34mjson[0m[1;34m"[0m: [32m"[0m[32minside[0m[32m"[0m[1m
|
||||||
|
}[0m text
|
||||||
|
[1m{[0m
|
||||||
|
[1;34m"[0m[1;34marray[0m[1;34m"[0m: [1m[[0m
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3[1m
|
||||||
|
][0m,
|
||||||
|
[1;34m"[0m[1;34mnested[0m[1;34m"[0m: [1m{[0m
|
||||||
|
[1;34m"[0m[1;34mdeep[0m[1;34m"[0m: [1m{[0m
|
||||||
|
[1;34m"[0m[1;34mvalue[0m[1;34m"[0m: [32m"[0m[32mfound[0m[32m"[0m[1m
|
||||||
|
}[0m[1m
|
||||||
|
}[0m[1m
|
||||||
|
}[0m
|
||||||
|
{2025-01-31} Event: {"event": "created", "user": "admin"}
|
||||||
|
Plain text that should not be modified
|
||||||
|
[32mInfo[0m: [1m{[0m
|
||||||
|
[1;34m"[0m[1;34mdetails[0m[1;34m"[0m: [32m"[0m[32mthis is a test log[0m[32m"[0m,
|
||||||
|
[1;34m"[0m[1;34mlevel[0m[1;34m"[0m: [32m"[0m[32minfo[0m[32m"[0m[1m
|
||||||
|
}[0m
|
||||||
|
[1m{[0m
|
||||||
|
[1;34m"[0m[1;34marray[0m[1;34m"[0m: [1m[[0m
|
||||||
|
[1m{[0m
|
||||||
|
[1;34m"[0m[1;34mid[0m[1;34m"[0m: 1[1m
|
||||||
|
}[0m,
|
||||||
|
[1m{[0m
|
||||||
|
[1;34m"[0m[1;34mid[0m[1;34m"[0m: 2[1m
|
||||||
|
}[0m,
|
||||||
|
[1m{[0m
|
||||||
|
[1;34m"[0m[1;34mid[0m[1;34m"[0m: 3[1m
|
||||||
|
}[0m[1m
|
||||||
|
][0m[1m
|
||||||
|
}[0m
|
||||||
|
An unformatted json log [1m{[0m
|
||||||
|
[1;34m"[0m[1;34mname[0m[1;34m"[0m: [32m"[0m[32mexample[0m[32m"[0m,
|
||||||
|
[1;34m"[0m[1;34mvalid[0m[1;34m"[0m: true[1m
|
||||||
|
}[0m
|
||||||
|
[34mDebug[0m: [1m{[0m
|
||||||
|
[1;34m"[0m[1;34mdata[0m[1;34m"[0m: [32m"[0m[32mtest[0m[32m"[0m,
|
||||||
|
[1;34m"[0m[1;34mitems[0m[1;34m"[0m: [1m[[0m
|
||||||
|
[1m{[0m
|
||||||
|
[1;34m"[0m[1;34mx[0m[1;34m"[0m: 10,
|
||||||
|
[1;34m"[0m[1;34my[0m[1;34m"[0m: 20[1m
|
||||||
|
}[0m[1m
|
||||||
|
][0m[1m
|
||||||
|
}[0m
|
||||||
|
Non-json message with curly braces {like this}
|
||||||
|
[1m{[0m
|
||||||
|
[1;34m"[0m[1;34mkey1[0m[1;34m"[0m: [32m"[0m[32mvalue1[0m[32m"[0m,
|
||||||
|
[1;34m"[0m[1;34mkey2[0m[1;34m"[0m: [1m{[0m
|
||||||
|
[1;34m"[0m[1;34msubkey[0m[1;34m"[0m: [32m"[0m[32msubvalue[0m[32m"[0m[1m
|
||||||
|
}[0m[1m
|
||||||
|
}[0m
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user