use crate::db::Database;
use crate::errors::{Result, TokenSaveError};
use std::collections::{HashMap, HashSet};
fn is_gated_path(path: &str) -> bool {
let ext = path.rsplit('.').next().unwrap_or_default();
matches!(
ext,
"py" | "pyi" | "js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx"
)
}
fn dir_of(path: &str) -> &str {
path.rfind('/').map_or("", |i| &path[..i])
}
fn imports_name(imports: &HashSet<String>, name: &str) -> bool {
imports
.iter()
.any(|entry| entry == name || entry.rsplit('.').next() == Some(name))
}
#[derive(Debug, Clone)]
pub struct HotTarget {
pub name: String,
pub file_path: String,
pub start_line: i64,
pub kind: String,
pub source_files: i64,
pub edges: i64,
}
#[derive(Debug, Clone)]
pub struct EdgeAudit {
pub total_edges: i64,
pub gated_edges: i64,
pub cross_file: i64,
pub sole_candidate_cross_file: i64,
pub unreachable: i64,
pub hot_targets: Vec<HotTarget>,
}
struct EdgeRow {
src_file: String,
dst_id: String,
dst_name: String,
dst_file: String,
dst_kind: String,
dst_line: i64,
dst_parent: Option<String>,
}
async fn query_rows(db: &Database, sql: &str) -> Result<libsql::Rows> {
db.conn()
.query(sql, ())
.await
.map_err(|e| TokenSaveError::Database {
message: format!("edge audit query failed: {e}"),
operation: "edge_audit".to_string(),
})
}
pub async fn audit(db: &Database, limit: usize) -> Result<EdgeAudit> {
let mut imports: HashMap<String, HashSet<String>> = HashMap::new();
let mut rows = query_rows(db, "SELECT file_path, name FROM nodes WHERE kind = 'use'").await?;
while let Some(row) = rows.next().await.map_err(|e| TokenSaveError::Database {
message: format!("edge audit read failed: {e}"),
operation: "edge_audit".to_string(),
})? {
let file: String = row.get(0).unwrap_or_default();
let name: String = row.get(1).unwrap_or_default();
imports.entry(file).or_default().insert(name);
}
let mut name_counts: HashMap<String, i64> = HashMap::new();
let mut rows = query_rows(
db,
"SELECT name, COUNT(*) FROM nodes WHERE kind <> 'file' AND kind <> 'use' GROUP BY name",
)
.await?;
while let Some(row) = rows.next().await.map_err(|e| TokenSaveError::Database {
message: format!("edge audit read failed: {e}"),
operation: "edge_audit".to_string(),
})? {
name_counts.insert(
row.get(0).unwrap_or_default(),
row.get(1).unwrap_or_default(),
);
}
let mut node_name: HashMap<String, String> = HashMap::new();
let mut rows = query_rows(db, "SELECT id, name FROM nodes").await?;
while let Some(row) = rows.next().await.map_err(|e| TokenSaveError::Database {
message: format!("edge audit read failed: {e}"),
operation: "edge_audit".to_string(),
})? {
node_name.insert(
row.get(0).unwrap_or_default(),
row.get(1).unwrap_or_default(),
);
}
let mut total_edges = 0i64;
let mut gated_edges = 0i64;
let mut cross_file = 0i64;
let mut sole_candidate_cross_file = 0i64;
let mut unreachable = 0i64;
let mut per_target: HashMap<String, (EdgeRow, i64, HashSet<String>)> = HashMap::new();
let mut rows = query_rows(
db,
"SELECT s.file_path, n.id, n.name, n.file_path, n.kind, n.start_line, n.parent_id \
FROM edges e \
JOIN nodes n ON n.id = e.target \
JOIN nodes s ON s.id = e.source",
)
.await?;
while let Some(row) = rows.next().await.map_err(|e| TokenSaveError::Database {
message: format!("edge audit read failed: {e}"),
operation: "edge_audit".to_string(),
})? {
total_edges += 1;
let r = EdgeRow {
src_file: row.get(0).unwrap_or_default(),
dst_id: row.get(1).unwrap_or_default(),
dst_name: row.get(2).unwrap_or_default(),
dst_file: row.get(3).unwrap_or_default(),
dst_kind: row.get(4).unwrap_or_default(),
dst_line: row.get(5).unwrap_or_default(),
dst_parent: row.get::<Option<String>>(6).unwrap_or_default(),
};
if !is_gated_path(&r.src_file) {
continue;
}
gated_edges += 1;
if r.src_file == r.dst_file {
continue;
}
cross_file += 1;
if name_counts.get(&r.dst_name).copied().unwrap_or(0) != 1 {
continue;
}
sole_candidate_cross_file += 1;
if dir_of(&r.src_file) == dir_of(&r.dst_file) {
continue;
}
let file_imports = imports.get(&r.src_file);
let reachable = file_imports.is_some_and(|imports| {
imports_name(imports, &r.dst_name)
|| r.dst_parent
.as_deref()
.and_then(|id| node_name.get(id))
.is_some_and(|parent| imports_name(imports, parent))
});
if reachable {
continue;
}
unreachable += 1;
let entry = per_target
.entry(r.dst_id.clone())
.or_insert_with(|| (r, 0, HashSet::new()));
entry.1 += 1;
entry.2.insert(entry.0.src_file.clone());
}
let mut hot: Vec<(EdgeRow, i64, HashSet<String>)> = per_target.into_values().collect();
hot.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.dst_name.cmp(&b.0.dst_name)));
let hot_targets = hot
.into_iter()
.take(limit)
.map(|(r, edges, files)| HotTarget {
name: r.dst_name,
file_path: r.dst_file,
start_line: r.dst_line,
kind: r.dst_kind,
source_files: files.len() as i64,
edges,
})
.collect();
Ok(EdgeAudit {
total_edges,
gated_edges,
cross_file,
sole_candidate_cross_file,
unreachable,
hot_targets,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gating_follows_the_resolver_language_list() {
for path in [
"a/b.py", "a/b.pyi", "x.js", "x.jsx", "x.mjs", "x.ts", "x.tsx",
] {
assert!(is_gated_path(path), "{path} should be gated");
}
for path in ["src/main.rs", "m.go", "a.rb", "x.java", "no_extension"] {
assert!(!is_gated_path(path), "{path} must not be gated");
}
}
}