use std::{fs, path::PathBuf};
use anyhow::{Context, Result};
use crate::ring::Matcher;
pub struct FileMatch {
pub file: PathBuf,
pub line: u64,
pub text: String,
pub before: Vec<String>,
pub after: Vec<String>,
}
pub struct Found {
pub matches: Vec<FileMatch>,
pub truncated: bool,
pub files: usize,
}
pub fn search_files(
files: &[PathBuf],
matcher: &Matcher,
max_results: usize,
context: usize,
) -> Result<Found> {
let mut matches = Vec::new();
let mut total = 0;
for file in files {
let bytes = fs::read(file).with_context(|| format!("reading {}", file.display()))?;
let mut lines: Vec<&[u8]> = bytes.split(|b| *b == b'\n').collect();
if lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
let hits: Vec<usize> = (0..lines.len()).filter(|i| matcher.find_end(lines[*i]).is_some()).collect();
total += hits.len();
for &i in hits.iter().rev() {
if matches.len() >= max_results {
break;
}
matches.push(FileMatch {
file: file.clone(),
line: (i + 1) as u64,
text: text_of(lines[i]),
before: lines[i.saturating_sub(context)..i].iter().map(|l| text_of(l)).collect(),
after: lines[i + 1..(i + 1 + context).min(lines.len())]
.iter()
.map(|l| text_of(l))
.collect(),
});
}
}
Ok(Found {
matches,
truncated: total > max_results,
files: files.len(),
})
}
fn text_of(line: &[u8]) -> String {
String::from_utf8_lossy(line).into_owned()
}
#[cfg(test)]
mod tests {
use std::env;
use super::*;
fn temp_files(name: &str, contents: &[&str]) -> Vec<PathBuf> {
let dir = env::temp_dir().join(format!("smon-search-test-{name}"));
if dir.exists() {
fs::remove_dir_all(&dir).unwrap();
}
fs::create_dir_all(&dir).unwrap();
contents
.iter()
.enumerate()
.map(|(i, text)| {
let path = dir.join(format!("{}.log", contents.len() - i));
fs::write(&path, text).unwrap();
path
})
.collect()
}
#[test]
fn matches_come_newest_first_across_and_within_files() {
let files = temp_files(
"order",
&["early panic\nok\nlate panic\n", "old panic\nnothing else\n"],
);
let matcher = Matcher::build("panic", false).unwrap();
let found = search_files(&files, &matcher, 100, 0).unwrap();
let texts: Vec<&str> = found.matches.iter().map(|m| m.text.as_str()).collect();
assert_eq!(texts, ["late panic", "early panic", "old panic"]);
assert_eq!(found.matches[0].line, 3);
assert_eq!(found.matches[2].file, files[1]);
assert!(!found.truncated);
assert_eq!(found.files, 2);
}
#[test]
fn cap_keeps_the_newest_and_reports_truncation() {
let files = temp_files("cap", &["hit 1\nhit 2\nhit 3\n"]);
let matcher = Matcher::build("hit", false).unwrap();
let found = search_files(&files, &matcher, 2, 0).unwrap();
let texts: Vec<&str> = found.matches.iter().map(|m| m.text.as_str()).collect();
assert_eq!(texts, ["hit 3", "hit 2"]);
assert!(found.truncated);
}
#[test]
fn context_surrounds_the_match_and_stops_at_file_edges() {
let files = temp_files("context", &["one\ntwo\npanic\nfour\n"]);
let matcher = Matcher::build("panic", false).unwrap();
let found = search_files(&files, &matcher, 100, 2).unwrap();
assert_eq!(found.matches[0].before, ["one", "two"]);
assert_eq!(found.matches[0].after, ["four"]);
}
#[test]
fn regex_matching_works_on_lines() {
let files = temp_files("regex", &["timeout (T2) 61 sec\ntimeout pending\n"]);
let matcher = Matcher::build(r"\(T\d\)", true).unwrap();
let found = search_files(&files, &matcher, 100, 0).unwrap();
assert_eq!(found.matches.len(), 1);
assert_eq!(found.matches[0].text, "timeout (T2) 61 sec");
}
}