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)]
69pub struct LogContextConfig {
70 pub keywords: Vec<String>,
72
73 pub context_lines: usize,
75
76 pub max_matches: usize,
79
80 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 #[must_use]
98 pub fn new() -> Self {
99 Self::default()
100 }
101
102 #[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 #[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 #[must_use]
121 pub fn with_context_lines(mut self, n: usize) -> Self {
122 self.context_lines = n;
123 self
124 }
125
126 #[must_use]
128 pub fn with_max_matches(mut self, n: usize) -> Self {
129 self.max_matches = n;
130 self
131 }
132
133 #[must_use]
135 pub fn case_sensitive(mut self, sensitive: bool) -> Self {
136 self.case_sensitive = sensitive;
137 self
138 }
139}
140
141#[derive(Debug, Clone, Serialize)]
147pub struct LogContextMatch {
148 pub line_number: usize,
150
151 pub keyword: String,
154
155 pub line: String,
157
158 pub before: Vec<String>,
161
162 pub after: Vec<String>,
165}
166
167#[derive(Debug, Clone, Serialize)]
169pub struct LogContextResult {
170 pub total_lines: usize,
172
173 pub match_count: usize,
177
178 pub truncated: bool,
182
183 pub matches: Vec<LogContextMatch>,
185}
186
187#[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 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 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#[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 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 let line: &str = line_buf.trim_end_matches(['\n', '\r']);
344 total_lines += 1;
345 let line_number = total_lines;
346
347 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 if !truncated {
368 let effective_count = matches.len() + pending.len();
369 if effective_count >= config.max_matches {
370 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 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 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#[cfg(test)]
451mod tests {
452 use super::*;
453
454 fn make_log(lines: &[&str]) -> String {
455 lines.join("\n")
456 }
457
458 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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}