chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:41:59 +08:00
commit e365e5df4d
508 changed files with 163373 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "logshape"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Summarize repetitive logs into high-signal templates."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
+931
View File
@@ -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");
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `logshape`.
fn main() {
std::process::exit(logshape::main_entry());
}
+161
View File
@@ -0,0 +1,161 @@
//! Integration tests for the `logshape` command.
use std::fs;
use std::path::PathBuf;
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::tempdir;
fn cargo_command() -> Command {
Command::cargo_bin("logshape").expect("binary")
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn fixture_relative(path: &str) -> PathBuf {
PathBuf::from("fixtures").join(path)
}
fn fixture(path: &str) -> PathBuf {
workspace_root().join(fixture_relative(path))
}
fn relative_to_workspace(path: &str) -> String {
fixture_relative(path).display().to_string()
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
#[test]
fn groups_repetitive_logs_in_text_mode() {
let mut command = cargo_command();
command
.arg(fixture("logs/repetitive.log"))
.assert()
.success()
.stdout(predicate::str::contains("count=2"))
.stdout(predicate::str::contains(
"connected to tcp://127.0.0.1:<num>",
))
.stdout(predicate::str::contains(
"failed request id=<num> path=<path>",
));
}
#[test]
fn emits_json_groups_with_limits() {
let mut command = cargo_command();
command
.arg("--json")
.arg("--top")
.arg("2")
.arg("--min-count")
.arg("2")
.arg(fixture("logs/repetitive.log"))
.assert()
.success()
.stdout(predicate::str::contains("\"count\":2"))
.stdout(predicate::str::contains("\"pattern\""))
.stdout(predicate::str::contains("\"sample\""))
.stdout(predicate::str::contains("retry in").not());
}
#[test]
fn supports_powershell_pipeline_input() {
let binary = assert_cmd::cargo::cargo_bin("logshape");
let input = relative_to_workspace("logs/repetitive.log");
let script = format!(
"[System.IO.File]::ReadLines('{}') | & '{}' --json --top 1",
input,
binary.display()
);
let mut command = pwsh_command(script);
command
.current_dir(workspace_root())
.assert()
.success()
.stdout(predicate::str::contains("\"count\":2"))
.stdout(predicate::str::contains("\"first_line\":1"));
}
#[test]
fn utf8_bom_stdin_groups_first_line_with_following_lines() {
let mut command = cargo_command();
command
.args(["--json", "--min-count", "2"])
.write_stdin(
"\u{feff}INFO connected to tcp://127.0.0.1:5000\nINFO connected to tcp://127.0.0.1:5001\n",
)
.assert()
.success()
.stdout(predicate::str::contains("\"count\":2"))
.stdout(predicate::str::contains("\"first_line\":1"));
}
#[test]
fn utf8_bom_log_file_groups_first_line_with_following_lines() {
let root = tempdir().expect("tempdir");
let log_path = root.path().join("bom.log");
fs::write(
&log_path,
"\u{feff}INFO connected to tcp://127.0.0.1:5000\nINFO connected to tcp://127.0.0.1:5001\n",
)
.expect("bom log");
let mut command = cargo_command();
command
.arg("--json")
.arg("--min-count")
.arg("2")
.arg(&log_path)
.assert()
.success()
.stdout(predicate::str::contains("\"count\":2"))
.stdout(predicate::str::contains(
"connected to tcp://127.0.0.1:<num>",
));
}
#[test]
fn invalid_utf8_log_file_reports_read_error_instead_of_grouping() {
let root = tempdir().expect("tempdir");
let log_path = root.path().join("invalid.log");
fs::write(&log_path, b"INFO ok\n\xff\xfe\xfd\n").expect("invalid log");
let mut command = cargo_command();
command
.arg(&log_path)
.assert()
.failure()
.stderr(predicate::str::contains("failed to read"))
.stderr(predicate::str::contains("valid UTF-8"));
}
#[test]
fn help_includes_grouping_examples() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--min-count"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains(
"logshape .\\fixtures\\logs\\repetitive.log",
));
}