Skip to main content

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}