1use serde::{Deserialize, Serialize};
27use std::{collections::VecDeque, io};
28
29pub const DEFAULT_KEYWORDS: &[&str] = &[
35 "error",
36 "failure",
37 "warning",
38 "warn",
39 "fatal",
40 "exception",
41 "critical",
42];
43
44pub const DEFAULT_CONTEXT_LINES: usize = 10;
46
47pub const DEFAULT_MAX_MATCHES: usize = 50;
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
69#[non_exhaustive]
70pub struct LogContextConfig {
71 pub keywords: Vec<String>,
73
74 pub context_lines: usize,
76
77 pub max_matches: usize,
80
81 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 #[must_use]
99 pub fn new() -> Self {
100 Self::default()
101 }
102
103 #[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 #[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 #[must_use]
122 pub fn with_context_lines(mut self, n: usize) -> Self {
123 self.context_lines = n;
124 self
125 }
126
127 #[must_use]
129 pub fn with_max_matches(mut self, n: usize) -> Self {
130 self.max_matches = n;
131 self
132 }
133
134 #[must_use]
136 pub fn case_sensitive(mut self, sensitive: bool) -> Self {
137 self.case_sensitive = sensitive;
138 self
139 }
140}
141
142#[derive(Debug, Clone, Serialize)]
148#[non_exhaustive]
149pub struct LogContextMatch {
150 pub line_number: usize,
152
153 pub keyword: String,
156
157 pub line: String,
159
160 pub before: Vec<String>,
163
164 pub after: Vec<String>,
167}
168
169#[derive(Debug, Clone, Serialize)]
171#[non_exhaustive]
172pub struct LogContextResult {
173 pub total_lines: usize,
175
176 pub match_count: usize,
180
181 pub truncated: bool,
185
186 pub matches: Vec<LogContextMatch>,
188}
189
190fn 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
212fn 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#[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#[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 let line: &str = line_buf.trim_end_matches(['\n', '\r']);
353 total_lines += 1;
354 let line_number = total_lines;
355
356 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 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 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 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 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#[cfg(test)]
442mod tests {
443 use super::*;
444
445 fn make_log(lines: &[&str]) -> String {
446 lines.join("\n")
447 }
448
449 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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}