Skip to main content

sanitize_engine/
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 sanitize_engine::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 sanitize_engine::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// Core function
189// ---------------------------------------------------------------------------
190
191/// Scan `content` for keyword matches and return surrounding context windows.
192///
193/// Each line is checked for any configured keyword as a substring match.
194/// When multiple keywords appear on the same line the first keyword in
195/// [`LogContextConfig::keywords`] wins. Line numbers in the output are
196/// 1-based to match standard editor and log viewer conventions.
197///
198/// This function is allocation-efficient: lines are collected once into a
199/// `Vec<&str>` and context slices reference that vec without additional copies
200/// until the final owned `String`s are built for the result.
201#[must_use]
202pub fn extract_context(content: &str, config: &LogContextConfig) -> LogContextResult {
203    let lines: Vec<&str> = content.lines().collect();
204    let total_lines = lines.len();
205
206    // Pre-normalise keywords once. Each pair is (normalised_for_comparison, original_index).
207    // We store the index so we can retrieve the original keyword string for output.
208    let normalised: Vec<String> = config
209        .keywords
210        .iter()
211        .map(|kw| {
212            if config.case_sensitive {
213                kw.clone()
214            } else {
215                kw.to_lowercase()
216            }
217        })
218        .collect();
219
220    let mut matches: Vec<LogContextMatch> = Vec::new();
221    let mut truncated = false;
222
223    for (i, &line) in lines.iter().enumerate() {
224        if matches.len() >= config.max_matches {
225            truncated = true;
226            break;
227        }
228
229        // Find the index of the first matching keyword.
230        let hit_idx = if config.case_sensitive {
231            normalised
232                .iter()
233                .position(|norm| line.contains(norm.as_str()))
234        } else {
235            let lower = line.to_lowercase();
236            normalised
237                .iter()
238                .position(|norm| lower.contains(norm.as_str()))
239        };
240
241        if let Some(idx) = hit_idx {
242            let before_start = i.saturating_sub(config.context_lines);
243            let after_end = (i + config.context_lines + 1).min(total_lines);
244
245            matches.push(LogContextMatch {
246                line_number: i + 1,
247                keyword: config.keywords[idx].clone(),
248                line: line.to_owned(),
249                before: lines[before_start..i]
250                    .iter()
251                    .map(|&s| s.to_owned())
252                    .collect(),
253                after: lines[i + 1..after_end]
254                    .iter()
255                    .map(|&s| s.to_owned())
256                    .collect(),
257            });
258        }
259    }
260
261    let match_count = matches.len();
262    LogContextResult {
263        total_lines,
264        match_count,
265        truncated,
266        matches,
267    }
268}
269
270/// Streaming variant of [`extract_context`] for large inputs.
271///
272/// Reads `reader` line by line using a sliding ring buffer of
273/// `config.context_lines` lines. Memory usage is
274/// `O(context_lines × max_line_length)` regardless of total file size,
275/// making it safe for multi-gigabyte log files.
276///
277/// Semantics match [`extract_context`]: case handling, `max_matches`,
278/// `truncated`, and first-keyword-wins on a line all behave identically.
279/// "Before" and "after" context windows are clipped at file boundaries.
280///
281/// # Example
282///
283/// ```rust
284/// use sanitize_engine::log_context::{LogContextConfig, extract_context_reader};
285/// use std::io::BufReader;
286///
287/// let data = b"INFO start\nERROR disk full\nINFO retrying\n";
288/// let config = LogContextConfig::new().with_context_lines(1);
289/// let result = extract_context_reader(BufReader::new(data.as_ref()), &config).unwrap();
290///
291/// assert_eq!(result.match_count, 1);
292/// assert_eq!(result.matches[0].line_number, 2);
293/// assert_eq!(result.matches[0].before, vec!["INFO start"]);
294/// assert_eq!(result.matches[0].after,  vec!["INFO retrying"]);
295/// ```
296///
297/// # Errors
298///
299/// Returns an [`io::Error`] if reading from `reader` fails.
300#[allow(clippy::too_many_lines)]
301pub fn extract_context_reader<R: io::BufRead>(
302    reader: R,
303    config: &LogContextConfig,
304) -> io::Result<LogContextResult> {
305    struct Pending {
306        line_number: usize,
307        keyword: String,
308        line: String,
309        before: Vec<String>,
310        after: Vec<String>,
311        remaining: usize,
312    }
313
314    let cap = config.context_lines;
315    let mut before_buf: VecDeque<String> = VecDeque::with_capacity(cap.saturating_add(1));
316    let mut pending: Vec<Pending> = Vec::new();
317    let mut matches: Vec<LogContextMatch> = Vec::new();
318    let mut truncated = false;
319    let mut total_lines: usize = 0;
320
321    // Pre-normalise keywords once (mirrors extract_context).
322    let normalised: Vec<String> = config
323        .keywords
324        .iter()
325        .map(|kw| {
326            if config.case_sensitive {
327                kw.clone()
328            } else {
329                kw.to_lowercase()
330            }
331        })
332        .collect();
333
334    let mut line_buf = String::new();
335    let mut reader = reader;
336    loop {
337        line_buf.clear();
338        let n = reader.read_line(&mut line_buf)?;
339        if n == 0 {
340            break;
341        }
342        // Strip trailing newline; preserve the rest of the line as-is.
343        let line: &str = line_buf.trim_end_matches(['\n', '\r']);
344        total_lines += 1;
345        let line_number = total_lines;
346
347        // Step 1: feed this line as "after" context to all pending matches.
348        let mut i = 0;
349        while i < pending.len() {
350            pending[i].after.push(line.to_owned());
351            pending[i].remaining -= 1;
352            if pending[i].remaining == 0 {
353                let p = pending.remove(i);
354                matches.push(LogContextMatch {
355                    line_number: p.line_number,
356                    keyword: p.keyword,
357                    line: p.line,
358                    before: p.before,
359                    after: p.after,
360                });
361            } else {
362                i += 1;
363            }
364        }
365
366        // Step 2: check if this line starts a new match.
367        if !truncated {
368            let effective_count = matches.len() + pending.len();
369            if effective_count >= config.max_matches {
370                // At the cap — check if this line would be a new match so we
371                // can set the truncated flag accurately.
372                let is_match = if config.case_sensitive {
373                    normalised.iter().any(|norm| line.contains(norm.as_str()))
374                } else {
375                    let lower = line.to_lowercase();
376                    normalised.iter().any(|norm| lower.contains(norm.as_str()))
377                };
378                if is_match {
379                    truncated = true;
380                }
381            } else {
382                let hit_idx = if config.case_sensitive {
383                    normalised
384                        .iter()
385                        .position(|norm| line.contains(norm.as_str()))
386                } else {
387                    let lower = line.to_lowercase();
388                    normalised
389                        .iter()
390                        .position(|norm| lower.contains(norm.as_str()))
391                };
392                if let Some(idx) = hit_idx {
393                    let before: Vec<String> = before_buf.iter().cloned().collect();
394                    if cap == 0 {
395                        matches.push(LogContextMatch {
396                            line_number,
397                            keyword: config.keywords[idx].clone(),
398                            line: line.to_owned(),
399                            before,
400                            after: Vec::new(),
401                        });
402                    } else {
403                        pending.push(Pending {
404                            line_number,
405                            keyword: config.keywords[idx].clone(),
406                            line: line.to_owned(),
407                            before,
408                            after: Vec::new(),
409                            remaining: cap,
410                        });
411                    }
412                }
413            }
414        }
415
416        // Step 3: advance the before-context ring buffer.
417        if cap > 0 {
418            if before_buf.len() >= cap {
419                before_buf.pop_front();
420            }
421            before_buf.push_back(line.to_owned());
422        }
423    }
424
425    // Flush pending matches whose "after" windows were not fully filled
426    // before EOF (context clipped at end of file).
427    for p in pending {
428        matches.push(LogContextMatch {
429            line_number: p.line_number,
430            keyword: p.keyword,
431            line: p.line,
432            before: p.before,
433            after: p.after,
434        });
435    }
436
437    let match_count = matches.len();
438    Ok(LogContextResult {
439        total_lines,
440        match_count,
441        truncated,
442        matches,
443    })
444}
445
446// ---------------------------------------------------------------------------
447// Tests
448// ---------------------------------------------------------------------------
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    fn make_log(lines: &[&str]) -> String {
455        lines.join("\n")
456    }
457
458    // ---- basic matching ----
459
460    #[test]
461    fn finds_error_line() {
462        let log = make_log(&["INFO start", "ERROR disk full", "INFO done"]);
463        let result = extract_context(&log, &LogContextConfig::new().with_context_lines(0));
464        assert_eq!(result.match_count, 1);
465        assert_eq!(result.matches[0].line_number, 2);
466        assert_eq!(result.matches[0].keyword, "error");
467        assert_eq!(result.matches[0].line, "ERROR disk full");
468    }
469
470    #[test]
471    fn case_insensitive_by_default() {
472        let log = make_log(&["WARNING high load", "Warning: retry", "warn: slow"]);
473        let result = extract_context(&log, &LogContextConfig::new().with_context_lines(0));
474        assert_eq!(result.match_count, 3);
475    }
476
477    #[test]
478    fn case_sensitive_skips_uppercase() {
479        let log = make_log(&["ERROR upper", "error lower"]);
480        let config = LogContextConfig::new()
481            .with_keywords(["error"])
482            .case_sensitive(true)
483            .with_context_lines(0);
484        let result = extract_context(&log, &config);
485        assert_eq!(result.match_count, 1);
486        assert_eq!(result.matches[0].line, "error lower");
487    }
488
489    // ---- context windows ----
490
491    #[test]
492    fn before_and_after_lines() {
493        let log = make_log(&["a", "b", "ERROR c", "d", "e"]);
494        let config = LogContextConfig::new()
495            .with_keywords(["error"])
496            .with_context_lines(1);
497        let result = extract_context(&log, &config);
498        assert_eq!(result.matches[0].before, vec!["b"]);
499        assert_eq!(result.matches[0].after, vec!["d"]);
500    }
501
502    #[test]
503    fn context_clipped_at_file_start() {
504        let log = make_log(&["ERROR first", "INFO second", "INFO third"]);
505        let config = LogContextConfig::new()
506            .with_keywords(["error"])
507            .with_context_lines(5);
508        let result = extract_context(&log, &config);
509        assert!(result.matches[0].before.is_empty());
510        assert_eq!(result.matches[0].after.len(), 2);
511    }
512
513    #[test]
514    fn context_clipped_at_file_end() {
515        let log = make_log(&["INFO first", "INFO second", "ERROR last"]);
516        let config = LogContextConfig::new()
517            .with_keywords(["error"])
518            .with_context_lines(5);
519        let result = extract_context(&log, &config);
520        assert_eq!(result.matches[0].before.len(), 2);
521        assert!(result.matches[0].after.is_empty());
522    }
523
524    #[test]
525    fn context_lines_zero() {
526        let log = make_log(&["a", "ERROR b", "c"]);
527        let config = LogContextConfig::new()
528            .with_keywords(["error"])
529            .with_context_lines(0);
530        let result = extract_context(&log, &config);
531        assert!(result.matches[0].before.is_empty());
532        assert!(result.matches[0].after.is_empty());
533    }
534
535    // ---- multiple matches ----
536
537    #[test]
538    fn multiple_matches_in_order() {
539        let log = make_log(&["ERROR a", "INFO b", "FATAL c"]);
540        let config = LogContextConfig::new()
541            .with_keywords(["error", "fatal"])
542            .with_context_lines(0);
543        let result = extract_context(&log, &config);
544        assert_eq!(result.match_count, 2);
545        assert_eq!(result.matches[0].line_number, 1);
546        assert_eq!(result.matches[0].keyword, "error");
547        assert_eq!(result.matches[1].line_number, 3);
548        assert_eq!(result.matches[1].keyword, "fatal");
549    }
550
551    #[test]
552    fn first_keyword_wins_on_same_line() {
553        let log = "ERROR and WARNING on same line";
554        let config = LogContextConfig::new()
555            .with_keywords(["error", "warning"])
556            .with_context_lines(0);
557        let result = extract_context(log, &config);
558        assert_eq!(result.match_count, 1);
559        assert_eq!(result.matches[0].keyword, "error");
560    }
561
562    // ---- max_matches and truncation ----
563
564    #[test]
565    fn truncated_when_max_reached() {
566        let lines: Vec<String> = (0..10).map(|i| format!("ERROR line {i}")).collect();
567        let log = lines.join("\n");
568        let config = LogContextConfig::new()
569            .with_keywords(["error"])
570            .with_max_matches(3)
571            .with_context_lines(0);
572        let result = extract_context(&log, &config);
573        assert_eq!(result.match_count, 3);
574        assert!(result.truncated);
575    }
576
577    #[test]
578    fn not_truncated_under_limit() {
579        let log = make_log(&["ERROR a", "INFO b", "ERROR c"]);
580        let config = LogContextConfig::new()
581            .with_keywords(["error"])
582            .with_max_matches(10)
583            .with_context_lines(0);
584        let result = extract_context(&log, &config);
585        assert_eq!(result.match_count, 2);
586        assert!(!result.truncated);
587    }
588
589    // ---- extra keywords ----
590
591    #[test]
592    fn extra_keywords_merge_with_defaults() {
593        let log = make_log(&["ERROR a", "OOMKILLED b"]);
594        let config = LogContextConfig::new()
595            .with_extra_keywords(["oomkilled"])
596            .with_context_lines(0);
597        let result = extract_context(&log, &config);
598        assert_eq!(result.match_count, 2);
599    }
600
601    #[test]
602    fn replace_keywords_removes_defaults() {
603        let log = make_log(&["ERROR a", "CUSTOM b"]);
604        let config = LogContextConfig::new()
605            .with_keywords(["custom"])
606            .with_context_lines(0);
607        let result = extract_context(&log, &config);
608        assert_eq!(result.match_count, 1);
609        assert_eq!(result.matches[0].keyword, "custom");
610    }
611
612    // ---- edge cases ----
613
614    #[test]
615    fn empty_content() {
616        let result = extract_context("", &LogContextConfig::new());
617        assert_eq!(result.total_lines, 0);
618        assert_eq!(result.match_count, 0);
619        assert!(!result.truncated);
620    }
621
622    #[test]
623    fn no_matches() {
624        let log = make_log(&["INFO all good", "DEBUG trace", "INFO done"]);
625        let result = extract_context(&log, &LogContextConfig::new());
626        assert_eq!(result.match_count, 0);
627        assert!(!result.truncated);
628        assert_eq!(result.total_lines, 3);
629    }
630
631    #[test]
632    fn single_line_match() {
633        let result = extract_context("ERROR only line", &LogContextConfig::new());
634        assert_eq!(result.total_lines, 1);
635        assert_eq!(result.match_count, 1);
636        assert!(result.matches[0].before.is_empty());
637        assert!(result.matches[0].after.is_empty());
638    }
639
640    #[test]
641    fn line_numbers_are_one_based() {
642        let log = make_log(&["INFO a", "INFO b", "ERROR c"]);
643        let config = LogContextConfig::new()
644            .with_keywords(["error"])
645            .with_context_lines(0);
646        let result = extract_context(&log, &config);
647        assert_eq!(result.matches[0].line_number, 3);
648    }
649
650    #[test]
651    fn keyword_original_case_preserved_in_output() {
652        let log = "TIMEOUT occurred";
653        let config = LogContextConfig::new()
654            .with_keywords(["Timeout"])
655            .with_context_lines(0);
656        let result = extract_context(log, &config);
657        assert_eq!(result.match_count, 1);
658        assert_eq!(result.matches[0].keyword, "Timeout");
659    }
660
661    // ---- extract_context_reader ----
662
663    fn reader_of(lines: &[&str]) -> std::io::BufReader<std::io::Cursor<Vec<u8>>> {
664        let s = lines.join("\n");
665        std::io::BufReader::new(std::io::Cursor::new(s.into_bytes()))
666    }
667
668    #[test]
669    fn reader_finds_error_line() {
670        let r = reader_of(&["INFO start", "ERROR disk full", "INFO done"]);
671        let result =
672            extract_context_reader(r, &LogContextConfig::new().with_context_lines(0)).unwrap();
673        assert_eq!(result.match_count, 1);
674        assert_eq!(result.matches[0].line_number, 2);
675        assert_eq!(result.matches[0].line, "ERROR disk full");
676    }
677
678    #[test]
679    fn reader_before_and_after_context() {
680        let r = reader_of(&["a", "b", "ERROR c", "d", "e"]);
681        let config = LogContextConfig::new()
682            .with_keywords(["error"])
683            .with_context_lines(1);
684        let result = extract_context_reader(r, &config).unwrap();
685        assert_eq!(result.matches[0].before, vec!["b"]);
686        assert_eq!(result.matches[0].after, vec!["d"]);
687    }
688
689    #[test]
690    fn reader_case_insensitive_by_default() {
691        let r = reader_of(&["Warning: high load", "WARNING again", "warn: slow"]);
692        let result =
693            extract_context_reader(r, &LogContextConfig::new().with_context_lines(0)).unwrap();
694        assert_eq!(result.match_count, 3);
695    }
696
697    #[test]
698    fn reader_case_sensitive_skips_uppercase() {
699        let r = reader_of(&["ERROR upper", "error lower"]);
700        let config = LogContextConfig::new()
701            .with_keywords(["error"])
702            .case_sensitive(true)
703            .with_context_lines(0);
704        let result = extract_context_reader(r, &config).unwrap();
705        assert_eq!(result.match_count, 1);
706        assert_eq!(result.matches[0].line, "error lower");
707    }
708
709    #[test]
710    fn reader_truncates_at_max_matches() {
711        let lines: Vec<String> = (0..10).map(|i| format!("ERROR line {i}")).collect();
712        let strs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
713        let r = reader_of(&strs);
714        let config = LogContextConfig::new()
715            .with_context_lines(0)
716            .with_max_matches(3);
717        let result = extract_context_reader(r, &config).unwrap();
718        assert_eq!(result.match_count, 3);
719        assert!(result.truncated);
720    }
721
722    #[test]
723    fn reader_after_context_clipped_at_eof() {
724        // Match is near the end — after-context window can't be fully filled.
725        let r = reader_of(&["a", "b", "ERROR c"]);
726        let config = LogContextConfig::new()
727            .with_keywords(["error"])
728            .with_context_lines(3);
729        let result = extract_context_reader(r, &config).unwrap();
730        assert_eq!(result.match_count, 1);
731        // Only 0 lines after the match before EOF.
732        assert!(result.matches[0].after.is_empty());
733    }
734
735    #[test]
736    fn reader_total_lines_counted() {
737        let r = reader_of(&["a", "b", "c", "d", "e"]);
738        let result =
739            extract_context_reader(r, &LogContextConfig::new().with_context_lines(0)).unwrap();
740        assert_eq!(result.total_lines, 5);
741        assert_eq!(result.match_count, 0);
742    }
743
744    #[test]
745    fn reader_empty_input() {
746        let r = reader_of(&[]);
747        let result =
748            extract_context_reader(r, &LogContextConfig::new().with_context_lines(0)).unwrap();
749        assert_eq!(result.total_lines, 0);
750        assert_eq!(result.match_count, 0);
751    }
752
753    // ---- serialization ----
754
755    #[test]
756    fn result_serializes_to_json() {
757        let log = make_log(&["INFO ok", "ERROR fail", "INFO ok"]);
758        let config = LogContextConfig::new()
759            .with_keywords(["error"])
760            .with_context_lines(1);
761        let result = extract_context(&log, &config);
762        let json = serde_json::to_string_pretty(&result).unwrap();
763        assert!(json.contains("\"line_number\": 2"));
764        assert!(json.contains("\"keyword\": \"error\""));
765        assert!(json.contains("\"total_lines\": 3"));
766        assert!(json.contains("\"truncated\": false"));
767    }
768}