use std::fs::canonicalize;
use std::path::Path;
use std::time::{Duration, Instant};
use ignore::{DirEntry, WalkBuilder};
use super::confinement::AllowedRoots;
pub(crate) const MAX_RESULTS: usize = 200;
pub(crate) const MAX_OUTPUT_BYTES: usize = 64 * 1024;
pub(crate) const SEARCH_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) fn confined_walker(roots: &AllowedRoots, start: &Path) -> WalkBuilder {
let roots = roots.clone();
let mut builder = WalkBuilder::new(start);
builder
.hidden(false)
.require_git(false)
.follow_links(true)
.sort_by_file_name(|a, b| a.cmp(b))
.filter_entry(move |entry| stays_inside(&roots, entry));
builder
}
pub(crate) fn walk_files(
walker: WalkBuilder,
deadline: Instant,
empty: &str,
mut visit: impl FnMut(&Path, &mut BoundedOutput) -> bool,
) -> String {
let mut out = BoundedOutput::default();
for entry in walker.build() {
if Instant::now() >= deadline {
out.truncate(Truncation::Timeout);
break;
}
let Ok(entry) = entry else { continue };
if entry.file_type().is_some_and(|kind| kind.is_file()) && !visit(entry.path(), &mut out) {
break;
}
}
out.finish(empty)
}
fn stays_inside(roots: &AllowedRoots, entry: &DirEntry) -> bool {
if entry.file_name() == ".git" {
return false;
}
if entry.path_is_symlink() {
return canonicalize(entry.path()).is_ok_and(|target| roots.contains(&target));
}
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Truncation {
Results,
Bytes,
Timeout,
}
#[derive(Debug, Default)]
pub(crate) struct BoundedOutput {
text: String,
count: usize,
truncation: Option<Truncation>,
}
impl BoundedOutput {
pub(crate) fn push(&mut self, line: &str) -> bool {
if self.truncation.is_some() {
return false;
}
if self.count == MAX_RESULTS {
self.truncation = Some(Truncation::Results);
return false;
}
let separator = usize::from(!self.text.is_empty());
if self.text.len() + separator + line.len() > MAX_OUTPUT_BYTES {
self.truncation = Some(Truncation::Bytes);
return false;
}
if separator == 1 {
self.text.push('\n');
}
self.text.push_str(line);
self.count += 1;
true
}
pub(crate) fn truncate(&mut self, reason: Truncation) {
self.truncation.get_or_insert(reason);
}
pub(crate) fn finish(self, empty: &str) -> String {
let notice = self.truncation.map(|reason| match reason {
Truncation::Results => {
format!("[truncated: stopped at {MAX_RESULTS} results, narrow the pattern or the path]")
}
Truncation::Bytes => format!(
"[truncated: output limit of {} KiB reached, narrow the pattern or the path]",
MAX_OUTPUT_BYTES / 1024
),
Truncation::Timeout => format!(
"[truncated: search stopped after {} s, results are partial, narrow the pattern or the path]",
SEARCH_TIMEOUT.as_secs()
),
});
match (self.text.is_empty(), notice) {
(true, None) => empty.to_string(),
(true, Some(notice)) => notice,
(false, None) => self.text,
(false, Some(notice)) => format!("{}\n{notice}", self.text),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_output_uses_the_empty_message() {
assert_eq!(
BoundedOutput::default().finish("No matches found"),
"No matches found"
);
}
#[test]
fn stops_at_max_results_and_says_so() {
let mut out = BoundedOutput::default();
for i in 0..MAX_RESULTS {
assert!(out.push(&format!("line {i}")));
}
assert!(!out.push("one too many"));
let text = out.finish("none");
assert_eq!(text.lines().count(), MAX_RESULTS + 1);
assert!(!text.contains("one too many"));
assert!(text.ends_with("narrow the pattern or the path]"), "{text}");
assert!(text.contains("stopped at 200 results"), "{text}");
}
#[test]
fn stops_at_max_bytes_and_says_so() {
let mut out = BoundedOutput::default();
let line = "x".repeat(1000);
while out.push(&line) {}
let text = out.finish("none");
let (results, notice) = text.rsplit_once('\n').expect("notice line");
assert!(results.len() <= MAX_OUTPUT_BYTES);
assert!(notice.contains("64 KiB"), "{notice}");
}
#[test]
fn first_truncation_reason_wins() {
let mut out = BoundedOutput::default();
out.push("a");
out.truncate(Truncation::Timeout);
out.truncate(Truncation::Results);
assert!(!out.push("b"));
let text = out.finish("none");
assert!(text.starts_with("a\n"), "{text}");
assert!(text.contains("after 10 s"), "{text}");
}
}