use serde::{Deserialize, Serialize};
use std::{collections::VecDeque, io};
pub const DEFAULT_KEYWORDS: &[&str] = &[
"error",
"failure",
"warning",
"warn",
"fatal",
"exception",
"critical",
];
pub const DEFAULT_CONTEXT_LINES: usize = 10;
pub const DEFAULT_MAX_MATCHES: usize = 50;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogContextConfig {
pub keywords: Vec<String>,
pub context_lines: usize,
pub max_matches: usize,
pub case_sensitive: bool,
}
impl Default for LogContextConfig {
fn default() -> Self {
Self {
keywords: DEFAULT_KEYWORDS.iter().map(|&s| s.to_owned()).collect(),
context_lines: DEFAULT_CONTEXT_LINES,
max_matches: DEFAULT_MAX_MATCHES,
case_sensitive: false,
}
}
}
impl LogContextConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_extra_keywords(
mut self,
extra: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.keywords.extend(extra.into_iter().map(Into::into));
self
}
#[must_use]
pub fn with_keywords(mut self, keywords: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.keywords = keywords.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_context_lines(mut self, n: usize) -> Self {
self.context_lines = n;
self
}
#[must_use]
pub fn with_max_matches(mut self, n: usize) -> Self {
self.max_matches = n;
self
}
#[must_use]
pub fn case_sensitive(mut self, sensitive: bool) -> Self {
self.case_sensitive = sensitive;
self
}
}
#[derive(Debug, Clone, Serialize)]
pub struct LogContextMatch {
pub line_number: usize,
pub keyword: String,
pub line: String,
pub before: Vec<String>,
pub after: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LogContextResult {
pub total_lines: usize,
pub match_count: usize,
pub truncated: bool,
pub matches: Vec<LogContextMatch>,
}
#[must_use]
pub fn extract_context(content: &str, config: &LogContextConfig) -> LogContextResult {
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
let normalised: Vec<String> = config
.keywords
.iter()
.map(|kw| {
if config.case_sensitive {
kw.clone()
} else {
kw.to_lowercase()
}
})
.collect();
let mut matches: Vec<LogContextMatch> = Vec::new();
let mut truncated = false;
for (i, &line) in lines.iter().enumerate() {
if matches.len() >= config.max_matches {
truncated = true;
break;
}
let hit_idx = if config.case_sensitive {
normalised
.iter()
.position(|norm| line.contains(norm.as_str()))
} else {
let lower = line.to_lowercase();
normalised
.iter()
.position(|norm| lower.contains(norm.as_str()))
};
if let Some(idx) = hit_idx {
let before_start = i.saturating_sub(config.context_lines);
let after_end = (i + config.context_lines + 1).min(total_lines);
matches.push(LogContextMatch {
line_number: i + 1,
keyword: config.keywords[idx].clone(),
line: line.to_owned(),
before: lines[before_start..i]
.iter()
.map(|&s| s.to_owned())
.collect(),
after: lines[i + 1..after_end]
.iter()
.map(|&s| s.to_owned())
.collect(),
});
}
}
let match_count = matches.len();
LogContextResult {
total_lines,
match_count,
truncated,
matches,
}
}
#[allow(clippy::too_many_lines)]
pub fn extract_context_reader<R: io::BufRead>(
reader: R,
config: &LogContextConfig,
) -> io::Result<LogContextResult> {
struct Pending {
line_number: usize,
keyword: String,
line: String,
before: Vec<String>,
after: Vec<String>,
remaining: usize,
}
let cap = config.context_lines;
let mut before_buf: VecDeque<String> = VecDeque::with_capacity(cap.saturating_add(1));
let mut pending: Vec<Pending> = Vec::new();
let mut matches: Vec<LogContextMatch> = Vec::new();
let mut truncated = false;
let mut total_lines: usize = 0;
let normalised: Vec<String> = config
.keywords
.iter()
.map(|kw| {
if config.case_sensitive {
kw.clone()
} else {
kw.to_lowercase()
}
})
.collect();
let mut line_buf = String::new();
let mut reader = reader;
loop {
line_buf.clear();
let n = reader.read_line(&mut line_buf)?;
if n == 0 {
break;
}
let line: &str = line_buf.trim_end_matches(['\n', '\r']);
total_lines += 1;
let line_number = total_lines;
let mut i = 0;
while i < pending.len() {
pending[i].after.push(line.to_owned());
pending[i].remaining -= 1;
if pending[i].remaining == 0 {
let p = pending.remove(i);
matches.push(LogContextMatch {
line_number: p.line_number,
keyword: p.keyword,
line: p.line,
before: p.before,
after: p.after,
});
} else {
i += 1;
}
}
if !truncated {
let effective_count = matches.len() + pending.len();
if effective_count >= config.max_matches {
let is_match = if config.case_sensitive {
normalised.iter().any(|norm| line.contains(norm.as_str()))
} else {
let lower = line.to_lowercase();
normalised.iter().any(|norm| lower.contains(norm.as_str()))
};
if is_match {
truncated = true;
}
} else {
let hit_idx = if config.case_sensitive {
normalised
.iter()
.position(|norm| line.contains(norm.as_str()))
} else {
let lower = line.to_lowercase();
normalised
.iter()
.position(|norm| lower.contains(norm.as_str()))
};
if let Some(idx) = hit_idx {
let before: Vec<String> = before_buf.iter().cloned().collect();
if cap == 0 {
matches.push(LogContextMatch {
line_number,
keyword: config.keywords[idx].clone(),
line: line.to_owned(),
before,
after: Vec::new(),
});
} else {
pending.push(Pending {
line_number,
keyword: config.keywords[idx].clone(),
line: line.to_owned(),
before,
after: Vec::new(),
remaining: cap,
});
}
}
}
}
if cap > 0 {
if before_buf.len() >= cap {
before_buf.pop_front();
}
before_buf.push_back(line.to_owned());
}
}
for p in pending {
matches.push(LogContextMatch {
line_number: p.line_number,
keyword: p.keyword,
line: p.line,
before: p.before,
after: p.after,
});
}
let match_count = matches.len();
Ok(LogContextResult {
total_lines,
match_count,
truncated,
matches,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn make_log(lines: &[&str]) -> String {
lines.join("\n")
}
#[test]
fn finds_error_line() {
let log = make_log(&["INFO start", "ERROR disk full", "INFO done"]);
let result = extract_context(&log, &LogContextConfig::new().with_context_lines(0));
assert_eq!(result.match_count, 1);
assert_eq!(result.matches[0].line_number, 2);
assert_eq!(result.matches[0].keyword, "error");
assert_eq!(result.matches[0].line, "ERROR disk full");
}
#[test]
fn case_insensitive_by_default() {
let log = make_log(&["WARNING high load", "Warning: retry", "warn: slow"]);
let result = extract_context(&log, &LogContextConfig::new().with_context_lines(0));
assert_eq!(result.match_count, 3);
}
#[test]
fn case_sensitive_skips_uppercase() {
let log = make_log(&["ERROR upper", "error lower"]);
let config = LogContextConfig::new()
.with_keywords(["error"])
.case_sensitive(true)
.with_context_lines(0);
let result = extract_context(&log, &config);
assert_eq!(result.match_count, 1);
assert_eq!(result.matches[0].line, "error lower");
}
#[test]
fn before_and_after_lines() {
let log = make_log(&["a", "b", "ERROR c", "d", "e"]);
let config = LogContextConfig::new()
.with_keywords(["error"])
.with_context_lines(1);
let result = extract_context(&log, &config);
assert_eq!(result.matches[0].before, vec!["b"]);
assert_eq!(result.matches[0].after, vec!["d"]);
}
#[test]
fn context_clipped_at_file_start() {
let log = make_log(&["ERROR first", "INFO second", "INFO third"]);
let config = LogContextConfig::new()
.with_keywords(["error"])
.with_context_lines(5);
let result = extract_context(&log, &config);
assert!(result.matches[0].before.is_empty());
assert_eq!(result.matches[0].after.len(), 2);
}
#[test]
fn context_clipped_at_file_end() {
let log = make_log(&["INFO first", "INFO second", "ERROR last"]);
let config = LogContextConfig::new()
.with_keywords(["error"])
.with_context_lines(5);
let result = extract_context(&log, &config);
assert_eq!(result.matches[0].before.len(), 2);
assert!(result.matches[0].after.is_empty());
}
#[test]
fn context_lines_zero() {
let log = make_log(&["a", "ERROR b", "c"]);
let config = LogContextConfig::new()
.with_keywords(["error"])
.with_context_lines(0);
let result = extract_context(&log, &config);
assert!(result.matches[0].before.is_empty());
assert!(result.matches[0].after.is_empty());
}
#[test]
fn multiple_matches_in_order() {
let log = make_log(&["ERROR a", "INFO b", "FATAL c"]);
let config = LogContextConfig::new()
.with_keywords(["error", "fatal"])
.with_context_lines(0);
let result = extract_context(&log, &config);
assert_eq!(result.match_count, 2);
assert_eq!(result.matches[0].line_number, 1);
assert_eq!(result.matches[0].keyword, "error");
assert_eq!(result.matches[1].line_number, 3);
assert_eq!(result.matches[1].keyword, "fatal");
}
#[test]
fn first_keyword_wins_on_same_line() {
let log = "ERROR and WARNING on same line";
let config = LogContextConfig::new()
.with_keywords(["error", "warning"])
.with_context_lines(0);
let result = extract_context(log, &config);
assert_eq!(result.match_count, 1);
assert_eq!(result.matches[0].keyword, "error");
}
#[test]
fn truncated_when_max_reached() {
let lines: Vec<String> = (0..10).map(|i| format!("ERROR line {i}")).collect();
let log = lines.join("\n");
let config = LogContextConfig::new()
.with_keywords(["error"])
.with_max_matches(3)
.with_context_lines(0);
let result = extract_context(&log, &config);
assert_eq!(result.match_count, 3);
assert!(result.truncated);
}
#[test]
fn not_truncated_under_limit() {
let log = make_log(&["ERROR a", "INFO b", "ERROR c"]);
let config = LogContextConfig::new()
.with_keywords(["error"])
.with_max_matches(10)
.with_context_lines(0);
let result = extract_context(&log, &config);
assert_eq!(result.match_count, 2);
assert!(!result.truncated);
}
#[test]
fn extra_keywords_merge_with_defaults() {
let log = make_log(&["ERROR a", "OOMKILLED b"]);
let config = LogContextConfig::new()
.with_extra_keywords(["oomkilled"])
.with_context_lines(0);
let result = extract_context(&log, &config);
assert_eq!(result.match_count, 2);
}
#[test]
fn replace_keywords_removes_defaults() {
let log = make_log(&["ERROR a", "CUSTOM b"]);
let config = LogContextConfig::new()
.with_keywords(["custom"])
.with_context_lines(0);
let result = extract_context(&log, &config);
assert_eq!(result.match_count, 1);
assert_eq!(result.matches[0].keyword, "custom");
}
#[test]
fn empty_content() {
let result = extract_context("", &LogContextConfig::new());
assert_eq!(result.total_lines, 0);
assert_eq!(result.match_count, 0);
assert!(!result.truncated);
}
#[test]
fn no_matches() {
let log = make_log(&["INFO all good", "DEBUG trace", "INFO done"]);
let result = extract_context(&log, &LogContextConfig::new());
assert_eq!(result.match_count, 0);
assert!(!result.truncated);
assert_eq!(result.total_lines, 3);
}
#[test]
fn single_line_match() {
let result = extract_context("ERROR only line", &LogContextConfig::new());
assert_eq!(result.total_lines, 1);
assert_eq!(result.match_count, 1);
assert!(result.matches[0].before.is_empty());
assert!(result.matches[0].after.is_empty());
}
#[test]
fn line_numbers_are_one_based() {
let log = make_log(&["INFO a", "INFO b", "ERROR c"]);
let config = LogContextConfig::new()
.with_keywords(["error"])
.with_context_lines(0);
let result = extract_context(&log, &config);
assert_eq!(result.matches[0].line_number, 3);
}
#[test]
fn keyword_original_case_preserved_in_output() {
let log = "TIMEOUT occurred";
let config = LogContextConfig::new()
.with_keywords(["Timeout"])
.with_context_lines(0);
let result = extract_context(log, &config);
assert_eq!(result.match_count, 1);
assert_eq!(result.matches[0].keyword, "Timeout");
}
fn reader_of(lines: &[&str]) -> std::io::BufReader<std::io::Cursor<Vec<u8>>> {
let s = lines.join("\n");
std::io::BufReader::new(std::io::Cursor::new(s.into_bytes()))
}
#[test]
fn reader_finds_error_line() {
let r = reader_of(&["INFO start", "ERROR disk full", "INFO done"]);
let result =
extract_context_reader(r, &LogContextConfig::new().with_context_lines(0)).unwrap();
assert_eq!(result.match_count, 1);
assert_eq!(result.matches[0].line_number, 2);
assert_eq!(result.matches[0].line, "ERROR disk full");
}
#[test]
fn reader_before_and_after_context() {
let r = reader_of(&["a", "b", "ERROR c", "d", "e"]);
let config = LogContextConfig::new()
.with_keywords(["error"])
.with_context_lines(1);
let result = extract_context_reader(r, &config).unwrap();
assert_eq!(result.matches[0].before, vec!["b"]);
assert_eq!(result.matches[0].after, vec!["d"]);
}
#[test]
fn reader_case_insensitive_by_default() {
let r = reader_of(&["Warning: high load", "WARNING again", "warn: slow"]);
let result =
extract_context_reader(r, &LogContextConfig::new().with_context_lines(0)).unwrap();
assert_eq!(result.match_count, 3);
}
#[test]
fn reader_case_sensitive_skips_uppercase() {
let r = reader_of(&["ERROR upper", "error lower"]);
let config = LogContextConfig::new()
.with_keywords(["error"])
.case_sensitive(true)
.with_context_lines(0);
let result = extract_context_reader(r, &config).unwrap();
assert_eq!(result.match_count, 1);
assert_eq!(result.matches[0].line, "error lower");
}
#[test]
fn reader_truncates_at_max_matches() {
let lines: Vec<String> = (0..10).map(|i| format!("ERROR line {i}")).collect();
let strs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
let r = reader_of(&strs);
let config = LogContextConfig::new()
.with_context_lines(0)
.with_max_matches(3);
let result = extract_context_reader(r, &config).unwrap();
assert_eq!(result.match_count, 3);
assert!(result.truncated);
}
#[test]
fn reader_after_context_clipped_at_eof() {
let r = reader_of(&["a", "b", "ERROR c"]);
let config = LogContextConfig::new()
.with_keywords(["error"])
.with_context_lines(3);
let result = extract_context_reader(r, &config).unwrap();
assert_eq!(result.match_count, 1);
assert!(result.matches[0].after.is_empty());
}
#[test]
fn reader_total_lines_counted() {
let r = reader_of(&["a", "b", "c", "d", "e"]);
let result =
extract_context_reader(r, &LogContextConfig::new().with_context_lines(0)).unwrap();
assert_eq!(result.total_lines, 5);
assert_eq!(result.match_count, 0);
}
#[test]
fn reader_empty_input() {
let r = reader_of(&[]);
let result =
extract_context_reader(r, &LogContextConfig::new().with_context_lines(0)).unwrap();
assert_eq!(result.total_lines, 0);
assert_eq!(result.match_count, 0);
}
#[test]
fn result_serializes_to_json() {
let log = make_log(&["INFO ok", "ERROR fail", "INFO ok"]);
let config = LogContextConfig::new()
.with_keywords(["error"])
.with_context_lines(1);
let result = extract_context(&log, &config);
let json = serde_json::to_string_pretty(&result).unwrap();
assert!(json.contains("\"line_number\": 2"));
assert!(json.contains("\"keyword\": \"error\""));
assert!(json.contains("\"total_lines\": 3"));
assert!(json.contains("\"truncated\": false"));
}
}