sqry_core/graph/edge.rs
1//! Edge types for the unified code graph
2//!
3//! This module defines edges representing relationships between code entities:
4//! calls, imports, exports, inheritance, HTTP requests, FFI calls, etc.
5
6use super::node::{NodeId, Span};
7use crate::relations::CallIdentityMetadata;
8use serde::{Deserialize, Serialize};
9use std::fmt;
10
11/// Unique identifier for an edge
12///
13/// Uses atomic counter for globally unique edge IDs across the codebase.
14/// The newtype pattern prevents accidentally mixing edge IDs with other numeric types.
15///
16/// # Examples
17///
18/// ```
19/// use sqry_core::graph::edge::EdgeId;
20///
21/// let edge1 = EdgeId::new();
22/// let edge2 = EdgeId::new();
23/// assert_ne!(edge1, edge2); // Each edge gets a unique ID
24/// ```
25#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Ord, PartialOrd)]
26pub struct EdgeId(u64);
27
28impl EdgeId {
29 /// Create a new edge ID with a globally unique value
30 ///
31 /// Uses an atomic counter to ensure thread-safe unique ID generation.
32 pub fn new() -> Self {
33 use std::sync::atomic::{AtomicU64, Ordering};
34 static COUNTER: AtomicU64 = AtomicU64::new(0);
35 Self(COUNTER.fetch_add(1, Ordering::SeqCst))
36 }
37
38 /// Get the raw u64 value of this edge ID
39 ///
40 /// This should only be used for serialization or interop with external systems.
41 #[must_use]
42 pub const fn as_u64(&self) -> u64 {
43 self.0
44 }
45
46 /// Create an `EdgeId` from a raw u64 value
47 ///
48 /// # Safety
49 ///
50 /// This should only be used when deserializing or reconstructing IDs from
51 /// external systems. Using this incorrectly could create duplicate IDs.
52 #[must_use]
53 pub const fn from_u64(id: u64) -> Self {
54 Self(id)
55 }
56}
57
58impl Default for EdgeId {
59 fn default() -> Self {
60 Self::new()
61 }
62}
63
64impl fmt::Display for EdgeId {
65 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
66 write!(f, "edge#{}", self.0)
67 }
68}
69
70/// Type of relationship between nodes
71#[derive(Debug, Clone, PartialEq)]
72pub enum EdgeKind {
73 /// Function call
74 Call {
75 /// Number of arguments passed
76 argument_count: usize,
77 /// Whether the call is async/await
78 is_async: bool,
79 },
80 /// Module import
81 Import {
82 /// Import alias (e.g., "import foo as bar")
83 alias: Option<String>,
84 /// Whether this is a wildcard import (e.g., "import *")
85 is_wildcard: bool,
86 },
87 /// Class inheritance
88 Inherits,
89 /// Interface implementation
90 Implements,
91 /// HTTP request (cross-language)
92 HTTPRequest {
93 /// HTTP method (GET, POST, etc.)
94 method: String,
95 /// HTTP endpoint path
96 endpoint: String,
97 },
98 /// Foreign Function Interface call (cross-language)
99 FFICall {
100 /// Type of FFI mechanism used
101 ffi_type: FFIType,
102 },
103 /// Field access
104 FieldAccess {
105 /// Name of the accessed field
106 field_name: String,
107 },
108 /// Database table read operation (SQL)
109 TableRead {
110 /// Name of the table being read
111 table_name: String,
112 /// Optional schema/database name
113 schema: Option<String>,
114 },
115 /// Database table write operation (SQL)
116 TableWrite {
117 /// Name of the table being written
118 table_name: String,
119 /// Optional schema/database name
120 schema: Option<String>,
121 /// Type of write operation (INSERT, UPDATE, DELETE)
122 operation: TableWriteOp,
123 },
124 /// Database trigger relationship (SQL)
125 TriggeredBy {
126 /// Name of the trigger
127 trigger_name: String,
128 /// Optional schema/database name
129 schema: Option<String>,
130 },
131 /// Flutter `MethodChannel` invocation (Dart)
132 ChannelInvoke {
133 /// Name of the platform channel
134 channel_name: String,
135 /// Method being invoked on the channel
136 method: String,
137 },
138 /// Flutter widget parent-child relationship (Dart)
139 WidgetChild {
140 /// Type of the child widget
141 widget_type: String,
142 },
143 /// Module export
144 ///
145 /// Represents an export statement that makes symbols available to other modules.
146 /// Supports all major export patterns including named, default, namespace, and
147 /// wildcard re-exports.
148 ///
149 /// # Per-Kind Field Semantics
150 ///
151 /// | ExportKind | symbol | alias | from_module | Example |
152 /// |------------|--------|-------|-------------|---------|
153 /// | Named | Some("foo") | None | None | `export { foo }` |
154 /// | Named | Some("foo") | Some("bar") | None | `export { foo as bar }` |
155 /// | Named | Some("foo") | None | Some("./mod") | `export { foo } from './mod'` |
156 /// | NamedTypeOnly | Some("Foo") | None | None | `export type { Foo }` |
157 /// | Default | Some("MyClass") | None | None | `export default MyClass` |
158 /// | Default | Some("default") | None | None | `export default function() {}` |
159 /// | Namespace | Some("ns") | None | Some("./mod") | `export * as ns from './mod'` |
160 /// | NamespaceTypeOnly | Some("Types") | None | Some("./types") | `export type * as Types from './types'` |
161 /// | AllFromModule | None | None | Some("./mod") | `export * from './mod'` |
162 /// | AllFromModuleTypeOnly | None | None | Some("./types") | `export type * from './types'` |
163 /// | Assignment | Some("Foo") | None | None | `export = Foo` |
164 /// | GlobalNamespace | Some("MyLib") | None | None | `export as namespace MyLib` |
165 Export {
166 /// Export kind discriminator (determines how other fields are interpreted)
167 kind: ExportKind,
168 /// Node being exported (optional - None for AllFromModule/AllFromModuleTypeOnly)
169 ///
170 /// - Named/NamedTypeOnly: the exported symbol name (required)
171 /// - Default: name of exported item, or "default" for anonymous (required)
172 /// - Namespace/NamespaceTypeOnly: the namespace binding name (required)
173 /// - AllFromModule/AllFromModuleTypeOnly: None (wildcard, no specific symbol)
174 /// - Assignment/GlobalNamespace: the exported binding name (required)
175 symbol: Option<String>,
176 /// Optional alias for re-exports (`export { foo as bar }`)
177 ///
178 /// First-class field (not metadata) for type safety.
179 alias: Option<String>,
180 /// Optional source module for re-exports (`export { foo } from './bar'`)
181 ///
182 /// First-class field (not metadata) for type safety.
183 from_module: Option<String>,
184 },
185}
186
187/// Type of table write operation
188#[derive(Debug, Clone, PartialEq, Eq, Hash)]
189pub enum TableWriteOp {
190 /// INSERT statement
191 Insert,
192 /// UPDATE statement
193 Update,
194 /// DELETE statement
195 Delete,
196 /// MERGE/UPSERT statement
197 Merge,
198}
199
200/// Discriminator for different export semantics
201///
202/// This enum distinguishes between various export patterns found across languages,
203/// enabling precise semantic representation of module exports.
204///
205/// # Examples
206///
207/// ```
208/// use sqry_core::graph::edge::ExportKind;
209///
210/// // Named export: `export { foo }`
211/// let named = ExportKind::Named;
212///
213/// // Default export: `export default MyClass`
214/// let default = ExportKind::Default;
215///
216/// // Wildcard re-export: `export * from './mod'`
217/// let all = ExportKind::AllFromModule;
218/// ```
219#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
220pub enum ExportKind {
221 /// Named export: `export { foo }` or `export { foo as bar }`
222 ///
223 /// - symbol = "foo" (required)
224 /// - alias = Some("bar") if renamed
225 Named,
226 /// Named type-only export: `export type { Foo }` (TypeScript)
227 ///
228 /// Type-only semantics inherent in variant (no metadata needed).
229 /// - symbol = "Foo" (required)
230 /// - alias = Some("Bar") if renamed
231 NamedTypeOnly,
232 /// Default export: `export default foo`
233 ///
234 /// - symbol = name of exported item, or "default" for anonymous
235 Default,
236 /// Namespace re-export: `export * as ns from './mod'`
237 ///
238 /// - symbol = Some("ns") (the namespace name, required)
239 /// - `from_module` = "./mod"
240 Namespace,
241 /// Type-only namespace re-export: `export type * as Types from './types'` (TypeScript)
242 ///
243 /// - symbol = Some("Types") (required)
244 /// - `from_module` = "./types"
245 NamespaceTypeOnly,
246 /// Wildcard re-export: `export * from './mod'`
247 ///
248 /// - symbol = None (NO sentinel string)
249 /// - `from_module` = "./mod"
250 AllFromModule,
251 /// Type-only wildcard re-export: `export type * from './types'` (TypeScript 5.0+)
252 ///
253 /// - symbol = None (NO sentinel string)
254 /// - `from_module` = "./types"
255 AllFromModuleTypeOnly,
256 /// TypeScript assignment export: `export = Foo` (UMD/CJS interop)
257 ///
258 /// - symbol = "Foo" (the exported binding, required)
259 Assignment,
260 /// TypeScript global namespace augmentation: `export as namespace Foo`
261 ///
262 /// - symbol = "Foo" (the global namespace name, required)
263 GlobalNamespace,
264}
265
266impl fmt::Display for ExportKind {
267 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
268 match self {
269 ExportKind::Named => write!(f, "named"),
270 ExportKind::NamedTypeOnly => write!(f, "named-type-only"),
271 ExportKind::Default => write!(f, "default"),
272 ExportKind::Namespace => write!(f, "namespace"),
273 ExportKind::NamespaceTypeOnly => write!(f, "namespace-type-only"),
274 ExportKind::AllFromModule => write!(f, "all-from-module"),
275 ExportKind::AllFromModuleTypeOnly => write!(f, "all-from-module-type-only"),
276 ExportKind::Assignment => write!(f, "assignment"),
277 ExportKind::GlobalNamespace => write!(f, "global-namespace"),
278 }
279 }
280}
281
282/// Type of FFI mechanism
283#[derive(Debug, Clone, PartialEq, Eq, Hash)]
284pub enum FFIType {
285 /// JavaScript node-ffi
286 NodeFFI,
287 /// Python ctypes
288 Ctypes,
289 /// Python cffi
290 CFFI,
291 /// Rust extern "C"
292 RustExtern,
293 /// JNI (Java Native Interface)
294 JNI,
295 /// Elixir NIFs or Erlang interop (:`erlang.module()`)
296 ElixirNIF,
297 /// R .`Call()` or .`External()` interface
298 RDotCall,
299 /// R Rcpp (C++ interface for R)
300 Rcpp,
301 /// Other/unknown FFI
302 Other(String),
303}
304
305impl fmt::Display for FFIType {
306 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
307 match self {
308 FFIType::NodeFFI => write!(f, "node-ffi"),
309 FFIType::Ctypes => write!(f, "ctypes"),
310 FFIType::CFFI => write!(f, "cffi"),
311 FFIType::RustExtern => write!(f, "extern-C"),
312 FFIType::JNI => write!(f, "JNI"),
313 FFIType::ElixirNIF => write!(f, "elixir-nif"),
314 FFIType::RDotCall => write!(f, "r-dotcall"),
315 FFIType::Rcpp => write!(f, "rcpp"),
316 FFIType::Other(s) => write!(f, "{s}"),
317 }
318 }
319}
320
321/// Detection strategy for an edge.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
323pub enum DetectionMethod {
324 /// Derived directly from AST analysis (tree-sitter queries / traversal).
325 ASTAnalysis,
326 /// Derived from type inference or static type information.
327 TypeInference,
328 /// Derived using heuristic detection (e.g., pattern matching on strings).
329 Heuristic,
330 /// Added manually by a user or external annotation.
331 Manual,
332 /// Strategy not recorded / unknown.
333 #[default]
334 Unknown,
335}
336
337/// Metadata for an edge
338#[derive(Debug, Clone)]
339pub struct EdgeMetadata {
340 /// Optional source span for the edge (call site, import location, etc.)
341 pub span: Option<Span>,
342 /// Confidence score for detected edges (0.0 to 1.0).
343 /// 1.0 = certain (static analysis)
344 /// 0.7-0.9 = high confidence (template literals)
345 /// 0.5-0.7 = medium confidence (variable endpoint)
346 /// <0.5 = low confidence (skip edge creation)
347 pub confidence: f32,
348 /// How this edge was detected.
349 pub detection_method: DetectionMethod,
350 /// Human-readable reason for edge detection.
351 pub reason: Option<String>,
352 /// Caller identity metadata (populated by language-specific `GraphBuilders`).
353 /// Contains qualified name, simple name, namespace, and method kind.
354 pub caller_identity: Option<CallIdentityMetadata>,
355 /// Callee identity metadata (populated by language-specific `GraphBuilders`).
356 /// Contains qualified name, simple name, namespace, and method kind.
357 pub callee_identity: Option<CallIdentityMetadata>,
358}
359
360impl Default for EdgeMetadata {
361 fn default() -> Self {
362 Self {
363 span: None,
364 confidence: 1.0,
365 detection_method: DetectionMethod::Unknown,
366 reason: None,
367 caller_identity: None,
368 callee_identity: None,
369 }
370 }
371}
372
373/// An edge in the code graph representing a relationship
374#[derive(Debug, Clone)]
375pub struct CodeEdge {
376 /// Unique identifier
377 pub id: EdgeId,
378 /// Source node
379 pub from: NodeId,
380 /// Target node
381 pub to: NodeId,
382 /// Edge type
383 pub kind: EdgeKind,
384 /// Additional metadata
385 pub metadata: EdgeMetadata,
386}
387
388impl CodeEdge {
389 /// Create a new code edge
390 #[must_use]
391 pub fn new(from: NodeId, to: NodeId, kind: EdgeKind) -> Self {
392 Self {
393 id: EdgeId::new(),
394 from,
395 to,
396 kind,
397 metadata: EdgeMetadata::default(),
398 }
399 }
400
401 /// Create a new code edge with metadata
402 #[must_use]
403 pub fn with_metadata(from: NodeId, to: NodeId, kind: EdgeKind, metadata: EdgeMetadata) -> Self {
404 Self {
405 id: EdgeId::new(),
406 from,
407 to,
408 kind,
409 metadata,
410 }
411 }
412
413 /// Check if this is a cross-language edge
414 ///
415 /// An edge is considered cross-language if:
416 /// - The source and target nodes are in different languages, OR
417 /// - The edge represents an HTTP request (service boundary), OR
418 /// - The edge represents an FFI call (language interop)
419 #[must_use]
420 pub fn is_cross_language(&self) -> bool {
421 // Different languages
422 if self.from.language != self.to.language {
423 return true;
424 }
425
426 // HTTP requests are always cross-language (service boundaries)
427 if matches!(self.kind, EdgeKind::HTTPRequest { .. }) {
428 return true;
429 }
430
431 // FFI calls are always cross-language (language interop)
432 if matches!(self.kind, EdgeKind::FFICall { .. }) {
433 return true;
434 }
435
436 false
437 }
438
439 /// Get HTTP method if this is an HTTP request
440 #[must_use]
441 pub fn http_method(&self) -> Option<&str> {
442 match &self.kind {
443 EdgeKind::HTTPRequest { method, .. } => Some(method),
444 _ => None,
445 }
446 }
447
448 /// Get HTTP endpoint if this is an HTTP request
449 #[must_use]
450 pub fn http_endpoint(&self) -> Option<&str> {
451 match &self.kind {
452 EdgeKind::HTTPRequest { endpoint, .. } => Some(endpoint),
453 _ => None,
454 }
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use crate::graph::node::Language;
462 use approx::assert_abs_diff_eq;
463
464 #[test]
465 fn test_edge_id_unique() {
466 let id1 = EdgeId::new();
467 let id2 = EdgeId::new();
468 let id3 = EdgeId::new();
469
470 assert_ne!(id1, id2);
471 assert_ne!(id2, id3);
472 assert_ne!(id1, id3);
473 }
474
475 #[test]
476 fn test_edge_creation() {
477 let from = NodeId::new(Language::JavaScript, "api.js", "fetchUsers");
478 let to = NodeId::new(Language::Python, "api.py", "get_users");
479
480 let edge = CodeEdge::new(
481 from.clone(),
482 to.clone(),
483 EdgeKind::HTTPRequest {
484 method: "GET".to_string(),
485 endpoint: "/api/users".to_string(),
486 },
487 );
488
489 assert_eq!(edge.from, from);
490 assert_eq!(edge.to, to);
491 assert!(edge.is_cross_language());
492 }
493
494 #[test]
495 fn test_cross_language_detection() {
496 let js_node = NodeId::new(Language::JavaScript, "api.js", "fetch");
497 let py_node = NodeId::new(Language::Python, "api.py", "handler");
498 let js_node2 = NodeId::new(Language::JavaScript, "utils.js", "helper");
499
500 let cross_language = CodeEdge::new(
501 js_node.clone(),
502 py_node,
503 EdgeKind::HTTPRequest {
504 method: "POST".to_string(),
505 endpoint: "/api/data".to_string(),
506 },
507 );
508
509 let same_lang = CodeEdge::new(
510 js_node,
511 js_node2,
512 EdgeKind::Call {
513 argument_count: 2,
514 is_async: true,
515 },
516 );
517
518 assert!(cross_language.is_cross_language());
519 assert!(!same_lang.is_cross_language());
520 }
521
522 #[test]
523 fn test_http_requests_are_cross_language() {
524 // HTTP requests should be cross-language even within same language
525 // because they represent service boundaries
526 let from = NodeId::new(Language::JavaScript, "api.js", "fetchUsers");
527 let to = NodeId::new(Language::JavaScript, "api.js", "httpGet");
528
529 let http_edge = CodeEdge::new(
530 from.clone(),
531 to.clone(),
532 EdgeKind::HTTPRequest {
533 method: "GET".to_string(),
534 endpoint: "/api/users".to_string(),
535 },
536 );
537
538 // HTTP request should be cross-language
539 assert!(http_edge.is_cross_language());
540
541 // Regular call between same nodes should NOT be cross-language
542 let call_edge = CodeEdge::new(
543 from,
544 to,
545 EdgeKind::Call {
546 argument_count: 1,
547 is_async: true,
548 },
549 );
550 assert!(!call_edge.is_cross_language());
551 }
552
553 #[test]
554 fn test_ffi_calls_are_cross_language() {
555 // FFI calls should be cross-language even within same language
556 // because they represent language interop boundaries
557 let from = NodeId::new(Language::Python, "api.py", "authenticate");
558 let to = NodeId::new(Language::Python, "api.py", "validate_token");
559
560 let ffi_edge = CodeEdge::new(
561 from.clone(),
562 to.clone(),
563 EdgeKind::FFICall {
564 ffi_type: FFIType::Ctypes,
565 },
566 );
567
568 // FFI call should be cross-language
569 assert!(ffi_edge.is_cross_language());
570
571 // Regular call between same nodes should NOT be cross-language
572 let call_edge = CodeEdge::new(
573 from,
574 to,
575 EdgeKind::Call {
576 argument_count: 1,
577 is_async: false,
578 },
579 );
580 assert!(!call_edge.is_cross_language());
581 }
582
583 #[test]
584 fn test_http_helpers() {
585 let from = NodeId::new(Language::JavaScript, "api.js", "fetch");
586 let to = NodeId::new(Language::Http, "api", "/users");
587
588 let edge = CodeEdge::new(
589 from,
590 to,
591 EdgeKind::HTTPRequest {
592 method: "GET".to_string(),
593 endpoint: "/api/users".to_string(),
594 },
595 );
596
597 assert_eq!(edge.http_method(), Some("GET"));
598 assert_eq!(edge.http_endpoint(), Some("/api/users"));
599 }
600
601 #[test]
602 fn test_edge_metadata() {
603 let from = NodeId::new(Language::JavaScript, "api.js", "fetch");
604 let to = NodeId::new(Language::Http, "api", "/users");
605
606 let metadata = EdgeMetadata {
607 span: None,
608 confidence: 0.8,
609 detection_method: DetectionMethod::Heuristic,
610 reason: Some("Template literal with interpolation".to_string()),
611 ..Default::default()
612 };
613
614 let edge = CodeEdge::with_metadata(
615 from,
616 to,
617 EdgeKind::HTTPRequest {
618 method: "GET".to_string(),
619 endpoint: "/api/users/${id}".to_string(),
620 },
621 metadata,
622 );
623
624 assert_abs_diff_eq!(edge.metadata.confidence, 0.8, epsilon = 1e-10);
625 assert!(edge.metadata.reason.is_some());
626 assert!(edge.metadata.span.is_none());
627 }
628
629 #[test]
630 fn test_ffi_type_display() {
631 assert_eq!(FFIType::NodeFFI.to_string(), "node-ffi");
632 assert_eq!(FFIType::Ctypes.to_string(), "ctypes");
633 assert_eq!(FFIType::RustExtern.to_string(), "extern-C");
634 assert_eq!(FFIType::Other("custom".to_string()).to_string(), "custom");
635 }
636}