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);
140 let mut lower_paths: Vec<(String, FragmentId)> = Vec::with_capacity(by_path.len());
141 let mut component_to_paths: FxHashMap<String, Vec<u32>> = FxHashMap::default();
142 for (path_str, ids) in &by_path {
143 let Some(rep) = ids.first().and_then(|id| reps.get(id.path.as_ref())) else {
147 continue;
148 };
149 let idx = lower_paths.len() as u32;
150 let lower = path_str.to_lowercase();
151 for comp in lower.split('/').filter(|c| !c.is_empty()) {
152 let posting = component_to_paths.entry(comp.to_string()).or_default();
153 if posting.last() != Some(&idx) {
154 posting.push(idx);
155 }
156 }
157 lower_paths.push((lower, rep.clone()));
158 }
159
160 Self {
161 by_name,
162 by_path,
163 lower_paths,
164 component_to_paths,
165 }
166 }
167}
168
169pub fn file_representatives(fragments: &[Fragment]) -> FxHashMap<String, FragmentId> {
180 let mut file_to_rep: FxHashMap<String, FragmentId> = FxHashMap::default();
181 let mut file_to_token_count: FxHashMap<String, u32> = FxHashMap::default();
182
183 for f in fragments {
184 let path = f.path().to_string();
185 let existing_count = file_to_token_count.get(&path).copied().unwrap_or(0);
186 if !file_to_rep.contains_key(&path) || f.token_count > existing_count {
187 file_to_rep.insert(path.clone(), f.id.clone());
188 file_to_token_count.insert(path, f.token_count);
189 }
190 }
191
192 file_to_rep
193}
194
195pub fn add_edge(
196 edges: &mut EdgeDict,
197 src: &FragmentId,
198 dst: &FragmentId,
199 weight: f64,
200 reverse_factor: f64,
201) {
202 let key_fwd = (src.clone(), dst.clone());
203 let existing_fwd = edges.get(&key_fwd).copied().unwrap_or(0.0);
204 if weight > existing_fwd {
205 edges.insert(key_fwd, weight);
206 }
207 let rev_w = weight * reverse_factor;
208 let key_rev = (dst.clone(), src.clone());
209 let existing_rev = edges.get(&key_rev).copied().unwrap_or(0.0);
210 if rev_w > existing_rev {
211 edges.insert(key_rev, rev_w);
212 }
213}
214
215pub fn add_edge_unidirectional(
216 edges: &mut EdgeDict,
217 src: &FragmentId,
218 dst: &FragmentId,
219 weight: f64,
220) {
221 let key = (src.clone(), dst.clone());
222 let existing = edges.get(&key).copied().unwrap_or(0.0);
223 if weight > existing {
224 edges.insert(key, weight);
225 }
226}
227
228pub fn add_edges_from_ids(
229 edges: &mut EdgeDict,
230 src: &FragmentId,
231 targets: &[FragmentId],
232 weight: f64,
233 reverse_factor: f64,
234) {
235 for target in targets {
236 if target != src {
237 add_edge(edges, src, target, weight, reverse_factor);
238 }
239 }
240}
241
242pub fn link_by_name(
243 src_id: &FragmentId,
244 name: &str,
245 idx: &FragmentIndex,
246 edges: &mut EdgeDict,
247 weight: f64,
248 reverse_factor: f64,
249) {
250 let target = name.split('/').next_back().unwrap_or(name).to_lowercase();
251 if let Some(frag_ids) = idx.by_name.get(&target) {
252 for fid in frag_ids {
253 if fid != src_id {
254 add_edge(edges, src_id, fid, weight, reverse_factor);
255 return;
256 }
257 }
258 }
259 link_by_path_match(src_id, name, idx, edges, weight, reverse_factor);
260}
261
262pub fn link_by_path_match(
272 src_id: &FragmentId,
273 ref_str: &str,
274 idx: &FragmentIndex,
275 edges: &mut EdgeDict,
276 weight: f64,
277 reverse_factor: f64,
278) {
279 let ref_norm = ref_str.trim_matches('/');
280 if ref_norm.is_empty() {
281 return;
282 }
283 let ref_lower = ref_norm.to_lowercase();
290 let Some(last) = ref_lower.split('/').next_back() else {
291 return;
292 };
293 let Some(posting) = idx.component_to_paths.get(last) else {
294 return;
295 };
296 let needle = format!("/{ref_lower}/");
297 let matched: Vec<&FragmentId> = posting
298 .iter()
299 .filter_map(|&pi| {
300 let (path_lower, rep) = &idx.lower_paths[pi as usize];
301 component_aligned(path_lower, &ref_lower, &needle).then_some(rep)
302 })
303 .collect();
304 if matched.len() > MAX_FILES_PER_PATH_REF {
310 return;
311 }
312 for rep in matched {
313 if rep != src_id {
314 add_edge(edges, src_id, rep, weight, reverse_factor);
315 }
316 }
317}
318
319const MAX_FILES_PER_PATH_REF: usize = 8;
322
323fn component_aligned(path: &str, reference: &str, interior_needle: &str) -> bool {
324 if path == reference {
325 return true;
326 }
327 if let Some(rest) = path.strip_suffix(reference) {
328 if rest.ends_with('/') {
329 return true;
330 }
331 }
332 if let Some(rest) = path.strip_prefix(reference) {
333 if rest.starts_with('/') {
334 return true;
335 }
336 }
337 path.contains(interior_needle)
341}
342
343pub fn read_file_cached<'a>(
344 path: &Path,
345 cache: Option<&'a FxHashMap<PathBuf, String>>,
346) -> Option<String> {
347 if let Some(c) = cache {
348 if let Some(content) = c.get(path) {
349 return Some(content.clone());
350 }
351 }
352 std::fs::read_to_string(path).ok()
353}
354
355fn candidate_rel_path(candidate: &Path, repo_root: Option<&Path>) -> String {
356 if let Some(root) = repo_root {
357 if let Ok(rel) = candidate.strip_prefix(root) {
358 return rel.to_string_lossy().to_lowercase();
359 }
360 }
361 candidate
362 .file_name()
363 .map(|n| n.to_string_lossy().to_lowercase())
364 .unwrap_or_default()
365}
366
367fn matches_any_ref(candidate_name: &str, candidate_rel: &str, refs: &FxHashSet<String>) -> bool {
368 for r in refs {
369 let ref_name = r.split('/').next_back().unwrap_or(r).to_lowercase();
370 if candidate_name == ref_name {
371 return true;
372 }
373 let ref_lower = r.to_lowercase();
374 if ref_lower.len() >= SEMANTIC_DISCOVERY.min_ref_length_for_path_match {
375 if let Some(idx) = candidate_rel.find(&ref_lower) {
376 let end_idx = idx + ref_lower.len();
377 let start_ok = idx == 0
378 || candidate_rel.as_bytes().get(idx - 1) == Some(&b'/')
379 || candidate_rel.as_bytes().get(idx - 1) == Some(&b'\\');
380 let end_ok = end_idx == candidate_rel.len()
381 || matches!(
382 candidate_rel.as_bytes().get(end_idx),
383 Some(b'/') | Some(b'\\') | Some(b'.')
384 );
385 if start_ok && end_ok {
386 return true;
387 }
388 }
389 }
390 }
391 false
392}
393
394pub fn discover_files_by_refs(
395 refs: &FxHashSet<String>,
396 changed_files: &[PathBuf],
397 all_candidates: &[PathBuf],
398 repo_root: Option<&Path>,
399) -> Vec<PathBuf> {
400 if refs.is_empty() {
401 return vec![];
402 }
403 let changed_set: FxHashSet<&PathBuf> = changed_files.iter().collect();
404 let mut discovered = Vec::new();
405 for candidate in all_candidates {
406 if changed_set.contains(candidate) {
407 continue;
408 }
409 let candidate_name = candidate
410 .file_name()
411 .map(|n| n.to_string_lossy().to_lowercase())
412 .unwrap_or_default();
413 let candidate_rel = candidate_rel_path(candidate, repo_root);
414 if matches_any_ref(&candidate_name, &candidate_rel, refs) {
415 discovered.push(candidate.clone());
416 }
417 }
418 discovered
419}
420
421pub fn file_ext(path: &Path) -> String {
422 path.extension()
423 .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
424 .unwrap_or_default()
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::types::FragmentKind;
431
432 fn frag(path: &str) -> Fragment {
433 Fragment {
434 id: FragmentId::new(std::sync::Arc::from(path), 1, 10),
435 kind: FragmentKind::Function,
436 content: std::sync::Arc::from("fn x() {}"),
437 identifiers: FxHashSet::default(),
438 token_count: 10,
439 symbol_name: None,
440 }
441 }
442
443 fn linked_paths(reference: &str, paths: &[&str]) -> Vec<String> {
444 let frags: Vec<Fragment> = paths.iter().map(|p| frag(p)).collect();
445 let idx = FragmentIndex::new(&frags, None);
446 let src = frag("src/origin.yml");
447 let mut edges: EdgeDict = FxHashMap::default();
448 link_by_path_match(&src.id, reference, &idx, &mut edges, 0.5, 0.5);
449 let mut out: Vec<String> = edges
452 .keys()
453 .map(|(_, dst)| dst.path.to_string())
454 .filter(|p| p != "src/origin.yml")
455 .collect();
456 out.sort();
457 out.dedup();
458 out
459 }
460
461 #[test]
462 fn a_reference_matches_only_whole_path_components() {
463 let paths = [
464 "roles/config/tasks/main.yml",
465 "src/preconfigured/app.rs",
466 "src/config.rs",
467 "deep/nested/config",
468 ];
469 let hit = linked_paths("config", &paths);
470 assert!(
471 hit.contains(&"roles/config/tasks/main.yml".to_string()),
472 "interior whole component must match"
473 );
474 assert!(
475 hit.contains(&"deep/nested/config".to_string()),
476 "suffix component must match"
477 );
478 assert!(
479 !hit.contains(&"src/preconfigured/app.rs".to_string()),
480 "substring inside a component must NOT match — that fan-out is the envoy hang"
481 );
482 assert!(
483 !hit.contains(&"src/config.rs".to_string()),
484 "`config` does not name `config.rs`; a file reference carries its extension"
485 );
486 }
487
488 #[test]
489 fn a_multi_component_reference_still_matches_its_file() {
490 let paths = ["source/common/buffer/buffer_impl.h", "other/buffer_impl.h"];
491 let hit = linked_paths("common/buffer/buffer_impl.h", &paths);
492 assert_eq!(hit, vec!["source/common/buffer/buffer_impl.h".to_string()]);
493 }
494
495 #[test]
496 fn a_package_prefix_reference_matches_files_under_it() {
497 let paths = ["pkg/api/server.go", "pkg/api2/other.go"];
498 let hit = linked_paths("pkg/api", &paths);
499 assert_eq!(hit, vec!["pkg/api/server.go".to_string()]);
500 }
501}