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    /// Synthetic placeholder node marker (C_SUPPRESS).
89    ///
90    /// Identifies internal-use-only nodes that language plugins emit as
91    /// shadows / scaffolding for binding-plane analysis (e.g. the Go
92    /// plugin's `<field:operand.field>` field-access placeholders and the
93    /// `name@<offset>` per-binding-site Variable nodes from the local-scope
94    /// resolver). These nodes must be suppressed from user-facing search
95    /// surfaces (`sqry search --exact`, MCP `semantic_search`,
96    /// `relation_query`, etc.) but remain reachable to internal callers
97    /// (binding plane, scope/alias analysis) via the explicit
98    /// `include_synthetic` opt-in path.
99    ///
100    /// Carries no payload — presence in the metadata store at a given
101    /// `NodeId` IS the flag.
102    ///
103    /// Wire format: serializes with `kind == NODE_METADATA_SYNTHETIC`
104    /// (discriminant 2) and BOTH `macro_data` and `classpath_data`
105    /// payload fields encoded as `None`. The on-wire shape stays at the
106    /// existing 5-field per-entry layout, so V10 snapshots written before
107    /// this variant existed (which only carry kinds 0 and 1) decode
108    /// without change, and V10 snapshots written after this variant
109    /// exists are decoded by the new reader by dispatching on `kind`.
110    /// The arena layout (`NodeEntry`) is unchanged.
111    Synthetic,
112}
113
114/// Discriminant values for `NodeMetadata` wire format.
115const NODE_METADATA_MACRO: u8 = 0;
116const NODE_METADATA_CLASSPATH: u8 = 1;
117const NODE_METADATA_SYNTHETIC: u8 = 2;
118
119/// Sparse metadata store keyed by full `NodeId` (index + generation).
120///
121/// Uses `(u32, u64)` tuple key to prevent stale metadata when the
122/// generational arena reuses a slot index with a new generation.
123/// A lookup with `NodeId { index: 5, generation: 3 }` will NOT match metadata
124/// stored for `NodeId { index: 5, generation: 2 }`.
125///
126/// # Memory characteristics
127///
128/// For a typical large codebase (100K nodes), only ~5-10% of nodes have
129/// metadata. A store with 10K entries at ~200 bytes each = ~2MB, which is
130/// acceptable given snapshots are already 10-50MB.
131///
132/// # Serialization
133///
134/// The in-memory representation uses `HashMap` for O(1) lookups. For postcard
135/// serialization (which doesn't support tuple keys natively), we serialize as
136/// a `Vec` of `NodeMetadataEntry` structs with explicit `index` and `generation`
137/// fields, then reconstruct the `HashMap` on deserialization.
138///
139/// For backwards compatibility, legacy entries serialized as bare
140/// `MacroNodeMetadata` (without a wrapping `NodeMetadata` enum tag) are
141/// transparently deserialized as `NodeMetadata::Macro`.
142#[derive(Debug, Clone, Default)]
143pub struct NodeMetadataStore {
144    /// Metadata entries keyed by `(NodeId::index(), NodeId::generation())`.
145    entries: HashMap<(u32, u64), NodeMetadata>,
146}
147
148/// Serialization wrapper for a V7 metadata entry with explicit discriminant.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150struct NodeMetadataEntryV7 {
151    index: u32,
152    generation: u64,
153    /// Discriminant: 0 = Macro, 1 = Classpath
154    kind: u8,
155    /// Macro metadata (present when kind == 0)
156    macro_data: Option<MacroNodeMetadata>,
157    /// Classpath metadata (present when kind == 1)
158    classpath_data: Option<ClasspathNodeMetadata>,
159}
160
161impl Serialize for NodeMetadataStore {
162    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
163        let entries: Vec<NodeMetadataEntryV7> = self
164            .entries
165            .iter()
166            .map(|(&(index, generation), metadata)| match metadata {
167                NodeMetadata::Macro(m) => NodeMetadataEntryV7 {
168                    index,
169                    generation,
170                    kind: NODE_METADATA_MACRO,
171                    macro_data: Some(m.clone()),
172                    classpath_data: None,
173                },
174                NodeMetadata::Classpath(c) => NodeMetadataEntryV7 {
175                    index,
176                    generation,
177                    kind: NODE_METADATA_CLASSPATH,
178                    macro_data: None,
179                    classpath_data: Some(c.clone()),
180                },
181                // C_SUPPRESS: Synthetic flag has no payload. Wire shape
182                // stays at the existing 5-field layout — both Option
183                // payload fields are `None`, only `kind` distinguishes
184                // the variant. Old V10 snapshots only carry kinds 0 and
185                // 1, so they continue to decode unchanged.
186                NodeMetadata::Synthetic => NodeMetadataEntryV7 {
187                    index,
188                    generation,
189                    kind: NODE_METADATA_SYNTHETIC,
190                    macro_data: None,
191                    classpath_data: None,
192                },
193            })
194            .collect();
195        entries.serialize(serializer)
196    }
197}
198
199impl<'de> Deserialize<'de> for NodeMetadataStore {
200    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
201        // V7 format: each entry has an explicit `kind` discriminant.
202        // Legacy V6 entries had no `kind` field, so `kind` will default to 0
203        // (which maps to Macro) via postcard's sequential field decoding.
204        // However, since we bumped the snapshot version to V7, V6 snapshots
205        // will be rejected at the magic byte check before reaching this code.
206        let entries: Vec<NodeMetadataEntryV7> = Vec::deserialize(deserializer)?;
207        let mut map = HashMap::with_capacity(entries.len());
208        for e in entries {
209            let metadata = match e.kind {
210                NODE_METADATA_CLASSPATH => {
211                    let data = e.classpath_data.ok_or_else(|| {
212                        serde::de::Error::custom(
213                            "missing classpath_data for Classpath metadata entry",
214                        )
215                    })?;
216                    NodeMetadata::Classpath(data)
217                }
218                NODE_METADATA_SYNTHETIC => NodeMetadata::Synthetic,
219                _ => {
220                    // Default: treat as Macro (covers both explicit 0 and legacy)
221                    let data = e.macro_data.unwrap_or_default();
222                    NodeMetadata::Macro(data)
223                }
224            };
225            map.insert((e.index, e.generation), metadata);
226        }
227        Ok(Self { entries: map })
228    }
229}
230
231impl NodeMetadataStore {
232    /// Create a new empty metadata store.
233    #[must_use]
234    pub fn new() -> Self {
235        Self::default()
236    }
237
238    /// Get metadata for a node, if any exists.
239    ///
240    /// Returns `None` if no metadata is stored for this node, or if the
241    /// generation doesn't match (indicating a stale reference).
242    #[must_use]
243    pub fn get(&self, node_id: NodeId) -> Option<&MacroNodeMetadata> {
244        match self.entries.get(&(node_id.index(), node_id.generation()))? {
245            NodeMetadata::Macro(m) => Some(m),
246            NodeMetadata::Classpath(_) | NodeMetadata::Synthetic => None,
247        }
248    }
249
250    /// Get the full `NodeMetadata` envelope for a node, if any exists.
251    ///
252    /// Returns `None` if no metadata is stored for this node, or if the
253    /// generation doesn't match (indicating a stale reference).
254    #[must_use]
255    pub fn get_metadata(&self, node_id: NodeId) -> Option<&NodeMetadata> {
256        self.entries.get(&(node_id.index(), node_id.generation()))
257    }
258
259    /// Get mutable metadata for a node, if any exists.
260    #[must_use]
261    pub fn get_mut(&mut self, node_id: NodeId) -> Option<&mut MacroNodeMetadata> {
262        match self
263            .entries
264            .get_mut(&(node_id.index(), node_id.generation()))?
265        {
266            NodeMetadata::Macro(m) => Some(m),
267            NodeMetadata::Classpath(_) | NodeMetadata::Synthetic => None,
268        }
269    }
270
271    /// Insert macro metadata for a node, replacing any existing entry.
272    ///
273    /// Convenience method that wraps the metadata in `NodeMetadata::Macro`.
274    pub fn insert(&mut self, node_id: NodeId, metadata: MacroNodeMetadata) {
275        self.entries.insert(
276            (node_id.index(), node_id.generation()),
277            NodeMetadata::Macro(metadata),
278        );
279    }
280
281    /// Insert typed metadata for a node, replacing any existing entry.
282    pub fn insert_metadata(&mut self, node_id: NodeId, metadata: NodeMetadata) {
283        self.entries
284            .insert((node_id.index(), node_id.generation()), metadata);
285    }
286
287    /// Mark a node as synthetic (C_SUPPRESS).
288    ///
289    /// Synthetic nodes are internal placeholders the language plugins
290    /// emit for binding-plane / scope analysis (e.g., the Go plugin's
291    /// `<field:operand.field>` field-access shadows and the
292    /// `name@<offset>` per-binding-site Variable nodes from the local
293    /// scope resolver). They MUST be filtered out of every user-facing
294    /// surface (CLI `search`, MCP `semantic_search` / `relation_query`,
295    /// etc.) but stay reachable to internal binding-plane callers via
296    /// the explicit `include_synthetic` opt-in path on
297    /// [`crate::graph::unified::concurrent::graph::GraphSnapshot::find_by_pattern_with_options`].
298    ///
299    /// If the node already has macro or classpath metadata, calling this
300    /// method REPLACES it with the synthetic marker. Synthetic nodes by
301    /// construction never carry macro/classpath provenance — the Go
302    /// plugin emits them as plain Variable / Property scaffolding —
303    /// so the replacement collision is benign.
304    pub fn mark_synthetic(&mut self, node_id: NodeId) {
305        self.entries.insert(
306            (node_id.index(), node_id.generation()),
307            NodeMetadata::Synthetic,
308        );
309    }
310
311    /// Returns `true` iff the node is flagged as synthetic.
312    ///
313    /// Returns `false` for non-synthetic nodes, missing entries, and
314    /// stale `NodeId` generations. Companion to [`Self::mark_synthetic`];
315    /// see [`NodeMetadata::Synthetic`] for the full semantic contract.
316    #[must_use]
317    pub fn is_synthetic(&self, node_id: NodeId) -> bool {
318        matches!(
319            self.entries.get(&(node_id.index(), node_id.generation())),
320            Some(NodeMetadata::Synthetic)
321        )
322    }
323
324    /// Get or insert default macro metadata for a node.
325    ///
326    /// # Panics
327    ///
328    /// Panics if the node already has a `Classpath` metadata entry at this key,
329    /// which indicates a programming error — callers must not mix metadata types
330    /// for the same node.
331    pub fn get_or_insert_default(&mut self, node_id: NodeId) -> &mut MacroNodeMetadata {
332        let entry = self
333            .entries
334            .entry((node_id.index(), node_id.generation()))
335            .or_insert_with(|| NodeMetadata::Macro(MacroNodeMetadata::default()));
336        match entry {
337            NodeMetadata::Macro(m) => m,
338            // If a non-macro entry already exists at this key, this is a
339            // programming error — callers should not mix metadata types for the
340            // same node. Panic in debug, return a default in release.
341            NodeMetadata::Classpath(_) => {
342                panic!("get_or_insert_default called on a Classpath metadata entry")
343            }
344            NodeMetadata::Synthetic => {
345                panic!("get_or_insert_default called on a Synthetic metadata entry")
346            }
347        }
348    }
349
350    /// Remove metadata for a node.
351    pub fn remove(&mut self, node_id: NodeId) -> Option<MacroNodeMetadata> {
352        match self
353            .entries
354            .remove(&(node_id.index(), node_id.generation()))?
355        {
356            NodeMetadata::Macro(m) => Some(m),
357            NodeMetadata::Classpath(_) | NodeMetadata::Synthetic => None,
358        }
359    }
360
361    /// Remove typed metadata for a node.
362    pub fn remove_metadata(&mut self, node_id: NodeId) -> Option<NodeMetadata> {
363        self.entries
364            .remove(&(node_id.index(), node_id.generation()))
365    }
366
367    /// Returns the number of nodes with metadata.
368    #[must_use]
369    pub fn len(&self) -> usize {
370        self.entries.len()
371    }
372
373    /// Returns true if no nodes have metadata.
374    #[must_use]
375    pub fn is_empty(&self) -> bool {
376        self.entries.is_empty()
377    }
378
379    /// Iterate over all metadata entries.
380    pub fn iter(&self) -> impl Iterator<Item = ((u32, u64), &MacroNodeMetadata)> {
381        self.entries.iter().filter_map(|(&k, v)| match v {
382            NodeMetadata::Macro(m) => Some((k, m)),
383            NodeMetadata::Classpath(_) | NodeMetadata::Synthetic => None,
384        })
385    }
386
387    /// Iterate over all metadata entries as typed `NodeMetadata`.
388    pub fn iter_all(&self) -> impl Iterator<Item = ((u32, u64), &NodeMetadata)> {
389        self.entries.iter().map(|(&k, v)| (k, v))
390    }
391
392    /// Merge another metadata store into this one.
393    ///
394    /// Entries from `other` overwrite existing entries with the same key.
395    pub fn merge(&mut self, other: &NodeMetadataStore) {
396        for (&key, value) in &other.entries {
397            self.entries.insert(key, value.clone());
398        }
399    }
400
401    /// Retain only entries whose `(index, generation)` key satisfies `keep`.
402    ///
403    /// Used by the Gate 0b [`NodeIdBearing`] impl
404    /// (`sqry-core/src/graph/unified/rebuild/coverage.rs`) to drop
405    /// metadata for tombstoned NodeIds during
406    /// `RebuildGraph::finalize()`. Exposed at `pub(crate)` scope because
407    /// only the rebuild pipeline needs predicate-based filtering;
408    /// downstream callers use the targeted [`Self::remove`] /
409    /// [`Self::remove_metadata`] entry points.
410    ///
411    /// `#[allow(dead_code)]` is present because Gate 0b delivers only
412    /// the scaffolding — the call sites in `RebuildGraph::finalize()`
413    /// (Gate 0c) and the residue check (Gate 0d) land in follow-up
414    /// commits. Unit coverage in
415    /// `sqry-core/src/graph/unified/rebuild/coverage.rs::tests` already
416    /// exercises this helper through the [`NodeIdBearing::retain_nodes`]
417    /// impl.
418    ///
419    /// [`NodeIdBearing`]: crate::graph::unified::rebuild::coverage::NodeIdBearing
420    /// [`NodeIdBearing::retain_nodes`]: crate::graph::unified::rebuild::coverage::NodeIdBearing::retain_nodes
421    #[allow(dead_code)]
422    pub(crate) fn retain_entries<F>(&mut self, mut keep: F)
423    where
424        F: FnMut(u32, u64) -> bool,
425    {
426        self.entries
427            .retain(|&(index, generation), _meta| keep(index, generation));
428    }
429}
430
431impl PartialEq for NodeMetadataStore {
432    fn eq(&self, other: &Self) -> bool {
433        self.entries == other.entries
434    }
435}
436
437impl Eq for NodeMetadataStore {}
438
439impl crate::graph::unified::memory::GraphMemorySize for NodeMetadataStore {
440    fn heap_bytes(&self) -> usize {
441        use crate::graph::unified::memory::HASHMAP_ENTRY_OVERHEAD;
442
443        let base = self.entries.capacity()
444            * (std::mem::size_of::<(u32, u64)>()
445                + std::mem::size_of::<NodeMetadata>()
446                + HASHMAP_ENTRY_OVERHEAD);
447        // Account for heap Strings inside each metadata variant.
448        let inner: usize = self
449            .entries
450            .values()
451            .map(|meta| match meta {
452                NodeMetadata::Macro(m) => {
453                    m.macro_source.as_ref().map_or(0, String::capacity)
454                        + m.cfg_condition.as_ref().map_or(0, String::capacity)
455                        + m.unresolved_attributes
456                            .iter()
457                            .map(String::capacity)
458                            .sum::<usize>()
459                        + m.unresolved_attributes.capacity() * std::mem::size_of::<String>()
460                }
461                NodeMetadata::Classpath(c) => {
462                    c.coordinates.as_ref().map_or(0, String::capacity)
463                        + c.jar_path.capacity()
464                        + c.fqn.capacity()
465                }
466                // Payload-less marker: only the enum tag itself, which
467                // is already counted by `mem::size_of::<NodeMetadata>()`
468                // in the `base` calculation above.
469                NodeMetadata::Synthetic => 0,
470            })
471            .sum();
472        base + inner
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn test_metadata_store_basic_operations() {
482        let mut store = NodeMetadataStore::new();
483        assert!(store.is_empty());
484        assert_eq!(store.len(), 0);
485
486        let node = NodeId::new(5, 1);
487        let metadata = MacroNodeMetadata {
488            macro_generated: Some(true),
489            macro_source: Some("derive_Debug".to_string()),
490            ..Default::default()
491        };
492
493        store.insert(node, metadata.clone());
494        assert_eq!(store.len(), 1);
495        assert!(!store.is_empty());
496
497        let retrieved = store.get(node).unwrap();
498        assert_eq!(retrieved.macro_generated, Some(true));
499        assert_eq!(retrieved.macro_source.as_deref(), Some("derive_Debug"));
500    }
501
502    #[test]
503    fn test_metadata_full_nodeid_key() {
504        let mut store = NodeMetadataStore::new();
505
506        let node_gen1 = NodeId::new(5, 1);
507        let node_gen2 = NodeId::new(5, 2);
508
509        store.insert(
510            node_gen1,
511            MacroNodeMetadata {
512                macro_generated: Some(true),
513                ..Default::default()
514            },
515        );
516
517        // Same index, different generation → should NOT match
518        assert!(store.get(node_gen2).is_none());
519
520        // Same index, same generation → should match
521        assert!(store.get(node_gen1).is_some());
522    }
523
524    #[test]
525    fn test_metadata_slot_reuse_no_stale_data() {
526        let mut store = NodeMetadataStore::new();
527
528        // Simulate: node at index 5 gen 1 has metadata
529        let old_node = NodeId::new(5, 1);
530        store.insert(
531            old_node,
532            MacroNodeMetadata {
533                cfg_condition: Some("test".to_string()),
534                ..Default::default()
535            },
536        );
537
538        // Simulate: slot 5 is reused with generation 2 (new node)
539        let new_node = NodeId::new(5, 2);
540
541        // New node should NOT see old metadata
542        assert!(store.get(new_node).is_none());
543
544        // Old node still accessible
545        assert_eq!(
546            store.get(old_node).unwrap().cfg_condition.as_deref(),
547            Some("test")
548        );
549    }
550
551    #[test]
552    fn test_metadata_store_postcard_roundtrip() {
553        let mut store = NodeMetadataStore::new();
554
555        store.insert(
556            NodeId::new(1, 0),
557            MacroNodeMetadata {
558                macro_generated: Some(true),
559                macro_source: Some("derive_Debug".to_string()),
560                cfg_condition: Some("test".to_string()),
561                cfg_active: Some(true),
562                proc_macro_kind: Some(ProcMacroFunctionKind::Derive),
563                expansion_cached: Some(false),
564                unresolved_attributes: vec!["my_attr".to_string()],
565            },
566        );
567
568        store.insert(
569            NodeId::new(42, 3),
570            MacroNodeMetadata {
571                cfg_condition: Some("feature = \"serde\"".to_string()),
572                ..Default::default()
573            },
574        );
575
576        let bytes = postcard::to_allocvec(&store).expect("serialize");
577        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
578
579        assert_eq!(store, deserialized);
580    }
581
582    #[test]
583    fn test_empty_metadata_store_zero_overhead() {
584        let store = NodeMetadataStore::new();
585        let bytes = postcard::to_allocvec(&store).expect("serialize");
586
587        // Empty HashMap serializes to a single varint length of 0
588        assert!(
589            bytes.len() <= 2,
590            "Empty store should serialize to minimal bytes, got {} bytes",
591            bytes.len()
592        );
593    }
594
595    #[test]
596    fn test_metadata_store_merge() {
597        let mut store1 = NodeMetadataStore::new();
598        let mut store2 = NodeMetadataStore::new();
599
600        store1.insert(
601            NodeId::new(1, 0),
602            MacroNodeMetadata {
603                macro_generated: Some(true),
604                ..Default::default()
605            },
606        );
607
608        store2.insert(
609            NodeId::new(2, 0),
610            MacroNodeMetadata {
611                cfg_condition: Some("test".to_string()),
612                ..Default::default()
613            },
614        );
615
616        store1.merge(&store2);
617        assert_eq!(store1.len(), 2);
618        assert!(store1.get(NodeId::new(1, 0)).is_some());
619        assert!(store1.get(NodeId::new(2, 0)).is_some());
620    }
621
622    #[test]
623    fn test_proc_macro_function_kind_serde() {
624        let kinds = [
625            ProcMacroFunctionKind::Derive,
626            ProcMacroFunctionKind::Attribute,
627            ProcMacroFunctionKind::FunctionLike,
628        ];
629
630        for kind in kinds {
631            let bytes = postcard::to_allocvec(&kind).expect("serialize");
632            let deserialized: ProcMacroFunctionKind =
633                postcard::from_bytes(&bytes).expect("deserialize");
634            assert_eq!(kind, deserialized);
635        }
636    }
637
638    #[test]
639    fn test_metadata_get_or_insert_default() {
640        let mut store = NodeMetadataStore::new();
641        let node = NodeId::new(10, 0);
642
643        // First access creates default
644        let meta = store.get_or_insert_default(node);
645        meta.cfg_condition = Some("test".to_string());
646
647        // Second access returns existing
648        let meta = store.get(node).unwrap();
649        assert_eq!(meta.cfg_condition.as_deref(), Some("test"));
650    }
651
652    #[test]
653    fn test_metadata_remove() {
654        let mut store = NodeMetadataStore::new();
655        let node = NodeId::new(1, 0);
656
657        store.insert(
658            node,
659            MacroNodeMetadata {
660                macro_generated: Some(true),
661                ..Default::default()
662            },
663        );
664
665        assert!(store.get(node).is_some());
666        let removed = store.remove(node);
667        assert!(removed.is_some());
668        assert!(store.get(node).is_none());
669        assert!(store.is_empty());
670    }
671
672    #[test]
673    fn test_metadata_store_large_scale() {
674        let mut store = NodeMetadataStore::new();
675
676        // Insert 10K entries (simulating ~10% of a 100K-node codebase)
677        for i in 0..10_000u32 {
678            store.insert(
679                NodeId::new(i, 0),
680                MacroNodeMetadata {
681                    cfg_condition: Some(format!("feature_{i}")),
682                    ..Default::default()
683                },
684            );
685        }
686
687        assert_eq!(store.len(), 10_000);
688
689        // Verify O(1) lookups
690        assert!(store.get(NodeId::new(0, 0)).is_some());
691        assert!(store.get(NodeId::new(5_000, 0)).is_some());
692        assert!(store.get(NodeId::new(9_999, 0)).is_some());
693        assert!(store.get(NodeId::new(10_000, 0)).is_none());
694
695        // Verify round-trip
696        let bytes = postcard::to_allocvec(&store).expect("serialize");
697        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
698        assert_eq!(store, deserialized);
699    }
700
701    #[test]
702    fn test_classpath_metadata_insert_and_get() {
703        let mut store = NodeMetadataStore::new();
704        let node = NodeId::new(100, 0);
705
706        let cp_meta = ClasspathNodeMetadata {
707            coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
708            jar_path: "/home/user/.m2/repository/guava-33.0.0.jar".to_string(),
709            fqn: "com.google.common.collect.ImmutableList".to_string(),
710            is_direct_dependency: true,
711        };
712
713        store.insert_metadata(node, NodeMetadata::Classpath(cp_meta.clone()));
714        assert_eq!(store.len(), 1);
715
716        // get() should return None (only returns macro metadata)
717        assert!(store.get(node).is_none());
718
719        // get_metadata() should return the classpath metadata
720        let retrieved = store.get_metadata(node).unwrap();
721        match retrieved {
722            NodeMetadata::Classpath(cp) => {
723                assert_eq!(cp.fqn, "com.google.common.collect.ImmutableList");
724                assert_eq!(
725                    cp.coordinates.as_deref(),
726                    Some("com.google.guava:guava:33.0.0")
727                );
728                assert!(cp.is_direct_dependency);
729            }
730            NodeMetadata::Macro(_) => panic!("expected Classpath variant"),
731            NodeMetadata::Synthetic => panic!("expected Classpath variant, got Synthetic"),
732        }
733    }
734
735    #[test]
736    fn test_classpath_metadata_postcard_roundtrip() {
737        let mut store = NodeMetadataStore::new();
738
739        // Mix of macro and classpath metadata
740        store.insert(
741            NodeId::new(1, 0),
742            MacroNodeMetadata {
743                macro_generated: Some(true),
744                ..Default::default()
745            },
746        );
747
748        store.insert_metadata(
749            NodeId::new(2, 0),
750            NodeMetadata::Classpath(ClasspathNodeMetadata {
751                coordinates: Some("org.slf4j:slf4j-api:2.0.0".to_string()),
752                jar_path: "slf4j-api-2.0.0.jar".to_string(),
753                fqn: "org.slf4j.Logger".to_string(),
754                is_direct_dependency: false,
755            }),
756        );
757
758        let bytes = postcard::to_allocvec(&store).expect("serialize");
759        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
760        assert_eq!(store, deserialized);
761        assert_eq!(deserialized.len(), 2);
762
763        // Verify macro entry
764        assert!(deserialized.get(NodeId::new(1, 0)).is_some());
765
766        // Verify classpath entry via get_metadata
767        let cp = deserialized.get_metadata(NodeId::new(2, 0)).unwrap();
768        assert!(matches!(cp, NodeMetadata::Classpath(_)));
769    }
770
771    #[test]
772    fn test_node_metadata_store_json_roundtrip() {
773        let mut store = NodeMetadataStore::new();
774
775        store.insert(
776            NodeId::new(1, 0),
777            MacroNodeMetadata {
778                macro_generated: Some(true),
779                macro_source: Some("serde_derive".to_string()),
780                ..Default::default()
781            },
782        );
783
784        store.insert_metadata(
785            NodeId::new(2, 0),
786            NodeMetadata::Classpath(ClasspathNodeMetadata {
787                coordinates: None,
788                jar_path: "rt.jar".to_string(),
789                fqn: "java.lang.String".to_string(),
790                is_direct_dependency: true,
791            }),
792        );
793
794        let json = serde_json::to_string(&store).unwrap();
795        let deserialized: NodeMetadataStore = serde_json::from_str(&json).unwrap();
796        assert_eq!(store, deserialized);
797    }
798
799    #[test]
800    fn test_iter_all_includes_both_types() {
801        let mut store = NodeMetadataStore::new();
802
803        store.insert(
804            NodeId::new(1, 0),
805            MacroNodeMetadata {
806                macro_generated: Some(true),
807                ..Default::default()
808            },
809        );
810
811        store.insert_metadata(
812            NodeId::new(2, 0),
813            NodeMetadata::Classpath(ClasspathNodeMetadata {
814                coordinates: None,
815                jar_path: "test.jar".to_string(),
816                fqn: "com.example.Test".to_string(),
817                is_direct_dependency: true,
818            }),
819        );
820
821        // iter() only yields macro entries
822        let macro_entries: Vec<_> = store.iter().collect();
823        assert_eq!(macro_entries.len(), 1);
824
825        // iter_all() yields all entries
826        let all_entries: Vec<_> = store.iter_all().collect();
827        assert_eq!(all_entries.len(), 2);
828    }
829
830    #[test]
831    fn test_remove_metadata_classpath() {
832        let mut store = NodeMetadataStore::new();
833        let node = NodeId::new(50, 0);
834
835        store.insert_metadata(
836            node,
837            NodeMetadata::Classpath(ClasspathNodeMetadata {
838                coordinates: None,
839                jar_path: "test.jar".to_string(),
840                fqn: "Test".to_string(),
841                is_direct_dependency: true,
842            }),
843        );
844
845        assert_eq!(store.len(), 1);
846
847        // remove() returns None for non-macro entries
848        let removed = store.remove(node);
849        assert!(removed.is_none());
850        // The entry is still gone from the store because remove() always removes
851        assert!(store.is_empty());
852    }
853
854    // ------------------------------------------------------------------
855    // C_SUPPRESS: NodeMetadata::Synthetic variant
856    // ------------------------------------------------------------------
857
858    #[test]
859    fn synthetic_mark_and_query() {
860        let mut store = NodeMetadataStore::new();
861        let node = NodeId::new(7, 1);
862
863        assert!(!store.is_synthetic(node), "missing entry must report false");
864
865        store.mark_synthetic(node);
866        assert!(store.is_synthetic(node));
867        assert_eq!(store.len(), 1);
868
869        // get() returns None — synthetic carries no MacroNodeMetadata payload.
870        assert!(store.get(node).is_none());
871
872        // get_metadata() returns the typed envelope.
873        assert!(matches!(
874            store.get_metadata(node),
875            Some(NodeMetadata::Synthetic),
876        ));
877
878        // Stale generation must NOT see the synthetic flag.
879        let stale = NodeId::new(7, 2);
880        assert!(!store.is_synthetic(stale));
881    }
882
883    #[test]
884    fn synthetic_replaces_other_variants() {
885        let mut store = NodeMetadataStore::new();
886        let node = NodeId::new(11, 1);
887
888        store.insert(
889            node,
890            MacroNodeMetadata {
891                cfg_condition: Some("test".to_string()),
892                ..Default::default()
893            },
894        );
895        assert!(store.get(node).is_some());
896
897        store.mark_synthetic(node);
898        assert!(store.is_synthetic(node));
899        // The macro payload is replaced — `get` no longer surfaces a MacroNodeMetadata.
900        assert!(store.get(node).is_none());
901        assert_eq!(store.len(), 1);
902    }
903
904    #[test]
905    fn synthetic_postcard_roundtrip_preserves_v10_wire_shape() {
906        // Build a store that mixes all three variants so the postcard
907        // round-trip exercises the `kind` discriminant dispatch end-to-end.
908        let mut store = NodeMetadataStore::new();
909
910        store.insert(
911            NodeId::new(1, 0),
912            MacroNodeMetadata {
913                macro_generated: Some(true),
914                ..Default::default()
915            },
916        );
917        store.insert_metadata(
918            NodeId::new(2, 0),
919            NodeMetadata::Classpath(ClasspathNodeMetadata {
920                coordinates: None,
921                jar_path: "x.jar".to_string(),
922                fqn: "com.example.X".to_string(),
923                is_direct_dependency: true,
924            }),
925        );
926        store.mark_synthetic(NodeId::new(3, 0));
927        store.mark_synthetic(NodeId::new(99, 5));
928
929        let bytes = postcard::to_allocvec(&store).expect("serialize");
930        let decoded: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
931
932        assert_eq!(store, decoded);
933        assert_eq!(decoded.len(), 4);
934        assert!(decoded.is_synthetic(NodeId::new(3, 0)));
935        assert!(decoded.is_synthetic(NodeId::new(99, 5)));
936        assert!(matches!(
937            decoded.get_metadata(NodeId::new(2, 0)),
938            Some(NodeMetadata::Classpath(_))
939        ));
940        assert!(matches!(
941            decoded.get_metadata(NodeId::new(1, 0)),
942            Some(NodeMetadata::Macro(_))
943        ));
944    }
945
946    #[test]
947    fn synthetic_v10_legacy_snapshot_decodes_without_synthetic_entries() {
948        // Simulate a "legacy" V10 snapshot written before the Synthetic
949        // variant existed — only kinds 0 and 1 appear on the wire.
950        let mut legacy = NodeMetadataStore::new();
951        legacy.insert(
952            NodeId::new(1, 0),
953            MacroNodeMetadata {
954                macro_generated: Some(true),
955                ..Default::default()
956            },
957        );
958        legacy.insert_metadata(
959            NodeId::new(2, 0),
960            NodeMetadata::Classpath(ClasspathNodeMetadata {
961                coordinates: None,
962                jar_path: "y.jar".to_string(),
963                fqn: "com.example.Y".to_string(),
964                is_direct_dependency: false,
965            }),
966        );
967
968        // Round-trip through the same writer/reader the new code uses;
969        // the legacy fixture has zero synthetic entries because they did
970        // not exist when it was written.
971        let bytes = postcard::to_allocvec(&legacy).expect("serialize");
972        let decoded: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
973
974        assert_eq!(legacy, decoded);
975        assert!(!decoded.is_synthetic(NodeId::new(1, 0)));
976        assert!(!decoded.is_synthetic(NodeId::new(2, 0)));
977        assert_eq!(decoded.len(), 2);
978    }
979
980    #[test]
981    fn test_remove_metadata_typed() {
982        let mut store = NodeMetadataStore::new();
983        let node = NodeId::new(50, 0);
984
985        store.insert_metadata(
986            node,
987            NodeMetadata::Classpath(ClasspathNodeMetadata {
988                coordinates: None,
989                jar_path: "test.jar".to_string(),
990                fqn: "Test".to_string(),
991                is_direct_dependency: true,
992            }),
993        );
994
995        // remove_metadata() returns the full NodeMetadata
996        let removed = store.remove_metadata(node);
997        assert!(matches!(removed, Some(NodeMetadata::Classpath(_))));
998        assert!(store.is_empty());
999    }
1000}