use std::collections::BTreeSet;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use anyhow::{Result, bail};
use regex::Regex;
use sinter_store::{EdgeFilter, Store};
use crate::lookup::{ensure_snapshot, open_store, unique_symbol_in};
const SUPPORTED: &str = "supported: affected(SYMBOL), deps(SYMBOL), file(PATH)";
#[derive(Debug, PartialEq)]
enum Within {
Affected(String),
Deps(String),
File(String),
}
fn parse_within(spec: &str) -> Result<Within> {
let trimmed = spec.trim();
let Some((verb, rest)) = trimmed.split_once('(') else {
bail!("`--within {spec}`: expected `verb(ARG)` — {SUPPORTED}");
};
let Some(arg) = rest.strip_suffix(')') else {
bail!("`--within {spec}`: missing closing `)` — {SUPPORTED}");
};
let arg = arg.trim();
if arg.is_empty() {
bail!("`--within {spec}`: empty argument — {SUPPORTED}");
}
match verb.trim() {
"affected" => Ok(Within::Affected(arg.to_string())),
"deps" => Ok(Within::Deps(arg.to_string())),
"file" => Ok(Within::File(arg.to_string())),
other => bail!("`--within {spec}`: unknown traversal `{other}` — {SUPPORTED}"),
}
}
fn union_files(per_within: Vec<Vec<String>>) -> Vec<String> {
per_within
.into_iter()
.flatten()
.collect::<BTreeSet<String>>()
.into_iter()
.collect()
}
fn files_of(
store: &Store,
within: &Within,
filter: &EdgeFilter,
max_depth: usize,
) -> Result<Vec<String>> {
let reached = match within {
Within::File(path) => return Ok(vec![path.clone()]),
Within::Affected(symbol) => {
let node = unique_symbol_in(store, symbol, filter.scopes.as_ref())?;
store.dependents(&node.id, filter, max_depth)?
}
Within::Deps(symbol) => {
let node = unique_symbol_in(store, symbol, filter.scopes.as_ref())?;
store.dependencies(&node.id, filter, max_depth)?
}
};
Ok(reached.into_iter().map(|r| r.node.file).collect())
}
struct Hit {
file: String,
line: usize,
text: String,
}
fn scan(
file: &str,
reader: impl BufRead,
pattern: &Regex,
keep: usize,
out: &mut Vec<Hit>,
) -> Option<usize> {
let mut total = 0;
for (index, line) in reader.lines().enumerate() {
let line = line.ok()?;
if !pattern.is_match(&line) {
continue;
}
total += 1;
if out.len() < keep {
out.push(Hit {
file: file.to_string(),
line: index + 1,
text: line.trim_end().to_string(),
});
}
}
Some(total)
}
fn compile(pattern: &str) -> Result<Regex> {
Regex::new(pattern).map_err(|error| {
let text = error.to_string();
let detail = text
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.unwrap_or("invalid pattern")
.trim()
.trim_start_matches("error: ");
anyhow::anyhow!("invalid regex `{pattern}`: {detail}")
})
}
pub(crate) fn json(
store: &Store,
repo: &Path,
pattern: &str,
within: &[String],
filter: &EdgeFilter,
max_depth: usize,
limit: usize,
) -> Result<serde_json::Value> {
let compiled = compile(pattern)?;
let bounds: Vec<Within> = within
.iter()
.map(|w| parse_within(w))
.collect::<Result<_>>()?;
let snapshot = ensure_snapshot(store, None)?;
let root = crate::pipeline::discover_root(repo);
let mut per_within = Vec::with_capacity(bounds.len());
for bound in &bounds {
per_within.push(files_of(store, bound, filter, max_depth)?);
}
let files = union_files(per_within);
let mut hits: Vec<Hit> = Vec::new();
let mut searched = 0usize;
let mut total = 0usize;
for file in &files {
let Ok(handle) = File::open(root.join(file)) else {
continue;
};
if let Some(count) = scan(file, BufReader::new(handle), &compiled, limit, &mut hits) {
searched += 1;
total += count;
}
}
let mut out = serde_json::json!({
"status": if total > 0 { "found" } else { "not_proven" },
"snapshot": snapshot,
"pattern": pattern,
"within": within,
"files_in_bound": files.len(),
"files_searched": searched,
"total": total,
"matches": hits.iter().map(|h| serde_json::json!({
"f": h.file,
"l": h.line,
"t": h.text,
})).collect::<Vec<_>>(),
});
if total > hits.len() {
out["truncated"] = serde_json::json!(total - hits.len());
}
Ok(out)
}
pub fn run(
repo: &Path,
pattern: &str,
within: &[String],
filter: &EdgeFilter,
max_depth: usize,
limit: usize,
as_json: bool,
) -> Result<bool> {
compile(pattern)?;
for spec in within {
parse_within(spec)?;
}
let store = open_store(repo)?;
let out = json(&store, repo, pattern, within, filter, max_depth, limit)?;
let total = out["total"].as_u64().unwrap_or(0) as usize;
if as_json {
crate::agent_protocol::write_json(&out)?;
return Ok(total > 0);
}
let hits = out["matches"].as_array().map_or(&[][..], Vec::as_slice);
println!(
"{total} matches · bound {} files ({} searched)",
out["files_in_bound"], out["files_searched"]
);
for hit in hits {
println!(
"{}:{}: {}",
hit["f"].as_str().unwrap_or_default(),
hit["l"],
hit["t"].as_str().unwrap_or_default()
);
}
if total > hits.len() {
println!(
"{} more matches below cutoff · `sinter grep --limit {total}` to widen",
total - hits.len()
);
}
Ok(total > 0)
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use std::path::Path;
use regex::Regex;
use sinter_store::EdgeFilter;
use super::{Within, parse_within, scan, union_files};
#[test]
fn within_forms_parse() {
assert_eq!(
parse_within("affected(Decision)").unwrap(),
Within::Affected("Decision".into())
);
assert_eq!(
parse_within(" deps( Foo::bar ) ").unwrap(),
Within::Deps("Foo::bar".into())
);
assert_eq!(
parse_within("file(src/main.rs)").unwrap(),
Within::File("src/main.rs".into())
);
}
#[test]
fn unknown_within_forms_are_rejected_with_the_supported_list() {
for spec in ["callers(X)", "affected", "affected(X", "affected()"] {
let error = parse_within(spec).unwrap_err().to_string();
assert!(
error.contains("affected(SYMBOL)") || error.contains("closing"),
"{spec}: {error}"
);
}
}
#[test]
fn file_sets_union_and_deduplicate() {
let files = union_files(vec![
vec!["b.rs".into(), "a.rs".into(), "b.rs".into()],
vec!["a.rs".into(), "c.rs".into()],
]);
assert_eq!(files, vec!["a.rs", "b.rs", "c.rs"]);
}
#[test]
fn matches_are_bounded_but_counted_in_full() {
let pattern = Regex::new("fn ").unwrap();
let text = "fn a() {}\nlet x = 1;\nfn b() {}\nfn c() {}\n";
let mut hits = Vec::new();
let total = scan("x.rs", Cursor::new(text), &pattern, 2, &mut hits).unwrap();
assert_eq!(total, 3);
assert_eq!(hits.len(), 2);
assert_eq!((hits[0].line, hits[1].line), (1, 3));
assert_eq!(hits[0].text, "fn a() {}");
}
#[test]
fn non_utf8_input_is_skipped_not_fatal() {
let pattern = Regex::new("a").unwrap();
let mut hits = Vec::new();
assert!(
scan(
"bin",
Cursor::new([0x61, 0xff, 0x0a]),
&pattern,
10,
&mut hits
)
.is_none()
);
}
#[test]
fn patterns_are_full_regex() {
let cases = [
("fn run", "pub fn run(", true),
("fn run", "fn walk(", false),
("^pub", "pub fn x", true),
("^pub", " pub fn x", false),
("x$", "let x", true),
("x$", "let x = 1", false),
(r"\d+", "v12", true),
(r"\d+", "vee", false),
("[A-Z][a-z]*", "Word", true),
("fn.*->.*Result", "fn run(x: u8) -> Result<bool>", true),
("dependents|dependencies", "pub fn dependencies(", true),
("dependents|dependencies", "pub fn nodes(", false),
("(ab)+c", "ababc", true),
(r"^\s{4}pub", " pub fn x", true),
];
for (pattern, line, want) in cases {
assert_eq!(
Regex::new(pattern).unwrap().is_match(line),
want,
"{pattern} vs {line}"
);
}
}
#[test]
fn an_invalid_regex_is_a_clean_error() {
let error = super::run(
Path::new("."),
"a[",
&["file(README.md)".to_string()],
&EdgeFilter::default(),
1,
1,
false,
)
.unwrap_err();
let message = format!("{error:#}");
assert_eq!(message, "invalid regex `a[`: unclosed character class");
}
}