use cosh_sdk::find::{GrepOptions, GrepOutputMode, grep as sdk_grep};
use cosh_sdk::hashline::format::format_hashline_header;
use cosh_sdk::rollback;
use super::common_ancestor;
use super::types::{ContextEntry, Grep, GrepFileEntry, GrepMatchEntry, GrepOutput};
const DEFAULT_FILE_LIMIT: usize = 20;
const MULTI_FILE_PER_FILE_MATCHES: u32 = 20;
const SINGLE_FILE_MATCHES: u32 = 200;
const INTERNAL_TOTAL_CAP: u32 = 2000;
const DEFAULT_MAX_COLUMN: u32 = 200;
pub const DEFAULT_GREP_TIMEOUT_MS: u32 = 5000;
pub type GrepMatchCallback = dyn Fn(&cosh_sdk::find::GrepMatch) + Send + Sync;
fn match_absolute_path(root: &str, match_path: &str) -> std::path::PathBuf {
let p = std::path::Path::new(match_path);
if p.is_absolute() {
p.to_path_buf()
} else {
std::path::Path::new(root).join(p)
}
}
fn hashline_entry_for_file(
root: &str,
match_path: &str,
seen_lines: &[(u32, String)],
) -> Option<GrepFileEntry> {
let abs = match_absolute_path(root, match_path);
let meta = std::fs::metadata(&abs).ok()?;
if meta.len() > u64::try_from(rollback::MAX_SNAPSHOT_BYTES).unwrap_or(u64::MAX) {
return None;
}
let text = std::fs::read_to_string(&abs).ok()?;
let key = std::fs::canonicalize(&abs)
.unwrap_or(abs)
.to_string_lossy()
.to_string();
let file_hash = rollback::record(&key, &text)?;
if !seen_lines.is_empty() {
rollback::record_seen_lines(&key, &file_hash, seen_lines);
}
Some(GrepFileEntry {
path: match_path.to_string(),
file_hash: file_hash.clone(),
header: format_hashline_header(&key, &file_hash),
})
}
fn group_by_file(
shown: &[GrepMatchEntry],
) -> (
Vec<&str>,
std::collections::HashMap<&str, Vec<GrepMatchEntry>>,
) {
let mut order: Vec<&str> = Vec::new();
let mut groups: std::collections::HashMap<&str, Vec<GrepMatchEntry>> =
std::collections::HashMap::new();
for m in shown {
let path = m.path.as_str();
if let Some(list) = groups.get_mut(path) {
list.push(m.clone());
} else {
order.push(path);
groups.insert(path, vec![m.clone()]);
}
}
(order, groups)
}
fn build_hashline_files(root: &str, shown: &[GrepMatchEntry]) -> Vec<GrepFileEntry> {
let (order, groups) = group_by_file(shown);
let mut entries = Vec::new();
for path in order.into_iter().take(DEFAULT_FILE_LIMIT) {
let mut seen_lines: Vec<(u32, String)> = Vec::new();
for m in groups.get(path).map(Vec::as_slice).unwrap_or_default() {
seen_lines.push((m.line_number, m.line.clone()));
for ctx in &m.context_before {
seen_lines.push((ctx.line_number, ctx.line.clone()));
}
for ctx in &m.context_after {
seen_lines.push((ctx.line_number, ctx.line.clone()));
}
}
if let Some(entry) = hashline_entry_for_file(root, path, &seen_lines) {
entries.push(entry);
}
}
entries
}
fn parse_line_range(raw: &str) -> Result<(u32, u32), String> {
let (start, end) = raw.split_once('-').ok_or_else(|| {
format!("line_range must be \"start-end\" (1-based, inclusive), got: {raw}")
})?;
let start: u32 = start.trim().parse().map_err(|_| {
format!("line_range must be \"start-end\" (1-based, inclusive), got: {raw}")
})?;
let end: u32 = end.trim().parse().map_err(|_| {
format!("line_range must be \"start-end\" (1-based, inclusive), got: {raw}")
})?;
if start < 1 || end < start {
return Err(format!(
"line_range must satisfy 1 <= start <= end, got: {raw}"
));
}
Ok((start, end))
}
fn apply_line_range(matches: Vec<GrepMatchEntry>, range: (u32, u32)) -> (Vec<GrepMatchEntry>, u32) {
let (start, end) = range;
let mut kept = 0u32;
let out = matches
.into_iter()
.filter_map(|mut m| {
if m.line_number < start || m.line_number > end {
return None;
}
m.context_before
.retain(|c| c.line_number >= start && c.line_number <= end);
m.context_after
.retain(|c| c.line_number >= start && c.line_number <= end);
kept += 1;
Some(m)
})
.collect();
(out, kept)
}
pub fn grep(grep: &Grep, pattern: &str, path: &str) -> Result<GrepOutput, String> {
grep_targets(grep, pattern, &[path.to_string()])
}
pub fn grep_targets(grep: &Grep, pattern: &str, targets: &[String]) -> Result<GrepOutput, String> {
grep_targets_with(grep, pattern, targets, None)
}
pub fn grep_targets_with(
grep: &Grep,
pattern: &str,
targets: &[String],
on_match: Option<std::sync::Arc<GrepMatchCallback>>,
) -> Result<GrepOutput, String> {
if targets.is_empty() {
return Err("no search targets provided".to_string());
}
let range = match &grep.line_range {
Some(raw) => Some(parse_line_range(raw)?),
None => None,
};
let resolved: Vec<std::path::PathBuf> = targets.iter().map(std::path::PathBuf::from).collect();
let multi = targets.len() > 1;
let is_file_scope = !multi && resolved[0].is_file();
let is_multi_scope = multi || !is_file_scope || grep.glob.is_some();
let per_file_cap = if is_multi_scope {
MULTI_FILE_PER_FILE_MATCHES
} else {
SINGLE_FILE_MATCHES
};
if range.is_some() {
for target in &resolved {
if !target.is_file() {
return Err(format!(
"line_range requires single-file targets, but `{}` is not a file",
target.display()
));
}
}
}
let (sdk_max_count, file_window) = match grep.max_count {
Some(cap) => (Some(cap), None),
None => (Some(INTERNAL_TOTAL_CAP), Some(DEFAULT_FILE_LIMIT)),
};
let effective_multiline = pattern.contains('\n') || pattern.contains("\\n");
let fetch_per_file = match range {
Some((start, end)) => end
.saturating_sub(start)
.saturating_add(1)
.max(per_file_cap)
.min(INTERNAL_TOTAL_CAP - 1)
.saturating_add(1),
None => per_file_cap + 1,
};
let ancestor = if multi {
common_ancestor(&resolved)
} else {
None
};
let root = ancestor
.as_ref()
.map(|a| a.to_string_lossy().to_string())
.unwrap_or_else(|| targets[0].clone());
let timeout_ms = grep.timeout_ms.or(Some(DEFAULT_GREP_TIMEOUT_MS));
let mut raw: Vec<GrepMatchEntry> = Vec::new();
let mut files_with_matches = 0u32;
let mut files_searched = 0u32;
let mut limit_reached = false;
let mut timed_out = false;
let mut seen: std::collections::HashSet<(String, u32)> = std::collections::HashSet::new();
for target in &resolved {
let wrapped_cb: Option<std::sync::Arc<GrepMatchCallback>> = on_match.as_ref().map(|cb| {
let cb = cb.clone();
let target_owned = target.to_string_lossy().to_string();
let ancestor = ancestor.clone();
let cb_arc: std::sync::Arc<GrepMatchCallback> =
std::sync::Arc::new(move |m: &cosh_sdk::find::GrepMatch| {
let path_out = if let Some(a) = ancestor.as_deref() {
let abs = std::path::Path::new(&target_owned).join(&m.path);
abs.strip_prefix(a)
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| m.path.clone())
} else {
m.path.clone()
};
cb(&cosh_sdk::find::GrepMatch {
path: format!("{path_out}:{}", m.line_number),
line_number: m.line_number,
line: String::new(),
context_before: None,
context_after: None,
truncated: None,
match_count: None,
});
});
cb_arc
});
let result = sdk_grep(GrepOptions {
pattern: pattern.to_owned(),
path: target.to_string_lossy().to_string(),
glob: grep.glob.clone(),
r#type: grep.file_type.clone(),
ignore_case: grep.ignore_case,
multiline: Some(effective_multiline),
hidden: grep.hidden,
gitignore: grep.gitignore,
cache: None,
max_count: sdk_max_count,
offset: None,
context_before: grep.context_before,
context_after: grep.context_after,
context: None,
max_columns: Some(DEFAULT_MAX_COLUMN),
mode: Some(GrepOutputMode::Content),
max_count_per_file: Some(fetch_per_file),
timeout_ms,
on_match: wrapped_cb,
})?;
files_with_matches = files_with_matches.saturating_add(result.files_with_matches);
files_searched = files_searched.saturating_add(result.files_searched);
limit_reached = limit_reached || result.limit_reached == Some(true);
timed_out = timed_out || result.timed_out;
for m in result.matches {
let path_out = if multi {
let abs = match_absolute_path(&target.to_string_lossy(), &m.path);
match ancestor.as_deref() {
Some(a) => abs
.strip_prefix(a)
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
.unwrap_or(m.path),
None => m.path,
}
} else {
m.path
};
if multi && !seen.insert((path_out.clone(), m.line_number)) {
continue;
}
raw.push(GrepMatchEntry {
path: path_out,
line_number: m.line_number,
line: m.line,
truncated: m.truncated,
context_before: m
.context_before
.unwrap_or_default()
.into_iter()
.map(|c| ContextEntry {
line_number: c.line_number,
line: c.line,
})
.collect(),
context_after: m
.context_after
.unwrap_or_default()
.into_iter()
.map(|c| ContextEntry {
line_number: c.line_number,
line: c.line,
})
.collect(),
});
}
}
let total_after_filter = match range {
Some(r) => {
let (filtered, kept) = apply_line_range(raw, r);
raw = filtered;
kept
}
None => u32::try_from(raw.len()).unwrap_or(u32::MAX),
};
let (order, mut groups) = group_by_file(&raw);
let mut per_file_limit_reached = false;
for path in &order {
if let Some(list) = groups.get_mut(path)
&& list.len() > per_file_cap as usize
{
per_file_limit_reached = true;
list.truncate(per_file_cap as usize);
}
}
let total_files = order.len();
let can_paginate = is_multi_scope && file_window.is_some();
let skip_files = if can_paginate {
(grep.skip.unwrap_or(0) as usize).min(total_files)
} else {
0
};
let window_files: Vec<&str> = if can_paginate {
match file_window {
Some(window) => order
.iter()
.skip(skip_files)
.take(window)
.copied()
.collect(),
None => order.iter().skip(skip_files).copied().collect(),
}
} else {
order.to_vec()
};
let file_limit_reached =
can_paginate && file_window.is_some() && total_files > skip_files + window_files.len();
let shown: Vec<GrepMatchEntry> = window_files
.iter()
.flat_map(|path| groups.get(*path).cloned().unwrap_or_default())
.collect();
let total_files_label = if limit_reached {
format!("{total_files}+")
} else {
total_files.to_string()
};
let next_skip = skip_files + window_files.len();
let window_note = file_limit_reached.then(|| {
format!(
"Showing files {}-{} of {}. Use skip={} for the next page, or narrow paths/pattern.",
skip_files + 1,
next_skip,
total_files_label,
next_skip
)
});
let timeout_note = timed_out.then(|| {
if shown.is_empty() {
"Search timed out before finding matches — the scan is incomplete, NOT proof of absence; narrow the scope instead of retrying blindly".to_string()
} else {
"Search timed out; results are partial and incomplete".to_string()
}
});
let (note, useless) = if shown.is_empty() {
if timed_out {
(timeout_note, None)
} else {
let skip_past_end = can_paginate
&& grep.skip.unwrap_or(0) > 0
&& total_files > 0
&& skip_files >= total_files;
let text = if skip_past_end {
format!(
"No more results ({} files total; skip={} is past the end)",
total_files_label,
grep.skip.unwrap_or(0)
)
} else {
"No matches found".to_string()
};
(Some(text), Some(true))
}
} else {
match timeout_note {
Some(t) => (
Some(match window_note {
Some(w) => format!("{t}\n{w}"),
None => t,
}),
None,
),
None => (window_note, None),
}
};
let files = build_hashline_files(&root, &shown);
Ok(GrepOutput {
matches: shown,
total_matches: total_after_filter,
files_with_matches,
files_searched,
file_limit_reached,
per_file_limit_reached,
note,
useless,
files,
timed_out: if timed_out { Some(true) } else { None },
})
}