use crate::hook::{cut_to, open_for_hook};
use core_api::repograph::{impact, ImpactOptions, ImpactReport};
use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
pub const MAX_CONTEXT_BYTES: usize = 600;
const MAX_NAMED: usize = 3;
fn edited_path(input: &serde_json::Value) -> Option<&str> {
input["tool_input"]["file_path"]
.as_str()
.filter(|p| !p.trim().is_empty())
}
fn looks_like_test(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.starts_with("test/")
|| lower.starts_with("tests/")
|| lower.contains("/test/")
|| lower.contains("/tests/")
|| lower.contains("_test.")
|| lower.contains(".test.")
|| lower.contains("/test_")
|| lower.starts_with("test_")
|| lower.contains("_spec.")
|| lower.contains(".spec.")
}
fn recorded_repo(db: &crate::structure::Db) -> Option<PathBuf> {
match db
.node_ref(crate::ingest_git::SYNC_KEY)
.and_then(|n| n.prop("repo"))
{
Some(core_api::Value::Str(s)) if !s.is_empty() => Some(PathBuf::from(s)),
_ => None,
}
}
fn repo_relative(path: &str, repo: Option<&Path>) -> Option<String> {
let p = Path::new(path);
let rel = if p.is_absolute() {
p.strip_prefix(repo?).ok()?
} else {
p.strip_prefix("./").unwrap_or(p)
};
let joined = rel
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/");
(!joined.is_empty()).then_some(joined)
}
#[must_use]
fn render(report: &ImpactReport) -> Option<String> {
let file = report.files.first()?;
let mut tests: Vec<&str> = Vec::new();
let mut callers: Vec<&str> = Vec::new();
for p in &file.importers {
if looks_like_test(&p.path) {
tests.push(&p.path);
} else {
callers.push(&p.path);
}
}
let mut partners: Vec<&str> = Vec::new();
for p in &file.partners {
if looks_like_test(&p.path) {
if !tests.contains(&p.path.as_str()) {
tests.push(&p.path);
}
} else if !callers.contains(&p.path.as_str()) {
partners.push(&p.path);
}
}
let mut sections: Vec<String> = Vec::new();
if !callers.is_empty() {
let truncated = file.importers.len() >= ImpactOptions::default().max_importers;
sections.push(format!(
"callers {} ({}{})",
join_capped(&callers),
callers.len(),
if truncated { "+more" } else { "" }
));
}
if !partners.is_empty() {
sections.push(format!("changes with {}", join_capped(&partners)));
}
if !tests.is_empty() {
sections.push(format!("tests {}", join_capped(&tests)));
}
if sections.is_empty() {
return None;
}
let mut out = format!("impact of editing {}: ", file.path);
for (i, s) in sections.iter().enumerate() {
if i > 0 {
let _ = write!(out, "; ");
}
out.push_str(s);
}
Some(cut_to(out, MAX_CONTEXT_BYTES))
}
fn join_capped(paths: &[&str]) -> String {
let shown = paths.len().min(MAX_NAMED);
let mut out = paths[..shown].join(", ");
if paths.len() > shown {
out.push('…');
}
out
}
#[must_use]
pub fn run(db_dir: &Path, payload: &str) -> Option<String> {
let input: serde_json::Value = serde_json::from_str(payload).ok()?;
let path = edited_path(&input)?;
let db = open_for_hook(db_dir)?;
let key = repo_relative(path, recorded_repo(&db).as_deref())?;
let report = impact(&db, &[key], &BTreeSet::new(), &ImpactOptions::default());
render(&report)
}
#[cfg(test)]
mod tests {
use super::{looks_like_test, repo_relative};
use std::path::Path;
#[test]
fn paths_resolve_against_the_recorded_root() {
let repo = Path::new("/home/me/proj");
assert_eq!(
repo_relative("/home/me/proj/src/lib.rs", Some(repo)).as_deref(),
Some("src/lib.rs")
);
assert_eq!(
repo_relative("./src/lib.rs", Some(repo)).as_deref(),
Some("src/lib.rs")
);
assert_eq!(
repo_relative("src/lib.rs", None).as_deref(),
Some("src/lib.rs"),
"a relative path needs no root"
);
assert_eq!(repo_relative("/etc/hosts", Some(repo)), None);
assert_eq!(repo_relative("/etc/hosts", None), None);
assert_eq!(repo_relative("./", Some(repo)), None);
}
#[test]
fn tests_are_told_apart_from_dependencies() {
for yes in [
"tests/install.rs",
"crates/cli/tests/install.rs",
"src/install_test.rs",
"web/app.test.ts",
"app/models_spec.rb",
] {
assert!(looks_like_test(yes), "{yes}");
}
for no in ["src/install.rs", "crates/cli/src/latest.rs", "protest/a.rs"] {
assert!(!looks_like_test(no), "{no}");
}
}
#[test]
fn a_dot_dot_path_is_flattened_rather_than_resolved() {
let repo = Path::new("/home/me/proj");
assert_eq!(
repo_relative("/home/me/proj/src/../src/lib.rs", Some(repo)).as_deref(),
Some("src/src/lib.rs"),
"lexical only: `..` is dropped, so the key simply does not match"
);
}
}