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    /// Retain only entries whose `(index, generation)` key satisfies `keep`.
319    ///
320    /// Used by the Gate 0b [`NodeIdBearing`] impl
321    /// (`sqry-core/src/graph/unified/rebuild/coverage.rs`) to drop
322    /// metadata for tombstoned NodeIds during
323    /// `RebuildGraph::finalize()`. Exposed at `pub(crate)` scope because
324    /// only the rebuild pipeline needs predicate-based filtering;
325    /// downstream callers use the targeted [`Self::remove`] /
326    /// [`Self::remove_metadata`] entry points.
327    ///
328    /// `#[allow(dead_code)]` is present because Gate 0b delivers only
329    /// the scaffolding — the call sites in `RebuildGraph::finalize()`
330    /// (Gate 0c) and the residue check (Gate 0d) land in follow-up
331    /// commits. Unit coverage in
332    /// `sqry-core/src/graph/unified/rebuild/coverage.rs::tests` already
333    /// exercises this helper through the [`NodeIdBearing::retain_nodes`]
334    /// impl.
335    ///
336    /// [`NodeIdBearing`]: crate::graph::unified::rebuild::coverage::NodeIdBearing
337    /// [`NodeIdBearing::retain_nodes`]: crate::graph::unified::rebuild::coverage::NodeIdBearing::retain_nodes
338    #[allow(dead_code)]
339    pub(crate) fn retain_entries<F>(&mut self, mut keep: F)
340    where
341        F: FnMut(u32, u64) -> bool,
342    {
343        self.entries
344            .retain(|&(index, generation), _meta| keep(index, generation));
345    }
346}
347
348impl PartialEq for NodeMetadataStore {
349    fn eq(&self, other: &Self) -> bool {
350        self.entries == other.entries
351    }
352}
353
354impl Eq for NodeMetadataStore {}
355
356impl crate::graph::unified::memory::GraphMemorySize for NodeMetadataStore {
357    fn heap_bytes(&self) -> usize {
358        use crate::graph::unified::memory::HASHMAP_ENTRY_OVERHEAD;
359
360        let base = self.entries.capacity()
361            * (std::mem::size_of::<(u32, u64)>()
362                + std::mem::size_of::<NodeMetadata>()
363                + HASHMAP_ENTRY_OVERHEAD);
364        // Account for heap Strings inside each metadata variant.
365        let inner: usize = self
366            .entries
367            .values()
368            .map(|meta| match meta {
369                NodeMetadata::Macro(m) => {
370                    m.macro_source.as_ref().map_or(0, String::capacity)
371                        + m.cfg_condition.as_ref().map_or(0, String::capacity)
372                        + m.unresolved_attributes
373                            .iter()
374                            .map(String::capacity)
375                            .sum::<usize>()
376                        + m.unresolved_attributes.capacity() * std::mem::size_of::<String>()
377                }
378                NodeMetadata::Classpath(c) => {
379                    c.coordinates.as_ref().map_or(0, String::capacity)
380                        + c.jar_path.capacity()
381                        + c.fqn.capacity()
382                }
383            })
384            .sum();
385        base + inner
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_metadata_store_basic_operations() {
395        let mut store = NodeMetadataStore::new();
396        assert!(store.is_empty());
397        assert_eq!(store.len(), 0);
398
399        let node = NodeId::new(5, 1);
400        let metadata = MacroNodeMetadata {
401            macro_generated: Some(true),
402            macro_source: Some("derive_Debug".to_string()),
403            ..Default::default()
404        };
405
406        store.insert(node, metadata.clone());
407        assert_eq!(store.len(), 1);
408        assert!(!store.is_empty());
409
410        let retrieved = store.get(node).unwrap();
411        assert_eq!(retrieved.macro_generated, Some(true));
412        assert_eq!(retrieved.macro_source.as_deref(), Some("derive_Debug"));
413    }
414
415    #[test]
416    fn test_metadata_full_nodeid_key() {
417        let mut store = NodeMetadataStore::new();
418
419        let node_gen1 = NodeId::new(5, 1);
420        let node_gen2 = NodeId::new(5, 2);
421
422        store.insert(
423            node_gen1,
424            MacroNodeMetadata {
425                macro_generated: Some(true),
426                ..Default::default()
427            },
428        );
429
430        // Same index, different generation → should NOT match
431        assert!(store.get(node_gen2).is_none());
432
433        // Same index, same generation → should match
434        assert!(store.get(node_gen1).is_some());
435    }
436
437    #[test]
438    fn test_metadata_slot_reuse_no_stale_data() {
439        let mut store = NodeMetadataStore::new();
440
441        // Simulate: node at index 5 gen 1 has metadata
442        let old_node = NodeId::new(5, 1);
443        store.insert(
444            old_node,
445            MacroNodeMetadata {
446                cfg_condition: Some("test".to_string()),
447                ..Default::default()
448            },
449        );
450
451        // Simulate: slot 5 is reused with generation 2 (new node)
452        let new_node = NodeId::new(5, 2);
453
454        // New node should NOT see old metadata
455        assert!(store.get(new_node).is_none());
456
457        // Old node still accessible
458        assert_eq!(
459            store.get(old_node).unwrap().cfg_condition.as_deref(),
460            Some("test")
461        );
462    }
463
464    #[test]
465    fn test_metadata_store_postcard_roundtrip() {
466        let mut store = NodeMetadataStore::new();
467
468        store.insert(
469            NodeId::new(1, 0),
470            MacroNodeMetadata {
471                macro_generated: Some(true),
472                macro_source: Some("derive_Debug".to_string()),
473                cfg_condition: Some("test".to_string()),
474                cfg_active: Some(true),
475                proc_macro_kind: Some(ProcMacroFunctionKind::Derive),
476                expansion_cached: Some(false),
477                unresolved_attributes: vec!["my_attr".to_string()],
478            },
479        );
480
481        store.insert(
482            NodeId::new(42, 3),
483            MacroNodeMetadata {
484                cfg_condition: Some("feature = \"serde\"".to_string()),
485                ..Default::default()
486            },
487        );
488
489        let bytes = postcard::to_allocvec(&store).expect("serialize");
490        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
491
492        assert_eq!(store, deserialized);
493    }
494
495    #[test]
496    fn test_empty_metadata_store_zero_overhead() {
497        let store = NodeMetadataStore::new();
498        let bytes = postcard::to_allocvec(&store).expect("serialize");
499
500        // Empty HashMap serializes to a single varint length of 0
501        assert!(
502            bytes.len() <= 2,
503            "Empty store should serialize to minimal bytes, got {} bytes",
504            bytes.len()
505        );
506    }
507
508    #[test]
509    fn test_metadata_store_merge() {
510        let mut store1 = NodeMetadataStore::new();
511        let mut store2 = NodeMetadataStore::new();
512
513        store1.insert(
514            NodeId::new(1, 0),
515            MacroNodeMetadata {
516                macro_generated: Some(true),
517                ..Default::default()
518            },
519        );
520
521        store2.insert(
522            NodeId::new(2, 0),
523            MacroNodeMetadata {
524                cfg_condition: Some("test".to_string()),
525                ..Default::default()
526            },
527        );
528
529        store1.merge(&store2);
530        assert_eq!(store1.len(), 2);
531        assert!(store1.get(NodeId::new(1, 0)).is_some());
532        assert!(store1.get(NodeId::new(2, 0)).is_some());
533    }
534
535    #[test]
536    fn test_proc_macro_function_kind_serde() {
537        let kinds = [
538            ProcMacroFunctionKind::Derive,
539            ProcMacroFunctionKind::Attribute,
540            ProcMacroFunctionKind::FunctionLike,
541        ];
542
543        for kind in kinds {
544            let bytes = postcard::to_allocvec(&kind).expect("serialize");
545            let deserialized: ProcMacroFunctionKind =
546                postcard::from_bytes(&bytes).expect("deserialize");
547            assert_eq!(kind, deserialized);
548        }
549    }
550
551    #[test]
552    fn test_metadata_get_or_insert_default() {
553        let mut store = NodeMetadataStore::new();
554        let node = NodeId::new(10, 0);
555
556        // First access creates default
557        let meta = store.get_or_insert_default(node);
558        meta.cfg_condition = Some("test".to_string());
559
560        // Second access returns existing
561        let meta = store.get(node).unwrap();
562        assert_eq!(meta.cfg_condition.as_deref(), Some("test"));
563    }
564
565    #[test]
566    fn test_metadata_remove() {
567        let mut store = NodeMetadataStore::new();
568        let node = NodeId::new(1, 0);
569
570        store.insert(
571            node,
572            MacroNodeMetadata {
573                macro_generated: Some(true),
574                ..Default::default()
575            },
576        );
577
578        assert!(store.get(node).is_some());
579        let removed = store.remove(node);
580        assert!(removed.is_some());
581        assert!(store.get(node).is_none());
582        assert!(store.is_empty());
583    }
584
585    #[test]
586    fn test_metadata_store_large_scale() {
587        let mut store = NodeMetadataStore::new();
588
589        // Insert 10K entries (simulating ~10% of a 100K-node codebase)
590        for i in 0..10_000u32 {
591            store.insert(
592                NodeId::new(i, 0),
593                MacroNodeMetadata {
594                    cfg_condition: Some(format!("feature_{i}")),
595                    ..Default::default()
596                },
597            );
598        }
599
600        assert_eq!(store.len(), 10_000);
601
602        // Verify O(1) lookups
603        assert!(store.get(NodeId::new(0, 0)).is_some());
604        assert!(store.get(NodeId::new(5_000, 0)).is_some());
605        assert!(store.get(NodeId::new(9_999, 0)).is_some());
606        assert!(store.get(NodeId::new(10_000, 0)).is_none());
607
608        // Verify round-trip
609        let bytes = postcard::to_allocvec(&store).expect("serialize");
610        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
611        assert_eq!(store, deserialized);
612    }
613
614    #[test]
615    fn test_classpath_metadata_insert_and_get() {
616        let mut store = NodeMetadataStore::new();
617        let node = NodeId::new(100, 0);
618
619        let cp_meta = ClasspathNodeMetadata {
620            coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
621            jar_path: "/home/user/.m2/repository/guava-33.0.0.jar".to_string(),
622            fqn: "com.google.common.collect.ImmutableList".to_string(),
623            is_direct_dependency: true,
624        };
625
626        store.insert_metadata(node, NodeMetadata::Classpath(cp_meta.clone()));
627        assert_eq!(store.len(), 1);
628
629        // get() should return None (only returns macro metadata)
630        assert!(store.get(node).is_none());
631
632        // get_metadata() should return the classpath metadata
633        let retrieved = store.get_metadata(node).unwrap();
634        match retrieved {
635            NodeMetadata::Classpath(cp) => {
636                assert_eq!(cp.fqn, "com.google.common.collect.ImmutableList");
637                assert_eq!(
638                    cp.coordinates.as_deref(),
639                    Some("com.google.guava:guava:33.0.0")
640                );
641                assert!(cp.is_direct_dependency);
642            }
643            NodeMetadata::Macro(_) => panic!("expected Classpath variant"),
644        }
645    }
646
647    #[test]
648    fn test_classpath_metadata_postcard_roundtrip() {
649        let mut store = NodeMetadataStore::new();
650
651        // Mix of macro and classpath metadata
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: Some("org.slf4j:slf4j-api:2.0.0".to_string()),
664                jar_path: "slf4j-api-2.0.0.jar".to_string(),
665                fqn: "org.slf4j.Logger".to_string(),
666                is_direct_dependency: false,
667            }),
668        );
669
670        let bytes = postcard::to_allocvec(&store).expect("serialize");
671        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
672        assert_eq!(store, deserialized);
673        assert_eq!(deserialized.len(), 2);
674
675        // Verify macro entry
676        assert!(deserialized.get(NodeId::new(1, 0)).is_some());
677
678        // Verify classpath entry via get_metadata
679        let cp = deserialized.get_metadata(NodeId::new(2, 0)).unwrap();
680        assert!(matches!(cp, NodeMetadata::Classpath(_)));
681    }
682
683    #[test]
684    fn test_node_metadata_store_json_roundtrip() {
685        let mut store = NodeMetadataStore::new();
686
687        store.insert(
688            NodeId::new(1, 0),
689            MacroNodeMetadata {
690                macro_generated: Some(true),
691                macro_source: Some("serde_derive".to_string()),
692                ..Default::default()
693            },
694        );
695
696        store.insert_metadata(
697            NodeId::new(2, 0),
698            NodeMetadata::Classpath(ClasspathNodeMetadata {
699                coordinates: None,
700                jar_path: "rt.jar".to_string(),
701                fqn: "java.lang.String".to_string(),
702                is_direct_dependency: true,
703            }),
704        );
705
706        let json = serde_json::to_string(&store).unwrap();
707        let deserialized: NodeMetadataStore = serde_json::from_str(&json).unwrap();
708        assert_eq!(store, deserialized);
709    }
710
711    #[test]
712    fn test_iter_all_includes_both_types() {
713        let mut store = NodeMetadataStore::new();
714
715        store.insert(
716            NodeId::new(1, 0),
717            MacroNodeMetadata {
718                macro_generated: Some(true),
719                ..Default::default()
720            },
721        );
722
723        store.insert_metadata(
724            NodeId::new(2, 0),
725            NodeMetadata::Classpath(ClasspathNodeMetadata {
726                coordinates: None,
727                jar_path: "test.jar".to_string(),
728                fqn: "com.example.Test".to_string(),
729                is_direct_dependency: true,
730            }),
731        );
732
733        // iter() only yields macro entries
734        let macro_entries: Vec<_> = store.iter().collect();
735        assert_eq!(macro_entries.len(), 1);
736
737        // iter_all() yields all entries
738        let all_entries: Vec<_> = store.iter_all().collect();
739        assert_eq!(all_entries.len(), 2);
740    }
741
742    #[test]
743    fn test_remove_metadata_classpath() {
744        let mut store = NodeMetadataStore::new();
745        let node = NodeId::new(50, 0);
746
747        store.insert_metadata(
748            node,
749            NodeMetadata::Classpath(ClasspathNodeMetadata {
750                coordinates: None,
751                jar_path: "test.jar".to_string(),
752                fqn: "Test".to_string(),
753                is_direct_dependency: true,
754            }),
755        );
756
757        assert_eq!(store.len(), 1);
758
759        // remove() returns None for non-macro entries
760        let removed = store.remove(node);
761        assert!(removed.is_none());
762        // The entry is still gone from the store because remove() always removes
763        assert!(store.is_empty());
764    }
765
766    #[test]
767    fn test_remove_metadata_typed() {
768        let mut store = NodeMetadataStore::new();
769        let node = NodeId::new(50, 0);
770
771        store.insert_metadata(
772            node,
773            NodeMetadata::Classpath(ClasspathNodeMetadata {
774                coordinates: None,
775                jar_path: "test.jar".to_string(),
776                fqn: "Test".to_string(),
777                is_direct_dependency: true,
778            }),
779        );
780
781        // remove_metadata() returns the full NodeMetadata
782        let removed = store.remove_metadata(node);
783        assert!(matches!(removed, Some(NodeMetadata::Classpath(_))));
784        assert!(store.is_empty());
785    }
786}