use std::path::Path;
use anyhow::{Context, Result};
use syn::visit::Visit;
use syn::{Expr, ImplItem, Item, ItemImpl, Lit};
use syn::spanned::Spanned;
use walkdir::WalkDir;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum Access {
Read,
Write,
}
impl Access {
pub fn as_str(self) -> &'static str {
match self {
Access::Read => "read",
Access::Write => "write",
}
}
}
pub const DYNAMIC_TABLE: &str = "<dynamic>";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct AccessEdge {
pub caller_fn: String,
pub crate_name: String,
pub table: String,
pub access: Access,
pub file: String,
pub line: u32,
}
impl AccessEdge {
pub fn key(&self) -> (String, String, String, &'static str, u32) {
(
self.caller_fn.clone(),
self.crate_name.clone(),
self.table.clone(),
self.access.as_str(),
self.line,
)
}
}
fn accessor_table_access(callee: &str) -> Option<Vec<(Option<&'static str>, Access)>> {
use Access::*;
let v: Vec<(Option<&'static str>, Access)> = match callee {
"query_test_results" => vec![(Some("test_results"), Read)],
"query_release_events" => vec![(Some("release_events"), Read)],
"query_agent_model_runs" => vec![(Some("agent_model_runs"), Read)],
"query_dep_graph_snapshots" => vec![(Some("dep_graph_edges"), Read)],
"query_bench_runs" | "query_bench_runs_async" => vec![(Some("bench_runs"), Read)],
"query_bench_telemetry" | "query_bench_telemetry_async" => {
vec![(Some("bench_telemetry"), Read)]
}
"query_mcp_stats" | "query_mcp_stats_async" => vec![(Some("mcp_requests"), Read)],
"query_sbom_components" | "query_sbom_components_async" => {
vec![(Some("sbom_components"), Read)]
}
"query_vuln_findings" | "query_vuln_findings_async" => {
vec![(Some("vuln_findings"), Read)]
}
"query_viz_actions" | "query_viz_actions_blocking" => vec![(Some("viz_actions"), Read)],
"load_all_events" => vec![(Some("funnel_events"), Read)],
"knowledge_scan_exists" => vec![(Some("knowledge_scans"), Read)],
"load_latest" => vec![
(Some("symbol_facts"), Read),
(Some("call_edges"), Read),
(Some("feature_gate_facts"), Read),
(Some("git_heat_facts"), Read),
],
"append_test_results" => vec![(Some("test_results"), Write)],
"append_release_events" => vec![(Some("release_events"), Write)],
"append_agent_model_runs" => vec![(Some("agent_model_runs"), Write)],
"record_dep_graph" => vec![(Some("dep_graph_edges"), Write)],
"append_bench_run" | "append_bench_run_async" => vec![
(Some("bench_runs"), Write),
(Some("bench_results"), Write),
(Some("test_outcomes"), Write),
],
"append_mcp_calls" | "append_mcp_calls_async" => vec![(Some("mcp_requests"), Write)],
"append_sbom_components" | "append_sbom_components_async" => {
vec![(Some("sbom_components"), Write)]
}
"append_vuln_findings" | "append_vuln_findings_async" => {
vec![(Some("vuln_findings"), Write)]
}
"append_viz_actions" | "append_viz_actions_blocking" => {
vec![(Some("viz_actions"), Write)]
}
"append_event" => vec![(Some("funnel_events"), Write)],
"persist_lineage" => vec![(Some("release_lineage"), Write)],
"append_symbol_scan" | "append_symbol_scan_async" => vec![
(Some("symbol_facts"), Write),
(Some("call_edges"), Write),
(Some("feature_gate_facts"), Write),
],
"append_git_heat_scan" | "append_git_heat_scan_async" => {
vec![(Some("git_heat_facts"), Write)]
}
"record_knowledge_scan" | "record_knowledge_scan_async" => {
vec![(Some("knowledge_scans"), Write)]
}
"scan_preview" | "scan_preview_async" => vec![(None, Read)],
"table_names" | "table_names_async" => vec![(None, Read)],
_ => return None,
};
Some(v)
}
pub fn scan_repo(root: &Path) -> Result<Vec<AccessEdge>> {
let mut out: Vec<AccessEdge> = Vec::new();
for entry in WalkDir::new(root)
.into_iter()
.filter_entry(|e| !is_skipped_dir(&e.file_name().to_string_lossy()))
{
let entry = entry.context("walkdir")?;
if entry.file_name() == "Cargo.toml" {
scan_one_crate(root, entry.path(), &mut out);
}
}
dedup_sorted(&mut out);
Ok(out)
}
pub fn scan_source(crate_name: &str, rel_file: &str, src: &str) -> Vec<AccessEdge> {
let mut out = Vec::new();
if let Ok(syntax) = syn::parse_file(src) {
let mut w = Walker {
crate_name: crate_name.to_string(),
file: rel_file.to_string(),
out: &mut out,
};
w.walk_items(&syntax.items);
}
dedup_sorted(&mut out);
out
}
fn dedup_sorted(out: &mut Vec<AccessEdge>) {
out.sort_by(|a, b| a.key().cmp(&b.key()));
out.dedup_by(|a, b| a.key() == b.key());
}
fn is_skipped_dir(name: &str) -> bool {
matches!(name, "target" | ".git" | "node_modules" | ".nornir")
}
fn scan_one_crate(repo_root: &Path, cargo_toml: &Path, out: &mut Vec<AccessEdge>) {
let Ok(text) = std::fs::read_to_string(cargo_toml) else { return };
let Ok(doc) = text.parse::<toml::Value>() else { return };
let Some(crate_name) = doc
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
else { return };
let Some(crate_dir) = cargo_toml.parent() else { return };
for sub in &["src", "tests", "benches", "examples"] {
let dir = crate_dir.join(sub);
if !dir.is_dir() { continue; }
for entry in WalkDir::new(&dir)
.into_iter()
.filter_entry(|e| !is_skipped_dir(&e.file_name().to_string_lossy()))
{
let Ok(entry) = entry else { continue };
if entry.file_type().is_file()
&& entry.path().extension().and_then(|e| e.to_str()) == Some("rs")
{
let Ok(text) = std::fs::read_to_string(entry.path()) else { continue };
let Ok(syntax) = syn::parse_file(&text) else { continue };
let rel = entry
.path()
.strip_prefix(repo_root)
.unwrap_or(entry.path())
.to_string_lossy()
.to_string();
let mut w = Walker {
crate_name: crate_name.to_string(),
file: rel,
out,
};
w.walk_items(&syntax.items);
}
}
}
}
struct Walker<'a> {
crate_name: String,
file: String,
out: &'a mut Vec<AccessEdge>,
}
impl<'a> Walker<'a> {
fn walk_items(&mut self, items: &[Item]) {
for item in items {
self.walk_item(item, "");
}
}
fn walk_item(&mut self, item: &Item, module_prefix: &str) {
match item {
Item::Fn(f) => self.walk_fn(&qualify(module_prefix, &f.sig.ident.to_string()), &f.block),
Item::Impl(i) => self.walk_impl(i, module_prefix),
Item::Mod(m) => {
if let Some((_, sub)) = &m.content {
let nested = qualify(module_prefix, &m.ident.to_string());
for it in sub {
self.walk_item(it, &nested);
}
}
}
_ => {}
}
}
fn walk_impl(&mut self, i: &ItemImpl, module_prefix: &str) {
let self_ty = type_ident(&i.self_ty);
let scope = qualify(module_prefix, &self_ty);
for it in &i.items {
if let ImplItem::Fn(f) = it {
self.walk_fn(&qualify(&scope, &f.sig.ident.to_string()), &f.block);
}
}
}
fn walk_fn(&mut self, caller: &str, block: &syn::Block) {
let mut cc = CallCollector {
caller: format!("{}::{}", self.crate_name, caller),
crate_name: self.crate_name.clone(),
file: self.file.clone(),
out: self.out,
};
cc.visit_block(block);
}
}
struct CallCollector<'a> {
caller: String,
crate_name: String,
file: String,
out: &'a mut Vec<AccessEdge>,
}
impl<'a> CallCollector<'a> {
fn record(
&mut self,
callee: &str,
args: &syn::punctuated::Punctuated<Expr, syn::Token![,]>,
line: u32,
) {
let Some(pairs) = accessor_table_access(callee) else { return };
for (table_opt, access) in pairs {
let table = match table_opt {
Some(t) => t.to_string(),
None => first_str_lit(args).unwrap_or_else(|| DYNAMIC_TABLE.to_string()),
};
self.out.push(AccessEdge {
caller_fn: self.caller.clone(),
crate_name: self.crate_name.clone(),
table,
access,
file: self.file.clone(),
line,
});
}
}
}
impl<'ast, 'a> Visit<'ast> for CallCollector<'a> {
fn visit_expr(&mut self, e: &'ast Expr) {
match e {
Expr::Call(c) => {
if let Expr::Path(p) = &*c.func {
let callee = last_seg(&p.path);
self.record(&callee, &c.args, c.func.span().start().line as u32);
}
}
Expr::MethodCall(m) => {
let callee = m.method.to_string();
self.record(&callee, &m.args, m.method.span().start().line as u32);
}
_ => {}
}
syn::visit::visit_expr(self, e);
}
}
fn qualify(prefix: &str, name: &str) -> String {
if prefix.is_empty() {
name.to_string()
} else {
format!("{prefix}::{name}")
}
}
fn last_seg(p: &syn::Path) -> String {
p.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default()
}
fn type_ident(ty: &syn::Type) -> String {
if let syn::Type::Path(tp) = ty {
last_seg(&tp.path)
} else {
"_".to_string()
}
}
fn first_str_lit(args: &syn::punctuated::Punctuated<Expr, syn::Token![,]>) -> Option<String> {
for a in args {
if let Expr::Lit(l) = a {
if let Lit::Str(s) = &l.lit {
return Some(s.value());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_named_read_and_write_edges() {
let src = r#"
async fn foo(wh: &Wh) {
let rows = query_test_results(wh, &sel).await.unwrap();
let _ = rows;
}
async fn bar(wh: &Wh) {
append_release_events(wh, &events).await.unwrap();
}
"#;
let edges = scan_source("demo", "src/lib.rs", src);
assert_eq!(edges.len(), 2, "exactly two edges: {edges:?}");
let foo = edges
.iter()
.find(|e| e.caller_fn == "demo::foo")
.expect("foo edge present");
assert_eq!(foo.table, "test_results");
assert_eq!(foo.access, Access::Read);
assert_eq!(foo.file, "src/lib.rs");
assert_eq!(foo.crate_name, "demo");
assert!(foo.line > 0, "real source line");
let bar = edges
.iter()
.find(|e| e.caller_fn == "demo::bar")
.expect("bar edge present");
assert_eq!(bar.table, "release_events");
assert_eq!(bar.access, Access::Write);
}
#[test]
fn multi_table_writer_emits_one_edge_per_table() {
let src = r#"
fn scan_and_store(wh: &Wh) {
append_symbol_scan(wh, &scan).unwrap();
}
"#;
let edges = scan_source("nornir", "src/mimir.rs", src);
let tables: Vec<&str> = edges.iter().map(|e| e.table.as_str()).collect();
assert!(tables.contains(&"symbol_facts"), "{tables:?}");
assert!(tables.contains(&"call_edges"));
assert!(tables.contains(&"feature_gate_facts"));
assert!(edges.iter().all(|e| e.access == Access::Write));
assert!(edges.iter().all(|e| e.caller_fn == "nornir::scan_and_store"));
}
#[test]
fn generic_scan_preview_reads_literal_table_arg() {
let src = r#"
fn mcp_load(wh: &Wh) {
let _ = wh.scan_preview("mcp_requests", 50_000);
}
fn dyn_load(wh: &Wh, t: &str) {
let _ = wh.scan_preview(t, 500);
}
"#;
let edges = scan_source("nornir", "src/viz/mcp_tab.rs", src);
let mcp = edges.iter().find(|e| e.caller_fn == "nornir::mcp_load").unwrap();
assert_eq!(mcp.table, "mcp_requests");
assert_eq!(mcp.access, Access::Read);
let dynamic = edges.iter().find(|e| e.caller_fn == "nornir::dyn_load").unwrap();
assert_eq!(dynamic.table, DYNAMIC_TABLE);
assert_eq!(dynamic.access, Access::Read);
}
#[test]
fn impl_methods_get_qualified_caller_names() {
let src = r#"
struct Loader;
impl Loader {
async fn hydrate(&self, wh: &Wh) {
let _ = query_release_events(wh, &sel).await;
}
}
"#;
let edges = scan_source("nornir", "src/viz/live.rs", src);
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].caller_fn, "nornir::Loader::hydrate");
assert_eq!(edges[0].table, "release_events");
assert_eq!(edges[0].access, Access::Read);
}
#[test]
fn non_accessor_calls_are_ignored() {
let src = r#"
fn unrelated() {
println!("hello");
let _ = format!("{}", 1);
helper();
}
"#;
let edges = scan_source("demo", "src/lib.rs", src);
assert!(edges.is_empty(), "no accessor calls → no edges: {edges:?}");
}
}