Skip to main content

sqry_core/graph/unified/storage/
metadata.rs

1//! Sparse node metadata store for macro boundary analysis and classpath provenance.
2//!
3//! This module provides [`NodeMetadataStore`], a sparse metadata store keyed by
4//! full [`NodeId`] (index + generation) to prevent stale metadata when the
5//! generational arena reuses a slot index with a new generation.
6//!
7//! Only nodes with metadata get entries, keeping memory overhead
8//! proportional to the number of annotated symbols rather than total node count.
9
10use std::collections::HashMap;
11
12use serde::{Deserialize, Serialize};
13
14use super::super::node::id::NodeId;
15
16/// Optional metadata for nodes that participate in macro boundary analysis.
17///
18/// Stored separately from [`NodeEntry`] to avoid bloating the arena for the
19/// majority of nodes that don't need macro metadata.
20///
21/// Each field is `Option` (or `Vec` with empty default) so only relevant
22/// metadata consumes space in the serialized representation.
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct MacroNodeMetadata {
25    /// Whether this symbol was generated by macro expansion.
26    pub macro_generated: Option<bool>,
27
28    /// Qualified name of the macro that generated this symbol.
29    pub macro_source: Option<String>,
30
31    /// The cfg predicate string (e.g., `"test"`, `"feature = \"serde\""`)
32    pub cfg_condition: Option<String>,
33
34    /// Whether this cfg is active (`None` = unknown, requires external config).
35    pub cfg_active: Option<bool>,
36
37    /// Proc-macro kind for proc-macro function nodes.
38    pub proc_macro_kind: Option<ProcMacroFunctionKind>,
39
40    /// Whether expansion data came from cache vs live `cargo expand`.
41    pub expansion_cached: Option<bool>,
42
43    /// Unresolved attribute paths that could not be positively identified
44    /// as proc-macro attributes. Stored for potential future resolution.
45    pub unresolved_attributes: Vec<String>,
46}
47
48/// Classification of proc-macro function types.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum ProcMacroFunctionKind {
52    /// `#[proc_macro_derive(Name)]` — generates impls for structs/enums.
53    Derive,
54    /// `#[proc_macro_attribute]` — transforms annotated items.
55    Attribute,
56    /// `#[proc_macro]` — function-like `my_macro!(...)` invocation.
57    FunctionLike,
58}
59
60/// Metadata for nodes originating from JVM classpath bytecode.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ClasspathNodeMetadata {
63    /// Maven coordinates (e.g., `"com.google.guava:guava:33.0.0"`).
64    pub coordinates: Option<String>,
65    /// JAR file path this node was extracted from.
66    pub jar_path: String,
67    /// Fully qualified name in JVM format (e.g., `"java.util.HashMap"`).
68    pub fqn: String,
69    /// Whether this is a direct or transitive dependency.
70    pub is_direct_dependency: bool,
71}
72
73/// Metadata that can be attached to graph nodes.
74///
75/// This enum is not directly `Serialize`/`Deserialize` because `postcard`
76/// does not support Rust enum variants wrapping structs. Instead,
77/// `NodeMetadataStore` handles serialization/deserialization through flat
78/// entry structs with explicit discriminant bytes.
79///
80/// For JSON use (e.g., MCP export), use the `serde_json`-compatible
81/// `to_json`/`from_json` convenience methods.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum NodeMetadata {
84    /// Rust macro-related metadata.
85    Macro(MacroNodeMetadata),
86    /// JVM classpath provenance metadata.
87    Classpath(ClasspathNodeMetadata),
88}
89
90/// Discriminant values for `NodeMetadata` wire format.
91const NODE_METADATA_MACRO: u8 = 0;
92const NODE_METADATA_CLASSPATH: u8 = 1;
93
94/// Sparse metadata store keyed by full `NodeId` (index + generation).
95///
96/// Uses `(u32, u64)` tuple key to prevent stale metadata when the
97/// generational arena reuses a slot index with a new generation.
98/// A lookup with `NodeId { index: 5, generation: 3 }` will NOT match metadata
99/// stored for `NodeId { index: 5, generation: 2 }`.
100///
101/// # Memory characteristics
102///
103/// For a typical large codebase (100K nodes), only ~5-10% of nodes have
104/// metadata. A store with 10K entries at ~200 bytes each = ~2MB, which is
105/// acceptable given snapshots are already 10-50MB.
106///
107/// # Serialization
108///
109/// The in-memory representation uses `HashMap` for O(1) lookups. For postcard
110/// serialization (which doesn't support tuple keys natively), we serialize as
111/// a `Vec` of `NodeMetadataEntry` structs with explicit `index` and `generation`
112/// fields, then reconstruct the `HashMap` on deserialization.
113///
114/// For backwards compatibility, legacy entries serialized as bare
115/// `MacroNodeMetadata` (without a wrapping `NodeMetadata` enum tag) are
116/// transparently deserialized as `NodeMetadata::Macro`.
117#[derive(Debug, Clone, Default)]
118pub struct NodeMetadataStore {
119    /// Metadata entries keyed by `(NodeId::index(), NodeId::generation())`.
120    entries: HashMap<(u32, u64), NodeMetadata>,
121}
122
123/// Serialization wrapper for a V7 metadata entry with explicit discriminant.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125struct NodeMetadataEntryV7 {
126    index: u32,
127    generation: u64,
128    /// Discriminant: 0 = Macro, 1 = Classpath
129    kind: u8,
130    /// Macro metadata (present when kind == 0)
131    macro_data: Option<MacroNodeMetadata>,
132    /// Classpath metadata (present when kind == 1)
133    classpath_data: Option<ClasspathNodeMetadata>,
134}
135
136impl Serialize for NodeMetadataStore {
137    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
138        let entries: Vec<NodeMetadataEntryV7> = self
139            .entries
140            .iter()
141            .map(|(&(index, generation), metadata)| match metadata {
142                NodeMetadata::Macro(m) => NodeMetadataEntryV7 {
143                    index,
144                    generation,
145                    kind: NODE_METADATA_MACRO,
146                    macro_data: Some(m.clone()),
147                    classpath_data: None,
148                },
149                NodeMetadata::Classpath(c) => NodeMetadataEntryV7 {
150                    index,
151                    generation,
152                    kind: NODE_METADATA_CLASSPATH,
153                    macro_data: None,
154                    classpath_data: Some(c.clone()),
155                },
156            })
157            .collect();
158        entries.serialize(serializer)
159    }
160}
161
162impl<'de> Deserialize<'de> for NodeMetadataStore {
163    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
164        // V7 format: each entry has an explicit `kind` discriminant.
165        // Legacy V6 entries had no `kind` field, so `kind` will default to 0
166        // (which maps to Macro) via postcard's sequential field decoding.
167        // However, since we bumped the snapshot version to V7, V6 snapshots
168        // will be rejected at the magic byte check before reaching this code.
169        let entries: Vec<NodeMetadataEntryV7> = Vec::deserialize(deserializer)?;
170        let mut map = HashMap::with_capacity(entries.len());
171        for e in entries {
172            let metadata = if e.kind == NODE_METADATA_CLASSPATH {
173                let data = e.classpath_data.ok_or_else(|| {
174                    serde::de::Error::custom("missing classpath_data for Classpath metadata entry")
175                })?;
176                NodeMetadata::Classpath(data)
177            } else {
178                // Default: treat as Macro (covers both explicit 0 and legacy)
179                let data = e.macro_data.unwrap_or_default();
180                NodeMetadata::Macro(data)
181            };
182            map.insert((e.index, e.generation), metadata);
183        }
184        Ok(Self { entries: map })
185    }
186}
187
188impl NodeMetadataStore {
189    /// Create a new empty metadata store.
190    #[must_use]
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    /// Get metadata for a node, if any exists.
196    ///
197    /// Returns `None` if no metadata is stored for this node, or if the
198    /// generation doesn't match (indicating a stale reference).
199    #[must_use]
200    pub fn get(&self, node_id: NodeId) -> Option<&MacroNodeMetadata> {
201        match self.entries.get(&(node_id.index(), node_id.generation()))? {
202            NodeMetadata::Macro(m) => Some(m),
203            NodeMetadata::Classpath(_) => None,
204        }
205    }
206
207    /// Get the full `NodeMetadata` envelope for a node, if any exists.
208    ///
209    /// Returns `None` if no metadata is stored for this node, or if the
210    /// generation doesn't match (indicating a stale reference).
211    #[must_use]
212    pub fn get_metadata(&self, node_id: NodeId) -> Option<&NodeMetadata> {
213        self.entries.get(&(node_id.index(), node_id.generation()))
214    }
215
216    /// Get mutable metadata for a node, if any exists.
217    #[must_use]
218    pub fn get_mut(&mut self, node_id: NodeId) -> Option<&mut MacroNodeMetadata> {
219        match self
220            .entries
221            .get_mut(&(node_id.index(), node_id.generation()))?
222        {
223            NodeMetadata::Macro(m) => Some(m),
224            NodeMetadata::Classpath(_) => None,
225        }
226    }
227
228    /// Insert macro metadata for a node, replacing any existing entry.
229    ///
230    /// Convenience method that wraps the metadata in `NodeMetadata::Macro`.
231    pub fn insert(&mut self, node_id: NodeId, metadata: MacroNodeMetadata) {
232        self.entries.insert(
233            (node_id.index(), node_id.generation()),
234            NodeMetadata::Macro(metadata),
235        );
236    }
237
238    /// Insert typed metadata for a node, replacing any existing entry.
239    pub fn insert_metadata(&mut self, node_id: NodeId, metadata: NodeMetadata) {
240        self.entries
241            .insert((node_id.index(), node_id.generation()), metadata);
242    }
243
244    /// Get or insert default macro metadata for a node.
245    ///
246    /// # Panics
247    ///
248    /// Panics if the node already has a `Classpath` metadata entry at this key,
249    /// which indicates a programming error — callers must not mix metadata types
250    /// for the same node.
251    pub fn get_or_insert_default(&mut self, node_id: NodeId) -> &mut MacroNodeMetadata {
252        let entry = self
253            .entries
254            .entry((node_id.index(), node_id.generation()))
255            .or_insert_with(|| NodeMetadata::Macro(MacroNodeMetadata::default()));
256        match entry {
257            NodeMetadata::Macro(m) => m,
258            // If a non-macro entry already exists at this key, this is a
259            // programming error — callers should not mix metadata types for the
260            // same node. Panic in debug, return a default in release.
261            NodeMetadata::Classpath(_) => {
262                panic!("get_or_insert_default called on a Classpath metadata entry")
263            }
264        }
265    }
266
267    /// Remove metadata for a node.
268    pub fn remove(&mut self, node_id: NodeId) -> Option<MacroNodeMetadata> {
269        match self
270            .entries
271            .remove(&(node_id.index(), node_id.generation()))?
272        {
273            NodeMetadata::Macro(m) => Some(m),
274            NodeMetadata::Classpath(_) => None,
275        }
276    }
277
278    /// Remove typed metadata for a node.
279    pub fn remove_metadata(&mut self, node_id: NodeId) -> Option<NodeMetadata> {
280        self.entries
281            .remove(&(node_id.index(), node_id.generation()))
282    }
283
284    /// Returns the number of nodes with metadata.
285    #[must_use]
286    pub fn len(&self) -> usize {
287        self.entries.len()
288    }
289
290    /// Returns true if no nodes have metadata.
291    #[must_use]
292    pub fn is_empty(&self) -> bool {
293        self.entries.is_empty()
294    }
295
296    /// Iterate over all metadata entries.
297    pub fn iter(&self) -> impl Iterator<Item = ((u32, u64), &MacroNodeMetadata)> {
298        self.entries.iter().filter_map(|(&k, v)| match v {
299            NodeMetadata::Macro(m) => Some((k, m)),
300            NodeMetadata::Classpath(_) => None,
301        })
302    }
303
304    /// Iterate over all metadata entries as typed `NodeMetadata`.
305    pub fn iter_all(&self) -> impl Iterator<Item = ((u32, u64), &NodeMetadata)> {
306        self.entries.iter().map(|(&k, v)| (k, v))
307    }
308
309    /// Merge another metadata store into this one.
310    ///
311    /// Entries from `other` overwrite existing entries with the same key.
312    pub fn merge(&mut self, other: &NodeMetadataStore) {
313        for (&key, value) in &other.entries {
314            self.entries.insert(key, value.clone());
315        }
316    }
317}
318
319impl PartialEq for NodeMetadataStore {
320    fn eq(&self, other: &Self) -> bool {
321        self.entries == other.entries
322    }
323}
324
325impl Eq for NodeMetadataStore {}
326
327impl crate::graph::unified::memory::GraphMemorySize for NodeMetadataStore {
328    fn heap_bytes(&self) -> usize {
329        use crate::graph::unified::memory::HASHMAP_ENTRY_OVERHEAD;
330
331        let base = self.entries.capacity()
332            * (std::mem::size_of::<(u32, u64)>()
333                + std::mem::size_of::<NodeMetadata>()
334                + HASHMAP_ENTRY_OVERHEAD);
335        // Account for heap Strings inside each metadata variant.
336        let inner: usize = self
337            .entries
338            .values()
339            .map(|meta| match meta {
340                NodeMetadata::Macro(m) => {
341                    m.macro_source.as_ref().map_or(0, String::capacity)
342                        + m.cfg_condition.as_ref().map_or(0, String::capacity)
343                        + m.unresolved_attributes
344                            .iter()
345                            .map(String::capacity)
346                            .sum::<usize>()
347                        + m.unresolved_attributes.capacity() * std::mem::size_of::<String>()
348                }
349                NodeMetadata::Classpath(c) => {
350                    c.coordinates.as_ref().map_or(0, String::capacity)
351                        + c.jar_path.capacity()
352                        + c.fqn.capacity()
353                }
354            })
355            .sum();
356        base + inner
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_metadata_store_basic_operations() {
366        let mut store = NodeMetadataStore::new();
367        assert!(store.is_empty());
368        assert_eq!(store.len(), 0);
369
370        let node = NodeId::new(5, 1);
371        let metadata = MacroNodeMetadata {
372            macro_generated: Some(true),
373            macro_source: Some("derive_Debug".to_string()),
374            ..Default::default()
375        };
376
377        store.insert(node, metadata.clone());
378        assert_eq!(store.len(), 1);
379        assert!(!store.is_empty());
380
381        let retrieved = store.get(node).unwrap();
382        assert_eq!(retrieved.macro_generated, Some(true));
383        assert_eq!(retrieved.macro_source.as_deref(), Some("derive_Debug"));
384    }
385
386    #[test]
387    fn test_metadata_full_nodeid_key() {
388        let mut store = NodeMetadataStore::new();
389
390        let node_gen1 = NodeId::new(5, 1);
391        let node_gen2 = NodeId::new(5, 2);
392
393        store.insert(
394            node_gen1,
395            MacroNodeMetadata {
396                macro_generated: Some(true),
397                ..Default::default()
398            },
399        );
400
401        // Same index, different generation → should NOT match
402        assert!(store.get(node_gen2).is_none());
403
404        // Same index, same generation → should match
405        assert!(store.get(node_gen1).is_some());
406    }
407
408    #[test]
409    fn test_metadata_slot_reuse_no_stale_data() {
410        let mut store = NodeMetadataStore::new();
411
412        // Simulate: node at index 5 gen 1 has metadata
413        let old_node = NodeId::new(5, 1);
414        store.insert(
415            old_node,
416            MacroNodeMetadata {
417                cfg_condition: Some("test".to_string()),
418                ..Default::default()
419            },
420        );
421
422        // Simulate: slot 5 is reused with generation 2 (new node)
423        let new_node = NodeId::new(5, 2);
424
425        // New node should NOT see old metadata
426        assert!(store.get(new_node).is_none());
427
428        // Old node still accessible
429        assert_eq!(
430            store.get(old_node).unwrap().cfg_condition.as_deref(),
431            Some("test")
432        );
433    }
434
435    #[test]
436    fn test_metadata_store_postcard_roundtrip() {
437        let mut store = NodeMetadataStore::new();
438
439        store.insert(
440            NodeId::new(1, 0),
441            MacroNodeMetadata {
442                macro_generated: Some(true),
443                macro_source: Some("derive_Debug".to_string()),
444                cfg_condition: Some("test".to_string()),
445                cfg_active: Some(true),
446                proc_macro_kind: Some(ProcMacroFunctionKind::Derive),
447                expansion_cached: Some(false),
448                unresolved_attributes: vec!["my_attr".to_string()],
449            },
450        );
451
452        store.insert(
453            NodeId::new(42, 3),
454            MacroNodeMetadata {
455                cfg_condition: Some("feature = \"serde\"".to_string()),
456                ..Default::default()
457            },
458        );
459
460        let bytes = postcard::to_allocvec(&store).expect("serialize");
461        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
462
463        assert_eq!(store, deserialized);
464    }
465
466    #[test]
467    fn test_empty_metadata_store_zero_overhead() {
468        let store = NodeMetadataStore::new();
469        let bytes = postcard::to_allocvec(&store).expect("serialize");
470
471        // Empty HashMap serializes to a single varint length of 0
472        assert!(
473            bytes.len() <= 2,
474            "Empty store should serialize to minimal bytes, got {} bytes",
475            bytes.len()
476        );
477    }
478
479    #[test]
480    fn test_metadata_store_merge() {
481        let mut store1 = NodeMetadataStore::new();
482        let mut store2 = NodeMetadataStore::new();
483
484        store1.insert(
485            NodeId::new(1, 0),
486            MacroNodeMetadata {
487                macro_generated: Some(true),
488                ..Default::default()
489            },
490        );
491
492        store2.insert(
493            NodeId::new(2, 0),
494            MacroNodeMetadata {
495                cfg_condition: Some("test".to_string()),
496                ..Default::default()
497            },
498        );
499
500        store1.merge(&store2);
501        assert_eq!(store1.len(), 2);
502        assert!(store1.get(NodeId::new(1, 0)).is_some());
503        assert!(store1.get(NodeId::new(2, 0)).is_some());
504    }
505
506    #[test]
507    fn test_proc_macro_function_kind_serde() {
508        let kinds = [
509            ProcMacroFunctionKind::Derive,
510            ProcMacroFunctionKind::Attribute,
511            ProcMacroFunctionKind::FunctionLike,
512        ];
513
514        for kind in kinds {
515            let bytes = postcard::to_allocvec(&kind).expect("serialize");
516            let deserialized: ProcMacroFunctionKind =
517                postcard::from_bytes(&bytes).expect("deserialize");
518            assert_eq!(kind, deserialized);
519        }
520    }
521
522    #[test]
523    fn test_metadata_get_or_insert_default() {
524        let mut store = NodeMetadataStore::new();
525        let node = NodeId::new(10, 0);
526
527        // First access creates default
528        let meta = store.get_or_insert_default(node);
529        meta.cfg_condition = Some("test".to_string());
530
531        // Second access returns existing
532        let meta = store.get(node).unwrap();
533        assert_eq!(meta.cfg_condition.as_deref(), Some("test"));
534    }
535
536    #[test]
537    fn test_metadata_remove() {
538        let mut store = NodeMetadataStore::new();
539        let node = NodeId::new(1, 0);
540
541        store.insert(
542            node,
543            MacroNodeMetadata {
544                macro_generated: Some(true),
545                ..Default::default()
546            },
547        );
548
549        assert!(store.get(node).is_some());
550        let removed = store.remove(node);
551        assert!(removed.is_some());
552        assert!(store.get(node).is_none());
553        assert!(store.is_empty());
554    }
555
556    #[test]
557    fn test_metadata_store_large_scale() {
558        let mut store = NodeMetadataStore::new();
559
560        // Insert 10K entries (simulating ~10% of a 100K-node codebase)
561        for i in 0..10_000u32 {
562            store.insert(
563                NodeId::new(i, 0),
564                MacroNodeMetadata {
565                    cfg_condition: Some(format!("feature_{i}")),
566                    ..Default::default()
567                },
568            );
569        }
570
571        assert_eq!(store.len(), 10_000);
572
573        // Verify O(1) lookups
574        assert!(store.get(NodeId::new(0, 0)).is_some());
575        assert!(store.get(NodeId::new(5_000, 0)).is_some());
576        assert!(store.get(NodeId::new(9_999, 0)).is_some());
577        assert!(store.get(NodeId::new(10_000, 0)).is_none());
578
579        // Verify round-trip
580        let bytes = postcard::to_allocvec(&store).expect("serialize");
581        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
582        assert_eq!(store, deserialized);
583    }
584
585    #[test]
586    fn test_classpath_metadata_insert_and_get() {
587        let mut store = NodeMetadataStore::new();
588        let node = NodeId::new(100, 0);
589
590        let cp_meta = ClasspathNodeMetadata {
591            coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
592            jar_path: "/home/user/.m2/repository/guava-33.0.0.jar".to_string(),
593            fqn: "com.google.common.collect.ImmutableList".to_string(),
594            is_direct_dependency: true,
595        };
596
597        store.insert_metadata(node, NodeMetadata::Classpath(cp_meta.clone()));
598        assert_eq!(store.len(), 1);
599
600        // get() should return None (only returns macro metadata)
601        assert!(store.get(node).is_none());
602
603        // get_metadata() should return the classpath metadata
604        let retrieved = store.get_metadata(node).unwrap();
605        match retrieved {
606            NodeMetadata::Classpath(cp) => {
607                assert_eq!(cp.fqn, "com.google.common.collect.ImmutableList");
608                assert_eq!(
609                    cp.coordinates.as_deref(),
610                    Some("com.google.guava:guava:33.0.0")
611                );
612                assert!(cp.is_direct_dependency);
613            }
614            NodeMetadata::Macro(_) => panic!("expected Classpath variant"),
615        }
616    }
617
618    #[test]
619    fn test_classpath_metadata_postcard_roundtrip() {
620        let mut store = NodeMetadataStore::new();
621
622        // Mix of macro and classpath metadata
623        store.insert(
624            NodeId::new(1, 0),
625            MacroNodeMetadata {
626                macro_generated: Some(true),
627                ..Default::default()
628            },
629        );
630
631        store.insert_metadata(
632            NodeId::new(2, 0),
633            NodeMetadata::Classpath(ClasspathNodeMetadata {
634                coordinates: Some("org.slf4j:slf4j-api:2.0.0".to_string()),
635                jar_path: "slf4j-api-2.0.0.jar".to_string(),
636                fqn: "org.slf4j.Logger".to_string(),
637                is_direct_dependency: false,
638            }),
639        );
640
641        let bytes = postcard::to_allocvec(&store).expect("serialize");
642        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
643        assert_eq!(store, deserialized);
644        assert_eq!(deserialized.len(), 2);
645
646        // Verify macro entry
647        assert!(deserialized.get(NodeId::new(1, 0)).is_some());
648
649        // Verify classpath entry via get_metadata
650        let cp = deserialized.get_metadata(NodeId::new(2, 0)).unwrap();
651        assert!(matches!(cp, NodeMetadata::Classpath(_)));
652    }
653
654    #[test]
655    fn test_node_metadata_store_json_roundtrip() {
656        let mut store = NodeMetadataStore::new();
657
658        store.insert(
659            NodeId::new(1, 0),
660            MacroNodeMetadata {
661                macro_generated: Some(true),
662                macro_source: Some("serde_derive".to_string()),
663                ..Default::default()
664            },
665        );
666
667        store.insert_metadata(
668            NodeId::new(2, 0),
669            NodeMetadata::Classpath(ClasspathNodeMetadata {
670                coordinates: None,
671                jar_path: "rt.jar".to_string(),
672                fqn: "java.lang.String".to_string(),
673                is_direct_dependency: true,
674            }),
675        );
676
677        let json = serde_json::to_string(&store).unwrap();
678        let deserialized: NodeMetadataStore = serde_json::from_str(&json).unwrap();
679        assert_eq!(store, deserialized);
680    }
681
682    #[test]
683    fn test_iter_all_includes_both_types() {
684        let mut store = NodeMetadataStore::new();
685
686        store.insert(
687            NodeId::new(1, 0),
688            MacroNodeMetadata {
689                macro_generated: Some(true),
690                ..Default::default()
691            },
692        );
693
694        store.insert_metadata(
695            NodeId::new(2, 0),
696            NodeMetadata::Classpath(ClasspathNodeMetadata {
697                coordinates: None,
698                jar_path: "test.jar".to_string(),
699                fqn: "com.example.Test".to_string(),
700                is_direct_dependency: true,
701            }),
702        );
703
704        // iter() only yields macro entries
705        let macro_entries: Vec<_> = store.iter().collect();
706        assert_eq!(macro_entries.len(), 1);
707
708        // iter_all() yields all entries
709        let all_entries: Vec<_> = store.iter_all().collect();
710        assert_eq!(all_entries.len(), 2);
711    }
712
713    #[test]
714    fn test_remove_metadata_classpath() {
715        let mut store = NodeMetadataStore::new();
716        let node = NodeId::new(50, 0);
717
718        store.insert_metadata(
719            node,
720            NodeMetadata::Classpath(ClasspathNodeMetadata {
721                coordinates: None,
722                jar_path: "test.jar".to_string(),
723                fqn: "Test".to_string(),
724                is_direct_dependency: true,
725            }),
726        );
727
728        assert_eq!(store.len(), 1);
729
730        // remove() returns None for non-macro entries
731        let removed = store.remove(node);
732        assert!(removed.is_none());
733        // The entry is still gone from the store because remove() always removes
734        assert!(store.is_empty());
735    }
736
737    #[test]
738    fn test_remove_metadata_typed() {
739        let mut store = NodeMetadataStore::new();
740        let node = NodeId::new(50, 0);
741
742        store.insert_metadata(
743            node,
744            NodeMetadata::Classpath(ClasspathNodeMetadata {
745                coordinates: None,
746                jar_path: "test.jar".to_string(),
747                fqn: "Test".to_string(),
748                is_direct_dependency: true,
749            }),
750        );
751
752        // remove_metadata() returns the full NodeMetadata
753        let removed = store.remove_metadata(node);
754        assert!(matches!(removed, Some(NodeMetadata::Classpath(_))));
755        assert!(store.is_empty());
756    }
757}