1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::extract::ExtractionResult;
7use crate::graph::{
8 Edge, EdgeKind, EdgeProvenance, FlowDirection, Node, NodeKind, NodeRole, Span, TerminalKind,
9 Visibility,
10};
11use crate::resolve::Import;
12
13const META_L10N_REF_KIND: &str = "l10n.ref_kind";
14const META_L10N_WRAPPER_NAME: &str = "l10n.wrapper_name";
15const META_L10N_WRAPPER_BASE: &str = "l10n.wrapper_base";
16const META_L10N_WRAPPER_SYMBOL: &str = "l10n.wrapper_symbol";
17const META_L10N_TABLE: &str = "l10n.table";
18const META_L10N_KEY: &str = "l10n.key";
19const META_L10N_FALLBACK: &str = "l10n.fallback";
20const META_L10N_ARG_COUNT: &str = "l10n.arg_count";
21const META_L10N_LITERAL: &str = "l10n.literal";
22const META_L10N_ARGUMENT_LABEL: &str = "l10n.argument_label";
23const META_L10N_WRAPPER_TABLE: &str = "l10n.wrapper.table";
24const META_L10N_WRAPPER_KEY: &str = "l10n.wrapper.key";
25const META_L10N_WRAPPER_FALLBACK: &str = "l10n.wrapper.fallback";
26const META_L10N_WRAPPER_ARG_COUNT: &str = "l10n.wrapper.arg_count";
27const META_ASSET_REF_KIND: &str = "asset.ref_kind";
28const META_ASSET_NAME: &str = "asset.name";
29const META_SWIFTUI_INVALIDATION_SOURCE: &str = "swiftui.invalidation_source";
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct SemanticDocument {
33 pub symbols: Vec<SemanticSymbol>,
34 pub relations: Vec<SemanticRelation>,
35 pub artifacts: Vec<SemanticArtifact>,
36 pub imports: Vec<Import>,
37}
38
39impl Default for SemanticDocument {
40 fn default() -> Self {
41 Self::new()
42 }
43}
44
45impl SemanticDocument {
46 pub fn new() -> Self {
47 Self {
48 symbols: Vec::new(),
49 relations: Vec::new(),
50 artifacts: Vec::new(),
51 imports: Vec::new(),
52 }
53 }
54
55 pub fn from_extraction_result(result: ExtractionResult) -> Self {
56 let mut document = Self::new();
57 document.imports = result.imports;
58 let symbol_ids: HashSet<String> = result.nodes.iter().map(|node| node.id.clone()).collect();
59
60 for node in result.nodes {
61 let (symbol, mut artifacts) = SemanticSymbol::from_node(node);
62 document.symbols.push(symbol);
63 document.artifacts.append(&mut artifacts);
64 }
65
66 for edge in result.edges {
67 document
68 .relations
69 .push(SemanticRelation::from_edge(edge, &symbol_ids));
70 }
71
72 document
73 }
74
75 pub fn into_extraction_result(self) -> ExtractionResult {
76 let mut relation_terminal_roles: HashMap<String, TerminalKind> = HashMap::new();
77 let symbol_ids: HashSet<&str> = self
78 .symbols
79 .iter()
80 .map(|symbol| symbol.id.as_str())
81 .collect();
82
83 for relation in &self.relations {
84 let Some(kind) = relation.terminal_kind else {
85 continue;
86 };
87
88 let terminal_symbol_id = match &relation.target {
89 SemanticTarget::Symbol(symbol_id) if symbol_ids.contains(symbol_id.as_str()) => {
90 symbol_id.clone()
91 }
92 _ => relation.source.clone(),
93 };
94 relation_terminal_roles
95 .entry(terminal_symbol_id)
96 .or_insert(kind);
97 }
98
99 let mut artifact_metadata: HashMap<&str, HashMap<String, String>> = HashMap::new();
100 for artifact in &self.artifacts {
101 let metadata = artifact_metadata.entry(artifact.symbol_id()).or_default();
102 artifact.write_metadata(metadata);
103 }
104
105 let nodes = self
106 .symbols
107 .into_iter()
108 .map(|symbol| {
109 let mut node = symbol.into_node();
110
111 if let Some(metadata) = artifact_metadata.remove(node.id.as_str()) {
112 node.metadata.extend(metadata);
113 }
114
115 if node.role.is_none()
116 && let Some(kind) = relation_terminal_roles.get(node.id.as_str())
117 {
118 node.role = Some(NodeRole::Terminal { kind: *kind });
119 }
120
121 node
122 })
123 .collect();
124
125 let edges = self
126 .relations
127 .into_iter()
128 .map(SemanticRelation::into_edge)
129 .collect();
130
131 ExtractionResult {
132 nodes,
133 edges,
134 imports: self.imports,
135 }
136 }
137
138 pub fn annotate_call_relations<F>(&mut self, mut classify: F)
139 where
140 F: FnMut(&SemanticRelation, Option<&SemanticSymbol>) -> Option<TerminalEffect>,
141 {
142 self.apply_call_relation_effects(&mut classify, false);
143 }
144
145 pub fn override_call_relations<F>(&mut self, mut classify: F)
146 where
147 F: FnMut(&SemanticRelation, Option<&SemanticSymbol>) -> Option<TerminalEffect>,
148 {
149 self.apply_call_relation_effects(&mut classify, true);
150 }
151
152 fn apply_call_relation_effects<F>(&mut self, classify: &mut F, overwrite_existing: bool)
153 where
154 F: FnMut(&SemanticRelation, Option<&SemanticSymbol>) -> Option<TerminalEffect>,
155 {
156 let symbols_by_id: HashMap<&str, &SemanticSymbol> = self
157 .symbols
158 .iter()
159 .map(|symbol| (symbol.id.as_str(), symbol))
160 .collect();
161
162 for relation in &mut self.relations {
163 if relation.kind != EdgeKind::Calls {
164 continue;
165 }
166
167 if !overwrite_existing
168 && (relation.direction.is_some()
169 || relation.operation.is_some()
170 || relation.terminal_kind.is_some())
171 {
172 continue;
173 }
174
175 let Some(effect) = classify(
176 relation,
177 symbols_by_id.get(relation.source.as_str()).copied(),
178 ) else {
179 continue;
180 };
181
182 relation.terminal_kind = Some(effect.terminal_kind);
183 relation.direction = Some(effect.direction);
184 relation.operation = Some(effect.operation);
185 }
186 }
187
188 pub fn stamp_module(mut self, module_name: Option<&str>) -> Self {
189 let Some(module_name) = module_name else {
190 return self;
191 };
192
193 for symbol in &mut self.symbols {
194 symbol.module.get_or_insert_with(|| module_name.to_string());
195 }
196
197 self
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct SemanticSymbol {
203 pub id: String,
204 pub kind: NodeKind,
205 pub name: String,
206 pub file: PathBuf,
207 pub span: Span,
208 pub visibility: Visibility,
209 pub properties: HashMap<String, String>,
210 pub annotations: Vec<SemanticAnnotation>,
211 #[serde(skip_serializing_if = "Option::is_none")]
212 pub signature: Option<String>,
213 #[serde(skip_serializing_if = "Option::is_none")]
214 pub doc_comment: Option<String>,
215 #[serde(skip_serializing_if = "Option::is_none")]
216 pub module: Option<String>,
217 #[serde(skip_serializing_if = "Option::is_none")]
218 pub snippet: Option<String>,
219 #[serde(skip_serializing_if = "Option::is_none")]
220 pub synthetic_kind: Option<String>,
221}
222
223impl SemanticSymbol {
224 fn from_node(mut node: Node) -> (Self, Vec<SemanticArtifact>) {
225 let mut annotations = Vec::new();
226
227 if let Some(role) = node.role.take() {
228 match role {
229 NodeRole::EntryPoint => annotations.push(SemanticAnnotation::EntryPoint),
230 NodeRole::Terminal { kind } => {
231 annotations.push(SemanticAnnotation::Terminal { kind });
232 }
233 NodeRole::Internal => annotations.push(SemanticAnnotation::Internal),
234 }
235 }
236
237 if let Some(value) = node.metadata.remove(META_SWIFTUI_INVALIDATION_SOURCE) {
238 annotations.push(SemanticAnnotation::Flag {
239 key: META_SWIFTUI_INVALIDATION_SOURCE.to_string(),
240 value,
241 });
242 }
243
244 let artifacts = extract_symbol_artifacts(&mut node.metadata, node.id.clone());
245 let synthetic_kind = match node.kind {
246 NodeKind::View => Some("swiftui_view".to_string()),
247 NodeKind::Branch => Some("swiftui_branch".to_string()),
248 _ => None,
249 };
250
251 let symbol = Self {
252 id: node.id,
253 kind: node.kind,
254 name: node.name,
255 file: node.file,
256 span: node.span,
257 visibility: node.visibility,
258 properties: node.metadata,
259 annotations,
260 signature: node.signature,
261 doc_comment: node.doc_comment,
262 module: node.module,
263 snippet: node.snippet,
264 synthetic_kind,
265 };
266
267 (symbol, artifacts)
268 }
269
270 fn into_node(self) -> Node {
271 let mut role = None;
272 let mut metadata = self.properties;
273
274 for annotation in &self.annotations {
275 match annotation {
276 SemanticAnnotation::EntryPoint if role.is_none() => {
277 role = Some(NodeRole::EntryPoint);
278 }
279 SemanticAnnotation::Terminal { kind } if role.is_none() => {
280 role = Some(NodeRole::Terminal { kind: *kind });
281 }
282 SemanticAnnotation::Internal if role.is_none() => {
283 role = Some(NodeRole::Internal);
284 }
285 SemanticAnnotation::Flag { key, value } => {
286 metadata.insert(key.clone(), value.clone());
287 }
288 _ => {}
289 }
290 }
291
292 Node {
293 id: self.id,
294 kind: self.kind,
295 name: self.name,
296 file: self.file,
297 span: self.span,
298 visibility: self.visibility,
299 metadata,
300 role,
301 signature: self.signature,
302 doc_comment: self.doc_comment,
303 module: self.module,
304 snippet: self.snippet,
305 }
306 }
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311pub enum ArtifactKind {
312 LocalizationRef,
313 LocalizationWrapperBinding,
314 AssetRef,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "snake_case", tag = "kind")]
319pub enum SemanticArtifact {
320 LocalizationRef {
321 symbol_id: String,
322 ref_kind: String,
323 #[serde(skip_serializing_if = "Option::is_none")]
324 wrapper_name: Option<String>,
325 #[serde(skip_serializing_if = "Option::is_none")]
326 wrapper_base: Option<String>,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 wrapper_symbol: Option<String>,
329 #[serde(skip_serializing_if = "Option::is_none")]
330 table: Option<String>,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 key: Option<String>,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 fallback: Option<String>,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 arg_count: Option<usize>,
337 #[serde(skip_serializing_if = "Option::is_none")]
338 literal: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
340 argument_label: Option<String>,
341 },
342 LocalizationWrapperBinding {
343 symbol_id: String,
344 table: String,
345 key: String,
346 #[serde(skip_serializing_if = "Option::is_none")]
347 fallback: Option<String>,
348 #[serde(skip_serializing_if = "Option::is_none")]
349 arg_count: Option<usize>,
350 },
351 AssetRef {
352 symbol_id: String,
353 ref_kind: String,
354 name: String,
355 },
356}
357
358impl SemanticArtifact {
359 pub fn symbol_id(&self) -> &str {
360 match self {
361 SemanticArtifact::LocalizationRef { symbol_id, .. }
362 | SemanticArtifact::LocalizationWrapperBinding { symbol_id, .. }
363 | SemanticArtifact::AssetRef { symbol_id, .. } => symbol_id,
364 }
365 }
366
367 pub fn kind(&self) -> ArtifactKind {
368 match self {
369 SemanticArtifact::LocalizationRef { .. } => ArtifactKind::LocalizationRef,
370 SemanticArtifact::LocalizationWrapperBinding { .. } => {
371 ArtifactKind::LocalizationWrapperBinding
372 }
373 SemanticArtifact::AssetRef { .. } => ArtifactKind::AssetRef,
374 }
375 }
376
377 fn write_metadata(&self, metadata: &mut HashMap<String, String>) {
378 match self {
379 SemanticArtifact::LocalizationRef {
380 ref_kind,
381 wrapper_name,
382 wrapper_base,
383 wrapper_symbol,
384 table,
385 key,
386 fallback,
387 arg_count,
388 literal,
389 argument_label,
390 ..
391 } => {
392 metadata.insert(META_L10N_REF_KIND.to_string(), ref_kind.clone());
393 if let Some(value) = wrapper_name {
394 metadata.insert(META_L10N_WRAPPER_NAME.to_string(), value.clone());
395 }
396 if let Some(value) = wrapper_base {
397 metadata.insert(META_L10N_WRAPPER_BASE.to_string(), value.clone());
398 }
399 if let Some(value) = wrapper_symbol {
400 metadata.insert(META_L10N_WRAPPER_SYMBOL.to_string(), value.clone());
401 }
402 if let Some(value) = table {
403 metadata.insert(META_L10N_TABLE.to_string(), value.clone());
404 }
405 if let Some(value) = key {
406 metadata.insert(META_L10N_KEY.to_string(), value.clone());
407 }
408 if let Some(value) = fallback {
409 metadata.insert(META_L10N_FALLBACK.to_string(), value.clone());
410 }
411 if let Some(value) = arg_count {
412 metadata.insert(META_L10N_ARG_COUNT.to_string(), value.to_string());
413 }
414 if let Some(value) = literal {
415 metadata.insert(META_L10N_LITERAL.to_string(), value.clone());
416 }
417 if let Some(value) = argument_label {
418 metadata.insert(META_L10N_ARGUMENT_LABEL.to_string(), value.clone());
419 }
420 }
421 SemanticArtifact::LocalizationWrapperBinding {
422 table,
423 key,
424 fallback,
425 arg_count,
426 ..
427 } => {
428 metadata.insert(META_L10N_WRAPPER_TABLE.to_string(), table.clone());
429 metadata.insert(META_L10N_WRAPPER_KEY.to_string(), key.clone());
430 if let Some(value) = fallback {
431 metadata.insert(META_L10N_WRAPPER_FALLBACK.to_string(), value.clone());
432 }
433 if let Some(value) = arg_count {
434 metadata.insert(META_L10N_WRAPPER_ARG_COUNT.to_string(), value.to_string());
435 }
436 }
437 SemanticArtifact::AssetRef { ref_kind, name, .. } => {
438 metadata.insert(META_ASSET_REF_KIND.to_string(), ref_kind.clone());
439 metadata.insert(META_ASSET_NAME.to_string(), name.clone());
440 }
441 }
442 }
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(rename_all = "snake_case", tag = "type")]
447pub enum SemanticAnnotation {
448 EntryPoint,
449 Terminal { kind: TerminalKind },
450 Internal,
451 Flag { key: String, value: String },
452}
453
454#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
455pub struct SemanticRelation {
456 pub source: String,
457 pub target: SemanticTarget,
458 pub kind: EdgeKind,
459 pub confidence: f64,
460 #[serde(skip_serializing_if = "Option::is_none")]
461 pub direction: Option<FlowDirection>,
462 #[serde(skip_serializing_if = "Option::is_none")]
463 pub operation: Option<String>,
464 #[serde(skip_serializing_if = "Option::is_none")]
465 pub condition: Option<String>,
466 #[serde(skip_serializing_if = "Option::is_none")]
467 pub async_boundary: Option<bool>,
468 #[serde(default, skip_serializing_if = "Vec::is_empty")]
469 pub provenance: Vec<EdgeProvenance>,
470 #[serde(skip_serializing_if = "Option::is_none")]
471 pub terminal_kind: Option<TerminalKind>,
472}
473
474impl SemanticRelation {
475 fn from_edge(edge: Edge, symbol_ids: &HashSet<String>) -> Self {
476 let target = if symbol_ids.contains(&edge.target) {
477 SemanticTarget::Symbol(edge.target)
478 } else {
479 SemanticTarget::ExternalRef(edge.target)
480 };
481 Self {
482 source: edge.source,
483 target,
484 kind: edge.kind,
485 confidence: edge.confidence,
486 direction: edge.direction,
487 operation: edge.operation,
488 condition: edge.condition,
489 async_boundary: edge.async_boundary,
490 provenance: edge.provenance,
491 terminal_kind: None,
492 }
493 }
494
495 fn into_edge(self) -> Edge {
496 Edge {
497 source: self.source,
498 target: self.target.into_raw(),
499 kind: self.kind,
500 confidence: self.confidence,
501 direction: self.direction,
502 operation: self.operation,
503 condition: self.condition,
504 async_boundary: self.async_boundary,
505 provenance: self.provenance,
506 }
507 }
508}
509
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
511#[serde(rename_all = "snake_case", tag = "type", content = "value")]
512pub enum SemanticTarget {
513 Symbol(String),
514 ExternalRef(String),
515}
516
517impl SemanticTarget {
518 pub fn as_raw(&self) -> &str {
519 match self {
520 SemanticTarget::Symbol(value) | SemanticTarget::ExternalRef(value) => value,
521 }
522 }
523
524 fn into_raw(self) -> String {
525 match self {
526 SemanticTarget::Symbol(value) | SemanticTarget::ExternalRef(value) => value,
527 }
528 }
529}
530
531#[derive(Debug, Clone, PartialEq, Eq)]
532pub struct TerminalEffect {
533 pub terminal_kind: TerminalKind,
534 pub direction: FlowDirection,
535 pub operation: String,
536}
537
538fn extract_symbol_artifacts(
539 metadata: &mut HashMap<String, String>,
540 symbol_id: String,
541) -> Vec<SemanticArtifact> {
542 let mut artifacts = Vec::new();
543
544 let wrapper_binding = (
545 metadata.remove(META_L10N_WRAPPER_TABLE),
546 metadata.remove(META_L10N_WRAPPER_KEY),
547 );
548 if let (Some(table), Some(key)) = wrapper_binding {
549 let fallback = metadata.remove(META_L10N_WRAPPER_FALLBACK);
550 let arg_count = metadata
551 .remove(META_L10N_WRAPPER_ARG_COUNT)
552 .and_then(|value| value.parse::<usize>().ok());
553 artifacts.push(SemanticArtifact::LocalizationWrapperBinding {
554 symbol_id: symbol_id.clone(),
555 table,
556 key,
557 fallback,
558 arg_count,
559 });
560 }
561
562 let localization_ref = metadata.remove(META_L10N_REF_KIND);
563 if let Some(ref_kind) = localization_ref {
564 let table = metadata.remove(META_L10N_TABLE);
565 let key = metadata.remove(META_L10N_KEY);
566 let fallback = metadata.remove(META_L10N_FALLBACK);
567 let arg_count = metadata
568 .remove(META_L10N_ARG_COUNT)
569 .and_then(|value| value.parse::<usize>().ok());
570 let literal = metadata.remove(META_L10N_LITERAL);
571 let wrapper_name = metadata.remove(META_L10N_WRAPPER_NAME);
572 let wrapper_base = metadata.remove(META_L10N_WRAPPER_BASE);
573 let wrapper_symbol = metadata.remove(META_L10N_WRAPPER_SYMBOL);
574 let argument_label = metadata.remove(META_L10N_ARGUMENT_LABEL);
575
576 artifacts.push(SemanticArtifact::LocalizationRef {
577 symbol_id: symbol_id.clone(),
578 ref_kind,
579 wrapper_name,
580 wrapper_base,
581 wrapper_symbol,
582 table,
583 key,
584 fallback,
585 arg_count,
586 literal,
587 argument_label,
588 });
589 }
590
591 let asset_ref = (
592 metadata.remove(META_ASSET_REF_KIND),
593 metadata.remove(META_ASSET_NAME),
594 );
595 if let (Some(ref_kind), Some(name)) = asset_ref {
596 artifacts.push(SemanticArtifact::AssetRef {
597 symbol_id,
598 ref_kind,
599 name,
600 });
601 }
602
603 artifacts
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 fn test_span() -> Span {
611 Span {
612 start: [1, 0],
613 end: [2, 0],
614 }
615 }
616
617 #[test]
618 fn round_trips_known_metadata_into_typed_artifacts() {
619 let mut metadata = HashMap::new();
620 metadata.insert(META_L10N_REF_KIND.to_string(), "literal".to_string());
621 metadata.insert(META_L10N_LITERAL.to_string(), "Hello".to_string());
622 metadata.insert(META_ASSET_REF_KIND.to_string(), "image".to_string());
623 metadata.insert(META_ASSET_NAME.to_string(), "hero".to_string());
624 metadata.insert(
625 META_SWIFTUI_INVALIDATION_SOURCE.to_string(),
626 "true".to_string(),
627 );
628 metadata.insert("async".to_string(), "true".to_string());
629
630 let result = ExtractionResult {
631 nodes: vec![Node {
632 id: "body".to_string(),
633 kind: NodeKind::View,
634 name: "Text".to_string(),
635 file: PathBuf::from("ContentView.swift"),
636 span: test_span(),
637 visibility: Visibility::Public,
638 metadata,
639 role: Some(NodeRole::EntryPoint),
640 signature: Some("var body: some View".to_string()),
641 doc_comment: None,
642 module: Some("Demo".to_string()),
643 snippet: None,
644 }],
645 edges: Vec::new(),
646 imports: Vec::new(),
647 };
648
649 let document = SemanticDocument::from_extraction_result(result);
650 assert_eq!(document.symbols.len(), 1);
651 assert_eq!(document.artifacts.len(), 2);
652 assert!(
653 document.symbols[0]
654 .annotations
655 .contains(&SemanticAnnotation::EntryPoint)
656 );
657 assert!(
658 document.symbols[0]
659 .annotations
660 .contains(&SemanticAnnotation::Flag {
661 key: META_SWIFTUI_INVALIDATION_SOURCE.to_string(),
662 value: "true".to_string(),
663 })
664 );
665 assert_eq!(
666 document.symbols[0]
667 .properties
668 .get("async")
669 .map(String::as_str),
670 Some("true")
671 );
672
673 let lowered = document.into_extraction_result();
674 let node = &lowered.nodes[0];
675 assert_eq!(node.role, Some(NodeRole::EntryPoint));
676 assert_eq!(
677 node.metadata.get(META_L10N_REF_KIND).map(String::as_str),
678 Some("literal")
679 );
680 assert_eq!(
681 node.metadata.get(META_ASSET_NAME).map(String::as_str),
682 Some("hero")
683 );
684 assert_eq!(
685 node.metadata
686 .get(META_SWIFTUI_INVALIDATION_SOURCE)
687 .map(String::as_str),
688 Some("true")
689 );
690 assert_eq!(node.metadata.get("async").map(String::as_str), Some("true"));
691 }
692
693 #[test]
694 fn relation_terminal_effect_marks_source_node_when_target_is_external() {
695 let mut document = SemanticDocument::new();
696 document.symbols.push(SemanticSymbol {
697 id: "caller".to_string(),
698 kind: NodeKind::Function,
699 name: "load".to_string(),
700 file: PathBuf::from("main.rs"),
701 span: test_span(),
702 visibility: Visibility::Public,
703 properties: HashMap::new(),
704 annotations: Vec::new(),
705 signature: None,
706 doc_comment: None,
707 module: None,
708 snippet: None,
709 synthetic_kind: None,
710 });
711 document.relations.push(SemanticRelation {
712 source: "caller".to_string(),
713 target: SemanticTarget::ExternalRef("reqwest::get".to_string()),
714 kind: EdgeKind::Calls,
715 confidence: 1.0,
716 direction: Some(FlowDirection::Read),
717 operation: Some("HTTP".to_string()),
718 condition: None,
719 async_boundary: None,
720 provenance: Vec::new(),
721 terminal_kind: Some(TerminalKind::Network),
722 });
723
724 let lowered = document.into_extraction_result();
725 assert_eq!(
726 lowered.nodes[0].role,
727 Some(NodeRole::Terminal {
728 kind: TerminalKind::Network,
729 })
730 );
731 assert_eq!(lowered.edges[0].direction, Some(FlowDirection::Read));
732 }
733
734 #[test]
735 fn annotate_call_relations_uses_source_symbol_context() {
736 let mut document = SemanticDocument::new();
737 document.symbols.push(SemanticSymbol {
738 id: "caller".to_string(),
739 kind: NodeKind::Function,
740 name: "load".to_string(),
741 file: PathBuf::from("main.rs"),
742 span: test_span(),
743 visibility: Visibility::Public,
744 properties: HashMap::new(),
745 annotations: Vec::new(),
746 signature: None,
747 doc_comment: None,
748 module: None,
749 snippet: None,
750 synthetic_kind: None,
751 });
752 document.relations.push(SemanticRelation {
753 source: "caller".to_string(),
754 target: SemanticTarget::ExternalRef("reqwest::get".to_string()),
755 kind: EdgeKind::Calls,
756 confidence: 1.0,
757 direction: None,
758 operation: None,
759 condition: None,
760 async_boundary: None,
761 provenance: Vec::new(),
762 terminal_kind: None,
763 });
764
765 document.annotate_call_relations(|relation, source| {
766 assert_eq!(relation.target.as_raw(), "reqwest::get");
767 assert_eq!(source.map(|symbol| symbol.name.as_str()), Some("load"));
768 Some(TerminalEffect {
769 terminal_kind: TerminalKind::Network,
770 direction: FlowDirection::Read,
771 operation: "HTTP".to_string(),
772 })
773 });
774
775 assert_eq!(
776 document.relations[0].terminal_kind,
777 Some(TerminalKind::Network)
778 );
779 assert_eq!(document.relations[0].operation.as_deref(), Some("HTTP"));
780 }
781
782 #[test]
783 fn override_call_relations_replaces_existing_effect() {
784 let mut document = SemanticDocument::new();
785 document.symbols.push(SemanticSymbol {
786 id: "caller".to_string(),
787 kind: NodeKind::Function,
788 name: "load".to_string(),
789 file: PathBuf::from("main.rs"),
790 span: test_span(),
791 visibility: Visibility::Public,
792 properties: HashMap::new(),
793 annotations: Vec::new(),
794 signature: None,
795 doc_comment: None,
796 module: None,
797 snippet: None,
798 synthetic_kind: None,
799 });
800 document.relations.push(SemanticRelation {
801 source: "caller".to_string(),
802 target: SemanticTarget::ExternalRef("reqwest::get".to_string()),
803 kind: EdgeKind::Calls,
804 confidence: 1.0,
805 direction: Some(FlowDirection::Read),
806 operation: Some("HTTP".to_string()),
807 condition: None,
808 async_boundary: None,
809 provenance: Vec::new(),
810 terminal_kind: Some(TerminalKind::Network),
811 });
812
813 document.override_call_relations(|_, _| {
814 Some(TerminalEffect {
815 terminal_kind: TerminalKind::Event,
816 direction: FlowDirection::Write,
817 operation: "CUSTOM".to_string(),
818 })
819 });
820
821 assert_eq!(
822 document.relations[0].terminal_kind,
823 Some(TerminalKind::Event)
824 );
825 assert_eq!(document.relations[0].direction, Some(FlowDirection::Write));
826 assert_eq!(document.relations[0].operation.as_deref(), Some("CUSTOM"));
827 }
828}