Skip to main content

lean_ctx/core/property_graph/
queries.rs

1//! Graph traversal queries: dependents, dependencies, impact analysis,
2//! dependency chains (BFS-based shortest path).
3//!
4//! All traversal queries support multi-edge traversal: imports, calls,
5//! exports, type_ref, tested_by, and more. Edge kinds are weighted
6//! for impact scoring.
7
8use std::collections::{HashMap, HashSet, VecDeque};
9
10use rusqlite::{Connection, params};
11
12#[derive(Debug, Clone)]
13pub struct GraphQuery;
14
15#[derive(Debug, Clone)]
16pub struct ImpactResult {
17    pub root_file: String,
18    pub affected_files: Vec<String>,
19    pub max_depth_reached: usize,
20    pub edges_traversed: usize,
21}
22
23#[derive(Debug, Clone)]
24pub struct DependencyChain {
25    pub path: Vec<String>,
26    pub depth: usize,
27}
28
29/// Edge kinds considered structural (code connectivity).
30const STRUCTURAL_EDGE_KINDS: &str =
31    "'imports','calls','exports','type_ref','tested_by','module','cochange','sibling'";
32
33/// Weight multiplier per edge kind for impact scoring.
34pub fn edge_weight(kind: &str) -> f64 {
35    match kind {
36        "imports" => 1.0,
37        "calls" => 0.8,
38        "exports" => 0.7,
39        "module" => 0.6,
40        "type_ref" => 0.5,
41        "tested_by" => 0.4,
42        "cochange" => 0.35,
43        "defines" => 0.3,
44        "sibling" => 0.25,
45        "changed_in" => 0.2,
46        _ => 0.1,
47    }
48}
49
50/// Files that depend on `file_path` via structural edges (imports, calls, type_ref, etc.).
51pub(super) fn dependents(conn: &Connection, file_path: &str) -> anyhow::Result<Vec<String>> {
52    let sql = format!(
53        "SELECT DISTINCT p_src.path
54         FROM edges e
55         JOIN nodes n_src ON e.source_id = n_src.id
56         JOIN nodes n_tgt ON e.target_id = n_tgt.id
57         JOIN paths p_src ON p_src.id = n_src.file_id
58         JOIN paths p_tgt ON p_tgt.id = n_tgt.file_id
59         WHERE p_tgt.path = ?1
60           AND p_src.path != ?1
61           AND e.kind IN ({STRUCTURAL_EDGE_KINDS})"
62    );
63    let mut stmt = conn.prepare(&sql)?;
64
65    let mut results: Vec<String> = stmt
66        .query_map(params![file_path], |row| row.get(0))?
67        .filter_map(std::result::Result::ok)
68        .collect();
69
70    results.sort();
71    results.dedup();
72    Ok(results)
73}
74
75/// Files that `file_path` depends on via structural edges.
76pub(super) fn dependencies(conn: &Connection, file_path: &str) -> anyhow::Result<Vec<String>> {
77    let sql = format!(
78        "SELECT DISTINCT p_tgt.path
79         FROM edges e
80         JOIN nodes n_src ON e.source_id = n_src.id
81         JOIN nodes n_tgt ON e.target_id = n_tgt.id
82         JOIN paths p_src ON p_src.id = n_src.file_id
83         JOIN paths p_tgt ON p_tgt.id = n_tgt.file_id
84         WHERE p_src.path = ?1
85           AND p_tgt.path != ?1
86           AND e.kind IN ({STRUCTURAL_EDGE_KINDS})"
87    );
88    let mut stmt = conn.prepare(&sql)?;
89
90    let mut results: Vec<String> = stmt
91        .query_map(params![file_path], |row| row.get(0))?
92        .filter_map(std::result::Result::ok)
93        .collect();
94
95    results.sort();
96    results.dedup();
97    Ok(results)
98}
99
100/// Weighted BFS from `file_path` following reverse structural edges up to `max_depth`.
101/// Edge weights attenuate propagation: calls edges carry less impact than imports.
102/// Nodes only propagate when cumulative weight exceeds the threshold (0.1).
103pub(super) fn impact_analysis(
104    conn: &Connection,
105    file_path: &str,
106    max_depth: usize,
107) -> anyhow::Result<ImpactResult> {
108    // Graph node keys use canonical `/` separators (see the builder walk);
109    // accept native Windows input too.
110    let file_path = file_path.replace('\\', "/");
111    let file_path = file_path.as_str();
112    let reverse_graph = build_weighted_reverse_graph(conn)?;
113    const PROPAGATION_THRESHOLD: f64 = 0.1;
114
115    let mut visited: HashSet<String> = HashSet::new();
116    let mut queue: VecDeque<(String, usize, f64)> = VecDeque::new();
117    let mut max_depth_reached = 0;
118    let mut edges_traversed = 0;
119
120    visited.insert(file_path.to_string());
121    queue.push_back((file_path.to_string(), 0, 1.0));
122
123    while let Some((current, depth, weight)) = queue.pop_front() {
124        if depth >= max_depth {
125            continue;
126        }
127
128        if let Some(dependents) = reverse_graph.get(&current) {
129            for (dep, ew) in dependents {
130                edges_traversed += 1;
131                let propagated = weight * ew;
132                if propagated < PROPAGATION_THRESHOLD {
133                    continue;
134                }
135                if visited.insert(dep.clone()) {
136                    let new_depth = depth + 1;
137                    if new_depth > max_depth_reached {
138                        max_depth_reached = new_depth;
139                    }
140                    queue.push_back((dep.clone(), new_depth, propagated));
141                }
142            }
143        }
144    }
145
146    visited.remove(file_path);
147
148    Ok(ImpactResult {
149        root_file: file_path.to_string(),
150        affected_files: visited.into_iter().collect(),
151        max_depth_reached,
152        edges_traversed,
153    })
154}
155
156/// BFS shortest path from `from` to `to` following structural edges.
157pub(super) fn dependency_chain(
158    conn: &Connection,
159    from: &str,
160    to: &str,
161) -> anyhow::Result<Option<DependencyChain>> {
162    // Same canonicalization as `impact_analysis`.
163    let from = from.replace('\\', "/");
164    let to = to.replace('\\', "/");
165    let from = from.as_str();
166    let to = to.as_str();
167    let forward_graph = build_forward_graph(conn)?;
168
169    let mut visited: HashSet<String> = HashSet::new();
170    let mut parent: HashMap<String, String> = HashMap::new();
171    let mut queue: VecDeque<String> = VecDeque::new();
172
173    visited.insert(from.to_string());
174    queue.push_back(from.to_string());
175
176    while let Some(current) = queue.pop_front() {
177        if current == to {
178            let mut path = vec![to.to_string()];
179            let mut cursor = to.to_string();
180            while let Some(prev) = parent.get(&cursor) {
181                path.push(prev.clone());
182                cursor = prev.clone();
183            }
184            path.reverse();
185            let depth = path.len() - 1;
186            return Ok(Some(DependencyChain { path, depth }));
187        }
188
189        if let Some(deps) = forward_graph.get(&current) {
190            for dep in deps {
191                if visited.insert(dep.clone()) {
192                    parent.insert(dep.clone(), current.clone());
193                    queue.push_back(dep.clone());
194                }
195            }
196        }
197    }
198
199    Ok(None)
200}
201
202/// Related files for a given path: direct neighbors via any structural edge,
203/// sorted by edge weight (strongest relationship first). Returns (path, weight) pairs.
204pub fn related_files(
205    conn: &Connection,
206    file_path: &str,
207    limit: usize,
208) -> anyhow::Result<Vec<(String, f64)>> {
209    let sql = format!(
210        "SELECT p_other.path, e.kind
211         FROM edges e
212         JOIN nodes n_self ON (e.source_id = n_self.id OR e.target_id = n_self.id)
213         JOIN nodes n_other ON (
214             (e.source_id = n_other.id AND e.target_id = n_self.id)
215             OR (e.target_id = n_other.id AND e.source_id = n_self.id)
216         )
217         JOIN paths p_self ON p_self.id = n_self.file_id
218         JOIN paths p_other ON p_other.id = n_other.file_id
219         WHERE p_self.path = ?1
220           AND p_other.path != ?1
221           AND e.kind IN ({STRUCTURAL_EDGE_KINDS})"
222    );
223    let mut stmt = conn.prepare(&sql)?;
224
225    let mut scores: HashMap<String, f64> = HashMap::new();
226    let rows = stmt.query_map(params![file_path], |row| {
227        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
228    })?;
229
230    for row in rows {
231        let (path, kind) = row?;
232        *scores.entry(path).or_default() += edge_weight(&kind);
233    }
234
235    let mut results: Vec<(String, f64)> = scores.into_iter().collect();
236    results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
237    results.truncate(limit);
238    Ok(results)
239}
240
241/// Graph connectivity stats for a file: incoming/outgoing edge counts by kind.
242pub fn file_connectivity(
243    conn: &Connection,
244    file_path: &str,
245) -> anyhow::Result<HashMap<String, (usize, usize)>> {
246    let mut result: HashMap<String, (usize, usize)> = HashMap::new();
247
248    let mut stmt_out = conn.prepare(
249        "SELECT e.kind, COUNT(*)
250         FROM edges e JOIN nodes n ON e.source_id = n.id
251         JOIN paths p ON p.id = n.file_id
252         WHERE p.path = ?1
253         GROUP BY e.kind",
254    )?;
255    let rows = stmt_out.query_map(params![file_path], |row| {
256        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
257    })?;
258    for row in rows {
259        let (kind, count) = row?;
260        result.entry(kind).or_insert((0, 0)).0 = count as usize;
261    }
262
263    let mut stmt_in = conn.prepare(
264        "SELECT e.kind, COUNT(*)
265         FROM edges e JOIN nodes n ON e.target_id = n.id
266         JOIN paths p ON p.id = n.file_id
267         WHERE p.path = ?1
268         GROUP BY e.kind",
269    )?;
270    let rows = stmt_in.query_map(params![file_path], |row| {
271        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
272    })?;
273    for row in rows {
274        let (kind, count) = row?;
275        result.entry(kind).or_insert((0, 0)).1 = count as usize;
276    }
277
278    Ok(result)
279}
280
281fn build_weighted_reverse_graph(
282    conn: &Connection,
283) -> anyhow::Result<HashMap<String, Vec<(String, f64)>>> {
284    let sql = format!(
285        "SELECT p_tgt.path, p_src.path, e.kind
286         FROM edges e
287         JOIN nodes n_src ON e.source_id = n_src.id
288         JOIN nodes n_tgt ON e.target_id = n_tgt.id
289         JOIN paths p_src ON p_src.id = n_src.file_id
290         JOIN paths p_tgt ON p_tgt.id = n_tgt.file_id
291         WHERE e.kind IN ({STRUCTURAL_EDGE_KINDS})
292           AND p_src.path != p_tgt.path"
293    );
294    let mut stmt = conn.prepare(&sql)?;
295
296    let mut graph: HashMap<String, HashMap<String, f64>> = HashMap::new();
297    let rows = stmt.query_map([], |row| {
298        Ok((
299            row.get::<_, String>(0)?,
300            row.get::<_, String>(1)?,
301            row.get::<_, String>(2)?,
302        ))
303    })?;
304
305    for row in rows {
306        let (target, source, kind) = row?;
307        let w = edge_weight(&kind);
308        let entry = graph
309            .entry(target)
310            .or_default()
311            .entry(source)
312            .or_insert(0.0);
313        if w > *entry {
314            *entry = w;
315        }
316    }
317
318    Ok(graph
319        .into_iter()
320        .map(|(k, v)| (k, v.into_iter().collect()))
321        .collect())
322}
323
324fn build_forward_graph(conn: &Connection) -> anyhow::Result<HashMap<String, Vec<String>>> {
325    let sql = format!(
326        "SELECT DISTINCT p_src.path, p_tgt.path
327         FROM edges e
328         JOIN nodes n_src ON e.source_id = n_src.id
329         JOIN nodes n_tgt ON e.target_id = n_tgt.id
330         JOIN paths p_src ON p_src.id = n_src.file_id
331         JOIN paths p_tgt ON p_tgt.id = n_tgt.file_id
332         WHERE e.kind IN ({STRUCTURAL_EDGE_KINDS})
333           AND p_src.path != p_tgt.path"
334    );
335    let mut stmt = conn.prepare(&sql)?;
336
337    let mut graph: HashMap<String, Vec<String>> = HashMap::new();
338    let rows = stmt.query_map([], |row| {
339        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
340    })?;
341
342    for row in rows {
343        let (source, target) = row?;
344        graph.entry(source).or_default().push(target);
345    }
346
347    for deps in graph.values_mut() {
348        deps.sort();
349        deps.dedup();
350    }
351    Ok(graph)
352}