1use std::path::{Path, PathBuf};
2
3use once_cell::sync::Lazy;
4use rustc_hash::{FxHashMap, FxHashSet};
5
6use crate::config::edge_weights::SEMANTIC_DISCOVERY;
7use crate::config::extensions::CODE_EXTENSIONS;
8use crate::types::{Fragment, FragmentId};
9
10use super::EdgeDict;
11
12pub trait EdgeBuilder: Send + Sync {
13 fn build(&self, fragments: &[Fragment], repo_root: Option<&Path>) -> EdgeDict;
14
15 fn discover_related_files(
16 &self,
17 _changed: &[PathBuf],
18 _candidates: &[PathBuf],
19 _repo_root: Option<&Path>,
20 _file_cache: Option<&FxHashMap<PathBuf, String>>,
21 ) -> Vec<PathBuf> {
22 vec![]
23 }
24
25 fn category_label(&self) -> Option<&str> {
26 None
27 }
28
29 fn is_expensive(&self) -> bool {
30 false
31 }
32
33 fn is_fallback(&self) -> bool {
38 false
39 }
40}
41
42static INDEX_FILE_STEMS: Lazy<FxHashSet<&str>> =
43 Lazy::new(|| ["__init__", "index", "mod"].iter().copied().collect());
44
45fn strip_source_prefix(parts: &[&str]) -> Vec<String> {
46 for (i, part) in parts.iter().enumerate() {
47 if *part == "src" || *part == "lib" || *part == "packages" {
48 return parts[i + 1..].iter().map(|s| s.to_string()).collect();
49 }
50 }
51 parts.iter().map(|s| s.to_string()).collect()
52}
53
54fn strip_file_extension(stem: &str) -> &str {
55 for ext in CODE_EXTENSIONS.iter() {
56 if let Some(stripped) = stem.strip_suffix(ext) {
57 return stripped;
58 }
59 }
60 stem
61}
62
63pub fn path_to_module(path: &Path, repo_root: Option<&Path>) -> String {
64 let effective = if let Some(root) = repo_root {
65 if path.is_absolute() {
66 path.strip_prefix(root).unwrap_or(path)
67 } else {
68 path
69 }
70 } else {
71 path
72 };
73
74 let parts_raw: Vec<&str> = effective.iter().filter_map(|c| c.to_str()).collect();
75 let mut parts = strip_source_prefix(&parts_raw);
76
77 if let Some(last) = parts.last_mut() {
78 let stripped = strip_file_extension(last).to_string();
79 *last = stripped;
80 }
81
82 if let Some(last) = parts.last() {
83 if INDEX_FILE_STEMS.contains(last.as_str()) {
84 parts.pop();
85 }
86 }
87
88 parts.join(".")
89}
90
91pub struct FragmentIndex {
92 pub by_name: FxHashMap<String, Vec<FragmentId>>,
93 pub by_path: FxHashMap<String, Vec<FragmentId>>,
94 lower_paths: Vec<(String, FragmentId)>,
103 component_to_paths: FxHashMap<String, Vec<u32>>,
104}
105
106impl FragmentIndex {
107 pub fn new(fragments: &[Fragment], repo_root: Option<&Path>) -> Self {
108 let mut by_name: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
109 let mut by_path: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
110
111 for f in fragments {
112 let path = Path::new(f.path());
113 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
114 by_name
115 .entry(name.to_lowercase())
116 .or_default()
117 .push(f.id.clone());
118 }
119 by_path
120 .entry(f.path().to_string())
121 .or_default()
122 .push(f.id.clone());
123
124 if let Some(root) = repo_root {
125 if let Ok(rel) = Path::new(f.path()).strip_prefix(root) {
126 let rel_str = rel.to_string_lossy().to_string();
127 by_path
128 .entry(rel_str.clone())
129 .or_default()
130 .push(f.id.clone());
131 let posix = rel_str.replace('\\', "/");
132 if posix != rel_str {
133 by_path.entry(posix).or_default().push(f.id.clone());
134 }
135 }
136 }
137 }
138
139 let reps = file_representatives(fragments);
146 let mut lower_paths: Vec<(String, FragmentId)> = Vec::with_capacity(reps.len());
147 let mut component_to_paths: FxHashMap<String, Vec<u32>> = FxHashMap::default();
148 let mut indexed_files: FxHashSet<&str> = FxHashSet::default();
149 for f in fragments {
150 if !indexed_files.insert(f.path()) {
151 continue;
152 }
153 let Some(rep) = reps.get(f.path()) else {
154 continue;
155 };
156 let matchable = repo_root
157 .and_then(|root| Path::new(f.path()).strip_prefix(root).ok())
158 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
159 .unwrap_or_else(|| f.path().replace('\\', "/"));
160 let idx = lower_paths.len() as u32;
161 let lower = matchable.to_lowercase();
162 for comp in lower.split('/').filter(|c| !c.is_empty()) {
163 let posting = component_to_paths.entry(comp.to_string()).or_default();
164 if posting.last() != Some(&idx) {
165 posting.push(idx);
166 }
167 }
168 lower_paths.push((lower, rep.clone()));
169 }
170
171 Self {
172 by_name,
173 by_path,
174 lower_paths,
175 component_to_paths,
176 }
177 }
178}
179
180pub fn file_representatives(fragments: &[Fragment]) -> FxHashMap<String, FragmentId> {
194 let mut file_to_rep: FxHashMap<String, FragmentId> = FxHashMap::default();
195 let mut file_to_token_count: FxHashMap<String, u32> = FxHashMap::default();
196
197 for f in fragments {
198 let path = f.path().to_string();
199 let existing_count = file_to_token_count.get(&path).copied().unwrap_or(0);
200 if !file_to_rep.contains_key(&path) || f.token_count > existing_count {
201 file_to_rep.insert(path.clone(), f.id.clone());
202 file_to_token_count.insert(path, f.token_count);
203 }
204 }
205
206 file_to_rep
207}
208
209pub fn add_edge(
210 edges: &mut EdgeDict,
211 src: &FragmentId,
212 dst: &FragmentId,
213 weight: f64,
214 reverse_factor: f64,
215) {
216 let key_fwd = (src.clone(), dst.clone());
217 let existing_fwd = edges.get(&key_fwd).copied().unwrap_or(0.0);
218 if weight > existing_fwd {
219 edges.insert(key_fwd, weight);
220 }
221 let rev_w = weight * reverse_factor;
222 let key_rev = (dst.clone(), src.clone());
223 let existing_rev = edges.get(&key_rev).copied().unwrap_or(0.0);
224 if rev_w > existing_rev {
225 edges.insert(key_rev, rev_w);
226 }
227}
228
229pub fn add_edge_unidirectional(
230 edges: &mut EdgeDict,
231 src: &FragmentId,
232 dst: &FragmentId,
233 weight: f64,
234) {
235 let key = (src.clone(), dst.clone());
236 let existing = edges.get(&key).copied().unwrap_or(0.0);
237 if weight > existing {
238 edges.insert(key, weight);
239 }
240}
241
242pub fn add_edges_from_ids(
243 edges: &mut EdgeDict,
244 src: &FragmentId,
245 targets: &[FragmentId],
246 weight: f64,
247 reverse_factor: f64,
248) {
249 for target in targets {
250 if target != src {
251 add_edge(edges, src, target, weight, reverse_factor);
252 }
253 }
254}
255
256pub fn link_by_name(
257 src_id: &FragmentId,
258 name: &str,
259 idx: &FragmentIndex,
260 edges: &mut EdgeDict,
261 weight: f64,
262 reverse_factor: f64,
263) {
264 let target = name.split('/').next_back().unwrap_or(name).to_lowercase();
265 if let Some(frag_ids) = idx.by_name.get(&target) {
266 for fid in frag_ids {
267 if fid != src_id {
268 add_edge(edges, src_id, fid, weight, reverse_factor);
269 return;
270 }
271 }
272 }
273 link_by_path_match(src_id, name, idx, edges, weight, reverse_factor);
274}
275
276pub fn link_by_path_match(
286 src_id: &FragmentId,
287 ref_str: &str,
288 idx: &FragmentIndex,
289 edges: &mut EdgeDict,
290 weight: f64,
291 reverse_factor: f64,
292) {
293 let ref_norm = ref_str.trim_matches('/');
294 if ref_norm.is_empty() {
295 return;
296 }
297 let ref_lower = ref_norm.to_lowercase();
304 let Some(last) = ref_lower.split('/').next_back() else {
305 return;
306 };
307 let Some(posting) = idx.component_to_paths.get(last) else {
308 return;
309 };
310 let needle = format!("/{ref_lower}/");
311 let matched: Vec<&FragmentId> = posting
312 .iter()
313 .filter_map(|&pi| {
314 let (path_lower, rep) = &idx.lower_paths[pi as usize];
315 component_aligned(path_lower, &ref_lower, &needle).then_some(rep)
316 })
317 .collect();
318 if matched.len() > MAX_FILES_PER_PATH_REF {
324 return;
325 }
326 for rep in matched {
327 if rep != src_id {
328 add_edge(edges, src_id, rep, weight, reverse_factor);
329 }
330 }
331}
332
333const MAX_FILES_PER_PATH_REF: usize = 8;
336
337fn component_aligned(path: &str, reference: &str, interior_needle: &str) -> bool {
338 if path == reference {
339 return true;
340 }
341 if let Some(rest) = path.strip_suffix(reference) {
342 if rest.ends_with('/') {
343 return true;
344 }
345 }
346 if let Some(rest) = path.strip_prefix(reference) {
347 if rest.starts_with('/') {
348 return true;
349 }
350 }
351 path.contains(interior_needle)
355}
356
357pub fn read_file_cached<'a>(
358 path: &Path,
359 cache: Option<&'a FxHashMap<PathBuf, String>>,
360) -> Option<String> {
361 if let Some(c) = cache {
362 if let Some(content) = c.get(path) {
363 return Some(content.clone());
364 }
365 }
366 std::fs::read_to_string(path).ok()
367}
368
369fn candidate_rel_path(candidate: &Path, repo_root: Option<&Path>) -> String {
370 if let Some(root) = repo_root {
371 if let Ok(rel) = candidate.strip_prefix(root) {
372 return rel.to_string_lossy().to_lowercase();
373 }
374 }
375 candidate
376 .file_name()
377 .map(|n| n.to_string_lossy().to_lowercase())
378 .unwrap_or_default()
379}
380
381fn matches_any_ref(candidate_name: &str, candidate_rel: &str, refs: &FxHashSet<String>) -> bool {
382 for r in refs {
383 let ref_name = r.split('/').next_back().unwrap_or(r).to_lowercase();
384 if candidate_name == ref_name {
385 return true;
386 }
387 let ref_lower = r.to_lowercase();
388 if ref_lower.len() >= SEMANTIC_DISCOVERY.min_ref_length_for_path_match {
389 if let Some(idx) = candidate_rel.find(&ref_lower) {
390 let end_idx = idx + ref_lower.len();
391 let start_ok = idx == 0
392 || candidate_rel.as_bytes().get(idx - 1) == Some(&b'/')
393 || candidate_rel.as_bytes().get(idx - 1) == Some(&b'\\');
394 let end_ok = end_idx == candidate_rel.len()
395 || matches!(
396 candidate_rel.as_bytes().get(end_idx),
397 Some(b'/') | Some(b'\\') | Some(b'.')
398 );
399 if start_ok && end_ok {
400 return true;
401 }
402 }
403 }
404 }
405 false
406}
407
408pub fn discover_files_by_refs(
409 refs: &FxHashSet<String>,
410 changed_files: &[PathBuf],
411 all_candidates: &[PathBuf],
412 repo_root: Option<&Path>,
413) -> Vec<PathBuf> {
414 if refs.is_empty() {
415 return vec![];
416 }
417 let changed_set: FxHashSet<&PathBuf> = changed_files.iter().collect();
418 let mut discovered = Vec::new();
419 for candidate in all_candidates {
420 if changed_set.contains(candidate) {
421 continue;
422 }
423 let candidate_name = candidate
424 .file_name()
425 .map(|n| n.to_string_lossy().to_lowercase())
426 .unwrap_or_default();
427 let candidate_rel = candidate_rel_path(candidate, repo_root);
428 if matches_any_ref(&candidate_name, &candidate_rel, refs) {
429 discovered.push(candidate.clone());
430 }
431 }
432 discovered
433}
434
435pub fn file_ext(path: &Path) -> String {
436 path.extension()
437 .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
438 .unwrap_or_default()
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use crate::types::FragmentKind;
445
446 fn frag(path: &str) -> Fragment {
447 Fragment {
448 id: FragmentId::new(std::sync::Arc::from(path), 1, 10),
449 kind: FragmentKind::Function,
450 content: std::sync::Arc::from("fn x() {}"),
451 identifiers: FxHashSet::default(),
452 token_count: 10,
453 symbol_name: None,
454 }
455 }
456
457 fn linked_paths(reference: &str, paths: &[&str]) -> Vec<String> {
458 let frags: Vec<Fragment> = paths.iter().map(|p| frag(p)).collect();
459 let idx = FragmentIndex::new(&frags, None);
460 let src = frag("src/origin.yml");
461 let mut edges: EdgeDict = FxHashMap::default();
462 link_by_path_match(&src.id, reference, &idx, &mut edges, 0.5, 0.5);
463 let mut out: Vec<String> = edges
466 .keys()
467 .map(|(_, dst)| dst.path.to_string())
468 .filter(|p| p != "src/origin.yml")
469 .collect();
470 out.sort();
471 out.dedup();
472 out
473 }
474
475 #[test]
476 fn a_reference_matches_only_whole_path_components() {
477 let paths = [
478 "roles/config/tasks/main.yml",
479 "src/preconfigured/app.rs",
480 "src/config.rs",
481 "deep/nested/config",
482 ];
483 let hit = linked_paths("config", &paths);
484 assert!(
485 hit.contains(&"roles/config/tasks/main.yml".to_string()),
486 "interior whole component must match"
487 );
488 assert!(
489 hit.contains(&"deep/nested/config".to_string()),
490 "suffix component must match"
491 );
492 assert!(
493 !hit.contains(&"src/preconfigured/app.rs".to_string()),
494 "substring inside a component must NOT match — that fan-out is the envoy hang"
495 );
496 assert!(
497 !hit.contains(&"src/config.rs".to_string()),
498 "`config` does not name `config.rs`; a file reference carries its extension"
499 );
500 }
501
502 #[test]
503 fn a_multi_component_reference_still_matches_its_file() {
504 let paths = ["source/common/buffer/buffer_impl.h", "other/buffer_impl.h"];
505 let hit = linked_paths("common/buffer/buffer_impl.h", &paths);
506 assert_eq!(hit, vec!["source/common/buffer/buffer_impl.h".to_string()]);
507 }
508
509 #[test]
510 fn a_package_prefix_reference_matches_files_under_it() {
511 let paths = ["pkg/api/server.go", "pkg/api2/other.go"];
512 let hit = linked_paths("pkg/api", &paths);
513 assert_eq!(hit, vec!["pkg/api/server.go".to_string()]);
514 }
515
516 fn linked_paths_rooted(reference: &str, root: &str, paths: &[&str]) -> Vec<String> {
517 let frags: Vec<Fragment> = paths.iter().map(|p| frag(p)).collect();
518 let idx = FragmentIndex::new(&frags, Some(Path::new(root)));
519 let src = frag("/ci/work/repo/src/origin.yml");
520 let mut edges: EdgeDict = FxHashMap::default();
521 link_by_path_match(&src.id, reference, &idx, &mut edges, 0.5, 0.5);
522 let mut out: Vec<String> = edges
523 .keys()
524 .map(|(_, dst)| dst.path.to_string())
525 .filter(|p| p != src.id.path.as_ref())
526 .collect();
527 out.sort();
528 out.dedup();
529 out
530 }
531
532 #[test]
533 fn the_ambiguity_cap_counts_distinct_files_not_index_keys() {
534 let paths: Vec<String> = (0..5)
538 .map(|i| format!("/ci/work/repo/lib/util/mod_{i}.py"))
539 .collect();
540 let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
541 let hit = linked_paths_rooted("lib/util", "/ci/work/repo", &refs);
542 assert_eq!(hit.len(), 5, "cap must count files, not duplicate keys");
543 }
544
545 #[test]
546 fn host_directory_components_are_not_matchable() {
547 let paths = ["/ci/work/repo/src/app.rs", "/ci/work/repo/src/db.rs"];
548 let hit = linked_paths_rooted("work", "/ci/work/repo", &paths);
549 assert!(
550 hit.is_empty(),
551 "components outside the repo root must not resolve a reference"
552 );
553 }
554}