Skip to main content

memscope_rs/analysis/
ownership_graph.rs

1//! Ownership Graph Engine
2//!
3//! This module provides a post-analysis engine for revealing Rust ownership propagation.
4//! It consumes Memory Passport events to build ownership graphs and detect issues.
5//!
6//! Design principles:
7//! - Zero runtime cost (post-analysis only)
8//! - Minimal data model (only track critical operations)
9//! - Focus on practical problems (Rc cycles, Arc clone storms)
10//!
11//! # Architecture
12//!
13//! ```text
14//! Runtime Tracking
15//!         │
16//!         │ Allocation Tracker
17//!         │
18//!         │ Memory Passport Tracker
19//!         │
20//!         │ Passport.events
21//!         ▼
22//! Ownership Graph Engine
23//!         │
24//!         ▼
25//! Dashboard Visualization
26//! ```
27//!
28//! # Key Features
29//!
30//! - Rc/Arc Cycle Detection
31//! - Arc Clone Storm Detection
32//! - Ownership Chain Compression
33//! - Root Cause Diagnostics
34
35use serde::{Deserialize, Serialize};
36
37use super::node_id::NodeId;
38
39/// Object identifier - unified with NodeId.
40///
41/// This type alias provides backward compatibility.
42/// Previously, ObjectId was a separate struct that wrapped u64.
43/// Now it is unified with NodeId to avoid duplication.
44/// Use NodeId directly for new code.
45pub type ObjectId = NodeId;
46
47/// Ownership operation types - only track critical operations
48#[repr(u8)]
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum OwnershipOp {
51    /// Object creation
52    Create,
53    /// Object deallocation
54    Drop,
55    /// Rc clone operation
56    RcClone,
57    /// Arc clone operation
58    ArcClone,
59    /// Move operation (value transfer)
60    Move,
61    /// Shared borrow operation (&T)
62    SharedBorrow,
63    /// Mutable borrow operation (&mut T)
64    MutBorrow,
65}
66
67/// Ownership event recorded in passport
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69pub struct OwnershipEvent {
70    /// Timestamp
71    pub ts: u64,
72    /// Operation type
73    pub op: OwnershipOp,
74    /// Source object
75    pub src: ObjectId,
76    /// Destination object (optional)
77    pub dst: Option<ObjectId>,
78}
79
80impl OwnershipEvent {
81    /// Create a new ownership event
82    pub fn new(ts: u64, op: OwnershipOp, src: ObjectId, dst: Option<ObjectId>) -> Self {
83        Self { ts, op, src, dst }
84    }
85}
86
87/// Node in ownership graph
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Node {
90    /// Node identifier
91    pub id: ObjectId,
92    /// Type name
93    pub type_name: String,
94    /// Size in bytes
95    pub size: usize,
96    /// Stack pointer (for StackOwner types like Arc/Rc)
97    pub stack_ptr: Option<usize>,
98}
99
100/// Edge kind
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102pub enum EdgeKind {
103    /// Owner relationship (A contains pointer to B)
104    Owns,
105    /// Contains relationship (Container → HeapOwner, e.g., HashMap → Vec)
106    Contains,
107    /// Borrow relationship (A borrows from B)
108    Borrows,
109    /// Rc clone edge
110    RcClone,
111    /// Arc clone edge
112    ArcClone,
113    /// Move edge (ownership transfer)
114    Move,
115    /// Shared borrow edge (&T)
116    SharedBorrow,
117    /// Mutable borrow edge (&mut T)
118    MutBorrow,
119}
120
121/// Edge in ownership graph
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct Edge {
124    /// From object
125    pub from: ObjectId,
126    /// To object
127    pub to: ObjectId,
128    /// Edge kind
129    pub op: EdgeKind,
130}
131
132/// Ownership graph representation
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct OwnershipGraph {
135    /// All nodes in the graph
136    pub nodes: Vec<Node>,
137    /// All edges in the graph
138    pub edges: Vec<Edge>,
139    /// Detected cycles
140    pub cycles: Vec<Vec<ObjectId>>,
141    /// Arc clone count for storm detection
142    pub arc_clone_count: usize,
143}
144
145impl OwnershipGraph {
146    /// Build ownership graph from MemoryView.
147    ///
148    /// Creates an ownership graph from the allocations in a MemoryView.
149    /// This is a convenience method for the unified analyzer API.
150    pub fn from_view(view: &crate::view::MemoryView) -> Self {
151        let allocations = view.allocations();
152        let passports: Vec<(ObjectId, String, usize, Vec<OwnershipEvent>)> = allocations
153            .iter()
154            .filter_map(|a| {
155                a.ptr.map(|ptr| {
156                    let id = ObjectId::from_ptr(ptr);
157                    let type_name = a.type_name.clone().unwrap_or_else(|| "unknown".to_string());
158                    // Create a basic create event for each allocation
159                    let event = OwnershipEvent::new(a.allocated_at, OwnershipOp::Create, id, None);
160                    (id, type_name, a.size, vec![event])
161                })
162            })
163            .collect();
164
165        Self::build(&passports)
166    }
167
168    /// Build ownership graph from passports with ownership events
169    pub fn build<T: AsRef<[OwnershipEvent]>>(passports: &[(ObjectId, String, usize, T)]) -> Self {
170        Self::build_with_analysis(passports, None, None)
171    }
172
173    /// Build ownership graph with four-layer analysis architecture
174    ///
175    /// This method integrates:
176    /// 1. rustdoc JSON - type information for move vs copy judgment
177    /// 2. syn AST - parse source code to identify ownership events
178    /// 3. Static inference - ownership state tracking
179    /// 4. Runtime tracing (optional)
180    ///
181    /// # Arguments
182    ///
183    /// * `passports` - Object passports with ownership events
184    /// * `rustdoc_json_path` - Optional path to rustdoc JSON for type info
185    /// * `source_code` - Optional source code for AST analysis
186    pub fn build_with_analysis<T: AsRef<[OwnershipEvent]>>(
187        passports: &[(ObjectId, String, usize, T)],
188        rustdoc_json_path: Option<&str>,
189        source_code: Option<&str>,
190    ) -> Self {
191        let mut nodes = Vec::new();
192        let mut edges = Vec::new();
193        let mut arc_clone_count = 0;
194
195        // Layer 1: Extract type information from rustdoc JSON
196        let type_db = if let Some(path) = rustdoc_json_path {
197            use crate::analysis::ownership_analyzer::RustdocExtractor;
198            use std::path::PathBuf;
199            let extractor = RustdocExtractor::new(PathBuf::from(path));
200            extractor.extract().ok()
201        } else {
202            None
203        };
204
205        // Layer 2: Analyze source code for ownership operations
206        let ast_ops = if let Some(code) = source_code {
207            use crate::analysis::ownership_analyzer::AstAnalyzer;
208            let analyzer = AstAnalyzer::new(code.to_string());
209            Some(analyzer.analyze())
210        } else {
211            None
212        };
213
214        // Layer 3: Simple ownership state tracking
215        let mut ownership_state: std::collections::HashMap<NodeId, bool> =
216            std::collections::HashMap::new();
217
218        for (id, type_name, size, events) in passports {
219            // Add node
220            nodes.push(Node {
221                id: *id,
222                type_name: type_name.clone(),
223                size: *size,
224                stack_ptr: None,
225            });
226
227            // Check if type is Copy using rustdoc info
228            let is_copy_type = if let Some(ref db) = type_db {
229                db.types.get(type_name).map(|t| t.is_copy).unwrap_or(false)
230            } else {
231                // Default heuristic: primitives are Copy
232                type_name.contains("i32")
233                    || type_name.contains("i64")
234                    || type_name.contains("f32")
235                    || type_name.contains("f64")
236                    || type_name.contains("bool")
237                    || type_name.contains("usize")
238            };
239
240            // Process events
241            for event in events.as_ref() {
242                match event.op {
243                    OwnershipOp::RcClone => {
244                        if let Some(dst) = event.dst {
245                            edges.push(Edge {
246                                from: event.src,
247                                to: dst,
248                                op: EdgeKind::RcClone,
249                            });
250                        }
251                    }
252                    OwnershipOp::ArcClone => {
253                        arc_clone_count += 1;
254                        if let Some(dst) = event.dst {
255                            edges.push(Edge {
256                                from: event.src,
257                                to: dst,
258                                op: EdgeKind::ArcClone,
259                            });
260                        }
261                    }
262                    OwnershipOp::Move => {
263                        if let Some(dst) = event.dst {
264                            // Only create Move edge if type is not Copy
265                            if !is_copy_type {
266                                edges.push(Edge {
267                                    from: event.src,
268                                    to: dst,
269                                    op: EdgeKind::Move,
270                                });
271                                // Track ownership transfer
272                                ownership_state.insert(event.src, false); // Source no longer owns
273                                ownership_state.insert(dst, true); // Destination now owns
274                            }
275                        }
276                    }
277                    OwnershipOp::SharedBorrow => {
278                        if let Some(dst) = event.dst {
279                            edges.push(Edge {
280                                from: event.src,
281                                to: dst,
282                                op: EdgeKind::SharedBorrow,
283                            });
284                        }
285                    }
286                    OwnershipOp::MutBorrow => {
287                        if let Some(dst) = event.dst {
288                            edges.push(Edge {
289                                from: event.src,
290                                to: dst,
291                                op: EdgeKind::MutBorrow,
292                            });
293                        }
294                    }
295                    OwnershipOp::Create | OwnershipOp::Drop => {
296                        // These don't create edges
297                    }
298                }
299            }
300        }
301
302        // Layer 4: Add edges from AST analysis (if available)
303        if let Some(ref ops) = ast_ops {
304            use crate::analysis::ownership_analyzer::OwnershipOp as AstOwnershipOp;
305            for op in ops {
306                match op {
307                    AstOwnershipOp::Move {
308                        target: _,
309                        source: _,
310                        line: _,
311                    } => {
312                        // Try to find corresponding object IDs
313                        // This is a simplified approach - in practice, you'd need a mapping from variable names to object IDs
314                        // For now, we skip this as it requires additional context
315                    }
316                    AstOwnershipOp::CallMove { .. } => {
317                        // Function call moves - requires more context
318                    }
319                    AstOwnershipOp::Borrow { .. } => {
320                        // Borrow operations - requires more context
321                    }
322                }
323            }
324        }
325
326        // Compress clone chains
327        Self::compress_clone_chains(&mut edges);
328
329        // Detect cycles
330        let cycles = Self::detect_cycles(&edges);
331
332        OwnershipGraph {
333            nodes,
334            edges,
335            cycles,
336            arc_clone_count,
337        }
338    }
339
340    /// Compress consecutive clone chains to reduce UI complexity.
341    ///
342    /// Uses a new vector to collect merged edges, avoiding O(n^2) complexity from repeated remove() calls.
343    fn compress_clone_chains(edges: &mut Vec<Edge>) {
344        if edges.len() < 2 {
345            return;
346        }
347
348        let mut result: Vec<Edge> = Vec::with_capacity(edges.len());
349        let mut i = 0;
350
351        while i < edges.len() {
352            let mut current = edges[i].clone();
353
354            // Try to merge with subsequent edges
355            while i + 1 < edges.len()
356                && current.op == edges[i + 1].op
357                && current.to == edges[i + 1].from
358            {
359                // Merge: extend current edge to skip the next one
360                current.to = edges[i + 1].to;
361                i += 1;
362            }
363
364            result.push(current);
365            i += 1;
366        }
367
368        *edges = result;
369    }
370
371    /// Detect cycles using existing DFS implementation
372    fn detect_cycles(edges: &[Edge]) -> Vec<Vec<ObjectId>> {
373        use crate::analysis::relationship_cycle_detector;
374
375        if edges.is_empty() {
376            return Vec::new();
377        }
378
379        // Convert edges to the format expected by the cycle detector
380        let relationships: Vec<(String, String, String)> = edges
381            .iter()
382            .map(|e| {
383                (
384                    format!("0x{:x}", e.from.0),
385                    format!("0x{:x}", e.to.0),
386                    format!("{:?}", e.op).to_lowercase(),
387                )
388            })
389            .collect();
390
391        let result = relationship_cycle_detector::detect_cycles_with_indices(&relationships);
392
393        // Convert cycle indices back to ObjectIds
394        let mut cycles = Vec::new();
395        let mut obj_id_map: std::collections::HashMap<String, ObjectId> =
396            std::collections::HashMap::new();
397
398        for edge in edges {
399            obj_id_map.insert(format!("0x{:x}", edge.from.0), edge.from);
400            obj_id_map.insert(format!("0x{:x}", edge.to.0), edge.to);
401        }
402
403        for (from_idx, to_idx) in result.cycle_edges {
404            if let (Some(from_label), Some(to_label)) = (
405                result.node_labels.get(from_idx),
406                result.node_labels.get(to_idx),
407            ) {
408                if let (Some(from_id), Some(to_id)) =
409                    (obj_id_map.get(from_label), obj_id_map.get(to_label))
410                {
411                    // Simple cycle representation
412                    cycles.push(vec![*from_id, *to_id]);
413                }
414            }
415        }
416
417        cycles
418    }
419
420    /// Check if Arc clone storm is detected
421    pub fn has_arc_clone_storm(&self, threshold: usize) -> bool {
422        self.arc_clone_count > threshold
423    }
424
425    /// Get all Rc clone edges
426    pub fn rc_clones(&self) -> Vec<&Edge> {
427        self.edges
428            .iter()
429            .filter(|e| e.op == EdgeKind::RcClone)
430            .collect()
431    }
432
433    /// Get all Arc clone edges
434    pub fn arc_clones(&self) -> Vec<&Edge> {
435        self.edges
436            .iter()
437            .filter(|e| e.op == EdgeKind::ArcClone)
438            .collect()
439    }
440
441    /// Generate diagnostics report for detected issues
442    pub fn diagnostics(&self, arc_storm_threshold: usize) -> OwnershipDiagnostics {
443        let mut issues = Vec::new();
444
445        // Check for Rc cycles
446        for cycle in &self.cycles {
447            let cycle_type = self.detect_cycle_type(cycle);
448            issues.push(DiagnosticIssue::RcCycle {
449                nodes: cycle.clone(),
450                cycle_type,
451            });
452        }
453
454        // Check for Arc clone storm
455        let arc_clone_count = self.arc_clones().len();
456        if arc_clone_count > arc_storm_threshold {
457            issues.push(DiagnosticIssue::ArcCloneStorm {
458                clone_count: arc_clone_count,
459                threshold: arc_storm_threshold,
460            });
461        }
462
463        OwnershipDiagnostics {
464            issues,
465            total_nodes: self.nodes.len(),
466            total_edges: self.edges.len(),
467            rc_clone_count: self.rc_clones().len(),
468            arc_clone_count,
469        }
470    }
471
472    /// Detect the type of cycle (Rc or Arc)
473    fn detect_cycle_type(&self, cycle: &[ObjectId]) -> CycleType {
474        // Check if any edge in the cycle is an RcClone
475        for edge in &self.edges {
476            if cycle.contains(&edge.from)
477                && cycle.contains(&edge.to)
478                && edge.op == EdgeKind::RcClone
479            {
480                return CycleType::Rc;
481            }
482        }
483        CycleType::Arc
484    }
485
486    /// Find root cause chain for memory growth
487    pub fn find_root_cause(&self) -> Option<RootCauseChain> {
488        // Check for Arc clone storm as root cause
489        if self.arc_clone_count > 50 {
490            return Some(RootCauseChain {
491                root_cause: RootCause::ArcCloneStorm,
492                description: format!(
493                    "Arc clone storm detected: {} clones causing memory proliferation",
494                    self.arc_clone_count
495                ),
496                impact: format!(
497                    "Potential memory spike from {} Arc clone operations",
498                    self.arc_clone_count
499                ),
500            });
501        }
502
503        // Check for Rc cycles as root cause
504        if !self.cycles.is_empty() {
505            return Some(RootCauseChain {
506                root_cause: RootCause::RcCycle,
507                description: format!(
508                    "Rc retain cycle detected: {} cycles found",
509                    self.cycles.len()
510                ),
511                impact: "Memory leak due to reference count cycles".to_string(),
512            });
513        }
514
515        None
516    }
517}
518
519/// Type of ownership cycle
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
521pub enum CycleType {
522    /// Rc reference cycle (memory leak)
523    Rc,
524    /// Arc reference cycle (potential leak)
525    Arc,
526}
527
528/// Diagnostic issue types
529#[derive(Debug, Clone, Serialize, Deserialize)]
530pub enum DiagnosticIssue {
531    /// Rc retain cycle detected
532    RcCycle {
533        nodes: Vec<ObjectId>,
534        cycle_type: CycleType,
535    },
536    /// Arc clone storm detected
537    ArcCloneStorm {
538        clone_count: usize,
539        threshold: usize,
540    },
541}
542
543/// Diagnostics output for ownership graph
544#[derive(Debug, Clone, Serialize, Deserialize)]
545pub struct OwnershipDiagnostics {
546    /// Detected issues
547    pub issues: Vec<DiagnosticIssue>,
548    /// Total number of nodes
549    pub total_nodes: usize,
550    /// Total number of edges
551    pub total_edges: usize,
552    /// Number of Rc clone operations
553    pub rc_clone_count: usize,
554    /// Number of Arc clone operations
555    pub arc_clone_count: usize,
556}
557
558impl OwnershipDiagnostics {
559    /// Check if any issues were detected
560    pub fn has_issues(&self) -> bool {
561        !self.issues.is_empty()
562    }
563
564    /// Get summary string for display
565    pub fn summary(&self) -> String {
566        let mut summary = format!(
567            "Ownership Graph: {} nodes, {} edges\n",
568            self.total_nodes, self.total_edges
569        );
570        for issue in &self.issues {
571            match issue {
572                DiagnosticIssue::RcCycle { nodes, cycle_type } => {
573                    summary.push_str(&format!(
574                        "🔴 {:?} Cycle detected: {} nodes\n",
575                        cycle_type,
576                        nodes.len()
577                    ));
578                }
579                DiagnosticIssue::ArcCloneStorm {
580                    clone_count,
581                    threshold,
582                } => {
583                    summary.push_str(&format!(
584                        "⚠ Arc Clone Storm: {} clones (threshold: {})\n",
585                        clone_count, threshold
586                    ));
587                }
588            }
589        }
590        summary
591    }
592}
593
594/// Root cause of memory issues
595#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
596pub enum RootCause {
597    /// Arc clone storm causing memory proliferation
598    ArcCloneStorm,
599    /// Rc retain cycle causing memory leak
600    RcCycle,
601}
602
603/// Root cause chain for memory growth analysis
604#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct RootCauseChain {
606    /// Root cause type
607    pub root_cause: RootCause,
608    /// Description of the root cause
609    pub description: String,
610    /// Impact on memory
611    pub impact: String,
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    #[test]
619    fn test_object_id_from_ptr() {
620        let id = ObjectId::from_ptr(0x1000);
621        assert_eq!(id.0, 0x1000);
622    }
623
624    #[test]
625    fn test_ownership_event_creation() {
626        let id = NodeId(0x1000);
627        let event = OwnershipEvent::new(1000, OwnershipOp::Create, id, None);
628        assert_eq!(event.ts, 1000);
629        assert_eq!(event.op, OwnershipOp::Create);
630    }
631
632    #[test]
633    fn test_graph_build_empty() {
634        let passports: Vec<(NodeId, String, usize, Vec<OwnershipEvent>)> = vec![];
635        let graph = OwnershipGraph::build(&passports);
636        assert!(graph.nodes.is_empty());
637        assert!(graph.edges.is_empty());
638        assert!(graph.cycles.is_empty());
639    }
640
641    #[test]
642    fn test_graph_build_rc_clone() {
643        let id1 = NodeId(0x1000);
644        let id2 = NodeId(0x2000);
645        let events = vec![OwnershipEvent::new(
646            1000,
647            OwnershipOp::RcClone,
648            id1,
649            Some(id2),
650        )];
651        let passports = vec![(id1, "Rc<i32>".to_string(), 8, events)];
652
653        let graph = OwnershipGraph::build(&passports);
654        assert_eq!(graph.nodes.len(), 1);
655        assert_eq!(graph.edges.len(), 1);
656        assert_eq!(graph.edges[0].op, EdgeKind::RcClone);
657    }
658
659    #[test]
660    fn test_graph_build_arc_clone_storm() {
661        let id1 = NodeId(0x1000);
662        let mut events = Vec::new();
663        for i in 0..100 {
664            let dst = NodeId(0x2000 + i);
665            events.push(OwnershipEvent::new(
666                i,
667                OwnershipOp::ArcClone,
668                id1,
669                Some(dst),
670            ));
671        }
672        let passports = vec![(id1, "Arc<i32>".to_string(), 8, events)];
673
674        let graph = OwnershipGraph::build(&passports);
675        assert_eq!(graph.arc_clone_count, 100);
676        assert!(graph.has_arc_clone_storm(50));
677    }
678
679    #[test]
680    fn test_compress_clone_chains() {
681        let id1 = NodeId(0x1000);
682        let id2 = NodeId(0x2000);
683        let id3 = NodeId(0x3000);
684        let id4 = NodeId(0x4000);
685
686        let events = vec![
687            OwnershipEvent::new(1000, OwnershipOp::ArcClone, id1, Some(id2)),
688            OwnershipEvent::new(2000, OwnershipOp::ArcClone, id2, Some(id3)),
689            OwnershipEvent::new(3000, OwnershipOp::ArcClone, id3, Some(id4)),
690        ];
691        let passports = vec![(id1, "Arc<i32>".to_string(), 8, events)];
692
693        let graph = OwnershipGraph::build(&passports);
694
695        // After compression, we should have only 1 edge: id1 -> id4
696        assert_eq!(graph.edges.len(), 1);
697        assert_eq!(graph.edges[0].from, id1);
698        assert_eq!(graph.edges[0].to, id4);
699    }
700
701    #[test]
702    fn test_diagnostics() {
703        let id1 = NodeId(0x1000);
704        let mut events = Vec::new();
705        for i in 0..100 {
706            let dst = NodeId(0x2000 + i);
707            events.push(OwnershipEvent::new(
708                i,
709                OwnershipOp::ArcClone,
710                id1,
711                Some(dst),
712            ));
713        }
714        let passports = vec![(id1, "Arc<i32>".to_string(), 8, events)];
715
716        let graph = OwnershipGraph::build(&passports);
717        let diagnostics = graph.diagnostics(50);
718
719        assert!(diagnostics.has_issues());
720        assert!(diagnostics.summary().contains("Arc Clone Storm"));
721    }
722
723    #[test]
724    fn test_root_cause_detection() {
725        let id1 = NodeId(0x1000);
726        let mut events = Vec::new();
727        for i in 0..100 {
728            let dst = NodeId(0x2000 + i);
729            events.push(OwnershipEvent::new(
730                i,
731                OwnershipOp::ArcClone,
732                id1,
733                Some(dst),
734            ));
735        }
736        let passports = vec![(id1, "Arc<i32>".to_string(), 8, events)];
737
738        let graph = OwnershipGraph::build(&passports);
739        let root_cause = graph.find_root_cause();
740
741        assert!(root_cause.is_some());
742        let chain = root_cause.unwrap();
743        assert_eq!(chain.root_cause, RootCause::ArcCloneStorm);
744    }
745
746    #[test]
747    fn test_ownership_op_move() {
748        let id1 = NodeId(0x1000);
749        let id2 = NodeId(0x2000);
750        let events = vec![OwnershipEvent::new(1000, OwnershipOp::Move, id1, Some(id2))];
751        let passports = vec![(id1, "String".to_string(), 24, events)];
752
753        let graph = OwnershipGraph::build(&passports);
754        assert_eq!(graph.edges.len(), 1);
755        assert_eq!(graph.edges[0].op, EdgeKind::Move);
756    }
757
758    #[test]
759    fn test_ownership_op_shared_borrow() {
760        let id1 = NodeId(0x1000);
761        let id2 = NodeId(0x2000);
762        let events = vec![OwnershipEvent::new(
763            1000,
764            OwnershipOp::SharedBorrow,
765            id1,
766            Some(id2),
767        )];
768        let passports = vec![(id1, "String".to_string(), 24, events)];
769
770        let graph = OwnershipGraph::build(&passports);
771        assert_eq!(graph.edges.len(), 1);
772        assert_eq!(graph.edges[0].op, EdgeKind::SharedBorrow);
773    }
774
775    #[test]
776    fn test_ownership_op_mut_borrow() {
777        let id1 = NodeId(0x1000);
778        let id2 = NodeId(0x2000);
779        let events = vec![OwnershipEvent::new(
780            1000,
781            OwnershipOp::MutBorrow,
782            id1,
783            Some(id2),
784        )];
785        let passports = vec![(id1, "String".to_string(), 24, events)];
786
787        let graph = OwnershipGraph::build(&passports);
788        assert_eq!(graph.edges.len(), 1);
789        assert_eq!(graph.edges[0].op, EdgeKind::MutBorrow);
790    }
791
792    #[test]
793    fn test_mixed_ownership_operations() {
794        let id1 = NodeId(0x1000);
795        let id2 = NodeId(0x2000);
796        let id3 = NodeId(0x3000);
797        let events = vec![
798            OwnershipEvent::new(1000, OwnershipOp::Create, id1, None),
799            OwnershipEvent::new(1100, OwnershipOp::Move, id1, Some(id2)),
800            OwnershipEvent::new(1200, OwnershipOp::SharedBorrow, id2, Some(id3)),
801            OwnershipEvent::new(1300, OwnershipOp::Drop, id3, None),
802        ];
803        let passports = vec![(id1, "String".to_string(), 24, events)];
804
805        let graph = OwnershipGraph::build(&passports);
806        assert_eq!(graph.edges.len(), 2);
807        assert_eq!(graph.edges[0].op, EdgeKind::Move);
808        assert_eq!(graph.edges[1].op, EdgeKind::SharedBorrow);
809    }
810}