|
|
|
@@ -0,0 +1,931 @@
|
|
|
|
|
//! The `logshape` command summarizes repetitive logs.
|
|
|
|
|
|
|
|
|
|
use std::cmp::Reverse;
|
|
|
|
|
use std::collections::{HashMap, hash_map::Entry};
|
|
|
|
|
use std::ffi::OsString;
|
|
|
|
|
use std::fmt::Write as _;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::io::{self, BufRead, Read};
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
|
|
use common::{
|
|
|
|
|
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice,
|
|
|
|
|
parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured,
|
|
|
|
|
should_read_stdin,
|
|
|
|
|
};
|
|
|
|
|
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
|
|
|
|
|
const PLACEHOLDER_LEVEL: &str = "<level>";
|
|
|
|
|
const PLACEHOLDER_MILLISECONDS: &str = "<num>ms";
|
|
|
|
|
const PLACEHOLDER_PATH: &str = "<path>";
|
|
|
|
|
const PLACEHOLDER_RETRY_SECONDS: &str = "<num>s";
|
|
|
|
|
const PLACEHOLDER_TIMESTAMP: &str = "<ts>";
|
|
|
|
|
|
|
|
|
|
const HELP: &str = "\
|
|
|
|
|
Summarize repetitive logs into high-signal templates.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
logshape [OPTIONS] [PATH...]
|
|
|
|
|
|
|
|
|
|
Options:
|
|
|
|
|
--format <FORMAT> Structured output format: text, json, toon
|
|
|
|
|
--json Shortcut for --format json
|
|
|
|
|
--toon Shortcut for --format toon
|
|
|
|
|
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
|
|
|
|
|
--color <WHEN> Control ANSI color output: auto, never
|
|
|
|
|
--quiet Suppress non-essential status output
|
|
|
|
|
--top <COUNT> Maximum number of grouped patterns to emit
|
|
|
|
|
--min-count <COUNT> Minimum count required for a group to emit
|
|
|
|
|
--keep-level Preserve INFO/WARN/ERROR tokens in the pattern
|
|
|
|
|
-h, --help Show this help text
|
|
|
|
|
-V, --version Show the command version
|
|
|
|
|
|
|
|
|
|
Examples:
|
|
|
|
|
logshape .\\fixtures\\logs\\repetitive.log
|
|
|
|
|
bat --style=plain --paging=never .\\fixtures\\logs\\repetitive.log | logshape --json | ConvertFrom-Json | Select-Object -ExpandProperty groups
|
|
|
|
|
logshape .\\fixtures\\logs\\repetitive.log --min-count 2 --top 5
|
|
|
|
|
";
|
|
|
|
|
|
|
|
|
|
/// CLI arguments for the `logshape` binary.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
struct Cli {
|
|
|
|
|
/// Shared output and stdin policy flags.
|
|
|
|
|
common: CommonArgs,
|
|
|
|
|
/// Maximum number of grouped patterns to emit.
|
|
|
|
|
top: usize,
|
|
|
|
|
/// Minimum number of matching lines required for a group to be emitted.
|
|
|
|
|
min_count: usize,
|
|
|
|
|
/// Preserve explicit INFO/WARN/ERROR tokens in the normalized pattern.
|
|
|
|
|
keep_level: bool,
|
|
|
|
|
/// Optional log files when stdin is empty.
|
|
|
|
|
paths: Vec<PathBuf>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
enum ParseOutcome {
|
|
|
|
|
Help,
|
|
|
|
|
Version,
|
|
|
|
|
Run,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
|
|
|
struct LogGroup {
|
|
|
|
|
pattern: String,
|
|
|
|
|
count: usize,
|
|
|
|
|
first_line: usize,
|
|
|
|
|
last_line: usize,
|
|
|
|
|
sample: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
|
|
|
struct LogShapeSummary {
|
|
|
|
|
line_count: usize,
|
|
|
|
|
group_count: usize,
|
|
|
|
|
top: usize,
|
|
|
|
|
min_count: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
|
|
|
struct LogShapeReport {
|
|
|
|
|
groups: Vec<LogGroup>,
|
|
|
|
|
summary: LogShapeSummary,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
struct GroupState {
|
|
|
|
|
count: usize,
|
|
|
|
|
first_line: usize,
|
|
|
|
|
last_line: usize,
|
|
|
|
|
sample: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parses CLI arguments and returns a process exit code.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn main_entry() -> i32 {
|
|
|
|
|
match parse_cli_from(std::env::args_os()) {
|
|
|
|
|
Ok((ParseOutcome::Help, _)) => {
|
|
|
|
|
print!("{HELP}");
|
|
|
|
|
ExitCode::Success.as_i32()
|
|
|
|
|
}
|
|
|
|
|
Ok((ParseOutcome::Version, _)) => {
|
|
|
|
|
println!("logshape {}", env!("CARGO_PKG_VERSION"));
|
|
|
|
|
ExitCode::Success.as_i32()
|
|
|
|
|
}
|
|
|
|
|
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
|
|
|
|
Ok(code) => code.as_i32(),
|
|
|
|
|
Err(error) => {
|
|
|
|
|
print_quick_help_error(&error, HELP);
|
|
|
|
|
error.exit_code().as_i32()
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Err(error) => {
|
|
|
|
|
print_quick_help_error(&error, HELP);
|
|
|
|
|
error.exit_code().as_i32()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
|
|
|
|
where
|
|
|
|
|
I: IntoIterator<Item = T>,
|
|
|
|
|
T: Into<OsString>,
|
|
|
|
|
{
|
|
|
|
|
let mut parser = lexopt::Parser::from_iter(args);
|
|
|
|
|
let mut cli = Cli {
|
|
|
|
|
common: CommonArgs::default(),
|
|
|
|
|
top: 10,
|
|
|
|
|
min_count: 1,
|
|
|
|
|
keep_level: false,
|
|
|
|
|
paths: Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
while let Some(argument) = parser
|
|
|
|
|
.next()
|
|
|
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
|
|
|
{
|
|
|
|
|
match argument {
|
|
|
|
|
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
|
|
|
|
|
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
|
|
|
|
|
Long("json") => cli.common.set_render_mode(RenderMode::Json),
|
|
|
|
|
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
|
|
|
|
|
Long("format") => {
|
|
|
|
|
let value = parser_value_string(&mut parser, "--format")?;
|
|
|
|
|
cli.common.set_render_mode(parse_format_choice(&value)?);
|
|
|
|
|
}
|
|
|
|
|
Long("input-format") => {
|
|
|
|
|
let value = parser_value_string(&mut parser, "--input-format")?;
|
|
|
|
|
cli.common.input_format = parse_input_format(&value)?;
|
|
|
|
|
}
|
|
|
|
|
Long("color") => {
|
|
|
|
|
let value = parser_value_string(&mut parser, "--color")?;
|
|
|
|
|
cli.common.color = parse_color_choice(&value)?;
|
|
|
|
|
}
|
|
|
|
|
Long("quiet") => cli.common.quiet = true,
|
|
|
|
|
Long("top") => {
|
|
|
|
|
cli.top = parse_positive_usize_flag(
|
|
|
|
|
"--top",
|
|
|
|
|
&parser_value_string(&mut parser, "--top")?,
|
|
|
|
|
)?;
|
|
|
|
|
}
|
|
|
|
|
Long("min-count") => {
|
|
|
|
|
cli.min_count = parse_positive_usize_flag(
|
|
|
|
|
"--min-count",
|
|
|
|
|
&parser_value_string(&mut parser, "--min-count")?,
|
|
|
|
|
)?;
|
|
|
|
|
}
|
|
|
|
|
Long("keep-level") => cli.keep_level = true,
|
|
|
|
|
ArgValue(path) => cli.paths.push(PathBuf::from(path)),
|
|
|
|
|
_ => {
|
|
|
|
|
return Err(CliError::usage(
|
|
|
|
|
"unsupported argument; use --help to see available options",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok((ParseOutcome::Run, cli))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
|
|
|
|
let value = parser
|
|
|
|
|
.value()
|
|
|
|
|
.map_err(|error| CliError::usage(error.to_string()))?;
|
|
|
|
|
value.into_string().map_err(|invalid| {
|
|
|
|
|
CliError::usage(format!(
|
|
|
|
|
"{flag} expects UTF-8 text, got '{}'",
|
|
|
|
|
invalid.to_string_lossy()
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
|
|
|
|
|
value
|
|
|
|
|
.parse::<usize>()
|
|
|
|
|
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_positive_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
|
|
|
|
|
let parsed = parse_usize_flag(flag, value)?;
|
|
|
|
|
if parsed == 0 {
|
|
|
|
|
return Err(CliError::usage(format!("{flag} must be greater than 0")));
|
|
|
|
|
}
|
|
|
|
|
Ok(parsed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
|
|
|
|
let lines = load_lines(cli)?;
|
|
|
|
|
let groups = group_lines(&lines, cli.keep_level, cli.min_count, cli.top);
|
|
|
|
|
let report = build_report(lines.len(), groups, cli);
|
|
|
|
|
|
|
|
|
|
match cli.common.render_mode() {
|
|
|
|
|
RenderMode::Json => print_json(&report)?,
|
|
|
|
|
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
|
|
|
|
|
RenderMode::Text => {
|
|
|
|
|
if report.groups.is_empty() {
|
|
|
|
|
if !cli.common.quiet {
|
|
|
|
|
println!("0 groups");
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
print!("{}", render_groups(&report.groups));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(map_result_count(report.groups.len()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_report(line_count: usize, groups: Vec<LogGroup>, cli: &Cli) -> LogShapeReport {
|
|
|
|
|
LogShapeReport {
|
|
|
|
|
summary: LogShapeSummary {
|
|
|
|
|
line_count,
|
|
|
|
|
group_count: groups.len(),
|
|
|
|
|
top: cli.top,
|
|
|
|
|
min_count: cli.min_count,
|
|
|
|
|
},
|
|
|
|
|
groups,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_lines(cli: &Cli) -> Result<Vec<String>, CliError> {
|
|
|
|
|
if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) {
|
|
|
|
|
let mut buffer = String::new();
|
|
|
|
|
io::stdin()
|
|
|
|
|
.read_to_string(&mut buffer)
|
|
|
|
|
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
|
|
|
|
|
if !buffer.trim().is_empty() {
|
|
|
|
|
return parse_lines(&buffer, cli.common.input_format);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if cli.paths.is_empty() {
|
|
|
|
|
return Err(CliError::usage(
|
|
|
|
|
"provide at least one log path or pipe log lines into stdin",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut lines = Vec::new();
|
|
|
|
|
let paths = common::expand_input_patterns(&cli.paths, "logshape")?;
|
|
|
|
|
for path in &paths {
|
|
|
|
|
let content = fs::read_to_string(path).map_err(|error| {
|
|
|
|
|
CliError::runtime(format!("failed to read {}: {error}", path.display()))
|
|
|
|
|
})?;
|
|
|
|
|
lines.extend(parse_lines(&content, cli.common.input_format)?);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(lines)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_lines(content: &str, input_format: InputFormat) -> Result<Vec<String>, CliError> {
|
|
|
|
|
let mut lines = Vec::new();
|
|
|
|
|
|
|
|
|
|
for (index, line) in io::Cursor::new(content).lines().enumerate() {
|
|
|
|
|
let raw =
|
|
|
|
|
line.map_err(|error| CliError::runtime(format!("failed to read line: {error}")))?;
|
|
|
|
|
let trimmed = raw.trim().trim_start_matches('\u{feff}');
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if is_low_signal_separator_line(trimmed) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match input_format {
|
|
|
|
|
InputFormat::Lines | InputFormat::Auto => {
|
|
|
|
|
if let Some(cleaned) = normalize_reader_wrapped_line(trimmed) {
|
|
|
|
|
lines.push(cleaned);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
InputFormat::Jsonl => {
|
|
|
|
|
let value = serde_json::from_str::<Value>(trimmed).map_err(|error| {
|
|
|
|
|
CliError::runtime(format!("invalid JSONL log line at {}: {error}", index + 1))
|
|
|
|
|
})?;
|
|
|
|
|
lines.push(json_line_value(&value, index + 1)?);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(lines)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn json_line_value(value: &Value, line_number: usize) -> Result<String, CliError> {
|
|
|
|
|
match value {
|
|
|
|
|
Value::String(text) => Ok(text.clone()),
|
|
|
|
|
Value::Object(object) => object
|
|
|
|
|
.get("line")
|
|
|
|
|
.or_else(|| object.get("message"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::to_owned)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
CliError::runtime(format!(
|
|
|
|
|
"JSON log line at {line_number} must be a string or object with line/message"
|
|
|
|
|
))
|
|
|
|
|
}),
|
|
|
|
|
Value::Array(_) | Value::Bool(_) | Value::Null | Value::Number(_) => {
|
|
|
|
|
Err(CliError::runtime(format!(
|
|
|
|
|
"JSON log line at {line_number} must be a string or object"
|
|
|
|
|
)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_reader_wrapped_line(line: &str) -> Option<String> {
|
|
|
|
|
if is_mercury_reader_header(line) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
if let Some(stripped) = strip_numbered_reader_prefix(line) {
|
|
|
|
|
return Some(stripped.to_owned());
|
|
|
|
|
}
|
|
|
|
|
Some(line.to_owned())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_low_signal_separator_line(line: &str) -> bool {
|
|
|
|
|
let trimmed = line.trim();
|
|
|
|
|
if trimmed.len() < 8 || trimmed.chars().any(|ch| ch.is_ascii_alphanumeric()) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
let mut chars = trimmed.chars();
|
|
|
|
|
let Some(first) = chars.next() else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
first.is_ascii_punctuation() && chars.all(|ch| ch == first)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_mercury_reader_header(line: &str) -> bool {
|
|
|
|
|
line.starts_with("path=")
|
|
|
|
|
&& line.contains(" lines=")
|
|
|
|
|
&& (line.contains(" reason=") || line.contains(" chunk="))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn strip_numbered_reader_prefix(line: &str) -> Option<&str> {
|
|
|
|
|
let bytes = line.as_bytes();
|
|
|
|
|
let mut index = 0_usize;
|
|
|
|
|
while index < bytes.len() && bytes[index].is_ascii_digit() {
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
if index == 0 || index + 1 >= bytes.len() || bytes[index] != b':' || bytes[index + 1] != b' ' {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some(&line[index + 2..])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn group_lines(lines: &[String], keep_level: bool, min_count: usize, top: usize) -> Vec<LogGroup> {
|
|
|
|
|
let mut groups = HashMap::<String, GroupState>::with_capacity(lines.len());
|
|
|
|
|
|
|
|
|
|
for (index, line) in lines.iter().enumerate() {
|
|
|
|
|
let pattern = normalize_line(line, keep_level);
|
|
|
|
|
match groups.entry(pattern) {
|
|
|
|
|
Entry::Occupied(mut entry) => {
|
|
|
|
|
let group = entry.get_mut();
|
|
|
|
|
group.count += 1;
|
|
|
|
|
group.last_line = index + 1;
|
|
|
|
|
}
|
|
|
|
|
Entry::Vacant(entry) => {
|
|
|
|
|
entry.insert(GroupState {
|
|
|
|
|
count: 1,
|
|
|
|
|
first_line: index + 1,
|
|
|
|
|
last_line: index + 1,
|
|
|
|
|
sample: line.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut rendered = groups
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter(|(_, group)| group.count >= min_count)
|
|
|
|
|
.map(|(pattern, group)| LogGroup {
|
|
|
|
|
pattern,
|
|
|
|
|
count: group.count,
|
|
|
|
|
first_line: group.first_line,
|
|
|
|
|
last_line: group.last_line,
|
|
|
|
|
sample: group.sample,
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
|
|
|
|
rendered.sort_unstable_by(|left, right| {
|
|
|
|
|
(Reverse(left.count), left.first_line, left.pattern.as_str()).cmp(&(
|
|
|
|
|
Reverse(right.count),
|
|
|
|
|
right.first_line,
|
|
|
|
|
right.pattern.as_str(),
|
|
|
|
|
))
|
|
|
|
|
});
|
|
|
|
|
rendered.truncate(top);
|
|
|
|
|
rendered
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_line(line: &str, keep_level: bool) -> String {
|
|
|
|
|
let normalized = replace_timestamps(line);
|
|
|
|
|
let normalized = replace_windows_paths(&normalized);
|
|
|
|
|
let normalized = replace_named_numeric_fields(&normalized, "worker");
|
|
|
|
|
let normalized = replace_named_numeric_fields(&normalized, "user");
|
|
|
|
|
let normalized = replace_named_numeric_fields(&normalized, "id");
|
|
|
|
|
let normalized = replace_generic_numeric_fields(&normalized);
|
|
|
|
|
let normalized = replace_millisecond_durations(&normalized);
|
|
|
|
|
let normalized = replace_retry_seconds(&normalized);
|
|
|
|
|
let normalized = replace_tcp_ports(&normalized);
|
|
|
|
|
if keep_level {
|
|
|
|
|
normalized
|
|
|
|
|
} else {
|
|
|
|
|
replace_levels(&normalized)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn replace_timestamps(input: &str) -> String {
|
|
|
|
|
replace_matches(input, |value, index| {
|
|
|
|
|
timestamp_match_len(value, index)
|
|
|
|
|
.map(|length| (index + length, PLACEHOLDER_TIMESTAMP.to_owned()))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn replace_windows_paths(input: &str) -> String {
|
|
|
|
|
replace_matches(input, |value, index| {
|
|
|
|
|
windows_path_match_len(value, index)
|
|
|
|
|
.map(|length| (index + length, PLACEHOLDER_PATH.to_owned()))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn replace_named_numeric_fields(input: &str, name: &str) -> String {
|
|
|
|
|
replace_matches(input, |value, index| {
|
|
|
|
|
named_numeric_field_match_len(value, index, name)
|
|
|
|
|
.map(|length| (index + length, format!("{name}=<num>")))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn replace_generic_numeric_fields(input: &str) -> String {
|
|
|
|
|
replace_matches(input, |value, index| {
|
|
|
|
|
generic_numeric_field_match(value, index).map(|(length, key_length)| {
|
|
|
|
|
(
|
|
|
|
|
index + length,
|
|
|
|
|
format!("{}=<num>", &value[index..index + key_length]),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn replace_millisecond_durations(input: &str) -> String {
|
|
|
|
|
replace_matches(input, |value, index| {
|
|
|
|
|
millisecond_duration_match_len(value, index)
|
|
|
|
|
.map(|length| (index + length, PLACEHOLDER_MILLISECONDS.to_owned()))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn replace_retry_seconds(input: &str) -> String {
|
|
|
|
|
replace_matches(input, |value, index| {
|
|
|
|
|
retry_seconds_match_len(value, index)
|
|
|
|
|
.map(|length| (index + length, PLACEHOLDER_RETRY_SECONDS.to_owned()))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn replace_tcp_ports(input: &str) -> String {
|
|
|
|
|
replace_matches(input, |value, index| {
|
|
|
|
|
tcp_port_match_len(value, index).map(|length| {
|
|
|
|
|
let prefix_end = tcp_port_prefix_end(value, index);
|
|
|
|
|
(
|
|
|
|
|
index + length,
|
|
|
|
|
format!("{}<num>", &value[index..prefix_end]),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn replace_levels(input: &str) -> String {
|
|
|
|
|
replace_matches(input, |value, index| {
|
|
|
|
|
level_match_len(value, index).map(|length| (index + length, PLACEHOLDER_LEVEL.to_owned()))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn replace_matches(
|
|
|
|
|
input: &str,
|
|
|
|
|
mut replacer: impl FnMut(&str, usize) -> Option<(usize, String)>,
|
|
|
|
|
) -> String {
|
|
|
|
|
let mut output = String::with_capacity(input.len());
|
|
|
|
|
let mut cursor = 0_usize;
|
|
|
|
|
let mut last_copied = 0_usize;
|
|
|
|
|
|
|
|
|
|
while cursor < input.len() {
|
|
|
|
|
if let Some((end, replacement)) = replacer(input, cursor) {
|
|
|
|
|
output.push_str(&input[last_copied..cursor]);
|
|
|
|
|
output.push_str(&replacement);
|
|
|
|
|
last_copied = end;
|
|
|
|
|
cursor = end;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cursor = next_char_boundary(input, cursor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if last_copied == 0 {
|
|
|
|
|
return input.to_owned();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
output.push_str(&input[last_copied..]);
|
|
|
|
|
output
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn next_char_boundary(input: &str, index: usize) -> usize {
|
|
|
|
|
input.get(index..).map_or(index, |suffix| {
|
|
|
|
|
suffix
|
|
|
|
|
.chars()
|
|
|
|
|
.next()
|
|
|
|
|
.map_or(input.len(), |value| index + value.len_utf8())
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn timestamp_match_len(input: &str, start: usize) -> Option<usize> {
|
|
|
|
|
const TIMESTAMP_LEN: usize = 20;
|
|
|
|
|
let slice = input.get(start..start + TIMESTAMP_LEN)?;
|
|
|
|
|
let bytes = slice.as_bytes();
|
|
|
|
|
let is_match = bytes[0..4].iter().all(u8::is_ascii_digit)
|
|
|
|
|
&& bytes[4] == b'-'
|
|
|
|
|
&& bytes[5..7].iter().all(u8::is_ascii_digit)
|
|
|
|
|
&& bytes[7] == b'-'
|
|
|
|
|
&& bytes[8..10].iter().all(u8::is_ascii_digit)
|
|
|
|
|
&& bytes[10] == b'T'
|
|
|
|
|
&& bytes[11..13].iter().all(u8::is_ascii_digit)
|
|
|
|
|
&& bytes[13] == b':'
|
|
|
|
|
&& bytes[14..16].iter().all(u8::is_ascii_digit)
|
|
|
|
|
&& bytes[16] == b':'
|
|
|
|
|
&& bytes[17..19].iter().all(u8::is_ascii_digit)
|
|
|
|
|
&& bytes[19] == b'Z';
|
|
|
|
|
if is_match && has_word_boundary(input, start, start + TIMESTAMP_LEN) {
|
|
|
|
|
Some(TIMESTAMP_LEN)
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn windows_path_match_len(input: &str, start: usize) -> Option<usize> {
|
|
|
|
|
let bytes = input.as_bytes();
|
|
|
|
|
let drive = *bytes.get(start)?;
|
|
|
|
|
if !drive.is_ascii_alphabetic() || bytes.get(start + 1).copied() != Some(b':') {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
if bytes.get(start + 2).copied() != Some(b'\\') {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut end = start + 3;
|
|
|
|
|
while let Some(byte) = bytes.get(end) {
|
|
|
|
|
if *byte == b' ' {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
end += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(end > start + 3).then_some(end - start)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn named_numeric_field_match_len(input: &str, start: usize, name: &str) -> Option<usize> {
|
|
|
|
|
let suffix = input.get(start..)?.strip_prefix(name)?.strip_prefix('=')?;
|
|
|
|
|
let digits = ascii_digit_prefix_len(suffix);
|
|
|
|
|
(digits > 0).then_some(name.len() + 1 + digits)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn generic_numeric_field_match(input: &str, start: usize) -> Option<(usize, usize)> {
|
|
|
|
|
if start > 0 && input[..start].chars().next_back().is_some_and(is_word_char) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let bytes = input.as_bytes();
|
|
|
|
|
let first = *bytes.get(start)?;
|
|
|
|
|
if !(first == b'_' || first.is_ascii_alphabetic()) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut key_end = start + 1;
|
|
|
|
|
while let Some(byte) = bytes.get(key_end).copied() {
|
|
|
|
|
if byte == b'_' || byte == b'-' || byte.is_ascii_alphanumeric() {
|
|
|
|
|
key_end += 1;
|
|
|
|
|
} else {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if bytes.get(key_end).copied() != Some(b'=') {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let value_start = key_end + 1;
|
|
|
|
|
let digits = ascii_digit_prefix_len(input.get(value_start..)?);
|
|
|
|
|
if digits == 0 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let end = value_start + digits;
|
|
|
|
|
if matches!(bytes.get(end).copied(), Some(b'.' | b':')) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
has_word_boundary(input, start, end).then_some((end - start, key_end - start))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn retry_seconds_match_len(input: &str, start: usize) -> Option<usize> {
|
|
|
|
|
let suffix = input.get(start..)?;
|
|
|
|
|
let digits = ascii_digit_prefix_len(suffix);
|
|
|
|
|
if digits == 0 || suffix.as_bytes().get(digits).copied() != Some(b's') {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let end = start + digits + 1;
|
|
|
|
|
has_word_boundary(input, start, end).then_some(digits + 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn millisecond_duration_match_len(input: &str, start: usize) -> Option<usize> {
|
|
|
|
|
let suffix = input.get(start..)?;
|
|
|
|
|
let digits = ascii_digit_prefix_len(suffix);
|
|
|
|
|
if digits == 0 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let space_len = suffix[digits..]
|
|
|
|
|
.bytes()
|
|
|
|
|
.take_while(|byte| *byte == b' ')
|
|
|
|
|
.count();
|
|
|
|
|
let unit_start = digits + space_len;
|
|
|
|
|
if !suffix[unit_start..].starts_with("ms") {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let length = unit_start + 2;
|
|
|
|
|
has_word_boundary(input, start, start + length).then_some(length)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn tcp_port_match_len(input: &str, start: usize) -> Option<usize> {
|
|
|
|
|
let suffix = input.get(start..)?.strip_prefix("tcp://")?;
|
|
|
|
|
let host_len = suffix
|
|
|
|
|
.bytes()
|
|
|
|
|
.take_while(|byte| byte.is_ascii_digit() || *byte == b'.')
|
|
|
|
|
.count();
|
|
|
|
|
if host_len == 0 || suffix.as_bytes().get(host_len).copied() != Some(b':') {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let port_start = host_len + 1;
|
|
|
|
|
let port_len = ascii_digit_prefix_len(&suffix[port_start..]);
|
|
|
|
|
(port_len > 0).then_some("tcp://".len() + host_len + 1 + port_len)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn tcp_port_prefix_end(input: &str, start: usize) -> usize {
|
|
|
|
|
let suffix = input
|
|
|
|
|
.get(start..)
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.strip_prefix("tcp://")
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let host_len = suffix
|
|
|
|
|
.bytes()
|
|
|
|
|
.take_while(|byte| byte.is_ascii_digit() || *byte == b'.')
|
|
|
|
|
.count();
|
|
|
|
|
start + "tcp://".len() + host_len + 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn level_match_len(input: &str, start: usize) -> Option<usize> {
|
|
|
|
|
["INFO", "WARN", "ERROR"]
|
|
|
|
|
.into_iter()
|
|
|
|
|
.find(|candidate| {
|
|
|
|
|
input.get(start..).is_some_and(|suffix| {
|
|
|
|
|
suffix.starts_with(candidate)
|
|
|
|
|
&& has_word_boundary(input, start, start + candidate.len())
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.map(str::len)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ascii_digit_prefix_len(input: &str) -> usize {
|
|
|
|
|
input.bytes().take_while(u8::is_ascii_digit).count()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn has_word_boundary(input: &str, start: usize, end: usize) -> bool {
|
|
|
|
|
let before_is_word = input[..start].chars().next_back().is_some_and(is_word_char);
|
|
|
|
|
let after_is_word = input[end..].chars().next().is_some_and(is_word_char);
|
|
|
|
|
before_is_word != input[start..end].chars().next().is_some_and(is_word_char)
|
|
|
|
|
&& input[..end].chars().next_back().is_some_and(is_word_char) != after_is_word
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_word_char(value: char) -> bool {
|
|
|
|
|
value == '_' || value.is_alphanumeric()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_groups(groups: &[LogGroup]) -> String {
|
|
|
|
|
let mut rendered = String::new();
|
|
|
|
|
|
|
|
|
|
for group in groups {
|
|
|
|
|
writeln!(
|
|
|
|
|
rendered,
|
|
|
|
|
"count={} first_line={} last_line={} pattern={} sample={}",
|
|
|
|
|
group.count, group.first_line, group.last_line, group.pattern, group.sample
|
|
|
|
|
)
|
|
|
|
|
.expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rendered
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use std::fs;
|
|
|
|
|
|
|
|
|
|
use common::ColorChoice;
|
|
|
|
|
use serde_json::json;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn common_args(json: bool, input_format: InputFormat) -> CommonArgs {
|
|
|
|
|
CommonArgs {
|
|
|
|
|
json,
|
|
|
|
|
format: None,
|
|
|
|
|
input_format,
|
|
|
|
|
color: ColorChoice::Never,
|
|
|
|
|
quiet: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn normalization_and_grouping_cover_high_signal_patterns() {
|
|
|
|
|
let normalized = normalize_line(
|
|
|
|
|
"2026-04-21T12:00:05Z ERROR worker=22 user=98 failed request id=992 path=C:\\games\\demo\\mods\\plugin.dll",
|
|
|
|
|
false,
|
|
|
|
|
);
|
|
|
|
|
assert!(normalized.contains("<ts>"));
|
|
|
|
|
assert!(normalized.contains("worker=<num>"));
|
|
|
|
|
assert!(normalized.contains("path=<path>"));
|
|
|
|
|
assert!(normalized.contains("<level>"));
|
|
|
|
|
|
|
|
|
|
let duration = normalize_line("retry took 120ms then 125 ms", false);
|
|
|
|
|
assert_eq!(duration, "retry took <num>ms then <num>ms");
|
|
|
|
|
let named_duration = normalize_line(
|
|
|
|
|
"INFO finished job duration_ms=141 rows=12 duration_ms=156",
|
|
|
|
|
false,
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
named_duration,
|
|
|
|
|
"<level> finished job duration_ms=<num> rows=<num> duration_ms=<num>"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let groups = group_lines(
|
|
|
|
|
&[
|
|
|
|
|
"2026-04-21T12:00:01Z INFO worker=12 user=42 connected to tcp://127.0.0.1:8080"
|
|
|
|
|
.to_string(),
|
|
|
|
|
"2026-04-21T12:00:02Z INFO worker=18 user=57 connected to tcp://127.0.0.1:8080"
|
|
|
|
|
.to_string(),
|
|
|
|
|
],
|
|
|
|
|
true,
|
|
|
|
|
1,
|
|
|
|
|
10,
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(groups.len(), 1);
|
|
|
|
|
assert_eq!(groups[0].count, 2);
|
|
|
|
|
assert_eq!(groups[0].first_line, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn json_line_parsing_and_run_usage_errors_cover_helpers() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_lines(
|
|
|
|
|
"{\"line\":\"hello\"}\n{\"message\":\"world\"}\n",
|
|
|
|
|
InputFormat::Jsonl,
|
|
|
|
|
)
|
|
|
|
|
.expect("jsonl"),
|
|
|
|
|
vec!["hello".to_string(), "world".to_string()]
|
|
|
|
|
);
|
|
|
|
|
assert!(json_line_value(&json!("plain"), 1).is_ok());
|
|
|
|
|
|
|
|
|
|
let error = parse_cli_from(["logshape", "--top", "0", "demo.log"])
|
|
|
|
|
.expect_err("zero top should fail");
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
error,
|
|
|
|
|
CliError::Usage(message)
|
|
|
|
|
if message.contains("--top must be greater than 0")
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let rendered = render_groups(&[LogGroup {
|
|
|
|
|
pattern: "demo".to_string(),
|
|
|
|
|
count: 2,
|
|
|
|
|
first_line: 1,
|
|
|
|
|
last_line: 2,
|
|
|
|
|
sample: "sample".to_string(),
|
|
|
|
|
}]);
|
|
|
|
|
assert!(rendered.contains("count=2"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parsing_and_grouping_cover_json_errors_sorting_and_level_preservation() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_lines("alpha\nbeta\n", InputFormat::Lines).expect("line input"),
|
|
|
|
|
vec!["alpha".to_string(), "beta".to_string()]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_lines("\"hello\"\n", InputFormat::Jsonl).expect("json string"),
|
|
|
|
|
vec!["hello".to_string()]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let invalid_json = parse_lines("{\"other\":\"field\"}\n", InputFormat::Jsonl)
|
|
|
|
|
.expect_err("missing field should fail");
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
invalid_json,
|
|
|
|
|
CliError::Runtime(message) if message.contains("line/message")
|
|
|
|
|
));
|
|
|
|
|
let invalid_value = json_line_value(&json!(3), 7).expect_err("number should fail");
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
invalid_value,
|
|
|
|
|
CliError::Runtime(message) if message.contains("must be a string or object")
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let groups = group_lines(
|
|
|
|
|
&[
|
|
|
|
|
"2026-04-21T12:00:01Z ERROR worker=12 failed".to_string(),
|
|
|
|
|
"2026-04-21T12:00:02Z WARN worker=12 failed".to_string(),
|
|
|
|
|
"2026-04-21T12:00:03Z ERROR worker=99 failed".to_string(),
|
|
|
|
|
],
|
|
|
|
|
true,
|
|
|
|
|
1,
|
|
|
|
|
2,
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(groups.len(), 2);
|
|
|
|
|
assert!(groups[0].pattern.contains("ERROR"));
|
|
|
|
|
|
|
|
|
|
let kept_level = normalize_line("2026-04-21T12:00:01Z INFO worker=1 connected", true);
|
|
|
|
|
assert!(kept_level.contains("INFO"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_lines_strips_chunkcat_and_snip_wrappers_from_stdin_text() {
|
|
|
|
|
let parsed = parse_lines(
|
|
|
|
|
concat!(
|
|
|
|
|
"path=C:\\temp\\log.txt chunk=3 lines=24:25 count=2\n",
|
|
|
|
|
"24: [INFO] service started\n",
|
|
|
|
|
"25: [ERROR] worker=8 failed id=44\n",
|
|
|
|
|
"path=C:\\temp\\log.txt lines=40:41 reason=lines\n",
|
|
|
|
|
"40: [INFO] service started\n"
|
|
|
|
|
),
|
|
|
|
|
InputFormat::Lines,
|
|
|
|
|
)
|
|
|
|
|
.expect("wrapped lines");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parsed,
|
|
|
|
|
vec![
|
|
|
|
|
"[INFO] service started".to_string(),
|
|
|
|
|
"[ERROR] worker=8 failed id=44".to_string(),
|
|
|
|
|
"[INFO] service started".to_string(),
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_lines_and_run_cover_file_errors_and_no_results() {
|
|
|
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
|
let log_path = temp.path().join("unique.log");
|
|
|
|
|
fs::write(&log_path, "alpha\nbeta\n").expect("fixture");
|
|
|
|
|
|
|
|
|
|
let exit = run(&Cli {
|
|
|
|
|
common: common_args(false, InputFormat::Auto),
|
|
|
|
|
top: 5,
|
|
|
|
|
min_count: 2,
|
|
|
|
|
keep_level: false,
|
|
|
|
|
paths: vec![log_path],
|
|
|
|
|
})
|
|
|
|
|
.expect("run should succeed");
|
|
|
|
|
assert_eq!(exit, ExitCode::NoResults);
|
|
|
|
|
|
|
|
|
|
let missing = load_lines(&Cli {
|
|
|
|
|
common: common_args(false, InputFormat::Auto),
|
|
|
|
|
top: 5,
|
|
|
|
|
min_count: 1,
|
|
|
|
|
keep_level: false,
|
|
|
|
|
paths: vec![temp.path().join("missing.log")],
|
|
|
|
|
})
|
|
|
|
|
.expect_err("missing file should fail");
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
missing,
|
|
|
|
|
CliError::Runtime(message) if message.contains("failed to read")
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
assert!(render_groups(&[]).is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn build_report_wraps_groups_in_stable_json_shape() {
|
|
|
|
|
let report = build_report(
|
|
|
|
|
3,
|
|
|
|
|
vec![LogGroup {
|
|
|
|
|
pattern: "demo".to_string(),
|
|
|
|
|
count: 2,
|
|
|
|
|
first_line: 1,
|
|
|
|
|
last_line: 2,
|
|
|
|
|
sample: "alpha".to_string(),
|
|
|
|
|
}],
|
|
|
|
|
&Cli {
|
|
|
|
|
common: common_args(true, InputFormat::Auto),
|
|
|
|
|
top: 5,
|
|
|
|
|
min_count: 1,
|
|
|
|
|
keep_level: false,
|
|
|
|
|
paths: Vec::new(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_eq!(report.summary.line_count, 3);
|
|
|
|
|
assert_eq!(report.summary.group_count, 1);
|
|
|
|
|
assert_eq!(report.groups[0].pattern, "demo");
|
|
|
|
|
}
|
|
|
|
|
}
|