1use std::collections::{BTreeMap, BTreeSet, 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 weak_files: Vec<String>,
22 pub max_depth_reached: usize,
23 pub edges_traversed: usize,
24}
25
26#[derive(Debug, Clone)]
27pub struct DependencyChain {
28 pub path: Vec<String>,
29 pub depth: usize,
30}
31
32pub type Served<'a> = &'a dyn Fn(&str) -> bool;
36
37const STRUCTURAL_EDGE_KINDS: &str = "'imports','calls','references','implements','extends','exports','type_ref','tested_by','module','cochange','sibling'";
39
40pub fn edge_weight(kind: &str) -> f64 {
42 match kind {
43 "imports" => 1.0,
44 "calls" | "references" => 0.8,
45 "exports" | "implements" | "extends" => 0.7,
46 "module" => 0.6,
47 "type_ref" => 0.5,
48 "tested_by" => 0.4,
49 "cochange" => 0.35,
50 "defines" => 0.3,
51 "sibling" => 0.25,
52 "changed_in" => 0.2,
53 _ => 0.1,
54 }
55}
56
57pub(super) fn dependents(conn: &Connection, file_path: &str) -> anyhow::Result<Vec<String>> {
59 let sql = format!(
60 "SELECT DISTINCT p_src.path
61 FROM edges e
62 JOIN nodes n_src ON e.source_id = n_src.id
63 JOIN nodes n_tgt ON e.target_id = n_tgt.id
64 JOIN paths p_src ON p_src.id = n_src.file_id
65 JOIN paths p_tgt ON p_tgt.id = n_tgt.file_id
66 WHERE p_tgt.path = ?1
67 AND p_src.path != ?1
68 AND e.kind IN ({STRUCTURAL_EDGE_KINDS})"
69 );
70 let mut stmt = conn.prepare(&sql)?;
71
72 let mut results: Vec<String> = stmt
73 .query_map(params![file_path], |row| row.get(0))?
74 .filter_map(std::result::Result::ok)
75 .collect();
76
77 results.sort();
78 results.dedup();
79 Ok(results)
80}
81
82pub(super) fn dependencies(conn: &Connection, file_path: &str) -> anyhow::Result<Vec<String>> {
84 let sql = format!(
85 "SELECT DISTINCT p_tgt.path
86 FROM edges e
87 JOIN nodes n_src ON e.source_id = n_src.id
88 JOIN nodes n_tgt ON e.target_id = n_tgt.id
89 JOIN paths p_src ON p_src.id = n_src.file_id
90 JOIN paths p_tgt ON p_tgt.id = n_tgt.file_id
91 WHERE p_src.path = ?1
92 AND p_tgt.path != ?1
93 AND e.kind IN ({STRUCTURAL_EDGE_KINDS})"
94 );
95 let mut stmt = conn.prepare(&sql)?;
96
97 let mut results: Vec<String> = stmt
98 .query_map(params![file_path], |row| row.get(0))?
99 .filter_map(std::result::Result::ok)
100 .collect();
101
102 results.sort();
103 results.dedup();
104 Ok(results)
105}
106
107pub(super) fn impact_analysis(
118 conn: &Connection,
119 file_path: &str,
120 max_depth: usize,
121 served: Served<'_>,
122) -> anyhow::Result<ImpactResult> {
123 let file_path = file_path.replace('\\', "/");
126 let file_path = file_path.as_str();
127 if !served(file_path) {
128 return Ok(ImpactResult {
129 root_file: file_path.to_string(),
130 affected_files: Vec::new(),
131 weak_files: Vec::new(),
132 max_depth_reached: 0,
133 edges_traversed: 0,
134 });
135 }
136 let reverse_graph = build_weighted_reverse_graph(conn)?;
137
138 let all = propagate(&reverse_graph, file_path, max_depth, false, served);
139 let strong = propagate(&reverse_graph, file_path, max_depth, true, served);
144 let weak_files: Vec<String> = all.reached.difference(&strong.reached).cloned().collect();
145
146 Ok(ImpactResult {
147 root_file: file_path.to_string(),
148 affected_files: all.reached.into_iter().collect(),
149 weak_files,
150 max_depth_reached: all.max_depth_reached,
151 edges_traversed: all.edges_traversed,
152 })
153}
154
155struct Propagation {
156 reached: BTreeSet<String>,
157 max_depth_reached: usize,
158 edges_traversed: usize,
159}
160
161fn propagate(
169 reverse_graph: &ReverseGraph,
170 root: &str,
171 max_depth: usize,
172 strong_only: bool,
173 served: Served<'_>,
174) -> Propagation {
175 const PROPAGATION_THRESHOLD: f64 = 0.1;
176 let mut best: HashMap<&str, f64> = HashMap::from([(root, 1.0)]);
178 let mut first_depth: BTreeMap<&str, usize> = BTreeMap::new();
179 let mut layer: BTreeMap<&str, f64> = BTreeMap::from([(root, 1.0)]);
180 let mut edges_traversed = 0;
181
182 for depth in 1..=max_depth {
183 let mut next: BTreeMap<&str, f64> = BTreeMap::new();
184 for (file, weight) in &layer {
185 for dep in reverse_graph.get(*file).into_iter().flatten() {
186 let Some(edge_weight) = (if strong_only {
187 dep.strong_weight
188 } else {
189 Some(dep.weight)
190 }) else {
191 continue;
192 };
193 if !served(dep.file.as_str()) {
194 continue;
195 }
196 edges_traversed += 1;
197 let propagated = weight * edge_weight;
198 if propagated < PROPAGATION_THRESHOLD
199 || best
200 .get(dep.file.as_str())
201 .is_some_and(|b| *b >= propagated)
202 {
203 continue;
204 }
205 let slot = next.entry(dep.file.as_str()).or_insert(0.0);
206 *slot = slot.max(propagated);
207 }
208 }
209 if next.is_empty() {
210 break;
211 }
212 for (file, weight) in &next {
213 best.insert(file, *weight);
214 first_depth.entry(file).or_insert(depth);
215 }
216 layer = next;
217 }
218 first_depth.remove(root);
219 Propagation {
220 max_depth_reached: first_depth.values().copied().max().unwrap_or(0),
221 reached: first_depth.into_keys().map(str::to_string).collect(),
222 edges_traversed,
223 }
224}
225
226pub(super) fn dependency_chain(
230 conn: &Connection,
231 from: &str,
232 to: &str,
233 served: Served<'_>,
234) -> anyhow::Result<Option<DependencyChain>> {
235 let from = from.replace('\\', "/");
237 let to = to.replace('\\', "/");
238 let from = from.as_str();
239 let to = to.as_str();
240 if !served(from) {
241 return Ok(None);
242 }
243 let forward_graph = build_forward_graph(conn)?;
244
245 let mut visited: HashSet<String> = HashSet::new();
246 let mut parent: HashMap<String, String> = HashMap::new();
247 let mut queue: VecDeque<String> = VecDeque::new();
248
249 visited.insert(from.to_string());
250 queue.push_back(from.to_string());
251
252 while let Some(current) = queue.pop_front() {
253 if current == to {
254 let mut path = vec![to.to_string()];
255 let mut cursor = to.to_string();
256 while let Some(prev) = parent.get(&cursor) {
257 path.push(prev.clone());
258 cursor = prev.clone();
259 }
260 path.reverse();
261 let depth = path.len() - 1;
262 return Ok(Some(DependencyChain { path, depth }));
263 }
264
265 if let Some(deps) = forward_graph.get(¤t) {
266 for dep in deps {
267 if !visited.contains(dep) && served(dep) && visited.insert(dep.clone()) {
268 parent.insert(dep.clone(), current.clone());
269 queue.push_back(dep.clone());
270 }
271 }
272 }
273 }
274
275 Ok(None)
276}
277
278pub fn related_files(
285 conn: &Connection,
286 file_path: &str,
287 limit: usize,
288 served: Served<'_>,
289) -> anyhow::Result<Vec<(String, f64)>> {
290 if !served(file_path) {
291 return Ok(Vec::new());
292 }
293 let sql = format!(
294 "SELECT p_other.path, e.kind, e.metadata
295 FROM edges e
296 JOIN nodes n_self ON (e.source_id = n_self.id OR e.target_id = n_self.id)
297 JOIN nodes n_other ON (
298 (e.source_id = n_other.id AND e.target_id = n_self.id)
299 OR (e.target_id = n_other.id AND e.source_id = n_self.id)
300 )
301 JOIN paths p_self ON p_self.id = n_self.file_id
302 JOIN paths p_other ON p_other.id = n_other.file_id
303 WHERE p_self.path = ?1
304 AND p_other.path != ?1
305 AND e.kind IN ({STRUCTURAL_EDGE_KINDS})"
306 );
307 let mut stmt = conn.prepare(&sql)?;
308
309 let mut strongest: BTreeMap<(String, String), f64> = BTreeMap::new();
316 let rows = stmt.query_map(params![file_path], |row| {
317 Ok((
318 row.get::<_, String>(0)?,
319 row.get::<_, String>(1)?,
320 row.get::<_, Option<String>>(2)?,
321 ))
322 })?;
323 for row in rows {
324 let (path, kind, metadata) = row?;
325 let w = evidence_weight(&kind, metadata.as_deref());
326 let slot = strongest.entry((path, kind)).or_insert(0.0);
327 if w > *slot {
328 *slot = w;
329 }
330 }
331
332 let mut scores: BTreeMap<String, f64> = BTreeMap::new();
333 for ((path, _), w) in strongest {
334 *scores.entry(path).or_default() += w;
335 }
336
337 let mut results: Vec<(String, f64)> = scores.into_iter().collect();
338 results.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
341 Ok(results
342 .into_iter()
343 .filter(|(path, _)| served(path))
344 .take(limit)
345 .collect())
346}
347
348pub(super) fn evidence_weight(kind: &str, metadata: Option<&str>) -> f64 {
352 edge_weight(kind) * crate::core::semantic::EdgeEvidence::weight_factor_of(metadata)
353}
354
355pub fn file_connectivity(
361 conn: &Connection,
362 file_path: &str,
363 served: Served<'_>,
364) -> anyhow::Result<HashMap<String, (usize, usize)>> {
365 let mut result: HashMap<String, (usize, usize)> = HashMap::new();
366 if !served(file_path) {
367 return Ok(result);
368 }
369 for (outgoing, own, other) in [
371 (true, "source_id", "target_id"),
372 (false, "target_id", "source_id"),
373 ] {
374 let mut stmt = conn.prepare(&format!(
375 "SELECT e.kind, po.path
376 FROM edges e
377 JOIN nodes n ON e.{own} = n.id
378 JOIN paths p ON p.id = n.file_id
379 JOIN nodes o ON e.{other} = o.id
380 JOIN paths po ON po.id = o.file_id
381 WHERE p.path = ?1"
382 ))?;
383 let rows = stmt.query_map(params![file_path], |row| {
384 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
385 })?;
386 for row in rows {
387 let (kind, other_path) = row?;
388 if !(other_path.is_empty() || other_path == file_path || served(&other_path)) {
389 continue;
390 }
391 let slot = result.entry(kind).or_insert((0, 0));
392 if outgoing {
393 slot.0 += 1;
394 } else {
395 slot.1 += 1;
396 }
397 }
398 }
399 Ok(result)
400}
401
402struct Dependent {
404 file: String,
405 weight: f64,
407 strong_weight: Option<f64>,
409}
410
411type ReverseGraph = BTreeMap<String, Vec<Dependent>>;
413
414fn build_weighted_reverse_graph(conn: &Connection) -> anyhow::Result<ReverseGraph> {
415 let sql = format!(
416 "SELECT p_tgt.path, p_src.path, e.kind, e.metadata
417 FROM edges e
418 JOIN nodes n_src ON e.source_id = n_src.id
419 JOIN nodes n_tgt ON e.target_id = n_tgt.id
420 JOIN paths p_src ON p_src.id = n_src.file_id
421 JOIN paths p_tgt ON p_tgt.id = n_tgt.file_id
422 WHERE e.kind IN ({STRUCTURAL_EDGE_KINDS})
423 AND p_src.path != p_tgt.path"
424 );
425 let mut stmt = conn.prepare(&sql)?;
426
427 let mut graph: BTreeMap<String, BTreeMap<String, (f64, Option<f64>)>> = BTreeMap::new();
428 let rows = stmt.query_map([], |row| {
429 Ok((
430 row.get::<_, String>(0)?,
431 row.get::<_, String>(1)?,
432 row.get::<_, String>(2)?,
433 row.get::<_, Option<String>>(3)?,
434 ))
435 })?;
436
437 for row in rows {
438 let (target, source, kind, metadata) = row?;
439 let w = evidence_weight(&kind, metadata.as_deref());
440 let strong = crate::core::semantic::EdgeEvidence::from_metadata(metadata.as_deref())
443 .is_none_or(|e| e.grade != crate::core::semantic::EvidenceGrade::HeuristicStructural);
444 let entry = graph
445 .entry(target)
446 .or_default()
447 .entry(source)
448 .or_insert((0.0, None));
449 entry.0 = entry.0.max(w);
450 if strong {
451 entry.1 = Some(entry.1.map_or(w, |s| s.max(w)));
452 }
453 }
454
455 Ok(graph
456 .into_iter()
457 .map(|(k, v)| {
458 (
459 k,
460 v.into_iter()
461 .map(|(file, (weight, strong_weight))| Dependent {
462 file,
463 weight,
464 strong_weight,
465 })
466 .collect(),
467 )
468 })
469 .collect())
470}
471
472fn build_forward_graph(conn: &Connection) -> anyhow::Result<HashMap<String, Vec<String>>> {
473 let sql = format!(
474 "SELECT DISTINCT p_src.path, p_tgt.path
475 FROM edges e
476 JOIN nodes n_src ON e.source_id = n_src.id
477 JOIN nodes n_tgt ON e.target_id = n_tgt.id
478 JOIN paths p_src ON p_src.id = n_src.file_id
479 JOIN paths p_tgt ON p_tgt.id = n_tgt.file_id
480 WHERE e.kind IN ({STRUCTURAL_EDGE_KINDS})
481 AND p_src.path != p_tgt.path"
482 );
483 let mut stmt = conn.prepare(&sql)?;
484
485 let mut graph: HashMap<String, Vec<String>> = HashMap::new();
486 let rows = stmt.query_map([], |row| {
487 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
488 })?;
489
490 for row in rows {
491 let (source, target) = row?;
492 graph.entry(source).or_default().push(target);
493 }
494
495 for deps in graph.values_mut() {
496 deps.sort();
497 deps.dedup();
498 }
499 Ok(graph)
500}