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 = match e.kind {
173                NODE_METADATA_CLASSPATH => {
174                    let data = e.classpath_data.ok_or_else(|| {
175                        serde::de::Error::custom(
176                            "missing classpath_data for Classpath metadata entry",
177                        )
178                    })?;
179                    NodeMetadata::Classpath(data)
180                }
181                _ => {
182                    // Default: treat as Macro (covers both explicit 0 and legacy)
183                    let data = e.macro_data.unwrap_or_default();
184                    NodeMetadata::Macro(data)
185                }
186            };
187            map.insert((e.index, e.generation), metadata);
188        }
189        Ok(Self { entries: map })
190    }
191}
192
193impl NodeMetadataStore {
194    /// Create a new empty metadata store.
195    #[must_use]
196    pub fn new() -> Self {
197        Self::default()
198    }
199
200    /// Get metadata for a node, if any exists.
201    ///
202    /// Returns `None` if no metadata is stored for this node, or if the
203    /// generation doesn't match (indicating a stale reference).
204    #[must_use]
205    pub fn get(&self, node_id: NodeId) -> Option<&MacroNodeMetadata> {
206        match self.entries.get(&(node_id.index(), node_id.generation()))? {
207            NodeMetadata::Macro(m) => Some(m),
208            NodeMetadata::Classpath(_) => None,
209        }
210    }
211
212    /// Get the full `NodeMetadata` envelope for a node, if any exists.
213    ///
214    /// Returns `None` if no metadata is stored for this node, or if the
215    /// generation doesn't match (indicating a stale reference).
216    #[must_use]
217    pub fn get_metadata(&self, node_id: NodeId) -> Option<&NodeMetadata> {
218        self.entries.get(&(node_id.index(), node_id.generation()))
219    }
220
221    /// Get mutable metadata for a node, if any exists.
222    #[must_use]
223    pub fn get_mut(&mut self, node_id: NodeId) -> Option<&mut MacroNodeMetadata> {
224        match self
225            .entries
226            .get_mut(&(node_id.index(), node_id.generation()))?
227        {
228            NodeMetadata::Macro(m) => Some(m),
229            NodeMetadata::Classpath(_) => None,
230        }
231    }
232
233    /// Insert macro metadata for a node, replacing any existing entry.
234    ///
235    /// Convenience method that wraps the metadata in `NodeMetadata::Macro`.
236    pub fn insert(&mut self, node_id: NodeId, metadata: MacroNodeMetadata) {
237        self.entries.insert(
238            (node_id.index(), node_id.generation()),
239            NodeMetadata::Macro(metadata),
240        );
241    }
242
243    /// Insert typed metadata for a node, replacing any existing entry.
244    pub fn insert_metadata(&mut self, node_id: NodeId, metadata: NodeMetadata) {
245        self.entries
246            .insert((node_id.index(), node_id.generation()), metadata);
247    }
248
249    /// Get or insert default macro metadata for a node.
250    pub fn get_or_insert_default(&mut self, node_id: NodeId) -> &mut MacroNodeMetadata {
251        let entry = self
252            .entries
253            .entry((node_id.index(), node_id.generation()))
254            .or_insert_with(|| NodeMetadata::Macro(MacroNodeMetadata::default()));
255        match entry {
256            NodeMetadata::Macro(m) => m,
257            // If a non-macro entry already exists at this key, this is a
258            // programming error — callers should not mix metadata types for the
259            // same node. Panic in debug, return a default in release.
260            NodeMetadata::Classpath(_) => {
261                panic!("get_or_insert_default called on a Classpath metadata entry")
262            }
263        }
264    }
265
266    /// Remove metadata for a node.
267    pub fn remove(&mut self, node_id: NodeId) -> Option<MacroNodeMetadata> {
268        match self
269            .entries
270            .remove(&(node_id.index(), node_id.generation()))?
271        {
272            NodeMetadata::Macro(m) => Some(m),
273            NodeMetadata::Classpath(_) => None,
274        }
275    }
276
277    /// Remove typed metadata for a node.
278    pub fn remove_metadata(&mut self, node_id: NodeId) -> Option<NodeMetadata> {
279        self.entries
280            .remove(&(node_id.index(), node_id.generation()))
281    }
282
283    /// Returns the number of nodes with metadata.
284    #[must_use]
285    pub fn len(&self) -> usize {
286        self.entries.len()
287    }
288
289    /// Returns true if no nodes have metadata.
290    #[must_use]
291    pub fn is_empty(&self) -> bool {
292        self.entries.is_empty()
293    }
294
295    /// Iterate over all metadata entries.
296    pub fn iter(&self) -> impl Iterator<Item = ((u32, u64), &MacroNodeMetadata)> {
297        self.entries.iter().filter_map(|(&k, v)| match v {
298            NodeMetadata::Macro(m) => Some((k, m)),
299            NodeMetadata::Classpath(_) => None,
300        })
301    }
302
303    /// Iterate over all metadata entries as typed `NodeMetadata`.
304    pub fn iter_all(&self) -> impl Iterator<Item = ((u32, u64), &NodeMetadata)> {
305        self.entries.iter().map(|(&k, v)| (k, v))
306    }
307
308    /// Merge another metadata store into this one.
309    ///
310    /// Entries from `other` overwrite existing entries with the same key.
311    pub fn merge(&mut self, other: &NodeMetadataStore) {
312        for (&key, value) in &other.entries {
313            self.entries.insert(key, value.clone());
314        }
315    }
316}
317
318impl PartialEq for NodeMetadataStore {
319    fn eq(&self, other: &Self) -> bool {
320        self.entries == other.entries
321    }
322}
323
324impl Eq for NodeMetadataStore {}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_metadata_store_basic_operations() {
332        let mut store = NodeMetadataStore::new();
333        assert!(store.is_empty());
334        assert_eq!(store.len(), 0);
335
336        let node = NodeId::new(5, 1);
337        let metadata = MacroNodeMetadata {
338            macro_generated: Some(true),
339            macro_source: Some("derive_Debug".to_string()),
340            ..Default::default()
341        };
342
343        store.insert(node, metadata.clone());
344        assert_eq!(store.len(), 1);
345        assert!(!store.is_empty());
346
347        let retrieved = store.get(node).unwrap();
348        assert_eq!(retrieved.macro_generated, Some(true));
349        assert_eq!(retrieved.macro_source.as_deref(), Some("derive_Debug"));
350    }
351
352    #[test]
353    fn test_metadata_full_nodeid_key() {
354        let mut store = NodeMetadataStore::new();
355
356        let node_gen1 = NodeId::new(5, 1);
357        let node_gen2 = NodeId::new(5, 2);
358
359        store.insert(
360            node_gen1,
361            MacroNodeMetadata {
362                macro_generated: Some(true),
363                ..Default::default()
364            },
365        );
366
367        // Same index, different generation → should NOT match
368        assert!(store.get(node_gen2).is_none());
369
370        // Same index, same generation → should match
371        assert!(store.get(node_gen1).is_some());
372    }
373
374    #[test]
375    fn test_metadata_slot_reuse_no_stale_data() {
376        let mut store = NodeMetadataStore::new();
377
378        // Simulate: node at index 5 gen 1 has metadata
379        let old_node = NodeId::new(5, 1);
380        store.insert(
381            old_node,
382            MacroNodeMetadata {
383                cfg_condition: Some("test".to_string()),
384                ..Default::default()
385            },
386        );
387
388        // Simulate: slot 5 is reused with generation 2 (new node)
389        let new_node = NodeId::new(5, 2);
390
391        // New node should NOT see old metadata
392        assert!(store.get(new_node).is_none());
393
394        // Old node still accessible
395        assert_eq!(
396            store.get(old_node).unwrap().cfg_condition.as_deref(),
397            Some("test")
398        );
399    }
400
401    #[test]
402    fn test_metadata_store_postcard_roundtrip() {
403        let mut store = NodeMetadataStore::new();
404
405        store.insert(
406            NodeId::new(1, 0),
407            MacroNodeMetadata {
408                macro_generated: Some(true),
409                macro_source: Some("derive_Debug".to_string()),
410                cfg_condition: Some("test".to_string()),
411                cfg_active: Some(true),
412                proc_macro_kind: Some(ProcMacroFunctionKind::Derive),
413                expansion_cached: Some(false),
414                unresolved_attributes: vec!["my_attr".to_string()],
415            },
416        );
417
418        store.insert(
419            NodeId::new(42, 3),
420            MacroNodeMetadata {
421                cfg_condition: Some("feature = \"serde\"".to_string()),
422                ..Default::default()
423            },
424        );
425
426        let bytes = postcard::to_allocvec(&store).expect("serialize");
427        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
428
429        assert_eq!(store, deserialized);
430    }
431
432    #[test]
433    fn test_empty_metadata_store_zero_overhead() {
434        let store = NodeMetadataStore::new();
435        let bytes = postcard::to_allocvec(&store).expect("serialize");
436
437        // Empty HashMap serializes to a single varint length of 0
438        assert!(
439            bytes.len() <= 2,
440            "Empty store should serialize to minimal bytes, got {} bytes",
441            bytes.len()
442        );
443    }
444
445    #[test]
446    fn test_metadata_store_merge() {
447        let mut store1 = NodeMetadataStore::new();
448        let mut store2 = NodeMetadataStore::new();
449
450        store1.insert(
451            NodeId::new(1, 0),
452            MacroNodeMetadata {
453                macro_generated: Some(true),
454                ..Default::default()
455            },
456        );
457
458        store2.insert(
459            NodeId::new(2, 0),
460            MacroNodeMetadata {
461                cfg_condition: Some("test".to_string()),
462                ..Default::default()
463            },
464        );
465
466        store1.merge(&store2);
467        assert_eq!(store1.len(), 2);
468        assert!(store1.get(NodeId::new(1, 0)).is_some());
469        assert!(store1.get(NodeId::new(2, 0)).is_some());
470    }
471
472    #[test]
473    fn test_proc_macro_function_kind_serde() {
474        let kinds = [
475            ProcMacroFunctionKind::Derive,
476            ProcMacroFunctionKind::Attribute,
477            ProcMacroFunctionKind::FunctionLike,
478        ];
479
480        for kind in kinds {
481            let bytes = postcard::to_allocvec(&kind).expect("serialize");
482            let deserialized: ProcMacroFunctionKind =
483                postcard::from_bytes(&bytes).expect("deserialize");
484            assert_eq!(kind, deserialized);
485        }
486    }
487
488    #[test]
489    fn test_metadata_get_or_insert_default() {
490        let mut store = NodeMetadataStore::new();
491        let node = NodeId::new(10, 0);
492
493        // First access creates default
494        let meta = store.get_or_insert_default(node);
495        meta.cfg_condition = Some("test".to_string());
496
497        // Second access returns existing
498        let meta = store.get(node).unwrap();
499        assert_eq!(meta.cfg_condition.as_deref(), Some("test"));
500    }
501
502    #[test]
503    fn test_metadata_remove() {
504        let mut store = NodeMetadataStore::new();
505        let node = NodeId::new(1, 0);
506
507        store.insert(
508            node,
509            MacroNodeMetadata {
510                macro_generated: Some(true),
511                ..Default::default()
512            },
513        );
514
515        assert!(store.get(node).is_some());
516        let removed = store.remove(node);
517        assert!(removed.is_some());
518        assert!(store.get(node).is_none());
519        assert!(store.is_empty());
520    }
521
522    #[test]
523    fn test_metadata_store_large_scale() {
524        let mut store = NodeMetadataStore::new();
525
526        // Insert 10K entries (simulating ~10% of a 100K-node codebase)
527        for i in 0..10_000u32 {
528            store.insert(
529                NodeId::new(i, 0),
530                MacroNodeMetadata {
531                    cfg_condition: Some(format!("feature_{i}")),
532                    ..Default::default()
533                },
534            );
535        }
536
537        assert_eq!(store.len(), 10_000);
538
539        // Verify O(1) lookups
540        assert!(store.get(NodeId::new(0, 0)).is_some());
541        assert!(store.get(NodeId::new(5_000, 0)).is_some());
542        assert!(store.get(NodeId::new(9_999, 0)).is_some());
543        assert!(store.get(NodeId::new(10_000, 0)).is_none());
544
545        // Verify round-trip
546        let bytes = postcard::to_allocvec(&store).expect("serialize");
547        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
548        assert_eq!(store, deserialized);
549    }
550
551    #[test]
552    fn test_classpath_metadata_insert_and_get() {
553        let mut store = NodeMetadataStore::new();
554        let node = NodeId::new(100, 0);
555
556        let cp_meta = ClasspathNodeMetadata {
557            coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
558            jar_path: "/home/user/.m2/repository/guava-33.0.0.jar".to_string(),
559            fqn: "com.google.common.collect.ImmutableList".to_string(),
560            is_direct_dependency: true,
561        };
562
563        store.insert_metadata(node, NodeMetadata::Classpath(cp_meta.clone()));
564        assert_eq!(store.len(), 1);
565
566        // get() should return None (only returns macro metadata)
567        assert!(store.get(node).is_none());
568
569        // get_metadata() should return the classpath metadata
570        let retrieved = store.get_metadata(node).unwrap();
571        match retrieved {
572            NodeMetadata::Classpath(cp) => {
573                assert_eq!(cp.fqn, "com.google.common.collect.ImmutableList");
574                assert_eq!(
575                    cp.coordinates.as_deref(),
576                    Some("com.google.guava:guava:33.0.0")
577                );
578                assert!(cp.is_direct_dependency);
579            }
580            NodeMetadata::Macro(_) => panic!("expected Classpath variant"),
581        }
582    }
583
584    #[test]
585    fn test_classpath_metadata_postcard_roundtrip() {
586        let mut store = NodeMetadataStore::new();
587
588        // Mix of macro and classpath metadata
589        store.insert(
590            NodeId::new(1, 0),
591            MacroNodeMetadata {
592                macro_generated: Some(true),
593                ..Default::default()
594            },
595        );
596
597        store.insert_metadata(
598            NodeId::new(2, 0),
599            NodeMetadata::Classpath(ClasspathNodeMetadata {
600                coordinates: Some("org.slf4j:slf4j-api:2.0.0".to_string()),
601                jar_path: "slf4j-api-2.0.0.jar".to_string(),
602                fqn: "org.slf4j.Logger".to_string(),
603                is_direct_dependency: false,
604            }),
605        );
606
607        let bytes = postcard::to_allocvec(&store).expect("serialize");
608        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
609        assert_eq!(store, deserialized);
610        assert_eq!(deserialized.len(), 2);
611
612        // Verify macro entry
613        assert!(deserialized.get(NodeId::new(1, 0)).is_some());
614
615        // Verify classpath entry via get_metadata
616        let cp = deserialized.get_metadata(NodeId::new(2, 0)).unwrap();
617        assert!(matches!(cp, NodeMetadata::Classpath(_)));
618    }
619
620    #[test]
621    fn test_node_metadata_store_json_roundtrip() {
622        let mut store = NodeMetadataStore::new();
623
624        store.insert(
625            NodeId::new(1, 0),
626            MacroNodeMetadata {
627                macro_generated: Some(true),
628                macro_source: Some("serde_derive".to_string()),
629                ..Default::default()
630            },
631        );
632
633        store.insert_metadata(
634            NodeId::new(2, 0),
635            NodeMetadata::Classpath(ClasspathNodeMetadata {
636                coordinates: None,
637                jar_path: "rt.jar".to_string(),
638                fqn: "java.lang.String".to_string(),
639                is_direct_dependency: true,
640            }),
641        );
642
643        let json = serde_json::to_string(&store).unwrap();
644        let deserialized: NodeMetadataStore = serde_json::from_str(&json).unwrap();
645        assert_eq!(store, deserialized);
646    }
647
648    #[test]
649    fn test_iter_all_includes_both_types() {
650        let mut store = NodeMetadataStore::new();
651
652        store.insert(
653            NodeId::new(1, 0),
654            MacroNodeMetadata {
655                macro_generated: Some(true),
656                ..Default::default()
657            },
658        );
659
660        store.insert_metadata(
661            NodeId::new(2, 0),
662            NodeMetadata::Classpath(ClasspathNodeMetadata {
663                coordinates: None,
664                jar_path: "test.jar".to_string(),
665                fqn: "com.example.Test".to_string(),
666                is_direct_dependency: true,
667            }),
668        );
669
670        // iter() only yields macro entries
671        let macro_entries: Vec<_> = store.iter().collect();
672        assert_eq!(macro_entries.len(), 1);
673
674        // iter_all() yields all entries
675        let all_entries: Vec<_> = store.iter_all().collect();
676        assert_eq!(all_entries.len(), 2);
677    }
678
679    #[test]
680    fn test_remove_metadata_classpath() {
681        let mut store = NodeMetadataStore::new();
682        let node = NodeId::new(50, 0);
683
684        store.insert_metadata(
685            node,
686            NodeMetadata::Classpath(ClasspathNodeMetadata {
687                coordinates: None,
688                jar_path: "test.jar".to_string(),
689                fqn: "Test".to_string(),
690                is_direct_dependency: true,
691            }),
692        );
693
694        assert_eq!(store.len(), 1);
695
696        // remove() returns None for non-macro entries
697        let removed = store.remove(node);
698        assert!(removed.is_none());
699        // The entry is still gone from the store because remove() always removes
700        assert!(store.is_empty());
701    }
702
703    #[test]
704    fn test_remove_metadata_typed() {
705        let mut store = NodeMetadataStore::new();
706        let node = NodeId::new(50, 0);
707
708        store.insert_metadata(
709            node,
710            NodeMetadata::Classpath(ClasspathNodeMetadata {
711                coordinates: None,
712                jar_path: "test.jar".to_string(),
713                fqn: "Test".to_string(),
714                is_direct_dependency: true,
715            }),
716        );
717
718        // remove_metadata() returns the full NodeMetadata
719        let removed = store.remove_metadata(node);
720        assert!(matches!(removed, Some(NodeMetadata::Classpath(_))));
721        assert!(store.is_empty());
722    }
723}