Skip to main content

rust_sanitize/
log_context.rs

1//! Log context extraction — finds keyword-matching lines and captures
2//! surrounding context windows for LLM-friendly log triage.
3//!
4//! The extractor scans sanitized output line-by-line for any configured
5//! keyword (substring match). For each hit it records the matching line,
6//! up to N lines of context before and after, and the 1-based line number
7//! so engineers can locate the entry in the original file.
8//!
9//! # Example
10//!
11//! ```rust
12//! use rust_sanitize::log_context::{LogContextConfig, extract_context};
13//!
14//! let log = "INFO  start\nERROR disk full\nINFO  retrying\nINFO  done";
15//!
16//! let config = LogContextConfig::new().with_context_lines(1);
17//! let result = extract_context(log, &config);
18//!
19//! assert_eq!(result.match_count, 1);
20//! assert_eq!(result.matches[0].line_number, 2);
21//! assert_eq!(result.matches[0].keyword, "error");
22//! assert_eq!(result.matches[0].before, vec!["INFO  start"]);
23//! assert_eq!(result.matches[0].after,  vec!["INFO  retrying"]);
24//! ```
25
26use serde::{Deserialize, Serialize};
27use std::{collections::VecDeque, io};
28
29// ---------------------------------------------------------------------------
30// Defaults
31// ---------------------------------------------------------------------------
32
33/// Built-in keywords used when no custom list is provided.
34pub const DEFAULT_KEYWORDS: &[&str] = &[
35    "error",
36    "failure",
37    "warning",
38    "warn",
39    "fatal",
40    "exception",
41    "critical",
42];
43
44/// Default lines of context captured before and after each match.
45pub const DEFAULT_CONTEXT_LINES: usize = 10;
46
47/// Default cap on matches returned in a single result.
48pub const DEFAULT_MAX_MATCHES: usize = 50;
49
50// ---------------------------------------------------------------------------
51// Config
52// ---------------------------------------------------------------------------
53
54/// Configuration for [`extract_context`].
55///
56/// Built with a fluent API; all setters consume and return `Self`.
57///
58/// # Example
59///
60/// ```rust
61/// use rust_sanitize::log_context::LogContextConfig;
62///
63/// let config = LogContextConfig::new()
64///     .with_extra_keywords(["timeout", "oomkilled"])
65///     .with_context_lines(15)
66///     .with_max_matches(100);
67/// ```
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct LogContextConfig {
70    /// Keywords to scan for. Each is matched as a substring of the line.
71    pub keywords: Vec<String>,
72
73    /// Lines of context captured before and after each match.
74    pub context_lines: usize,
75
76    /// Maximum number of matches to return before setting
77    /// [`LogContextResult::truncated`].
78    pub max_matches: usize,
79
80    /// When `true`, keyword matching is case-sensitive. Default: `false`.
81    pub case_sensitive: bool,
82}
83
84impl Default for LogContextConfig {
85    fn default() -> Self {
86        Self {
87            keywords: DEFAULT_KEYWORDS.iter().map(|&s| s.to_owned()).collect(),
88            context_lines: DEFAULT_CONTEXT_LINES,
89            max_matches: DEFAULT_MAX_MATCHES,
90            case_sensitive: false,
91        }
92    }
93}
94
95impl LogContextConfig {
96    /// Create a config with default settings.
97    #[must_use]
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Merge additional keywords into the existing list without replacing defaults.
103    #[must_use]
104    pub fn with_extra_keywords(
105        mut self,
106        extra: impl IntoIterator<Item = impl Into<String>>,
107    ) -> Self {
108        self.keywords.extend(extra.into_iter().map(Into::into));
109        self
110    }
111
112    /// Replace all keywords with the given list.
113    #[must_use]
114    pub fn with_keywords(mut self, keywords: impl IntoIterator<Item = impl Into<String>>) -> Self {
115        self.keywords = keywords.into_iter().map(Into::into).collect();
116        self
117    }
118
119    /// Set how many lines of context to capture around each match.
120    #[must_use]
121    pub fn with_context_lines(mut self, n: usize) -> Self {
122        self.context_lines = n;
123        self
124    }
125
126    /// Set the maximum number of matches to return.
127    #[must_use]
128    pub fn with_max_matches(mut self, n: usize) -> Self {
129        self.max_matches = n;
130        self
131    }
132
133    /// Set case-sensitivity for keyword matching.
134    #[must_use]
135    pub fn case_sensitive(mut self, sensitive: bool) -> Self {
136        self.case_sensitive = sensitive;
137        self
138    }
139}
140
141// ---------------------------------------------------------------------------
142// Output types
143// ---------------------------------------------------------------------------
144
145/// A single keyword match with surrounding context lines.
146#[derive(Debug, Clone, Serialize)]
147pub struct LogContextMatch {
148    /// 1-based line number of the matching line.
149    pub line_number: usize,
150
151    /// The keyword that triggered this match (preserves original casing
152    /// from the config, not the casing found in the log line).
153    pub keyword: String,
154
155    /// The matching line as-is from the (sanitized) content.
156    pub line: String,
157
158    /// Up to [`LogContextConfig::context_lines`] lines immediately before
159    /// the match, in document order.
160    pub before: Vec<String>,
161
162    /// Up to [`LogContextConfig::context_lines`] lines immediately after
163    /// the match, in document order.
164    pub after: Vec<String>,
165}
166
167/// Output of [`extract_context`].
168#[derive(Debug, Clone, Serialize)]
169pub struct LogContextResult {
170    /// Total number of lines in the input.
171    pub total_lines: usize,
172
173    /// Number of matches present in [`Self::matches`].
174    /// When [`Self::truncated`] is `true` this equals `max_matches`
175    /// and additional matches exist beyond what was returned.
176    pub match_count: usize,
177
178    /// `true` when scanning stopped early because `max_matches` was reached.
179    /// The caller should increase `max_matches` or narrow the keyword list
180    /// if full coverage is required.
181    pub truncated: bool,
182
183    /// The matched lines and their context windows, in document order.
184    pub matches: Vec<LogContextMatch>,
185}
186
187// ---------------------------------------------------------------------------
188// Internal helpers
189// ---------------------------------------------------------------------------
190
191/// Normalise keywords for comparison.
192///
193/// Returns each keyword as-is in case-sensitive mode, or lowercased otherwise.
194/// Both `extract_context` and `extract_context_reader` call this once at the
195/// top so the normalisation cost is paid only once per invocation.
196fn normalize_keywords(keywords: &[String], case_sensitive: bool) -> Vec<String> {
197    keywords
198        .iter()
199        .map(|kw| {
200            if case_sensitive {
201                kw.clone()
202            } else {
203                kw.to_lowercase()
204            }
205        })
206        .collect()
207}
208
209/// Return the index of the first keyword that appears in `line`, or `None`.
210///
211/// `normalised` is the pre-normalised keyword list from [`normalize_keywords`].
212/// When `case_sensitive` is false, `line` is lowercased before comparison.
213fn line_first_hit(line: &str, normalised: &[String], case_sensitive: bool) -> Option<usize> {
214    if case_sensitive {
215        normalised
216            .iter()
217            .position(|norm| line.contains(norm.as_str()))
218    } else {
219        let lower = line.to_lowercase();
220        normalised
221            .iter()
222            .position(|norm| lower.contains(norm.as_str()))
223    }
224}
225
226// ---------------------------------------------------------------------------
227// Core function
228// ---------------------------------------------------------------------------
229
230/// Scan `content` for keyword matches and return surrounding context windows.
231///
232/// Each line is checked for any configured keyword as a substring match.
233/// When multiple keywords appear on the same line the first keyword in
234/// [`LogContextConfig::keywords`] wins. Line numbers in the output are
235/// 1-based to match standard editor and log viewer conventions.
236///
237/// This function is allocation-efficient: lines are collected once into a
238/// `Vec<&str>` and context slices reference that vec without additional copies
239/// until the final owned `String`s are built for the result.
240#[must_use]
241pub fn extract_context(content: &str, config: &LogContextConfig) -> LogContextResult {
242    let lines: Vec<&str> = content.lines().collect();
243    let total_lines = lines.len();
244
245    let normalised = normalize_keywords(&config.keywords, config.case_sensitive);
246
247    let mut matches: Vec<LogContextMatch> = Vec::new();
248    let mut truncated = false;
249
250    for (i, &line) in lines.iter().enumerate() {
251        if matches.len() >= config.max_matches {
252            truncated = true;
253            break;
254        }
255
256        let hit_idx = line_first_hit(line, &normalised, config.case_sensitive);
257
258        if let Some(idx) = hit_idx {
259            let before_start = i.saturating_sub(config.context_lines);
260            let after_end = (i + config.context_lines + 1).min(total_lines);
261
262            matches.push(LogContextMatch {
263                line_number: i + 1,
264                keyword: config.keywords[idx].clone(),
265                line: line.to_owned(),
266                before: lines[before_start..i]
267                    .iter()
268                    .map(|&s| s.to_owned())
269                    .collect(),
270                after: lines[i + 1..after_end]
271                    .iter()
272                    .map(|&s| s.to_owned())
273                    .collect(),
274            });
275        }
276    }
277
278    let match_count = matches.len();
279    LogContextResult {
280        total_lines,
281        match_count,
282        truncated,
283        matches,
284    }
285}
286
287/// Streaming variant of [`extract_context`] for large inputs.
288///
289/// Reads `reader` line by line using a sliding ring buffer of
290/// `config.context_lines` lines. Memory usage is
291/// `O(context_lines × max_line_length)` regardless of total file size,
292/// making it safe for multi-gigabyte log files.
293///
294/// Semantics match [`extract_context`]: case handling, `max_matches`,
295/// `truncated`, and first-keyword-wins on a line all behave identically.
296/// "Before" and "after" context windows are clipped at file boundaries.
297///
298/// # Example
299///
300/// ```rust
301/// use rust_sanitize::log_context::{LogContextConfig, extract_context_reader};
302/// use std::io::BufReader;
303///
304/// let data = b"INFO start\nERROR disk full\nINFO retrying\n";
305/// let config = LogContextConfig::new().with_context_lines(1);
306/// let result = extract_context_reader(BufReader::new(data.as_ref()), &config).unwrap();
307///
308/// assert_eq!(result.match_count, 1);
309/// assert_eq!(result.matches[0].line_number, 2);
310/// assert_eq!(result.matches[0].before, vec!["INFO start"]);
311/// assert_eq!(result.matches[0].after,  vec!["INFO retrying"]);
312/// ```
313///
314/// # Errors
315///
316/// Returns an [`io::Error`] if reading from `reader` fails.
317#[allow(clippy::too_many_lines)]
318pub fn extract_context_reader<R: io::BufRead>(
319    reader: R,
320    config: &LogContextConfig,
321) -> io::Result<LogContextResult> {
322    struct Pending {
323        line_number: usize,
324        keyword: String,
325        line: String,
326        before: Vec<String>,
327        after: Vec<String>,
328        remaining: usize,
329    }
330
331    let cap = config.context_lines;
332    let mut before_buf: VecDeque<String> = VecDeque::with_capacity(cap.saturating_add(1));
333    let mut pending: Vec<Pending> = Vec::new();
334    let mut matches: Vec<LogContextMatch> = Vec::new();
335    let mut truncated = false;
336    let mut total_lines: usize = 0;
337
338    let normalised = normalize_keywords(&config.keywords, config.case_sensitive);
339
340    let mut line_buf = String::new();
341    let mut reader = reader;
342    loop {
343        line_buf.clear();
344        let n = reader.read_line(&mut line_buf)?;
345        if n == 0 {
346            break;
347        }
348        // Strip trailing newline; preserve the rest of the line as-is.
349        let line: &str = line_buf.trim_end_matches(['\n', '\r']);
350        total_lines += 1;
351        let line_number = total_lines;
352
353        // Step 1: feed this line as "after" context to all pending matches.
354        let mut i = 0;
355        while i < pending.len() {
356            pending[i].after.push(line.to_owned());
357            pending[i].remaining -= 1;
358            if pending[i].remaining == 0 {
359                let p = pending.remove(i);
360                matches.push(LogContextMatch {
361                    line_number: p.line_number,
362                    keyword: p.keyword,
363                    line: p.line,
364                    before: p.before,
365                    after: p.after,
366                });
367            } else {
368                i += 1;
369            }
370        }
371
372        // Step 2: check if this line starts a new match.
373        if !truncated {
374            let effective_count = matches.len() + pending.len();
375            let hit_idx = line_first_hit(line, &normalised, config.case_sensitive);
376            if effective_count >= config.max_matches {
377                // At the cap — if this line is a match, set the truncated flag.
378                if hit_idx.is_some() {
379                    truncated = true;
380                }
381            } else if let Some(idx) = hit_idx {
382                let before: Vec<String> = before_buf.iter().cloned().collect();
383                if cap == 0 {
384                    matches.push(LogContextMatch {
385                        line_number,
386                        keyword: config.keywords[idx].clone(),
387                        line: line.to_owned(),
388                        before,
389                        after: Vec::new(),
390                    });
391                } else {
392                    pending.push(Pending {
393                        line_number,
394                        keyword: config.keywords[idx].clone(),
395                        line: line.to_owned(),
396                        before,
397                        after: Vec::new(),
398                        remaining: cap,
399                    });
400                }
401            }
402        }
403
404        // Step 3: advance the before-context ring buffer.
405        if cap > 0 {
406            if before_buf.len() >= cap {
407                before_buf.pop_front();
408            }
409            before_buf.push_back(line.to_owned());
410        }
411    }
412
413    // Flush pending matches whose "after" windows were not fully filled
414    // before EOF (context clipped at end of file).
415    for p in pending {
416        matches.push(LogContextMatch {
417            line_number: p.line_number,
418            keyword: p.keyword,
419            line: p.line,
420            before: p.before,
421            after: p.after,
422        });
423    }
424
425    let match_count = matches.len();
426    Ok(LogContextResult {
427        total_lines,
428        match_count,
429        truncated,
430        matches,
431    })
432}
433
434// ---------------------------------------------------------------------------
435// Tests
436// ---------------------------------------------------------------------------
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn make_log(lines: &[&str]) -> String {
443        lines.join("\n")
444    }
445
446    // ---- basic matching ----
447
448    #[test]
449    fn finds_error_line() {
450        let log = make_log(&["INFO start", "ERROR disk full", "INFO done"]);
451        let result = extract_context(&log, &LogContextConfig::new().with_context_lines(0));
452        assert_eq!(result.match_count, 1);
453        assert_eq!(result.matches[0].line_number, 2);
454        assert_eq!(result.matches[0].keyword, "error");
455        assert_eq!(result.matches[0].line, "ERROR disk full");
456    }
457
458    #[test]
459    fn case_insensitive_by_default() {
460        let log = make_log(&["WARNING high load", "Warning: retry", "warn: slow"]);
461        let result = extract_context(&log, &LogContextConfig::new().with_context_lines(0));
462        assert_eq!(result.match_count, 3);
463    }
464
465    #[test]
466    fn case_sensitive_skips_uppercase() {
467        let log = make_log(&["ERROR upper", "error lower"]);
468        let config = LogContextConfig::new()
469            .with_keywords(["error"])
470            .case_sensitive(true)
471            .with_context_lines(0);
472        let result = extract_context(&log, &config);
473        assert_eq!(result.match_count, 1);
474        assert_eq!(result.matches[0].line, "error lower");
475    }
476
477    // ---- context windows ----
478
479    #[test]
480    fn before_and_after_lines() {
481        let log = make_log(&["a", "b", "ERROR c", "d", "e"]);
482        let config = LogContextConfig::new()
483            .with_keywords(["error"])
484            .with_context_lines(1);
485        let result = extract_context(&log, &config);
486        assert_eq!(result.matches[0].before, vec!["b"]);
487        assert_eq!(result.matches[0].after, vec!["d"]);
488    }
489
490    #[test]
491    fn context_clipped_at_file_start() {
492        let log = make_log(&["ERROR first", "INFO second", "INFO third"]);
493        let config = LogContextConfig::new()
494            .with_keywords(["error"])
495            .with_context_lines(5);
496        let result = extract_context(&log, &config);
497        assert!(result.matches[0].before.is_empty());
498        assert_eq!(result.matches[0].after.len(), 2);
499    }
500
501    #[test]
502    fn context_clipped_at_file_end() {
503        let log = make_log(&["INFO first", "INFO second", "ERROR last"]);
504        let config = LogContextConfig::new()
505            .with_keywords(["error"])
506            .with_context_lines(5);
507        let result = extract_context(&log, &config);
508        assert_eq!(result.matches[0].before.len(), 2);
509        assert!(result.matches[0].after.is_empty());
510    }
511
512    #[test]
513    fn context_lines_zero() {
514        let log = make_log(&["a", "ERROR b", "c"]);
515        let config = LogContextConfig::new()
516            .with_keywords(["error"])
517            .with_context_lines(0);
518        let result = extract_context(&log, &config);
519        assert!(result.matches[0].before.is_empty());
520        assert!(result.matches[0].after.is_empty());
521    }
522
523    // ---- multiple matches ----
524
525    #[test]
526    fn multiple_matches_in_order() {
527        let log = make_log(&["ERROR a", "INFO b", "FATAL c"]);
528        let config = LogContextConfig::new()
529            .with_keywords(["error", "fatal"])
530            .with_context_lines(0);
531        let result = extract_context(&log, &config);
532        assert_eq!(result.match_count, 2);
533        assert_eq!(result.matches[0].line_number, 1);
534        assert_eq!(result.matches[0].keyword, "error");
535        assert_eq!(result.matches[1].line_number, 3);
536        assert_eq!(result.matches[1].keyword, "fatal");
537    }
538
539    #[test]
540    fn first_keyword_wins_on_same_line() {
541        let log = "ERROR and WARNING on same line";
542        let config = LogContextConfig::new()
543            .with_keywords(["error", "warning"])
544            .with_context_lines(0);
545        let result = extract_context(log, &config);
546        assert_eq!(result.match_count, 1);
547        assert_eq!(result.matches[0].keyword, "error");
548    }
549
550    // ---- max_matches and truncation ----
551
552    #[test]
553    fn truncated_when_max_reached() {
554        let lines: Vec<String> = (0..10).map(|i| format!("ERROR line {i}")).collect();
555        let log = lines.join("\n");
556        let config = LogContextConfig::new()
557            .with_keywords(["error"])
558            .with_max_matches(3)
559            .with_context_lines(0);
560        let result = extract_context(&log, &config);
561        assert_eq!(result.match_count, 3);
562        assert!(result.truncated);
563    }
564
565    #[test]
566    fn not_truncated_under_limit() {
567        let log = make_log(&["ERROR a", "INFO b", "ERROR c"]);
568        let config = LogContextConfig::new()
569            .with_keywords(["error"])
570            .with_max_matches(10)
571            .with_context_lines(0);
572        let result = extract_context(&log, &config);
573        assert_eq!(result.match_count, 2);
574        assert!(!result.truncated);
575    }
576
577    // ---- extra keywords ----
578
579    #[test]
580    fn extra_keywords_merge_with_defaults() {
581        let log = make_log(&["ERROR a", "OOMKILLED b"]);
582        let config = LogContextConfig::new()
583            .with_extra_keywords(["oomkilled"])
584            .with_context_lines(0);
585        let result = extract_context(&log, &config);
586        assert_eq!(result.match_count, 2);
587    }
588
589    #[test]
590    fn replace_keywords_removes_defaults() {
591        let log = make_log(&["ERROR a", "CUSTOM b"]);
592        let config = LogContextConfig::new()
593            .with_keywords(["custom"])
594            .with_context_lines(0);
595        let result = extract_context(&log, &config);
596        assert_eq!(result.match_count, 1);
597        assert_eq!(result.matches[0].keyword, "custom");
598    }
599
600    // ---- edge cases ----
601
602    #[test]
603    fn empty_content() {
604        let result = extract_context("", &LogContextConfig::new());
605        assert_eq!(result.total_lines, 0);
606        assert_eq!(result.match_count, 0);
607        assert!(!result.truncated);
608    }
609
610    #[test]
611    fn no_matches() {
612        let log = make_log(&["INFO all good", "DEBUG trace", "INFO done"]);
613        let result = extract_context(&log, &LogContextConfig::new());
614        assert_eq!(result.match_count, 0);
615        assert!(!result.truncated);
616        assert_eq!(result.total_lines, 3);
617    }
618
619    #[test]
620    fn single_line_match() {
621        let result = extract_context("ERROR only line", &LogContextConfig::new());
622        assert_eq!(result.total_lines, 1);
623        assert_eq!(result.match_count, 1);
624        assert!(result.matches[0].before.is_empty());
625        assert!(result.matches[0].after.is_empty());
626    }
627
628    #[test]
629    fn line_numbers_are_one_based() {
630        let log = make_log(&["INFO a", "INFO b", "ERROR c"]);
631        let config = LogContextConfig::new()
632            .with_keywords(["error"])
633            .with_context_lines(0);
634        let result = extract_context(&log, &config);
635        assert_eq!(result.matches[0].line_number, 3);
636    }
637
638    #[test]
639    fn keyword_original_case_preserved_in_output() {
640        let log = "TIMEOUT occurred";
641        let config = LogContextConfig::new()
642            .with_keywords(["Timeout"])
643            .with_context_lines(0);
644        let result = extract_context(log, &config);
645        assert_eq!(result.match_count, 1);
646        assert_eq!(result.matches[0].keyword, "Timeout");
647    }
648
649    // ---- extract_context_reader ----
650
651    fn reader_of(lines: &[&str]) -> std::io::BufReader<std::io::Cursor<Vec<u8>>> {
652        let s = lines.join("\n");
653        std::io::BufReader::new(std::io::Cursor::new(s.into_bytes()))
654    }
655
656    #[test]
657    fn reader_finds_error_line() {
658        let r = reader_of(&["INFO start", "ERROR disk full", "INFO done"]);
659        let result =
660            extract_context_reader(r, &LogContextConfig::new().with_context_lines(0)).unwrap();
661        assert_eq!(result.match_count, 1);
662        assert_eq!(result.matches[0].line_number, 2);
663        assert_eq!(result.matches[0].line, "ERROR disk full");
664    }
665
666    #[test]
667    fn reader_before_and_after_context() {
668        let r = reader_of(&["a", "b", "ERROR c", "d", "e"]);
669        let config = LogContextConfig::new()
670            .with_keywords(["error"])
671            .with_context_lines(1);
672        let result = extract_context_reader(r, &config).unwrap();
673        assert_eq!(result.matches[0].before, vec!["b"]);
674        assert_eq!(result.matches[0].after, vec!["d"]);
675    }
676
677    #[test]
678    fn reader_case_insensitive_by_default() {
679        let r = reader_of(&["Warning: high load", "WARNING again", "warn: slow"]);
680        let result =
681            extract_context_reader(r, &LogContextConfig::new().with_context_lines(0)).unwrap();
682        assert_eq!(result.match_count, 3);
683    }
684
685    #[test]
686    fn reader_case_sensitive_skips_uppercase() {
687        let r = reader_of(&["ERROR upper", "error lower"]);
688        let config = LogContextConfig::new()
689            .with_keywords(["error"])
690            .case_sensitive(true)
691            .with_context_lines(0);
692        let result = extract_context_reader(r, &config).unwrap();
693        assert_eq!(result.match_count, 1);
694        assert_eq!(result.matches[0].line, "error lower");
695    }
696
697    #[test]
698    fn reader_truncates_at_max_matches() {
699        let lines: Vec<String> = (0..10).map(|i| format!("ERROR line {i}")).collect();
700        let strs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
701        let r = reader_of(&strs);
702        let config = LogContextConfig::new()
703            .with_context_lines(0)
704            .with_max_matches(3);
705        let result = extract_context_reader(r, &config).unwrap();
706        assert_eq!(result.match_count, 3);
707        assert!(result.truncated);
708    }
709
710    #[test]
711    fn reader_after_context_clipped_at_eof() {
712        // Match is near the end — after-context window can't be fully filled.
713        let r = reader_of(&["a", "b", "ERROR c"]);
714        let config = LogContextConfig::new()
715            .with_keywords(["error"])
716            .with_context_lines(3);
717        let result = extract_context_reader(r, &config).unwrap();
718        assert_eq!(result.match_count, 1);
719        // Only 0 lines after the match before EOF.
720        assert!(result.matches[0].after.is_empty());
721    }
722
723    #[test]
724    fn reader_total_lines_counted() {
725        let r = reader_of(&["a", "b", "c", "d", "e"]);
726        let result =
727            extract_context_reader(r, &LogContextConfig::new().with_context_lines(0)).unwrap();
728        assert_eq!(result.total_lines, 5);
729        assert_eq!(result.match_count, 0);
730    }
731
732    #[test]
733    fn reader_empty_input() {
734        let r = reader_of(&[]);
735        let result =
736            extract_context_reader(r, &LogContextConfig::new().with_context_lines(0)).unwrap();
737        assert_eq!(result.total_lines, 0);
738        assert_eq!(result.match_count, 0);
739    }
740
741    // ---- serialization ----
742
743    #[test]
744    fn result_serializes_to_json() {
745        let log = make_log(&["INFO ok", "ERROR fail", "INFO ok"]);
746        let config = LogContextConfig::new()
747            .with_keywords(["error"])
748            .with_context_lines(1);
749        let result = extract_context(&log, &config);
750        let json = serde_json::to_string_pretty(&result).unwrap();
751        assert!(json.contains("\"line_number\": 2"));
752        assert!(json.contains("\"keyword\": \"error\""));
753        assert!(json.contains("\"total_lines\": 3"));
754        assert!(json.contains("\"truncated\": false"));
755    }
756}