use crate::db::{EdgeTypeCensus, GraphDb};
use crate::repograph::facts::str_prop;
use crate::repograph::map::{file_pagerank, spent, SYNC_KEY};
use crate::repograph::render::{basename, dir_components, sanitize, top_tokens};
use core_storage::fs::Fs;
use core_storage::Value;
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::time::{Duration, Instant};
const MAX_LABEL_PROPS: usize = 12;
const MAX_END_LABELS: usize = 3;
const ROLE_PROBE_ROWS: usize = 20;
const HOW_MANY_MIN: usize = 3;
const LINKED_BY_ALL_MAX_TYPES: usize = 3;
const IDENTITY_PROPS: [&str; 2] = ["id", "key"];
const HIDDEN_PROPS: [&str; 1] = ["embedding"];
const SHORT_SHA: usize = 7;
const ROLE_TOKENS: usize = 2;
const RANK_BUDGET: Duration = Duration::from_secs(3);
const DEPENDENCY_EDGES: [&str; 2] = ["IMPORTS", "CALLS"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BriefOptions {
pub max_files: usize,
pub max_symbols: usize,
pub budget: Duration,
}
impl Default for BriefOptions {
fn default() -> Self {
Self {
max_files: 25,
max_symbols: 25,
budget: RANK_BUDGET,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LabelBrief {
pub label: String,
pub nodes: usize,
pub props: Vec<String>,
pub hidden_props: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct EdgeTypeBrief {
pub edge_type: String,
pub rule: Option<String>,
pub hidden_rules: usize,
pub src: Vec<String>,
pub dst: Vec<String>,
pub edges: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Recipe {
pub question: String,
pub call: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SchemaBrief {
pub nodes: usize,
pub labels: Vec<LabelBrief>,
pub edge_types: Vec<EdgeTypeBrief>,
pub commits: Option<u64>,
pub roles: Vec<(String, Vec<String>)>,
pub recipes: Vec<Recipe>,
pub partial: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BriefReport {
pub repo: String,
pub files: usize,
pub symbols: usize,
pub edges: usize,
pub last_sync: Option<String>,
pub key_files: Vec<(String, String)>,
pub key_symbols: Vec<(String, String)>,
pub schema: Option<SchemaBrief>,
}
#[must_use]
pub fn brief<F: Fs>(db: &GraphDb<F>, opts: &BriefOptions) -> BriefReport {
let deadline = Instant::now().checked_add(opts.budget);
let mut file_keys: Vec<String> = db
.nodes_with_label("File")
.iter()
.map(|n| n.key().to_string())
.collect();
file_keys.sort();
if !db.has_node(SYNC_KEY) {
return BriefReport {
repo: String::new(),
files: file_keys.len(),
symbols: db.nodes_with_label("Symbol").len(),
edges: usize::try_from(db.edge_count()).unwrap_or(usize::MAX),
last_sync: None,
key_files: Vec::new(),
key_symbols: Vec::new(),
schema: Some(memory_schema(db, deadline)),
};
}
let (ranked, _truncated) = file_pagerank(db, &file_keys, deadline);
let connected = connected_files(db);
let key_files = ranked
.iter()
.filter(|(k, _)| connected.contains(k.as_str()))
.take(opts.max_files)
.map(|(k, _)| (sanitize(k), role_of(db, k, &file_keys)))
.collect();
let mut callers: BTreeMap<String, usize> = BTreeMap::new();
for (_src, dst, _w) in db.weighted_edges("CALLS", None) {
*callers.entry(dst).or_default() += 1;
}
let symbols = db.nodes_with_label("Symbol");
let mut ranked_symbols: Vec<(String, usize, String)> = symbols
.iter()
.map(|n| {
let key = n.key().to_string();
let called = callers.get(&key).copied().unwrap_or(0);
(key, called, first_line(n.prop("signature")))
})
.collect();
ranked_symbols.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let key_symbols = ranked_symbols
.into_iter()
.take(opts.max_symbols)
.map(|(k, _, sig)| (sanitize(&k), sig))
.collect();
BriefReport {
repo: str_prop(db, SYNC_KEY, "repo")
.map(|p| sanitize(basename(p.trim_end_matches('/'))))
.unwrap_or_default(),
files: file_keys.len(),
symbols: symbols.len(),
edges: usize::try_from(db.edge_count()).unwrap_or(usize::MAX),
last_sync: str_prop(db, SYNC_KEY, "sha")
.map(|s| sanitize(&s).chars().take(SHORT_SHA).collect()),
key_files,
key_symbols,
schema: None,
}
}
fn memory_schema<F: Fs>(db: &GraphDb<F>, deadline: Option<Instant>) -> SchemaBrief {
let nodes = db.all_nodes_for_export();
let mut partial = false;
let mut by_label: BTreeMap<String, (usize, BTreeSet<String>)> = BTreeMap::new();
let mut counted = 0usize;
for n in &nodes {
if spent(deadline) {
partial = true;
break;
}
counted += 1;
let entry = by_label.entry(n.label.clone()).or_default();
entry.0 += 1;
entry.1.extend(
n.props
.keys()
.filter(|p| !HIDDEN_PROPS.contains(&p.as_str()))
.cloned(),
);
}
let mut by_type: BTreeMap<String, EdgeTypeCensus> = BTreeMap::new();
if spent(deadline) {
partial = true;
} else {
for c in db.edge_type_census() {
if spent(deadline) {
partial = true;
break;
}
by_type.insert(c.edge_type.clone(), c);
}
}
let mut labels: Vec<LabelBrief> = by_label
.iter()
.map(|(label, (count, props))| {
let all: Vec<String> = props.iter().map(|p| sanitize(p)).collect();
let hidden = all.len().saturating_sub(MAX_LABEL_PROPS);
LabelBrief {
label: sanitize(label),
nodes: *count,
props: all.into_iter().take(MAX_LABEL_PROPS).collect(),
hidden_props: hidden,
}
})
.collect();
labels.sort_by(|a, b| b.nodes.cmp(&a.nodes).then(a.label.cmp(&b.label)));
let mut edge_types: Vec<EdgeTypeBrief> = by_type
.values()
.map(|c| EdgeTypeBrief {
edge_type: sanitize(&c.edge_type),
rule: c.rules.first().map(|r| sanitize(r)),
hidden_rules: c.rules.len().saturating_sub(1),
src: ends(&c.src_labels),
dst: ends(&c.dst_labels),
edges: usize::try_from(c.edges).unwrap_or(usize::MAX),
})
.collect();
edge_types.sort_by(|a, b| b.edges.cmp(&a.edges).then(a.edge_type.cmp(&b.edge_type)));
let mut roles: Vec<(String, Vec<String>)> = db
.roles()
.into_iter()
.map(|r| {
(
sanitize(&r.name),
r.labels.iter().map(|l| sanitize(l)).collect(),
)
})
.collect();
roles.sort();
let mut rule_fields: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for rule in db.rules() {
let mut fields = BTreeSet::new();
predicate_fields(&rule.predicate, &mut fields);
for label in [&rule.src_label, &rule.dst_label] {
rule_fields
.entry(label.clone())
.or_default()
.extend(fields.iter().cloned());
}
}
let (commits, latest_commit) = if spent(deadline) {
partial = true;
(None, None)
} else {
let floor = db.wal_horizon_floor();
let total = db.wal_total_commits().unwrap_or(floor);
(
Some(total.saturating_sub(floor)),
(total > floor).then(|| total - 1),
)
};
let recipes = recipes(
&nodes,
&by_type,
&labels,
&edge_types,
&roles,
&rule_fields,
latest_commit,
);
SchemaBrief {
nodes: counted,
labels,
edge_types,
commits,
roles,
recipes,
partial,
}
}
fn predicate_fields(p: &core_rules::Predicate, out: &mut BTreeSet<String>) {
use core_rules::Predicate as P;
match p {
P::KeyMatch { field }
| P::FieldEqual { field }
| P::Overlap { field, .. }
| P::NumericWithin { field, .. }
| P::GeoRadius { field, .. }
| P::VectorSimilar { field, .. } => {
out.insert(field.clone());
}
P::All(parts) | P::Any(parts) => {
for part in parts {
predicate_fields(part, out);
}
}
}
}
fn ends(labels: &[String]) -> Vec<String> {
labels
.iter()
.take(MAX_END_LABELS)
.map(|l| sanitize(l))
.collect()
}
fn recipes(
nodes: &[crate::db::NodeInfo],
by_type: &BTreeMap<String, EdgeTypeCensus>,
labels: &[LabelBrief],
edge_types: &[EdgeTypeBrief],
roles: &[(String, Vec<String>)],
rule_fields: &BTreeMap<String, BTreeSet<String>>,
latest_commit: Option<u64>,
) -> Vec<Recipe> {
let sample_of = |t: &EdgeTypeBrief| by_type.get(&t.edge_type).and_then(|c| c.sample.clone());
let pair = edge_types
.iter()
.filter(|t| t.rule.is_some())
.find_map(sample_of)
.or_else(|| edge_types.iter().find_map(sample_of));
let (a, b) = match &pair {
Some((a, b)) => (sanitize(a), sanitize(b)),
None => ("<a>".to_string(), "<b>".to_string()),
};
let key = match &pair {
Some((a, _)) => sanitize(a),
None => nodes
.first()
.map_or_else(|| "<key>".to_string(), |n| sanitize(&n.key)),
};
let key_node = nodes.iter().find(|n| n.key == key);
let field = key_node
.and_then(|n| {
let watched = rule_fields.get(&n.label);
let mut usable = n.props.keys().filter(|f| {
!IDENTITY_PROPS.contains(&f.as_str()) && !HIDDEN_PROPS.contains(&f.as_str())
});
usable
.clone()
.find(|f| watched.is_some_and(|w| w.contains(*f)))
.or_else(|| usable.next())
})
.map_or_else(|| "<field>".to_string(), |f| sanitize(f));
let intersection = key_node.and_then(|n| types_from(&n.label, edge_types));
let (role, probe_label) = roles
.iter()
.find_map(|(name, visible)| {
labels
.iter()
.find(|l| visible.contains(&l.label))
.map(|l| (name.clone(), l.label.clone()))
})
.unwrap_or_else(|| {
(
roles
.first()
.map_or_else(|| "<name>".to_string(), |(n, _)| n.clone()),
labels
.first()
.map_or_else(|| "<label>".to_string(), |l| l.label.clone()),
)
});
let (l1, etype, l2) = edge_types.first().map_or_else(
|| ("<L1>".to_string(), "<TYPE>".to_string(), "<L2>".to_string()),
|t| {
(
t.src.first().cloned().unwrap_or_else(|| "<L1>".to_string()),
t.edge_type.clone(),
t.dst.first().cloned().unwrap_or_else(|| "<L2>".to_string()),
)
},
);
let mut out = vec![
Recipe {
question: "why".to_string(),
call: format!(
"explain_association {a} {b} — returns each relationship's rule and \
the values the two share, so there is no need to fetch raw lists to \
compare by hand"
),
},
Recipe {
question: "relationships".to_string(),
call: relationships_call(&key, &intersection),
},
];
if let Some(at) = latest_commit {
out.push(Recipe {
question: "as of".to_string(),
call: as_of_call(&key, at, &intersection),
});
}
out.extend([
Recipe {
question: "what if".to_string(),
call: what_if_call(&key, &field, &intersection),
},
Recipe {
question: "who may see".to_string(),
call: format!(
"query 'MATCH (n:{probe_label}) RETURN key(n) LIMIT {ROLE_PROBE_ROWS}' role: {role}"
),
},
Recipe {
question: "how many".to_string(),
call: format!(
"MATCH (a:{l1})-[:{etype}]->(b:{l2}) WITH key(b) AS b_key, count(a) AS n \
WHERE n >= {HOW_MANY_MIN} RETURN b_key, n"
),
},
]);
if edge_types.len() > 1 {
if let Some(recipe) = linked_by_all_recipe(labels, edge_types) {
out.push(recipe);
}
}
out
}
fn linked_by_all_recipe(labels: &[LabelBrief], edge_types: &[EdgeTypeBrief]) -> Option<Recipe> {
let src = &labels
.iter()
.find(|l| edge_types.iter().any(|t| t.src.contains(&l.label)))?
.label;
let (types, dst) = types_from(src, edge_types)?;
let mut pattern = format!("(a:{src})-[:{}]->(b:{dst})", types[0]);
for t in &types[1..] {
pattern.push_str(&format!(", (a)-[:{t}]->(b)"));
}
Some(Recipe {
question: "linked by all of".to_string(),
call: format!(
"MATCH {pattern} WITH b, count(DISTINCT a) AS n WHERE n >= 1 RETURN key(b), n \
ORDER BY n DESC LIMIT 20 — add `WHERE a.<field> = …` before WITH to filter \
the source side; one MATCH with comma-separated patterns intersects, \
separate MATCHes do not"
),
})
}
fn types_from(src: &str, edge_types: &[EdgeTypeBrief]) -> Option<(Vec<String>, String)> {
let mut by_dst: BTreeMap<&str, usize> = BTreeMap::new();
for t in edge_types.iter().filter(|t| t.src.iter().any(|s| s == src)) {
for dst in &t.dst {
*by_dst.entry(dst.as_str()).or_default() += t.edges;
}
}
let (dst, _) = by_dst
.into_iter()
.max_by_key(|(name, n)| (*n, std::cmp::Reverse(*name)))?;
let types: Vec<String> = edge_types
.iter()
.filter(|t| t.src.iter().any(|s| s == src) && t.dst.iter().any(|d| d.as_str() == dst))
.take(LINKED_BY_ALL_MAX_TYPES)
.map(|t| t.edge_type.clone())
.collect();
(!types.is_empty()).then(|| (types, dst.to_string()))
}
fn relationships_call(key: &str, intersection: &Option<(Vec<String>, String)>) -> String {
match intersection {
Some((types, dst)) => format!(
"node_edges {key} all_of: [{}] label: {dst} — or edge_type: {} for one \
type's partner keys",
types.join(", "),
types[0]
),
None => format!("node_edges {key}"),
}
}
fn as_of_call(key: &str, at: u64, intersection: &Option<(Vec<String>, String)>) -> String {
let note = "— commits carry no dates: take `at` from node_history/edge_history \
commit numbers or the dataset's date→commit map";
match intersection {
Some((types, dst)) if types.len() >= 2 => format!(
"edges_at {key} {at} all_of: [{}] label: {dst} {note}",
types.join(", ")
),
Some((types, _)) => format!("edges_at {key} {at} edge_type: {} {note}", types[0]),
None => format!("edges_at {key} {at}"),
}
}
fn what_if_call(key: &str, field: &str, intersection: &Option<(Vec<String>, String)>) -> String {
match intersection {
Some((types, _)) => format!(
"what_if {key} {field} <value> edge_type: {} — the partners that would be \
lost or gained under that type",
types[0]
),
None => format!("what_if {key} {field} <value>"),
}
}
fn connected_files<F: Fs>(db: &GraphDb<F>) -> BTreeSet<String> {
let mut sym_file: BTreeMap<String, String> = BTreeMap::new();
for node in db.nodes_with_label("Symbol") {
if let Some(Value::Str(file)) = node.prop("file_id") {
sym_file.insert(node.key().to_string(), file);
}
}
let mut out = BTreeSet::new();
for edge_type in DEPENDENCY_EDGES {
for (src, dst, _w) in db.weighted_edges(edge_type, None) {
for end in [src, dst] {
match sym_file.get(&end) {
Some(file) => out.insert(file.clone()),
None => out.insert(end),
};
}
}
}
out
}
fn first_line(v: Option<Value>) -> String {
match v {
Some(Value::Str(s)) => sanitize(s.lines().next().unwrap_or_default().trim()),
_ => String::new(),
}
}
fn role_of<F: Fs>(db: &GraphDb<F>, key: &str, file_keys: &[String]) -> String {
if let Some(role) = str_prop(db, key, "role") {
let role = sanitize(role.trim());
if !role.is_empty() {
return role;
}
}
let dir = dir_components(key).join("/");
if dir.is_empty() {
return String::new(); }
let prefix = format!("{dir}/");
let neighbours: Vec<String> = file_keys
.iter()
.filter(|k| k.starts_with(&prefix))
.cloned()
.collect();
sanitize(&top_tokens(&neighbours, &dir, ROLE_TOKENS, true).join(", "))
}