use std::collections::{BTreeMap, HashSet};
use std::env;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const HARD_MAX_LINES: usize = 40;
const HARD_MAX_BYTES: usize = 8_000;
const HARD_MAX_CONTEXT: usize = 5;
const MAX_SCAN_MATCHES: usize = 50_000;
const MAX_STORED_MATCHES: usize = 5_000;
const MAX_LINE_BYTES: usize = 400;
const SHOW_MAX_MATCHES: usize = 20;
const MAX_SURVEY_TERMS: usize = 12;
const MAX_SURVEY_PATHS: usize = 8;
const EXCLUDES: &[&str] = &[
"!.git/**",
"!target/**",
"!node_modules/**",
"!vendor/**",
"!dist/**",
"!build/**",
"!coverage/**",
"!scratch/**",
"!tmp/**",
"!generated/**",
"!*.log",
"!*.jsonl",
"!*.xml",
"!*.min.js",
"!*.map",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Survey,
Scout,
Sample,
Show,
}
impl Kind {
fn parse(value: &str) -> Option<Self> {
match value {
"survey" => Some(Self::Survey),
"scout" => Some(Self::Scout),
"sample" => Some(Self::Sample),
"show" => Some(Self::Show),
_ => None,
}
}
fn defaults(self) -> (usize, usize) {
match self {
Self::Survey => (20, 4_000),
Self::Scout => (15, 4_000),
Self::Sample => (20, 6_000),
Self::Show => (30, 8_000),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SearchMode {
Fixed,
Identifier,
Word,
Regex,
}
impl SearchMode {
fn label(self) -> &'static str {
match self {
Self::Fixed => "fixed",
Self::Identifier => "identifier",
Self::Word => "word",
Self::Regex => "regex",
}
}
}
#[derive(Debug)]
struct Args {
kind: Kind,
terms: Vec<String>,
paths: Vec<PathBuf>,
max_lines: usize,
max_bytes: usize,
context: usize,
mode: SearchMode,
}
#[derive(Clone, Debug)]
struct Match {
path: String,
line: usize,
column: usize,
text: String,
}
#[derive(Debug)]
struct SearchResult {
matches: Vec<Match>,
counts: BTreeMap<String, usize>,
scanned_matches: usize,
scan_limited: bool,
}
#[derive(Debug)]
struct SurveyOverallRow {
term: String,
matches: usize,
files: usize,
dominant_path: String,
scan_limited: bool,
}
#[derive(Debug)]
struct SurveyPathRow {
term: String,
matches: usize,
files: usize,
top_directory: String,
scan_limited: bool,
}
struct Budget {
max_lines: usize,
max_bytes: usize,
lines: usize,
bytes: usize,
truncated: bool,
}
impl Budget {
fn new(max_lines: usize, max_bytes: usize) -> Self {
Self {
max_lines,
max_bytes,
lines: 0,
bytes: 0,
truncated: false,
}
}
fn write(&mut self, value: impl AsRef<str>) {
if self.lines >= self.max_lines || self.bytes >= self.max_bytes {
self.truncated = true;
return;
}
let remaining = self.max_bytes.saturating_sub(self.bytes + 1);
if remaining == 0 {
self.truncated = true;
return;
}
let original = value.as_ref();
let mut end = original.len().min(remaining).min(MAX_LINE_BYTES);
while end > 0 && !original.is_char_boundary(end) {
end -= 1;
}
let clipped = &original[..end];
if clipped.len() < original.len() {
self.truncated = true;
}
println!("{clipped}");
self.lines += 1;
self.bytes += clipped.len() + 1;
}
fn has_room(&self) -> bool {
self.lines < self.max_lines && self.bytes < self.max_bytes
}
fn write_started_block(&mut self, value: impl AsRef<str>) {
let original = value.as_ref();
let mut end = original.len().min(MAX_LINE_BYTES);
while end > 0 && !original.is_char_boundary(end) {
end -= 1;
}
let clipped = &original[..end];
if clipped.len() < original.len() {
self.truncated = true;
}
println!("{clipped}");
self.lines += 1;
self.bytes += clipped.len() + 1;
if self.lines > self.max_lines || self.bytes > self.max_bytes {
self.truncated = true;
}
}
fn finish(&mut self) {
if !self.truncated || self.lines >= self.max_lines || self.bytes + 25 > self.max_bytes {
return;
}
self.write("[output truncated; narrow query]");
}
}
fn main() {
let values: Vec<_> = env::args().skip(1).collect();
if matches!(
values.first().map(String::as_str),
Some("-h" | "--help" | "help")
) {
println!("{}", usage());
return;
}
if matches!(values.first().map(String::as_str), Some("-V" | "--version")) {
println!("asrch {}", env!("CARGO_PKG_VERSION"));
return;
}
let args = match parse_args(values) {
Ok(args) => args,
Err(message) => exit_error(&message, 2),
};
for path in &args.paths {
if !path.exists() {
exit_error(&format!("path does not exist: {}", path.display()), 2);
}
}
if args.kind == Kind::Show && !args.paths[0].is_file() {
exit_error("show requires an explicit file path", 2);
}
let mut out = Budget::new(args.max_lines, args.max_bytes);
if args.kind == Kind::Survey {
print_survey(&args, &mut out);
} else {
let result = match search(&args, &args.terms[0], &args.paths[0]) {
Ok(result) => result,
Err(message) => exit_error(&format!("asrch: {message}"), 1),
};
if args.kind == Kind::Show
&& (result.scan_limited || result.scanned_matches > SHOW_MAX_MATCHES)
{
exit_error(
&format!(
"show has too many matches ({}); narrow the query before showing snippets",
total_label(&result)
),
2,
);
}
match args.kind {
Kind::Survey => unreachable!(),
Kind::Scout => print_scout(&args, &result, &mut out),
Kind::Sample => print_sample(&args, &result, &mut out),
Kind::Show => print_show(&args, &result, &mut out),
}
}
out.finish();
}
fn parse_args(values: Vec<String>) -> Result<Args, String> {
if values.is_empty() {
return Err("missing command".to_string());
}
let kind = Kind::parse(&values[0]).ok_or_else(|| format!("unknown command: {}", values[0]))?;
let mut positional = Vec::new();
let mut terms = Vec::new();
let mut max_lines = None;
let mut max_bytes = None;
let mut context = 2;
let mut mode = SearchMode::Fixed;
let mut mode_set = false;
let mut index = 1;
while index < values.len() {
match values[index].as_str() {
"--term" => {
index += 1;
terms.push(
values
.get(index)
.ok_or_else(|| "--term requires a value".to_string())?
.clone(),
);
}
"--max-lines" => {
index += 1;
max_lines = Some(parse_number(values.get(index), "--max-lines")?);
}
"--max-bytes" => {
index += 1;
max_bytes = Some(parse_number(values.get(index), "--max-bytes")?);
}
"--context" => {
index += 1;
context = parse_number(values.get(index), "--context")?;
}
"--identifier" => set_mode(&mut mode, &mut mode_set, SearchMode::Identifier)?,
"--word" => set_mode(&mut mode, &mut mode_set, SearchMode::Word)?,
"--regex" => set_mode(&mut mode, &mut mode_set, SearchMode::Regex)?,
"-h" | "--help" => return Err("help requested".to_string()),
value if value.starts_with('-') => return Err(format!("unknown option: {value}")),
_ => positional.push(values[index].clone()),
}
index += 1;
}
if kind == Kind::Survey {
if terms.is_empty() {
return Err("survey requires at least one --term".to_string());
}
if terms.len() > MAX_SURVEY_TERMS {
return Err(format!(
"survey accepts at most {MAX_SURVEY_TERMS} terms; split the comparison"
));
}
if mode == SearchMode::Regex {
return Err("survey does not accept --regex; pass literal terms".to_string());
}
if positional.len() > MAX_SURVEY_PATHS {
return Err(format!(
"survey accepts at most {MAX_SURVEY_PATHS} paths; split the comparison"
));
}
} else {
if !terms.is_empty() {
return Err("--term is only valid for survey".to_string());
}
if positional.is_empty() {
return Err("missing query".to_string());
}
if positional.len() > 2 {
return Err("too many positional arguments".to_string());
}
terms.push(positional.remove(0));
if mode == SearchMode::Regex && has_unescaped_pipe(&terms[0]) {
return Err(
"OR regexes are not accepted by single-query commands; use `asrch survey --term ...`"
.to_string(),
);
}
}
if terms.iter().any(|term| term.is_empty()) {
return Err("queries and survey terms must not be empty".to_string());
}
if kind == Kind::Show && positional.len() != 1 {
return Err("show requires an explicit file path".to_string());
}
if kind != Kind::Show && context != 2 {
return Err(match kind {
Kind::Sample => {
"sample does not accept --context; it already shows fixed one-line context. Use `asrch show <query> <file> --context N` after narrowing.".to_string()
}
Kind::Scout => {
"scout does not accept --context; use `asrch sample` or narrow to `asrch show <query> <file> --context N`.".to_string()
}
Kind::Survey => {
"survey does not accept --context; it compares terms without showing matches.".to_string()
}
Kind::Show => unreachable!(),
});
}
let (default_lines, default_bytes) = kind.defaults();
Ok(Args {
kind,
terms,
paths: if positional.is_empty() {
vec![PathBuf::from(".")]
} else {
positional.into_iter().map(PathBuf::from).collect()
},
max_lines: max_lines.unwrap_or(default_lines).clamp(1, HARD_MAX_LINES),
max_bytes: max_bytes.unwrap_or(default_bytes).clamp(64, HARD_MAX_BYTES),
context: context.min(HARD_MAX_CONTEXT),
mode,
})
}
fn set_mode(mode: &mut SearchMode, mode_set: &mut bool, value: SearchMode) -> Result<(), String> {
if *mode_set {
return Err("search mode options are mutually exclusive".to_string());
}
*mode = value;
*mode_set = true;
Ok(())
}
fn has_unescaped_pipe(value: &str) -> bool {
let mut escaped = false;
for ch in value.chars() {
if ch == '|' && !escaped {
return true;
}
escaped = ch == '\\' && !escaped;
if ch != '\\' {
escaped = false;
}
}
false
}
fn exit_error(message: &str, code: i32) -> ! {
print_error(message);
std::process::exit(code);
}
fn print_error(message: &str) {
let mut end = message.len().min(MAX_LINE_BYTES);
while end > 0 && !message.is_char_boundary(end) {
end -= 1;
}
eprintln!("{}", &message[..end]);
}
fn parse_number(value: Option<&String>, option: &str) -> Result<usize, String> {
value
.ok_or_else(|| format!("{option} requires a value"))?
.parse::<usize>()
.map_err(|_| format!("{option} requires a non-negative integer"))
}
fn usage() -> &'static str {
"Usage:
asrch survey --term <text> [--term <text> ...] [path ...] [--identifier | --word]
asrch scout <query> [path] [--identifier | --word | --regex]
asrch sample <query> [path] [--identifier | --word | --regex]
asrch show <query> <file> [--context N] [--identifier | --word | --regex]
Queries are fixed strings by default. Use survey for multiple terms.
Hard limits: 40 output lines, 8000 output bytes, and 5 context lines."
}
fn rg_command(args: &Args, query: &str, path: &Path) -> Command {
let mut command = Command::new("rg");
command.args([
"--null",
"--line-number",
"--column",
"--no-heading",
"--with-filename",
"--color",
"never",
"--no-messages",
"--sort",
"path",
"--max-columns",
"300",
"--max-columns-preview",
]);
let effective_query = (args.mode == SearchMode::Identifier).then(|| identifier_pattern(query));
match args.mode {
SearchMode::Fixed => {
command.arg("--fixed-strings");
}
SearchMode::Identifier => {}
SearchMode::Word => {
command.args(["--fixed-strings", "--word-regexp"]);
}
SearchMode::Regex => {}
}
for glob in EXCLUDES {
command.args(["--glob", glob]);
}
command
.arg("--")
.arg(effective_query.as_deref().unwrap_or(query))
.arg(path);
command.stdout(Stdio::piped()).stderr(Stdio::piped());
command
}
fn identifier_pattern(value: &str) -> String {
format!("(^|[^A-Za-z0-9_]){}([^A-Za-z0-9_]|$)", regex_escape(value))
}
fn regex_escape(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
if matches!(
ch,
'\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$'
) {
escaped.push('\\');
}
escaped.push(ch);
}
escaped
}
fn search(args: &Args, query: &str, path: &Path) -> Result<SearchResult, String> {
let mut child = rg_command(args, query, path)
.spawn()
.map_err(|error| format!("failed to start ripgrep: {error}"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "failed to read ripgrep output".to_string())?;
let mut reader = BufReader::new(stdout);
let mut raw = Vec::new();
let mut matches = Vec::new();
let mut counts = BTreeMap::new();
let mut scanned_matches = 0;
let mut scan_limited = false;
loop {
raw.clear();
let read = reader
.read_until(b'\n', &mut raw)
.map_err(|error| format!("failed to read ripgrep output: {error}"))?;
if read == 0 {
break;
}
if let Some(item) = parse_match(&raw) {
scanned_matches += 1;
*counts.entry(item.path.clone()).or_insert(0) += 1;
if matches.len() < MAX_STORED_MATCHES {
matches.push(item);
}
if scanned_matches >= MAX_SCAN_MATCHES {
scan_limited = true;
let _ = child.kill();
break;
}
}
}
let status = child
.wait()
.map_err(|error| format!("failed to wait for ripgrep: {error}"))?;
if !scan_limited && !matches!(status.code(), Some(0 | 1)) {
let stderr = child
.stderr
.take()
.map(|stream| {
let mut text = String::new();
let _ = BufReader::new(stream).read_line(&mut text);
text.trim().to_string()
})
.unwrap_or_default();
return Err(if stderr.is_empty() {
format!("ripgrep exited with {status}")
} else {
stderr
});
}
Ok(SearchResult {
matches,
counts,
scanned_matches,
scan_limited,
})
}
fn parse_match(raw: &[u8]) -> Option<Match> {
let nul = raw.iter().position(|byte| *byte == 0)?;
let path = String::from_utf8_lossy(&raw[..nul]).to_string();
let rest = String::from_utf8_lossy(&raw[nul + 1..]);
let mut fields = rest.trim_end_matches(['\r', '\n']).splitn(3, ':');
let line = fields.next()?.parse().ok()?;
let column = fields.next()?.parse().ok()?;
let text = fields.next()?.to_string();
Some(Match {
path,
line,
column,
text,
})
}
fn total_label(result: &SearchResult) -> String {
if result.scan_limited {
format!("at least {}", result.scanned_matches)
} else {
result.scanned_matches.to_string()
}
}
fn toon_value(value: &str) -> String {
if !value.is_empty()
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/' | '='))
{
return value.to_string();
}
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('"');
for ch in value.chars() {
if matches!(ch, '"' | '\\') {
quoted.push('\\');
}
quoted.push(ch);
}
quoted.push('"');
quoted
}
fn print_survey(args: &Args, out: &mut Budget) {
out.write("survey:");
out.write(format!(" terms: {}", args.terms.len()));
out.write(format!(" paths: {}", args.paths.len()));
out.write(format!(" mode: {}", args.mode.label()));
let mut overall_rows = Vec::new();
let mut by_path: BTreeMap<String, Vec<SurveyPathRow>> = BTreeMap::new();
for term in &args.terms {
let mut total_matches = 0;
let mut total_files = 0;
let mut scan_limited = false;
let mut dominant_path = "-".to_string();
let mut dominant_matches = 0;
for path in &args.paths {
let result = match search(args, term, path) {
Ok(result) => result,
Err(message) => exit_error(&format!("asrch: {message}"), 1),
};
let path_label = path.display().to_string();
total_matches += result.scanned_matches;
total_files += result.counts.len();
scan_limited |= result.scan_limited;
if result.scanned_matches > dominant_matches {
dominant_matches = result.scanned_matches;
dominant_path = path_label.clone();
}
if result.scanned_matches == 0 && !result.scan_limited {
continue;
}
let top_directory = ranked_directories(&result.counts)
.into_iter()
.next()
.map(|(path, _)| path)
.unwrap_or_else(|| "-".to_string());
by_path.entry(path_label).or_default().push(SurveyPathRow {
term: term.clone(),
matches: result.scanned_matches,
files: result.counts.len(),
top_directory,
scan_limited: result.scan_limited,
});
}
overall_rows.push(SurveyOverallRow {
term: term.clone(),
matches: total_matches,
files: total_files,
dominant_path,
scan_limited,
});
}
out.write("overall[term,matches,files,dominant_path]:");
for row in &overall_rows {
let match_label = if row.scan_limited {
format!(">={}", row.matches)
} else {
row.matches.to_string()
};
out.write(format!(
" {},{match_label},{},{}",
toon_value(&row.term),
row.files,
toon_value(&row.dominant_path)
));
}
out.write("by_path:");
for (path, rows) in by_path {
out.write(format!(
" {}[term,matches,files,top_directory]:",
toon_value(&path)
));
for row in rows {
let match_label = if row.scan_limited {
format!(">={}", row.matches)
} else {
row.matches.to_string()
};
out.write(format!(
" {},{match_label},{},{}",
toon_value(&row.term),
row.files,
toon_value(&row.top_directory)
));
}
}
let sum: usize = overall_rows.iter().map(|row| row.matches).sum();
if let Some(row) = overall_rows.iter().max_by_key(|row| row.matches)
&& sum > 0
&& row.matches.saturating_mul(100) >= sum.saturating_mul(80)
&& overall_rows.len() > 1
{
out.write("warnings:");
out.write(format!(
" - {} dominates survey: {}/{sum} matching lines",
toon_value(&row.term),
row.matches
));
}
if args.mode == SearchMode::Fixed {
let short_terms: Vec<_> = args
.terms
.iter()
.filter(|term| term.chars().count() <= 3)
.collect();
if !short_terms.is_empty() {
if sum == 0
|| overall_rows
.iter()
.max_by_key(|row| row.matches)
.is_none_or(|row| row.matches.saturating_mul(100) < sum.saturating_mul(80))
{
out.write("warnings:");
}
let terms = short_terms
.iter()
.map(|term| toon_value(term))
.collect::<Vec<_>>()
.join(",");
out.write(format!(
" - short partial-match terms: {terms}; consider --identifier or --word"
));
}
}
out.write("next: choose one useful term and path, then run asrch scout");
}
fn print_scout(args: &Args, result: &SearchResult, out: &mut Budget) {
out.write("scout:");
out.write(format!(" query: {}", toon_value(&args.terms[0])));
out.write(format!(
" path: {}",
toon_value(&args.paths[0].display().to_string())
));
out.write(format!(" mode: {}", args.mode.label()));
out.write(format!(" matches: {}", total_label(result)));
out.write(format!(" files: {}", result.counts.len()));
broad_notice_toon(result, out);
if result.scanned_matches == 0 {
out.write("top_directories[path,matches]:");
out.write("top_files[path,matches]:");
out.write("next: try another term or path");
return;
}
out.write("top_directories[path,matches]:");
for (path, count) in ranked_directories(&result.counts).into_iter().take(5) {
out.write(format!(" {},{count}", toon_value(&path)));
}
out.write("top_files[path,matches]:");
for (path, count) in ranked_counts(&result.counts).into_iter().take(5) {
out.write(format!(" {},{count}", toon_value(path)));
}
out.write("next: narrow the path, then use asrch sample");
}
fn ranked_counts(counts: &BTreeMap<String, usize>) -> Vec<(&String, &usize)> {
let mut ranked: Vec<_> = counts.iter().collect();
ranked.sort_by(|(path_a, count_a), (path_b, count_b)| {
count_b.cmp(count_a).then_with(|| path_a.cmp(path_b))
});
ranked
}
fn ranked_directories(counts: &BTreeMap<String, usize>) -> Vec<(String, usize)> {
let mut directories = BTreeMap::new();
for (path, count) in counts {
let directory = Path::new(path)
.parent()
.map(|parent| parent.display().to_string())
.filter(|parent| !parent.is_empty())
.unwrap_or_else(|| ".".to_string());
*directories.entry(directory).or_insert(0) += count;
}
let mut ranked: Vec<_> = directories.into_iter().collect();
ranked.sort_by(|(path_a, count_a), (path_b, count_b)| {
count_b.cmp(count_a).then_with(|| path_a.cmp(path_b))
});
ranked
}
fn print_sample(args: &Args, result: &SearchResult, out: &mut Budget) {
out.write(format!(
"sample: query={:?} path={} mode={} matches={} files={}",
args.terms[0],
args.paths[0].display(),
args.mode.label(),
total_label(result),
result.counts.len()
));
broad_notice(result, out);
if result.matches.is_empty() {
out.write("No matches.");
return;
}
for item in representative_clusters(&result.matches) {
print_snippet(&item, 1, out);
}
if result.scanned_matches > result.matches.len() || result.scan_limited {
out.write("More matches exist; narrow the query or path before `show`.");
}
}
fn representative_clusters(matches: &[Match]) -> Vec<Match> {
let mut by_file: BTreeMap<&str, Vec<&Match>> = BTreeMap::new();
for item in matches {
by_file.entry(&item.path).or_default().push(item);
}
let mut files = Vec::new();
for items in by_file.values() {
let mut clusters = Vec::new();
let mut previous_line = None;
for item in items {
if previous_line.is_none_or(|line| item.line > line + 2) {
clusters.push((*item).clone());
}
previous_line = Some(item.line);
}
files.push(prioritize_clusters(clusters));
}
let mut result = Vec::new();
for round in 0..3 {
for clusters in &files {
if let Some(item) = clusters.get(round) {
result.push(item.clone());
}
}
}
result
}
fn prioritize_clusters(clusters: Vec<Match>) -> Vec<Match> {
if clusters.len() <= 3 {
return clusters;
}
let middle = clusters.len() / 2;
vec![
clusters[0].clone(),
clusters[middle].clone(),
clusters[clusters.len() - 1].clone(),
]
}
fn print_show(args: &Args, result: &SearchResult, out: &mut Budget) {
out.write(format!(
"show: query={:?} file={} mode={} matches={} context={}",
args.terms[0],
args.paths[0].display(),
args.mode.label(),
total_label(result),
args.context
));
if result.matches.is_empty() {
out.write("No matches.");
return;
}
let mut seen = HashSet::new();
for item in &result.matches {
if !seen.insert(item.line) {
continue;
}
if !out.has_room() {
out.write("More snippets exist; narrow the query or reduce context.");
break;
}
print_snippet_started_block(item, args.context, out);
}
}
fn print_snippet(item: &Match, context: usize, out: &mut Budget) {
let path = Path::new(&item.path);
let Ok(content) = fs::read_to_string(path) else {
out.write(format_match(item));
return;
};
let lines: Vec<_> = content.lines().collect();
let start = item.line.saturating_sub(context + 1);
let end = (item.line + context).min(lines.len());
out.write(format!("-- {}:{}:{}", item.path, item.line, item.column));
for (index, text) in lines[start..end].iter().enumerate() {
let number = start + index + 1;
let marker = if number == item.line { '>' } else { ' ' };
out.write(format!("{marker}{number:>6} | {text}"));
}
}
fn print_snippet_started_block(item: &Match, context: usize, out: &mut Budget) {
let path = Path::new(&item.path);
let Ok(content) = fs::read_to_string(path) else {
out.write_started_block(format_match(item));
return;
};
let lines: Vec<_> = content.lines().collect();
let start = item.line.saturating_sub(context + 1);
let end = (item.line + context).min(lines.len());
out.write_started_block(format!("-- {}:{}:{}", item.path, item.line, item.column));
for (index, text) in lines[start..end].iter().enumerate() {
let number = start + index + 1;
let marker = if number == item.line { '>' } else { ' ' };
out.write_started_block(format!("{marker}{number:>6} | {text}"));
}
}
fn format_match(item: &Match) -> String {
format!("{}:{}:{}:{}", item.path, item.line, item.column, item.text)
}
fn broad_notice(result: &SearchResult, out: &mut Budget) {
if result.scan_limited {
out.write("Query is too broad; scan limit reached. Narrow the query or path.");
} else if result.scanned_matches > 1_000 || result.counts.len() > 100 {
out.write("Query is broad; narrow the query or path before reading matches.");
}
}
fn broad_notice_toon(result: &SearchResult, out: &mut Budget) {
if result.scan_limited {
out.write("warnings:");
out.write(" - query too broad: scan limit reached");
} else if result.scanned_matches > 1_000 || result.counts.len() > 100 {
out.write("warnings:");
out.write(" - query broad: narrow query or path before reading matches");
}
}