Skip to main content

grapha_core/
merge.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::extract::ExtractionResult;
4use crate::graph::{EdgeKind, Graph, NodeKind};
5
6struct NameEntry {
7    id: String,
8    module: Option<String>,
9    file: String,
10}
11
12struct ResolveContext<'a> {
13    raw_target: &'a str,
14    source_module: Option<&'a str>,
15    source_imports: Option<&'a HashSet<String>>,
16    prefix_hint: Option<&'a str>,
17    source_owner_names: &'a [String],
18    candidate_to_owner_names: &'a HashMap<String, Vec<String>>,
19    candidate_file_stems: &'a HashMap<String, String>,
20}
21
22fn looks_like_file_path(segment: &str) -> bool {
23    segment.contains('/') || segment.ends_with(".rs") || segment.ends_with(".swift")
24}
25
26fn target_segments(target: &str) -> Vec<&str> {
27    let mut colon_parts: Vec<_> = target.split("::").collect();
28    if !colon_parts.is_empty() && looks_like_file_path(colon_parts[0]) {
29        colon_parts.remove(0);
30    }
31
32    if colon_parts.len() > 1 {
33        return colon_parts;
34    }
35
36    let mut segments = Vec::new();
37    if let Some(single_part) = colon_parts.into_iter().next() {
38        segments.extend(single_part.split('.').filter(|segment| !segment.is_empty()));
39    }
40    if segments.is_empty() {
41        segments.push(target);
42    }
43    segments
44}
45
46fn target_symbol_name(target: &str) -> &str {
47    let segments = target_segments(target);
48    segments.last().copied().unwrap_or(target)
49}
50
51fn target_prefix_hint(target: &str) -> Option<String> {
52    let segments = target_segments(target);
53    if segments.len() < 2 {
54        return None;
55    }
56
57    let hint = segments[segments.len() - 2];
58    if matches!(hint, "crate" | "super") {
59        None
60    } else {
61        Some(hint.to_string())
62    }
63}
64
65fn should_enforce_hint(target: &str, hint: &str) -> bool {
66    hint.eq_ignore_ascii_case("self")
67        || target.contains('.')
68        || hint.chars().any(|ch| ch.is_ascii_uppercase())
69}
70
71pub fn merge(results: Vec<ExtractionResult>) -> Graph {
72    let mut graph = Graph::new();
73
74    let mut file_imports: HashMap<String, HashSet<String>> = HashMap::new();
75    for result in &results {
76        for import in &result.imports {
77            if let Some(first_node) = result.nodes.first() {
78                let file_key = first_node.file.to_string_lossy().to_string();
79                let module_name = import
80                    .path
81                    .strip_prefix("import ")
82                    .unwrap_or(&import.path)
83                    .to_string();
84                file_imports
85                    .entry(file_key)
86                    .or_default()
87                    .insert(module_name);
88            }
89        }
90    }
91
92    for result in &results {
93        graph.nodes.extend(result.nodes.iter().cloned());
94    }
95
96    let node_ids: HashSet<&str> = graph.nodes.iter().map(|node| node.id.as_str()).collect();
97
98    let mut name_to_entries: HashMap<&str, Vec<NameEntry>> = HashMap::new();
99    for node in &graph.nodes {
100        if matches!(node.kind, NodeKind::View | NodeKind::Branch) {
101            continue;
102        }
103        name_to_entries
104            .entry(node.name.as_str())
105            .or_default()
106            .push(NameEntry {
107                id: node.id.clone(),
108                module: node.module.clone(),
109                file: node.file.to_string_lossy().to_string(),
110            });
111    }
112
113    let id_to_info: HashMap<&str, (Option<&str>, &str)> = graph
114        .nodes
115        .iter()
116        .map(|node| {
117            (
118                node.id.as_str(),
119                (node.module.as_deref(), node.file.to_str().unwrap_or("")),
120            )
121        })
122        .collect();
123
124    let id_to_name: HashMap<&str, &str> = graph
125        .nodes
126        .iter()
127        .map(|node| (node.id.as_str(), node.name.as_str()))
128        .collect();
129    let candidate_file_stems: HashMap<String, String> = graph
130        .nodes
131        .iter()
132        .filter_map(|node| {
133            node.file
134                .file_stem()
135                .and_then(|stem| stem.to_str())
136                .map(|stem| (node.id.clone(), stem.to_ascii_lowercase()))
137        })
138        .collect();
139    let mut candidate_to_owner_names: HashMap<String, Vec<String>> = HashMap::new();
140    for result in &results {
141        for edge in &result.edges {
142            if edge.kind == EdgeKind::Contains
143                && let Some(parent_name) = id_to_name.get(edge.source.as_str())
144            {
145                candidate_to_owner_names
146                    .entry(edge.target.clone())
147                    .or_default()
148                    .push(parent_name.to_string());
149            } else if edge.kind == EdgeKind::Implements
150                && let Some(owner_name) = id_to_name.get(edge.target.as_str())
151            {
152                candidate_to_owner_names
153                    .entry(edge.source.clone())
154                    .or_default()
155                    .push(owner_name.to_string());
156            }
157        }
158    }
159
160    let all_edges: Vec<_> = results
161        .into_iter()
162        .flat_map(|result| result.edges)
163        .collect();
164
165    // Build child → parent type mapping for scoping Reads edges.
166    // If source X is contained by type T, reads from X should prefer
167    // targets that are also contained by T (siblings in the same type).
168    let mut child_to_parents: HashMap<String, Vec<String>> = HashMap::new();
169    for edge in &all_edges {
170        if edge.kind == EdgeKind::Contains
171            && node_ids.contains(edge.target.as_str())
172            && node_ids.contains(edge.source.as_str())
173        {
174            child_to_parents
175                .entry(edge.target.clone())
176                .or_default()
177                .push(edge.source.clone());
178        }
179    }
180
181    for mut edge in all_edges {
182        let is_external_usr_call = edge.target.starts_with("s:")
183            && edge.kind == EdgeKind::Calls
184            && !node_ids.contains(edge.target.as_str());
185
186        if node_ids.contains(edge.target.as_str())
187            || edge.kind == EdgeKind::Uses
188            || edge.kind == EdgeKind::Implements
189            || (edge.kind == EdgeKind::Calls
190                && (edge.direction.is_some() || edge.operation.is_some()))
191            || is_external_usr_call
192        {
193            graph.edges.push(edge);
194            continue;
195        }
196
197        let target_name = target_symbol_name(&edge.target);
198        let Some(candidates) = name_to_entries.get(target_name) else {
199            continue;
200        };
201        if candidates.is_empty() {
202            continue;
203        }
204
205        let (source_module, source_file) = id_to_info
206            .get(edge.source.as_str())
207            .copied()
208            .unwrap_or((None, ""));
209        let source_imports = file_imports.get(source_file);
210        let source_owner_names = candidate_to_owner_names
211            .get(&edge.source)
212            .cloned()
213            .unwrap_or_default();
214        let owned_hint = target_prefix_hint(&edge.target);
215        let prefix_hint = edge.operation.as_deref().or(owned_hint.as_deref());
216
217        if candidates.len() == 1 {
218            let candidate = &candidates[0];
219            let same_module = modules_match(source_module, candidate.module.as_deref());
220            if same_module {
221                if let Some(hint) = prefix_hint
222                    && !candidate_matches_hint(
223                        candidate,
224                        hint,
225                        &source_owner_names,
226                        &candidate_to_owner_names,
227                        &candidate_file_stems,
228                    )
229                    && should_enforce_hint(&edge.target, hint)
230                {
231                    continue;
232                }
233                edge.target = candidate.id.clone();
234                edge.confidence *= 0.9;
235                graph.edges.push(edge);
236            } else {
237                let imported = source_imports
238                    .and_then(|imports| {
239                        candidate
240                            .module
241                            .as_deref()
242                            .map(|module| imports.contains(module))
243                    })
244                    .unwrap_or(false);
245                if imported {
246                    edge.target = candidate.id.clone();
247                    edge.confidence *= 0.7;
248                    graph.edges.push(edge);
249                }
250            }
251            continue;
252        }
253
254        // For Reads edges: scope resolution to siblings of the same type.
255        // Without this, "viewModel" resolves to ALL viewModel properties in the module.
256        //
257        // Strategy: use USR prefix matching. If source is s:4Room0A4PageV4bodyQrvp,
258        // its type prefix is s:4Room0A4PageV. Prefer candidates whose ID shares
259        // this prefix (they're members of the same type). Falls back to Contains
260        // edge lookup, then same-file, then normal resolution.
261        if edge.kind == EdgeKind::Reads && candidates.len() > 1 {
262            // Try USR prefix: strip the member suffix to get the type prefix
263            let usr_prefix = if edge.source.starts_with("s:") {
264                usr_type_prefix(&edge.source)
265            } else {
266                None
267            };
268
269            if let Some(prefix) = usr_prefix {
270                let siblings: Vec<&NameEntry> = candidates
271                    .iter()
272                    .filter(|c| c.id.starts_with(&prefix))
273                    .collect();
274                if siblings.len() == 1 {
275                    edge.target = siblings[0].id.clone();
276                    edge.confidence *= 0.9;
277                    graph.edges.push(edge);
278                    continue;
279                }
280                if !siblings.is_empty() {
281                    // Multiple siblings with same prefix — pick same file
282                    let same_file: Vec<&&NameEntry> = siblings
283                        .iter()
284                        .filter(|c| {
285                            id_to_info
286                                .get(c.id.as_str())
287                                .is_some_and(|(_, f)| *f == source_file)
288                        })
289                        .collect();
290                    if same_file.len() == 1 {
291                        edge.target = same_file[0].id.clone();
292                        edge.confidence *= 0.9;
293                        graph.edges.push(edge);
294                        continue;
295                    }
296                }
297                // No siblings found with same USR prefix — this property
298                // is not a member of the source's type. Drop the read edge
299                // rather than resolving to unrelated types.
300                continue;
301            }
302
303            // Fallback: Contains-edge-based sibling matching
304            if let Some(source_owners) = child_to_parents.get(&edge.source) {
305                let sibling_candidates: Vec<&NameEntry> = candidates
306                    .iter()
307                    .filter(|c| {
308                        candidate_to_owner_names.get(&c.id).is_some_and(|owners| {
309                            owners.iter().any(|owner| {
310                                source_owners.iter().any(|so| {
311                                    id_to_name.get(so.as_str()).is_some_and(|n| *n == owner)
312                                })
313                            })
314                        })
315                    })
316                    .collect();
317                if sibling_candidates.len() == 1 {
318                    edge.target = sibling_candidates[0].id.clone();
319                    edge.confidence *= 0.9;
320                    graph.edges.push(edge);
321                    continue;
322                }
323            }
324        }
325
326        let resolve_context = ResolveContext {
327            raw_target: &edge.target,
328            source_module,
329            source_imports,
330            prefix_hint,
331            source_owner_names: &source_owner_names,
332            candidate_to_owner_names: &candidate_to_owner_names,
333            candidate_file_stems: &candidate_file_stems,
334        };
335        let resolved = resolve_candidates(candidates, &resolve_context);
336        for (candidate_id, factor) in resolved {
337            let mut resolved_edge = edge.clone();
338            resolved_edge.target = candidate_id;
339            resolved_edge.confidence *= factor;
340            graph.edges.push(resolved_edge);
341        }
342    }
343
344    graph
345}
346
347/// Extract the type prefix from a USR string.
348/// e.g., "s:4Room0A4PageV4bodyQrvp" → "s:4Room0A4PageV"
349/// USR structure: s:<module><type>V<member> where V marks the type boundary.
350fn usr_type_prefix(usr: &str) -> Option<String> {
351    // Find the last 'V' that's followed by lowercase (member name start)
352    // Swift USRs use V to end type names: s:4Room0A4PageV4bodyQrvp
353    //                                                    ^ type ends here
354    let bytes = usr.as_bytes();
355    let mut last_v_pos = None;
356    for i in (2..bytes.len()).rev() {
357        if bytes[i] == b'V'
358            && i + 1 < bytes.len()
359            && (bytes[i + 1].is_ascii_digit() || bytes[i + 1].is_ascii_lowercase())
360        {
361            last_v_pos = Some(i + 1);
362            break;
363        }
364    }
365    last_v_pos.map(|pos| usr[..pos].to_string())
366}
367
368fn candidate_matches_hint(
369    candidate: &NameEntry,
370    hint: &str,
371    source_owner_names: &[String],
372    candidate_to_owner_names: &HashMap<String, Vec<String>>,
373    candidate_file_stems: &HashMap<String, String>,
374) -> bool {
375    let normalized_hint = hint.to_ascii_lowercase();
376
377    if normalized_hint == "self" {
378        return source_owner_names.iter().any(|source_owner| {
379            candidate_to_owner_names
380                .get(&candidate.id)
381                .is_some_and(|owners| {
382                    owners
383                        .iter()
384                        .any(|owner| owner.eq_ignore_ascii_case(source_owner))
385                })
386        });
387    }
388
389    if candidate_to_owner_names
390        .get(&candidate.id)
391        .is_some_and(|owners| {
392            owners.iter().any(|owner| {
393                owner.eq_ignore_ascii_case(hint)
394                    || owner
395                        .to_ascii_lowercase()
396                        .starts_with(normalized_hint.as_str())
397            })
398        })
399    {
400        return true;
401    }
402
403    candidate_file_stems
404        .get(&candidate.id)
405        .is_some_and(|stem| stem == &normalized_hint)
406}
407
408fn resolve_candidates(
409    candidates: &[NameEntry],
410    context: &ResolveContext<'_>,
411) -> Vec<(String, f64)> {
412    let same_module: Vec<&NameEntry> = candidates
413        .iter()
414        .filter(|candidate| modules_match(context.source_module, candidate.module.as_deref()))
415        .collect();
416    if same_module.len() == 1 {
417        if let Some(hint) = context.prefix_hint
418            && !candidate_matches_hint(
419                same_module[0],
420                hint,
421                context.source_owner_names,
422                context.candidate_to_owner_names,
423                context.candidate_file_stems,
424            )
425            && should_enforce_hint(context.raw_target, hint)
426        {
427            return Vec::new();
428        }
429        return vec![(same_module[0].id.clone(), 0.9)];
430    }
431
432    if same_module.len() > 1 {
433        if let Some(hint) = context.prefix_hint {
434            let narrowed: Vec<&&NameEntry> = same_module
435                .iter()
436                .filter(|candidate| {
437                    candidate_matches_hint(
438                        candidate,
439                        hint,
440                        context.source_owner_names,
441                        context.candidate_to_owner_names,
442                        context.candidate_file_stems,
443                    )
444                })
445                .collect();
446            if narrowed.len() == 1 {
447                return vec![(narrowed[0].id.clone(), 0.85)];
448            }
449            if narrowed.is_empty() && should_enforce_hint(context.raw_target, hint) {
450                return Vec::new();
451            }
452        }
453
454        // Cap ambiguous resolution: if too many distinct files contain
455        // candidates after disambiguation, drop the edge entirely. A missing
456        // edge is better than N false positives (e.g., "horizontal", "top").
457        // Count unique files, not raw candidates, so a type with extensions
458        // in the same file isn't penalized.
459        let unique_files: HashSet<&str> = same_module.iter().map(|c| c.file.as_str()).collect();
460        if unique_files.len() > 3 {
461            return Vec::new();
462        }
463        return same_module
464            .iter()
465            .map(|candidate| (candidate.id.clone(), 0.4))
466            .collect();
467    }
468
469    if let Some(imports) = context.source_imports {
470        let imported: Vec<&NameEntry> = candidates
471            .iter()
472            .filter(|candidate| {
473                candidate
474                    .module
475                    .as_deref()
476                    .is_some_and(|module| imports.contains(module))
477            })
478            .collect();
479        if imported.len() == 1 {
480            return vec![(imported[0].id.clone(), 0.8)];
481        }
482        if imported.len() > 1 {
483            let unique_files: HashSet<&str> = imported.iter().map(|c| c.file.as_str()).collect();
484            if unique_files.len() > 3 {
485                return Vec::new();
486            }
487            return imported
488                .iter()
489                .map(|candidate| (candidate.id.clone(), 0.3))
490                .collect();
491        }
492    }
493
494    Vec::new()
495}
496
497fn modules_match(left: Option<&str>, right: Option<&str>) -> bool {
498    match (left, right) {
499        (Some(left), Some(right)) => left == right,
500        (None, None) => true,
501        _ => false,
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::graph::*;
509    use std::collections::HashMap;
510    use std::path::PathBuf;
511
512    fn make_node(id: &str, name: &str, kind: NodeKind) -> Node {
513        Node {
514            id: id.to_string(),
515            kind,
516            name: name.to_string(),
517            file: PathBuf::from("test.rs"),
518            span: Span {
519                start: [0, 0],
520                end: [0, 0],
521            },
522            visibility: Visibility::Public,
523            metadata: HashMap::new(),
524            role: None,
525            signature: None,
526            doc_comment: None,
527            module: None,
528            snippet: None,
529        }
530    }
531
532    #[test]
533    fn merges_nodes_from_multiple_results() {
534        let left = ExtractionResult {
535            nodes: vec![make_node("a::Foo", "Foo", NodeKind::Struct)],
536            edges: vec![],
537            imports: vec![],
538        };
539        let right = ExtractionResult {
540            nodes: vec![make_node("b::Bar", "Bar", NodeKind::Struct)],
541            edges: vec![],
542            imports: vec![],
543        };
544
545        let graph = merge(vec![left, right]);
546        assert_eq!(graph.nodes.len(), 2);
547    }
548
549    #[test]
550    fn drops_edges_with_unresolved_targets() {
551        let result = ExtractionResult {
552            nodes: vec![make_node("a::main", "main", NodeKind::Function)],
553            edges: vec![Edge {
554                source: "a::main".to_string(),
555                target: "nonexistent::foo".to_string(),
556                kind: EdgeKind::Calls,
557                confidence: 1.0,
558                direction: None,
559                operation: None,
560                condition: None,
561                async_boundary: None,
562                provenance: Vec::new(),
563            }],
564            imports: vec![],
565        };
566
567        let graph = merge(vec![result]);
568        assert_eq!(graph.edges.len(), 0);
569    }
570
571    #[test]
572    fn keeps_uses_edges_even_if_target_unresolved() {
573        let result = ExtractionResult {
574            nodes: vec![],
575            edges: vec![Edge {
576                source: "a.rs".to_string(),
577                target: "use std::collections::HashMap;".to_string(),
578                kind: EdgeKind::Uses,
579                confidence: 1.0,
580                direction: None,
581                operation: None,
582                condition: None,
583                async_boundary: None,
584                provenance: Vec::new(),
585            }],
586            imports: vec![],
587        };
588
589        let graph = merge(vec![result]);
590        assert_eq!(graph.edges.len(), 1);
591    }
592
593    #[test]
594    fn same_module_candidate_wins() {
595        let mut helper = make_node("mod_a::helper", "helper", NodeKind::Function);
596        helper.module = Some("mod_a".to_string());
597        let mut caller = make_node("mod_a::main", "main", NodeKind::Function);
598        caller.module = Some("mod_a".to_string());
599
600        let graph = merge(vec![
601            ExtractionResult {
602                nodes: vec![caller],
603                edges: vec![Edge {
604                    source: "mod_a::main".to_string(),
605                    target: "unknown::helper".to_string(),
606                    kind: EdgeKind::Calls,
607                    confidence: 1.0,
608                    direction: None,
609                    operation: None,
610                    condition: None,
611                    async_boundary: None,
612                    provenance: Vec::new(),
613                }],
614                imports: vec![],
615            },
616            ExtractionResult {
617                nodes: vec![helper],
618                edges: vec![],
619                imports: vec![],
620            },
621        ]);
622
623        let call_edge = graph
624            .edges
625            .iter()
626            .find(|edge| edge.kind == EdgeKind::Calls)
627            .unwrap();
628        assert_eq!(call_edge.target, "mod_a::helper");
629        assert!((call_edge.confidence - 0.9).abs() < 0.001);
630    }
631
632    #[test]
633    fn owner_hint_disambiguates_same_module_candidates() {
634        let mut room_page_ext = make_node("room::RoomPageExt", "RoomPage", NodeKind::Extension);
635        room_page_ext.module = Some("Room".to_string());
636        let mut kroom_page_ext = make_node("room::KRoomPageExt", "KRoomPage", NodeKind::Extension);
637        kroom_page_ext.module = Some("Room".to_string());
638
639        let mut room_helper = make_node(
640            "room::RoomPage::chatRoomFragViewPanel",
641            "chatRoomFragViewPanel",
642            NodeKind::Property,
643        );
644        room_helper.module = Some("Room".to_string());
645        let mut kroom_helper = make_node(
646            "room::KRoomPage::chatRoomFragViewPanel",
647            "chatRoomFragViewPanel",
648            NodeKind::Property,
649        );
650        kroom_helper.module = Some("Room".to_string());
651
652        let mut body_view = make_node(
653            "room::RoomPage::body::view:chatRoomFragViewPanel",
654            "chatRoomFragViewPanel",
655            NodeKind::View,
656        );
657        body_view.module = Some("Room".to_string());
658
659        let source = body_view.id.clone();
660        let graph = merge(vec![
661            ExtractionResult {
662                nodes: vec![body_view],
663                edges: vec![Edge {
664                    source: source.clone(),
665                    target: "room::RoomPage.swift::chatRoomFragViewPanel".to_string(),
666                    kind: EdgeKind::TypeRef,
667                    confidence: 1.0,
668                    direction: None,
669                    operation: Some("RoomPage".to_string()),
670                    condition: None,
671                    async_boundary: None,
672                    provenance: Vec::new(),
673                }],
674                imports: vec![],
675            },
676            ExtractionResult {
677                nodes: vec![room_page_ext, kroom_page_ext, room_helper, kroom_helper],
678                edges: vec![
679                    Edge {
680                        source: "room::RoomPage::chatRoomFragViewPanel".to_string(),
681                        target: "room::RoomPageExt".to_string(),
682                        kind: EdgeKind::Implements,
683                        confidence: 1.0,
684                        direction: None,
685                        operation: None,
686                        condition: None,
687                        async_boundary: None,
688                        provenance: Vec::new(),
689                    },
690                    Edge {
691                        source: "room::KRoomPage::chatRoomFragViewPanel".to_string(),
692                        target: "room::KRoomPageExt".to_string(),
693                        kind: EdgeKind::Implements,
694                        confidence: 1.0,
695                        direction: None,
696                        operation: None,
697                        condition: None,
698                        async_boundary: None,
699                        provenance: Vec::new(),
700                    },
701                ],
702                imports: vec![],
703            },
704        ]);
705
706        let type_refs: Vec<_> = graph
707            .edges
708            .iter()
709            .filter(|edge| edge.source == source && edge.kind == EdgeKind::TypeRef)
710            .collect();
711
712        assert_eq!(type_refs.len(), 1);
713        assert_eq!(type_refs[0].target, "room::RoomPage::chatRoomFragViewPanel");
714        assert!((type_refs[0].confidence - 0.85).abs() < 0.001);
715    }
716
717    #[test]
718    fn qualified_call_hint_drops_false_local_resolution() {
719        let caller = make_node("sqlite.rs::open", "open", NodeKind::Function);
720        let callee = make_node("sqlite.rs::helper", "open", NodeKind::Function);
721
722        let graph = merge(vec![ExtractionResult {
723            nodes: vec![caller, callee],
724            edges: vec![Edge {
725                source: "sqlite.rs::open".to_string(),
726                target: "Connection::open".to_string(),
727                kind: EdgeKind::Calls,
728                confidence: 1.0,
729                direction: None,
730                operation: None,
731                condition: None,
732                async_boundary: None,
733                provenance: Vec::new(),
734            }],
735            imports: vec![],
736        }]);
737
738        assert!(graph.edges.is_empty());
739    }
740
741    #[test]
742    fn module_hint_resolves_call_by_file_stem() {
743        let caller = Node {
744            file: PathBuf::from("lib.rs"),
745            ..make_node("lib.rs::run", "run", NodeKind::Function)
746        };
747        let callee = Node {
748            file: PathBuf::from("utils.rs"),
749            ..make_node("utils.rs::helper", "helper", NodeKind::Function)
750        };
751
752        let graph = merge(vec![ExtractionResult {
753            nodes: vec![caller, callee],
754            edges: vec![Edge {
755                source: "lib.rs::run".to_string(),
756                target: "utils::helper".to_string(),
757                kind: EdgeKind::Calls,
758                confidence: 1.0,
759                direction: None,
760                operation: None,
761                condition: None,
762                async_boundary: None,
763                provenance: Vec::new(),
764            }],
765            imports: vec![],
766        }]);
767
768        assert_eq!(graph.edges.len(), 1);
769        assert_eq!(graph.edges[0].target, "utils.rs::helper");
770    }
771
772    #[test]
773    fn self_field_read_resolves_to_matching_field() {
774        let struct_node = make_node("sqlite.rs::SqliteStore", "SqliteStore", NodeKind::Struct);
775        let field_node = make_node("sqlite.rs::SqliteStore.path", "path", NodeKind::Field);
776        let impl_node = make_node("sqlite.rs::impl_SqliteStore", "SqliteStore", NodeKind::Impl);
777        let fn_node = make_node(
778            "sqlite.rs::impl_SqliteStore::open",
779            "open",
780            NodeKind::Function,
781        );
782
783        let graph = merge(vec![ExtractionResult {
784            nodes: vec![struct_node, field_node, impl_node, fn_node],
785            edges: vec![
786                Edge {
787                    source: "sqlite.rs::SqliteStore".to_string(),
788                    target: "sqlite.rs::SqliteStore.path".to_string(),
789                    kind: EdgeKind::Contains,
790                    confidence: 1.0,
791                    direction: None,
792                    operation: None,
793                    condition: None,
794                    async_boundary: None,
795                    provenance: Vec::new(),
796                },
797                Edge {
798                    source: "sqlite.rs::impl_SqliteStore".to_string(),
799                    target: "sqlite.rs::impl_SqliteStore::open".to_string(),
800                    kind: EdgeKind::Contains,
801                    confidence: 1.0,
802                    direction: None,
803                    operation: None,
804                    condition: None,
805                    async_boundary: None,
806                    provenance: Vec::new(),
807                },
808                Edge {
809                    source: "sqlite.rs::impl_SqliteStore::open".to_string(),
810                    target: "self.path".to_string(),
811                    kind: EdgeKind::Reads,
812                    confidence: 0.8,
813                    direction: None,
814                    operation: None,
815                    condition: None,
816                    async_boundary: None,
817                    provenance: Vec::new(),
818                },
819            ],
820            imports: vec![],
821        }]);
822
823        let read_edge = graph
824            .edges
825            .iter()
826            .find(|edge| edge.kind == EdgeKind::Reads)
827            .unwrap();
828        assert_eq!(read_edge.target, "sqlite.rs::SqliteStore.path");
829    }
830}