Skip to main content

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