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
187fn 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
209fn 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#[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#[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 let line: &str = line_buf.trim_end_matches(['\n', '\r']);
350 total_lines += 1;
351 let line_number = total_lines;
352
353 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 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 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 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 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#[cfg(test)]
439mod tests {
440 use super::*;
441
442 fn make_log(lines: &[&str]) -> String {
443 lines.join("\n")
444 }
445
446 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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}