Skip to main content

sqry_core/graph/unified/persistence/
format.rs

1//! Binary format definition for graph persistence.
2//!
3//! This module defines the on-disk format for persisted graphs.
4
5use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8
9use super::manifest::ConfigProvenance;
10
11/// Magic bytes identifying a sqry graph file (legacy alias for V7).
12///
13/// Version history:
14/// - V1: Initial format (bincode)
15/// - V2: Added config provenance support (bincode)
16/// - V3: Added plugin version tracking (bincode)
17/// - V4: Migrated to postcard serialization with length-prefixed framing
18/// - V5: Added `HttpMethod::All` variant for wildcard endpoint matching
19/// - V6: Added `NodeMetadataStore` for macro boundary analysis + `CfgGate` edge kind
20/// - V7: Added classpath NodeKind/EdgeKind variants, `NodeMetadata` enum, `FileEntry.is_external`
21/// - V8 (Phase 1 fact-layer hardening): Adds `GraphHeader.fact_epoch`, dense `NodeProvenanceStore`,
22///   dense `EdgeProvenanceStore`, and `FileEntry` attribution fields (`content_hash`, `indexed_at`,
23///   `source_uri`). The legacy `MAGIC_BYTES` / `VERSION` exports are preserved during Phase 1
24///   to keep existing call sites compiling; later units bump the writer to V8 and treat V7 as
25///   read-only.
26pub const MAGIC_BYTES: &[u8; 13] = b"SQRY_GRAPH_V7";
27
28/// Legacy V7 format version constant, preserved for existing call sites.
29///
30/// See [`CURRENT_VERSION`] / [`FormatVersion`] for the Phase 1+ versioning contract.
31pub const VERSION: u32 = 7;
32
33/// Phase 1 V7 magic bytes (re-export under the versioned name).
34///
35/// Equal to [`MAGIC_BYTES`]; the versioned name makes the legacy path explicit in
36/// reader dispatch logic (`load_from_path` branching on magic bytes).
37pub const MAGIC_BYTES_V7: &[u8; 13] = b"SQRY_GRAPH_V7";
38
39/// Phase 1 V8 magic bytes.
40///
41/// Emitted by the Phase 1 fact-layer writer (P1U06) and accepted by the Phase 1
42/// reader (P1U07). The magic is the sole versioning contract — no in-format
43/// revision counter is introduced.
44pub const MAGIC_BYTES_V8: &[u8; 13] = b"SQRY_GRAPH_V8";
45
46/// Phase 2 V9 magic bytes.
47///
48/// Emitted by the Phase 2 binding-plane writer (P2U12) and accepted by the V9
49/// reader. V9 extends V8 with `ScopeArena`, `AliasTable`, `ShadowTable`, and
50/// `ScopeProvenanceStore` fields. V8 snapshots are upconverted to V9 inline on
51/// load by running `derive_binding_plane`.
52pub const MAGIC_BYTES_V9: &[u8; 13] = b"SQRY_GRAPH_V9";
53
54/// Phase 3 V10 magic bytes.
55///
56/// Emitted by the Phase 3 derived-db writer (DB03) and accepted by the V10
57/// reader. V10 extends V9 with `FileSegmentTable`. V9 snapshots are
58/// upconverted to V10 inline on load by rebuilding the segment table from
59/// the node arena.
60pub const MAGIC_BYTES_V10: &[u8; 14] = b"SQRY_GRAPH_V10";
61
62/// Phase A (C indirect-call precision) V11 magic bytes.
63///
64/// Emitted by the Phase A snapshot writer (U03) and accepted by the V11
65/// reader. V11 extends V10 with:
66/// - `StoredEntry { typed, flags }` metadata-store wire format (see U02).
67///   The bitset-style `NodeFlags` channel lets a node carry SYNTHETIC,
68///   `ADDRESS_TAKEN`, and `CALLSITE_PROMISCUOUS` independently of any
69///   `TypedMetadata::Macro` or `TypedMetadata::Classpath` payload.
70/// - Reserved `c_indirect_tables: Option<_>` slot on the snapshot envelope
71///   for the Phase A C-icall side tables (populated by U09; absent in V10).
72///
73/// V10 snapshots are upconverted to V11 inline on load by mapping the legacy
74/// metadata variants into the new `StoredEntry` shape and setting
75/// `c_indirect_tables` to `None`.
76pub const MAGIC_BYTES_V11: &[u8; 14] = b"SQRY_GRAPH_V11";
77
78/// Phase β joint-stubs V12 magic bytes.
79///
80/// Emitted by the Phase β joint-stubs writer (this PR) and accepted by the
81/// V12 reader. V12 extends V11 with TWO new snapshot-envelope slots that
82/// carry per-snapshot side tables for the framework-routes (Plan A) and
83/// dispatch-tables (Plan B) work. The shapes themselves are reserved-empty
84/// in this PR; the downstream extractor / resolver PRs populate them.
85///
86/// V12 envelope additions:
87/// - `framework_routes: BTreeMap<NodeId, FrameworkRouteMetadata>` — Plan A
88///   slot. Empty for non-extractor workspaces (and for every V11 → V12
89///   upconvert). Re-attached onto the in-memory
90///   `NodeMetadataStore::framework_routes` field on load.
91/// - `dispatch_tables: DispatchTables` — Plan B slot. Empty for
92///   non-resolver workspaces (and for every V11 → V12 upconvert).
93///   Re-attached onto the in-memory `NodeMetadataStore::dispatch_tables`
94///   field on load.
95///
96/// V11 snapshots are upconverted to V12 inline on load by zero-initialising
97/// the two new envelope slots. No metadata-store reshape is required (V11
98/// metadata-store wire shape is preserved bit-for-bit inside V12 envelopes).
99pub const MAGIC_BYTES_V12: &[u8; 14] = b"SQRY_GRAPH_V12";
100
101/// T3 V13 magic bytes.
102///
103/// Emitted by the T3 writer (Cluster A) and accepted by the V13 reader. V13
104/// is shape-identical to V12 — no fields are added or removed from the
105/// snapshot data struct. The bump exists purely so V12 readers reject
106/// snapshots containing the `EdgeKind::Wraps` variant (added in T3) rather
107/// than silently failing to decode an unknown discriminant. V12 snapshots
108/// upconvert to V13 inline on load via an identity-mapping `upconvert_v12_to_v13`.
109pub const MAGIC_BYTES_V13: &[u8; 14] = b"SQRY_GRAPH_V13";
110
111/// T2 V14 magic bytes.
112///
113/// Emitted by the T2 writer (Go channel pairing + generic instantiation) and
114/// accepted by the V14 reader. V14 carries the new `NodeKind::Channel` variant
115/// (inserted before `Other`, shifting `Other`'s positional postcard
116/// discriminant) plus the additive `EdgeKind::ChannelPeer` /
117/// `EdgeKind::Instantiates` variants. Because the `NodeKind` shift is NOT
118/// additive, V13 snapshots are upconverted to V14 inline on load via
119/// `upconvert_v13_to_v14`, which deserializes the node arena + `kind_index`
120/// through the frozen `NodeKindV13` wire mirror (`legacy_v13.rs`) and
121/// re-keys them under the new `NodeKind` layout. A V13-or-earlier reader
122/// opening a V14 snapshot rejects it at the magic gate.
123pub const MAGIC_BYTES_V14: &[u8; 14] = b"SQRY_GRAPH_V14";
124
125/// Body-shape-descriptor V15 magic bytes.
126///
127/// Emitted by the V15 writer (per-function identifier-blind `ShapeDescriptor`
128/// side table) and accepted by the V15 reader. V15 is shape-identical to V14 on
129/// the wire **plus** one appended envelope slot: a flat
130/// `Vec<(NodeId, ShapeDescriptor)>` carrying the `shape_descriptors` side table
131/// (the `NodeMetadataStore` custom serde only emits `entries`, so the descriptors
132/// ride a dedicated payload field, mirroring the V12 framework-route / dispatch
133/// envelope slots). V14 snapshots upconvert to V15 inline on load via
134/// `upconvert_v14_to_v15`, which moves every V14 field by value and leaves the
135/// descriptor table empty. A V14-or-earlier reader opening a V15 snapshot rejects
136/// it at the magic gate.
137pub const MAGIC_BYTES_V15: &[u8; 14] = b"SQRY_GRAPH_V15";
138
139/// Definition-signal V16 magic bytes.
140///
141/// Emitted by the V16 writer and accepted by the V16 reader. V16 is
142/// shape-identical to V15 on the wire: the only change is the `NodeEntry`
143/// gains a trailing `is_definition: bool` field (positional postcard, appended
144/// last with `#[serde(default)]`). A pre-V16 stream therefore decodes
145/// `is_definition = false` for every node, so the `upconvert_v15_to_v16`
146/// upconvert is a no-op that only advances the version. The bump exists so the
147/// `definition_signal_present` runtime marker can distinguish a snapshot that
148/// carried genuine definition signal (>= V16) from a pre-V16 snapshot whose
149/// `is_definition` bits are all defaulted `false`. A V15-or-earlier reader
150/// opening a V16 snapshot rejects it at the magic gate.
151pub const MAGIC_BYTES_V16: &[u8; 14] = b"SQRY_GRAPH_V16";
152
153/// Import-classification-signal V17 magic bytes.
154///
155/// Emitted by the V17 writer and accepted by the V17 reader. V17 is
156/// shape-identical to V16 on the wire: the import-classification bits
157/// (`NodeFlags::IMPORT_STDLIB` / `NodeFlags::IMPORT_RELATIVE`) ride the existing
158/// `StoredEntry.flags` channel that has round-tripped since V11, so no field is
159/// added or removed. The `upconvert_v16_to_v17` upconvert is a no-op that only
160/// advances the version. The bump exists so the
161/// `import_classification_signal_present` runtime marker can distinguish a
162/// snapshot that carried genuine import-classification signal (>= V17) from a
163/// pre-V17 snapshot whose import nodes never had their stdlib / relative bits
164/// classified (all clear, but clear-on-purpose is indistinguishable from
165/// never-classified without the marker). A V16-or-earlier reader opening a V17
166/// snapshot rejects it at the magic gate.
167pub const MAGIC_BYTES_V17: &[u8; 14] = b"SQRY_GRAPH_V17";
168
169/// Legacy V7 numeric version, exposed with a versioned name so the Phase 1 reader
170/// dispatch can cite it explicitly. Equal to [`VERSION`].
171pub const LEGACY_VERSION_V7: u32 = 7;
172
173/// Typed snapshot format version.
174///
175/// Phase 1 introduces V8 as read/write and preserves V7 as a read-only compatibility
176/// path. Phase 2 introduces V9 as read/write and preserves V8 as an upconvert path.
177/// Later format additions bump the magic bytes (V10, …) rather than relying on any
178/// in-format revision counter.
179#[repr(u32)]
180#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
181pub enum FormatVersion {
182    /// Legacy V7 — read-only after Phase 1 lands.
183    V7 = 7,
184    /// V8 — read/write after Phase 1, upconvert source after Phase 2.
185    V8 = 8,
186    /// V9 — read/write after Phase 2 (binding plane: `ScopeArena`, `AliasTable`,
187    /// `ShadowTable`, `ScopeProvenanceStore`). V8 snapshots are upconverted to V9
188    /// inline on load by running `derive_binding_plane`.
189    V9 = 9,
190    /// V10 — read/write after Phase 3 (derived DB: `FileSegmentTable`). V9
191    /// snapshots are upconverted to V10 inline on load by rebuilding the
192    /// segment table from the node arena.
193    V10 = 10,
194    /// V11 — read/write after Phase A C-icall precision (`StoredEntry`
195    /// metadata wire format + reserved `c_indirect_tables` envelope slot).
196    /// V10 snapshots are upconverted to V11 inline on load by mapping the
197    /// legacy `NodeMetadata` variants into `StoredEntry { typed, flags }`
198    /// and setting `c_indirect_tables` to `None`.
199    V11 = 11,
200    /// V12 — read/write after Phase β joint-stubs. Extends V11 with two new
201    /// envelope slots: `framework_routes` (Plan A) and `dispatch_tables`
202    /// (Plan B). Both are zero-initialised by the V11 → V12 upconvert.
203    V12 = 12,
204    /// V13 — read/write after T3 (error chains: `EdgeKind::Wraps`).
205    /// Shape-identical to V12; the bump exists so V12 readers reject snapshots
206    /// containing the new edge variant. V12 snapshots upconvert to V13 inline
207    /// on load via an identity mapping.
208    V13 = 13,
209    /// V14 — read/write after T2 (Go channel pairing: `NodeKind::Channel`,
210    /// `EdgeKind::ChannelPeer`; generic instantiation: `EdgeKind::Instantiates`).
211    /// The `NodeKind::Channel` insert-before-`Other` shifts `Other`'s positional
212    /// postcard discriminant, so V13 snapshots are upconverted to V14 inline on
213    /// load via `upconvert_v13_to_v14`: the node arena + `kind_index` decode
214    /// through the frozen `NodeKindV13` mirror, the arena is translated so
215    /// legacy `Other` nodes land at their new position, and `kind_index` is
216    /// rebuilt from the translated arena.
217    V14 = 14,
218    /// V15 — read/write after the body-shape-descriptor feature. Shape-identical
219    /// to V14 on the wire plus one appended envelope slot carrying the
220    /// `shape_descriptors` side table (`Vec<(NodeId, ShapeDescriptor)>`). V14
221    /// snapshots upconvert to V15 inline on load via `upconvert_v14_to_v15`
222    /// (every field moves by value, the descriptor table comes up empty), so an
223    /// existing V14 snapshot loads with no descriptors until the next
224    /// `sqry index --force` repopulates them. A V14-or-earlier reader rejects a
225    /// V15 snapshot at the magic gate.
226    V15 = 15,
227    /// V16: read/write after the definition-signal feature. Shape-identical to
228    /// V15 on the wire plus a trailing `NodeEntry.is_definition: bool`
229    /// (`#[serde(default)]`, appended last). V15 snapshots upconvert to V16
230    /// inline on load via `upconvert_v15_to_v16` (a no-op that advances the
231    /// version; every node decodes `is_definition = false`). The
232    /// `definition_signal_present` marker is `true` only for V16+ loads (and
233    /// fresh in-process builds), so a pre-V16 snapshot's all-`false`
234    /// `is_definition` bits are never mistaken for genuine signal. A
235    /// V15-or-earlier reader rejects a V16 snapshot at the magic gate.
236    V16 = 16,
237    /// V17: read/write after the import-classification-signal feature.
238    /// Shape-identical to V16 on the wire: the import-classification bits
239    /// (`NodeFlags::IMPORT_STDLIB` / `NodeFlags::IMPORT_RELATIVE`) ride the
240    /// existing `StoredEntry.flags` channel (round-tripped since V11), so no
241    /// field is added. V16 snapshots upconvert to V17 inline on load via
242    /// `upconvert_v16_to_v17` (a no-op that advances the version). The
243    /// `import_classification_signal_present` marker is `true` only for V17+
244    /// loads (and fresh in-process builds), so a pre-V17 snapshot's cleared
245    /// import bits are reported as unclassified rather than as authoritative
246    /// "not stdlib / not relative". A V16-or-earlier reader rejects a V17
247    /// snapshot at the magic gate.
248    V17 = 17,
249}
250
251impl FormatVersion {
252    /// Returns the magic-byte sequence identifying this format version.
253    #[must_use]
254    pub const fn magic(self) -> &'static [u8] {
255        match self {
256            Self::V7 => MAGIC_BYTES_V7.as_slice(),
257            Self::V8 => MAGIC_BYTES_V8.as_slice(),
258            Self::V9 => MAGIC_BYTES_V9.as_slice(),
259            Self::V10 => MAGIC_BYTES_V10.as_slice(),
260            Self::V11 => MAGIC_BYTES_V11.as_slice(),
261            Self::V12 => MAGIC_BYTES_V12.as_slice(),
262            Self::V13 => MAGIC_BYTES_V13.as_slice(),
263            Self::V14 => MAGIC_BYTES_V14.as_slice(),
264            Self::V15 => MAGIC_BYTES_V15.as_slice(),
265            Self::V16 => MAGIC_BYTES_V16.as_slice(),
266            Self::V17 => MAGIC_BYTES_V17.as_slice(),
267        }
268    }
269
270    /// Returns the numeric version tag (matches the trailing digit of the magic).
271    #[must_use]
272    pub const fn as_u32(self) -> u32 {
273        self as u32
274    }
275
276    /// Parses a magic-byte prefix into a `FormatVersion`.
277    ///
278    /// Returns `None` if the bytes do not match any known format magic.
279    #[must_use]
280    pub fn from_magic(bytes: &[u8]) -> Option<Self> {
281        // V17, V16, V15, V14, V13, V12, V11, and V10 magics are all 14 bytes.
282        // Check newest first (V17, V16, V15, ..., V10) so a longer / newer magic
283        // is never mis-classified as an older one by a less-strict comparison path.
284        if bytes.len() >= MAGIC_BYTES_V17.len()
285            && bytes[..MAGIC_BYTES_V17.len()] == *MAGIC_BYTES_V17
286        {
287            return Some(Self::V17);
288        }
289        if bytes.len() >= MAGIC_BYTES_V16.len()
290            && bytes[..MAGIC_BYTES_V16.len()] == *MAGIC_BYTES_V16
291        {
292            return Some(Self::V16);
293        }
294        if bytes.len() >= MAGIC_BYTES_V15.len()
295            && bytes[..MAGIC_BYTES_V15.len()] == *MAGIC_BYTES_V15
296        {
297            return Some(Self::V15);
298        }
299        if bytes.len() >= MAGIC_BYTES_V14.len()
300            && bytes[..MAGIC_BYTES_V14.len()] == *MAGIC_BYTES_V14
301        {
302            return Some(Self::V14);
303        }
304        if bytes.len() >= MAGIC_BYTES_V13.len()
305            && bytes[..MAGIC_BYTES_V13.len()] == *MAGIC_BYTES_V13
306        {
307            return Some(Self::V13);
308        }
309        if bytes.len() >= MAGIC_BYTES_V12.len()
310            && bytes[..MAGIC_BYTES_V12.len()] == *MAGIC_BYTES_V12
311        {
312            return Some(Self::V12);
313        }
314        if bytes.len() >= MAGIC_BYTES_V11.len()
315            && bytes[..MAGIC_BYTES_V11.len()] == *MAGIC_BYTES_V11
316        {
317            return Some(Self::V11);
318        }
319        if bytes.len() >= MAGIC_BYTES_V10.len()
320            && bytes[..MAGIC_BYTES_V10.len()] == *MAGIC_BYTES_V10
321        {
322            return Some(Self::V10);
323        }
324        if bytes.len() < MAGIC_BYTES_V7.len() {
325            return None;
326        }
327        let prefix = &bytes[..MAGIC_BYTES_V7.len()];
328        if prefix == MAGIC_BYTES_V7 {
329            Some(Self::V7)
330        } else if prefix == MAGIC_BYTES_V8 {
331            Some(Self::V8)
332        } else if prefix == MAGIC_BYTES_V9 {
333            Some(Self::V9)
334        } else {
335            None
336        }
337    }
338}
339
340/// Current writer format version (import-classification-signal marker: V17).
341pub const CURRENT_VERSION: FormatVersion = FormatVersion::V17;
342
343/// Header for persisted graph files.
344///
345/// The header provides metadata about the graph for validation
346/// and efficient loading.
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct GraphHeader {
349    /// Format version (for compatibility checking)
350    pub version: u32,
351
352    /// Number of nodes in the graph
353    pub node_count: usize,
354
355    /// Number of edges in the graph
356    pub edge_count: usize,
357
358    /// Number of interned strings
359    pub string_count: usize,
360
361    /// Number of registered files
362    pub file_count: usize,
363
364    /// Timestamp when graph was saved (unix epoch seconds)
365    pub timestamp: u64,
366
367    /// Configuration provenance - records which config was used to build this graph.
368    #[serde(default)]
369    pub config_provenance: Option<ConfigProvenance>,
370
371    /// Plugin versions used to build this graph (`plugin_id` → version).
372    ///
373    /// Tracks which language plugin versions were active during indexing.
374    /// Used to detect stale indexes when plugin versions change.
375    #[serde(default)]
376    pub plugin_versions: HashMap<String, String>,
377
378    /// Monotonic fact-layer epoch stamped at save time (Phase 1+).
379    ///
380    /// Strictly increases across successive saves of the same snapshot file,
381    /// including across process restarts: the writer reads the existing
382    /// header (if any) before stamping and computes
383    /// `max(prev_epoch + 1, SystemTime::now().as_secs())`.
384    ///
385    /// Defaulted to `0` for V7 snapshots and for `GraphHeader::new` /
386    /// `with_provenance` constructors. The epoch is stamped by the Phase 1
387    /// V8 writer (P1U06); this unit only introduces the field and accessors.
388    ///
389    /// Format: plain `u64`, serde-default `0` so postcard deserialization of
390    /// older headers that did not carry the field continues to succeed.
391    #[serde(default)]
392    pub fact_epoch: u64,
393}
394
395impl GraphHeader {
396    /// Creates a new graph header with the given counts.
397    #[must_use]
398    pub fn new(
399        node_count: usize,
400        edge_count: usize,
401        string_count: usize,
402        file_count: usize,
403    ) -> Self {
404        Self {
405            version: VERSION,
406            node_count,
407            edge_count,
408            string_count,
409            file_count,
410            timestamp: std::time::SystemTime::now()
411                .duration_since(std::time::UNIX_EPOCH)
412                .unwrap_or_default()
413                .as_secs(),
414            config_provenance: None,
415            plugin_versions: HashMap::new(),
416            fact_epoch: 0,
417        }
418    }
419
420    /// Creates a new graph header with config provenance.
421    #[must_use]
422    pub fn with_provenance(
423        node_count: usize,
424        edge_count: usize,
425        string_count: usize,
426        file_count: usize,
427        provenance: ConfigProvenance,
428    ) -> Self {
429        Self {
430            version: VERSION,
431            node_count,
432            edge_count,
433            string_count,
434            file_count,
435            timestamp: std::time::SystemTime::now()
436                .duration_since(std::time::UNIX_EPOCH)
437                .unwrap_or_default()
438                .as_secs(),
439            config_provenance: Some(provenance),
440            plugin_versions: HashMap::new(),
441            fact_epoch: 0,
442        }
443    }
444
445    /// Creates a new graph header with config provenance and plugin versions.
446    #[must_use]
447    pub fn with_provenance_and_plugins(
448        node_count: usize,
449        edge_count: usize,
450        string_count: usize,
451        file_count: usize,
452        provenance: ConfigProvenance,
453        plugin_versions: HashMap<String, String>,
454    ) -> Self {
455        Self {
456            version: VERSION,
457            node_count,
458            edge_count,
459            string_count,
460            file_count,
461            timestamp: std::time::SystemTime::now()
462                .duration_since(std::time::UNIX_EPOCH)
463                .unwrap_or_default()
464                .as_secs(),
465            config_provenance: Some(provenance),
466            plugin_versions,
467            fact_epoch: 0,
468        }
469    }
470
471    /// Returns the config provenance if available.
472    #[must_use]
473    pub fn provenance(&self) -> Option<&ConfigProvenance> {
474        self.config_provenance.as_ref()
475    }
476
477    /// Checks if the graph was built with tracked config provenance.
478    #[must_use]
479    pub fn has_provenance(&self) -> bool {
480        self.config_provenance.is_some()
481    }
482
483    /// Returns the plugin versions used to build this graph.
484    #[must_use]
485    pub fn plugin_versions(&self) -> &HashMap<String, String> {
486        &self.plugin_versions
487    }
488
489    /// Sets the plugin versions for this graph header.
490    pub fn set_plugin_versions(&mut self, versions: HashMap<String, String>) {
491        self.plugin_versions = versions;
492    }
493
494    /// Returns the monotonic fact-layer epoch stamped on this header.
495    ///
496    /// Returns `0` for headers created via `new` / `with_provenance` /
497    /// `with_provenance_and_plugins` before the Phase 1 writer stamps a
498    /// real epoch (P1U06), and for legacy V7 snapshots loaded through the
499    /// backwards-read path (P1U07).
500    #[must_use]
501    pub fn fact_epoch(&self) -> u64 {
502        self.fact_epoch
503    }
504
505    /// Sets the monotonic fact-layer epoch on this header.
506    ///
507    /// Intended for use by the Phase 1 V8 writer (P1U06), which computes
508    /// the epoch via a `FactEpochClock` helper and stamps it immediately
509    /// before serialization. Also used by tests.
510    pub fn set_fact_epoch(&mut self, epoch: u64) {
511        self.fact_epoch = epoch;
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use std::collections::HashMap;
519    use std::path::PathBuf;
520
521    fn make_test_provenance() -> ConfigProvenance {
522        ConfigProvenance {
523            config_file: PathBuf::from(".sqry/graph/config/config.json"),
524            config_checksum: "abc123def456".to_string(),
525            schema_version: 1,
526            overrides: HashMap::new(),
527            build_timestamp: std::time::SystemTime::now()
528                .duration_since(std::time::UNIX_EPOCH)
529                .unwrap_or_default()
530                .as_secs(),
531            build_host: Some("test-host".to_string()),
532        }
533    }
534
535    #[test]
536    fn test_magic_bytes() {
537        assert_eq!(MAGIC_BYTES, b"SQRY_GRAPH_V7");
538        assert_eq!(MAGIC_BYTES.len(), 13);
539    }
540
541    #[test]
542    fn test_version() {
543        assert_eq!(VERSION, 7);
544    }
545
546    #[test]
547    fn test_graph_header_new() {
548        let header = GraphHeader::new(100, 50, 200, 10);
549
550        assert_eq!(header.version, VERSION);
551        assert_eq!(header.node_count, 100);
552        assert_eq!(header.edge_count, 50);
553        assert_eq!(header.string_count, 200);
554        assert_eq!(header.file_count, 10);
555        assert!(header.timestamp > 0);
556        assert!(header.config_provenance.is_none());
557    }
558
559    #[test]
560    fn test_graph_header_with_provenance() {
561        let provenance = make_test_provenance();
562        let header = GraphHeader::with_provenance(100, 50, 200, 10, provenance);
563
564        assert_eq!(header.version, VERSION);
565        assert_eq!(header.node_count, 100);
566        assert_eq!(header.edge_count, 50);
567        assert!(header.config_provenance.is_some());
568        assert_eq!(
569            header.config_provenance.as_ref().unwrap().config_checksum,
570            "abc123def456"
571        );
572    }
573
574    #[test]
575    fn test_graph_header_provenance_method() {
576        let header = GraphHeader::new(10, 5, 20, 2);
577        assert!(header.provenance().is_none());
578
579        let provenance = make_test_provenance();
580        let header_with = GraphHeader::with_provenance(10, 5, 20, 2, provenance);
581        assert!(header_with.provenance().is_some());
582        assert_eq!(
583            header_with.provenance().unwrap().config_checksum,
584            "abc123def456"
585        );
586    }
587
588    #[test]
589    fn test_graph_header_has_provenance() {
590        let header = GraphHeader::new(10, 5, 20, 2);
591        assert!(!header.has_provenance());
592
593        let provenance = make_test_provenance();
594        let header_with = GraphHeader::with_provenance(10, 5, 20, 2, provenance);
595        assert!(header_with.has_provenance());
596    }
597
598    #[test]
599    fn test_graph_header_clone() {
600        let header = GraphHeader::new(100, 50, 200, 10);
601        let cloned = header.clone();
602
603        assert_eq!(header.version, cloned.version);
604        assert_eq!(header.node_count, cloned.node_count);
605        assert_eq!(header.edge_count, cloned.edge_count);
606        assert_eq!(header.string_count, cloned.string_count);
607        assert_eq!(header.file_count, cloned.file_count);
608    }
609
610    #[test]
611    fn test_graph_header_debug() {
612        let header = GraphHeader::new(100, 50, 200, 10);
613        let debug_str = format!("{header:?}");
614
615        assert!(debug_str.contains("GraphHeader"));
616        assert!(debug_str.contains("version"));
617        assert!(debug_str.contains("node_count"));
618    }
619
620    #[test]
621    fn test_graph_header_timestamp_is_recent() {
622        let header = GraphHeader::new(10, 5, 20, 2);
623        let now = std::time::SystemTime::now()
624            .duration_since(std::time::UNIX_EPOCH)
625            .unwrap()
626            .as_secs();
627
628        // Timestamp should be within 1 second of now
629        assert!(header.timestamp <= now);
630        assert!(header.timestamp >= now - 1);
631    }
632
633    #[test]
634    fn test_graph_header_zero_counts() {
635        let header = GraphHeader::new(0, 0, 0, 0);
636
637        assert_eq!(header.node_count, 0);
638        assert_eq!(header.edge_count, 0);
639        assert_eq!(header.string_count, 0);
640        assert_eq!(header.file_count, 0);
641    }
642
643    #[test]
644    fn test_graph_header_large_counts() {
645        let header = GraphHeader::new(1_000_000, 5_000_000, 10_000_000, 100_000);
646
647        assert_eq!(header.node_count, 1_000_000);
648        assert_eq!(header.edge_count, 5_000_000);
649        assert_eq!(header.string_count, 10_000_000);
650        assert_eq!(header.file_count, 100_000);
651    }
652
653    #[test]
654    fn test_graph_header_plugin_versions_empty_by_default() {
655        let header = GraphHeader::new(10, 5, 20, 2);
656        assert!(header.plugin_versions().is_empty());
657    }
658
659    #[test]
660    fn test_graph_header_set_plugin_versions() {
661        let mut header = GraphHeader::new(10, 5, 20, 2);
662
663        let mut versions = HashMap::new();
664        versions.insert("rust".to_string(), "3.3.0".to_string());
665        versions.insert("javascript".to_string(), "3.3.0".to_string());
666
667        header.set_plugin_versions(versions.clone());
668
669        assert_eq!(header.plugin_versions().len(), 2);
670        assert_eq!(
671            header.plugin_versions().get("rust"),
672            Some(&"3.3.0".to_string())
673        );
674        assert_eq!(
675            header.plugin_versions().get("javascript"),
676            Some(&"3.3.0".to_string())
677        );
678    }
679
680    // ------------------------------------------------------------------
681    // Phase 1 P1U02: GraphHeader.fact_epoch (additive u64)
682    // ------------------------------------------------------------------
683
684    #[test]
685    fn phase1_graph_header_new_defaults_fact_epoch_to_zero() {
686        let header = GraphHeader::new(10, 5, 20, 2);
687        assert_eq!(header.fact_epoch, 0);
688        assert_eq!(header.fact_epoch(), 0);
689    }
690
691    #[test]
692    fn phase1_graph_header_with_provenance_defaults_fact_epoch_to_zero() {
693        let header = GraphHeader::with_provenance(10, 5, 20, 2, make_test_provenance());
694        assert_eq!(header.fact_epoch, 0);
695    }
696
697    #[test]
698    fn phase1_graph_header_set_fact_epoch_round_trip() {
699        let mut header = GraphHeader::new(10, 5, 20, 2);
700        header.set_fact_epoch(42);
701        assert_eq!(header.fact_epoch(), 42);
702    }
703
704    #[test]
705    fn phase1_graph_header_postcard_round_trip_with_fact_epoch() {
706        let mut header = GraphHeader::new(100, 50, 200, 10);
707        header.set_fact_epoch(1_234_567);
708
709        let encoded = postcard::to_allocvec(&header).expect("encode");
710        let decoded: GraphHeader = postcard::from_bytes(&encoded).expect("decode");
711
712        assert_eq!(decoded.fact_epoch(), 1_234_567);
713        assert_eq!(decoded.node_count, 100);
714        assert_eq!(decoded.edge_count, 50);
715    }
716
717    #[test]
718    fn phase1_graph_header_fact_epoch_preserved_through_clone() {
719        let mut header = GraphHeader::new(10, 5, 20, 2);
720        header.set_fact_epoch(9_999);
721        let cloned = header.clone();
722        assert_eq!(cloned.fact_epoch(), 9_999);
723    }
724
725    // ------------------------------------------------------------------
726    // Phase 1 P1U01: FormatVersion enum + V7/V8 magic constants
727    // ------------------------------------------------------------------
728
729    #[test]
730    fn phase1_magic_bytes_v7_matches_legacy() {
731        assert_eq!(MAGIC_BYTES_V7, b"SQRY_GRAPH_V7");
732        assert_eq!(MAGIC_BYTES_V7, MAGIC_BYTES);
733        assert_eq!(MAGIC_BYTES_V7.len(), 13);
734    }
735
736    #[test]
737    fn phase1_magic_bytes_v8_is_distinct_and_13_bytes() {
738        assert_eq!(MAGIC_BYTES_V8, b"SQRY_GRAPH_V8");
739        assert_eq!(MAGIC_BYTES_V8.len(), 13);
740        assert_ne!(MAGIC_BYTES_V8, MAGIC_BYTES_V7);
741    }
742
743    #[test]
744    fn phase1_legacy_version_v7_equals_seven() {
745        assert_eq!(LEGACY_VERSION_V7, 7);
746    }
747
748    #[test]
749    fn phase1_format_version_discriminants() {
750        assert_eq!(FormatVersion::V7 as u32, 7);
751        assert_eq!(FormatVersion::V8 as u32, 8);
752        assert_eq!(FormatVersion::V9 as u32, 9);
753        assert_eq!(FormatVersion::V10 as u32, 10);
754        assert_eq!(FormatVersion::V11 as u32, 11);
755        assert_eq!(FormatVersion::V12 as u32, 12);
756        assert_eq!(FormatVersion::V13 as u32, 13);
757        assert_eq!(FormatVersion::V14 as u32, 14);
758        assert_eq!(FormatVersion::V15 as u32, 15);
759        assert_eq!(FormatVersion::V16 as u32, 16);
760        assert_eq!(FormatVersion::V17 as u32, 17);
761    }
762
763    #[test]
764    fn current_version_is_v17() {
765        assert_eq!(CURRENT_VERSION, FormatVersion::V17);
766    }
767
768    #[test]
769    fn import_classification_magic_bytes_v17_is_distinct_and_14_bytes() {
770        assert_eq!(MAGIC_BYTES_V17, b"SQRY_GRAPH_V17");
771        assert_eq!(MAGIC_BYTES_V17.len(), 14);
772        assert_ne!(MAGIC_BYTES_V17.as_slice(), MAGIC_BYTES_V16.as_slice());
773        assert_ne!(MAGIC_BYTES_V17.as_slice(), MAGIC_BYTES_V15.as_slice());
774    }
775
776    /// V17 must be tried before V16/older so a `SQRY_GRAPH_V17` prefix is never
777    /// mis-classified as an older 14-byte magic.
778    #[test]
779    fn import_classification_format_version_dispatch_v17_before_older() {
780        let mut buf = MAGIC_BYTES_V17.to_vec();
781        buf.extend_from_slice(&[0u8; 8]);
782        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V17));
783
784        let mut buf16 = MAGIC_BYTES_V16.to_vec();
785        buf16.extend_from_slice(&[0u8; 8]);
786        assert_eq!(FormatVersion::from_magic(&buf16), Some(FormatVersion::V16));
787    }
788
789    #[test]
790    fn import_classification_format_version_v17_magic_round_trip() {
791        let v = FormatVersion::V17;
792        let bytes = v.magic();
793        assert_eq!(bytes, MAGIC_BYTES_V17.as_slice());
794        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
795    }
796
797    #[test]
798    fn definition_magic_bytes_v16_is_distinct_and_14_bytes() {
799        assert_eq!(MAGIC_BYTES_V16, b"SQRY_GRAPH_V16");
800        assert_eq!(MAGIC_BYTES_V16.len(), 14);
801        assert_ne!(MAGIC_BYTES_V16.as_slice(), MAGIC_BYTES_V15.as_slice());
802        assert_ne!(MAGIC_BYTES_V16.as_slice(), MAGIC_BYTES_V14.as_slice());
803    }
804
805    /// V16 must be tried before V15/older so a `SQRY_GRAPH_V16` prefix is never
806    /// mis-classified as an older 14-byte magic.
807    #[test]
808    fn definition_format_version_dispatch_v16_before_older() {
809        let mut buf = MAGIC_BYTES_V16.to_vec();
810        buf.extend_from_slice(&[0u8; 8]);
811        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V16));
812
813        let mut buf15 = MAGIC_BYTES_V15.to_vec();
814        buf15.extend_from_slice(&[0u8; 8]);
815        assert_eq!(FormatVersion::from_magic(&buf15), Some(FormatVersion::V15));
816    }
817
818    #[test]
819    fn definition_format_version_v16_magic_round_trip() {
820        let v = FormatVersion::V16;
821        let bytes = v.magic();
822        assert_eq!(bytes, MAGIC_BYTES_V16.as_slice());
823        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
824    }
825
826    #[test]
827    fn shape_magic_bytes_v15_is_distinct_and_14_bytes() {
828        assert_eq!(MAGIC_BYTES_V15, b"SQRY_GRAPH_V15");
829        assert_eq!(MAGIC_BYTES_V15.len(), 14);
830        assert_ne!(MAGIC_BYTES_V15.as_slice(), MAGIC_BYTES_V14.as_slice());
831        assert_ne!(MAGIC_BYTES_V15.as_slice(), MAGIC_BYTES_V13.as_slice());
832    }
833
834    /// V15 must be tried before V14/older so a `SQRY_GRAPH_V15` prefix is never
835    /// mis-classified as an older 14-byte magic.
836    #[test]
837    fn shape_format_version_dispatch_v15_before_older() {
838        let mut buf = MAGIC_BYTES_V15.to_vec();
839        buf.extend_from_slice(&[0u8; 8]);
840        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V15));
841
842        let mut buf14 = MAGIC_BYTES_V14.to_vec();
843        buf14.extend_from_slice(&[0u8; 8]);
844        assert_eq!(FormatVersion::from_magic(&buf14), Some(FormatVersion::V14));
845    }
846
847    #[test]
848    fn shape_format_version_v15_magic_round_trip() {
849        let v = FormatVersion::V15;
850        let bytes = v.magic();
851        assert_eq!(bytes, MAGIC_BYTES_V15.as_slice());
852        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
853    }
854
855    #[test]
856    fn t2_magic_bytes_v14_is_distinct_and_14_bytes() {
857        assert_eq!(MAGIC_BYTES_V14, b"SQRY_GRAPH_V14");
858        assert_eq!(MAGIC_BYTES_V14.len(), 14);
859        assert_ne!(MAGIC_BYTES_V14.as_slice(), MAGIC_BYTES_V13.as_slice());
860        assert_ne!(MAGIC_BYTES_V14.as_slice(), MAGIC_BYTES_V12.as_slice());
861    }
862
863    #[test]
864    fn t2_format_version_from_magic_v14() {
865        assert_eq!(
866            FormatVersion::from_magic(MAGIC_BYTES_V14),
867            Some(FormatVersion::V14),
868        );
869    }
870
871    /// V14 must be tried before V13/V12/V11/V10 so a `SQRY_GRAPH_V14` prefix
872    /// is never mis-classified as an older 14-byte magic.
873    #[test]
874    fn t2_format_version_dispatch_v14_before_older() {
875        let mut buf = MAGIC_BYTES_V14.to_vec();
876        buf.extend_from_slice(&[0u8; 8]);
877        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V14));
878
879        let mut buf13 = MAGIC_BYTES_V13.to_vec();
880        buf13.extend_from_slice(&[0u8; 8]);
881        assert_eq!(FormatVersion::from_magic(&buf13), Some(FormatVersion::V13));
882    }
883
884    #[test]
885    fn t2_format_version_v14_magic_round_trip() {
886        let v = FormatVersion::V14;
887        let bytes = v.magic();
888        assert_eq!(bytes, MAGIC_BYTES_V14.as_slice());
889        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
890    }
891
892    #[test]
893    fn t3_magic_bytes_v13_is_distinct_and_14_bytes() {
894        assert_eq!(MAGIC_BYTES_V13, b"SQRY_GRAPH_V13");
895        assert_eq!(MAGIC_BYTES_V13.len(), 14);
896        assert_ne!(MAGIC_BYTES_V13.as_slice(), MAGIC_BYTES_V12.as_slice());
897        assert_ne!(MAGIC_BYTES_V13.as_slice(), MAGIC_BYTES_V10.as_slice());
898    }
899
900    #[test]
901    fn t3_format_version_from_magic_v13() {
902        assert_eq!(
903            FormatVersion::from_magic(MAGIC_BYTES_V13),
904            Some(FormatVersion::V13),
905        );
906    }
907
908    /// V13 and the other 14-byte magics (V12/V11/V10) must each resolve to
909    /// their own version. The dispatch tries V13 FIRST, so a buffer starting
910    /// with `SQRY_GRAPH_V13` must resolve to `FormatVersion::V13`. Guards
911    /// against a future refactor that re-orders the comparisons and silently
912    /// routes V13 through an older upconvert path.
913    #[test]
914    fn t3_format_version_dispatch_v13_before_v12_v11_v10() {
915        let mut buf = MAGIC_BYTES_V13.to_vec();
916        buf.extend_from_slice(&[0u8; 8]);
917        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V13));
918
919        let mut buf12 = MAGIC_BYTES_V12.to_vec();
920        buf12.extend_from_slice(&[0u8; 8]);
921        assert_eq!(FormatVersion::from_magic(&buf12), Some(FormatVersion::V12));
922    }
923
924    #[test]
925    fn t3_format_version_v13_magic_round_trip() {
926        let v = FormatVersion::V13;
927        let bytes = v.magic();
928        assert_eq!(bytes, MAGIC_BYTES_V13.as_slice());
929        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
930    }
931
932    #[test]
933    fn phase_a_magic_bytes_v11_is_distinct_and_14_bytes() {
934        assert_eq!(MAGIC_BYTES_V11, b"SQRY_GRAPH_V11");
935        assert_eq!(MAGIC_BYTES_V11.len(), 14);
936        assert_ne!(MAGIC_BYTES_V11, MAGIC_BYTES_V10);
937    }
938
939    #[test]
940    fn phase_a_format_version_from_magic_v11() {
941        assert_eq!(
942            FormatVersion::from_magic(MAGIC_BYTES_V11),
943            Some(FormatVersion::V11),
944        );
945    }
946
947    /// V11 and V10 magics are equal-length (14 bytes). The dispatch tries V11
948    /// before V10, so a buffer starting with `SQRY_GRAPH_V11` must resolve to
949    /// `FormatVersion::V11`, not V10. Guards against a future refactor that
950    /// re-orders the comparisons and silently routes V11 through the V10
951    /// upconvert path.
952    #[test]
953    fn phase_a_format_version_dispatch_v11_before_v10() {
954        let mut buf = MAGIC_BYTES_V11.to_vec();
955        // Append trailing bytes so the dispatch sees a "real-looking" payload.
956        buf.extend_from_slice(&[0u8; 8]);
957        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V11));
958
959        let mut buf10 = MAGIC_BYTES_V10.to_vec();
960        buf10.extend_from_slice(&[0u8; 8]);
961        assert_eq!(FormatVersion::from_magic(&buf10), Some(FormatVersion::V10));
962    }
963
964    #[test]
965    fn phase_a_format_version_v11_magic_round_trip() {
966        let v = FormatVersion::V11;
967        let bytes = v.magic();
968        assert_eq!(bytes, MAGIC_BYTES_V11.as_slice());
969        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
970    }
971
972    #[test]
973    fn phase1_format_version_from_magic_v7() {
974        assert_eq!(
975            FormatVersion::from_magic(MAGIC_BYTES_V7),
976            Some(FormatVersion::V7),
977        );
978    }
979
980    #[test]
981    fn phase1_format_version_from_magic_v8() {
982        assert_eq!(
983            FormatVersion::from_magic(MAGIC_BYTES_V8),
984            Some(FormatVersion::V8),
985        );
986    }
987
988    #[test]
989    fn phase2_magic_bytes_v9_is_distinct_and_13_bytes() {
990        assert_eq!(MAGIC_BYTES_V9, b"SQRY_GRAPH_V9");
991        assert_eq!(MAGIC_BYTES_V9.len(), 13);
992        assert_ne!(MAGIC_BYTES_V9, MAGIC_BYTES_V7);
993        assert_ne!(MAGIC_BYTES_V9, MAGIC_BYTES_V8);
994    }
995
996    #[test]
997    fn phase2_format_version_from_magic_v9() {
998        assert_eq!(
999            FormatVersion::from_magic(MAGIC_BYTES_V9),
1000            Some(FormatVersion::V9),
1001        );
1002    }
1003
1004    #[test]
1005    fn phase1_format_version_from_magic_unknown() {
1006        assert_eq!(FormatVersion::from_magic(b"SQRY_GRAPH_V1"), None);
1007        assert_eq!(FormatVersion::from_magic(b"NOT_A_GRAPH_!"), None);
1008    }
1009
1010    #[test]
1011    fn phase1_format_version_magic_round_trip() {
1012        for version in [
1013            FormatVersion::V7,
1014            FormatVersion::V8,
1015            FormatVersion::V9,
1016            FormatVersion::V10,
1017            FormatVersion::V11,
1018            FormatVersion::V12,
1019            FormatVersion::V13,
1020        ] {
1021            let bytes = version.magic();
1022            assert_eq!(FormatVersion::from_magic(bytes), Some(version));
1023        }
1024    }
1025
1026    // ------------------------------------------------------------------
1027    // Phase β joint-stubs: V12 magic + dispatch
1028    // ------------------------------------------------------------------
1029
1030    #[test]
1031    fn phase_beta_magic_bytes_v12_is_distinct_and_14_bytes() {
1032        assert_eq!(MAGIC_BYTES_V12, b"SQRY_GRAPH_V12");
1033        assert_eq!(MAGIC_BYTES_V12.len(), 14);
1034        assert_ne!(MAGIC_BYTES_V12, MAGIC_BYTES_V11);
1035        assert_ne!(MAGIC_BYTES_V12, MAGIC_BYTES_V10);
1036    }
1037
1038    #[test]
1039    fn phase_beta_format_version_from_magic_v12() {
1040        assert_eq!(
1041            FormatVersion::from_magic(MAGIC_BYTES_V12),
1042            Some(FormatVersion::V12),
1043        );
1044    }
1045
1046    /// V12, V11, and V10 magics are equal-length (14 bytes). The dispatch
1047    /// tries V12 FIRST, then V11, then V10 — so a buffer that starts with
1048    /// `SQRY_GRAPH_V12` must resolve to V12, not V11 or V10. Mirrors the
1049    /// `phase_a_format_version_dispatch_v11_before_v10` guard.
1050    #[test]
1051    fn phase_beta_format_version_dispatch_v12_before_v11_v10() {
1052        let mut buf = MAGIC_BYTES_V12.to_vec();
1053        buf.extend_from_slice(&[0u8; 8]);
1054        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V12));
1055
1056        let mut buf11 = MAGIC_BYTES_V11.to_vec();
1057        buf11.extend_from_slice(&[0u8; 8]);
1058        assert_eq!(FormatVersion::from_magic(&buf11), Some(FormatVersion::V11));
1059
1060        let mut buf10 = MAGIC_BYTES_V10.to_vec();
1061        buf10.extend_from_slice(&[0u8; 8]);
1062        assert_eq!(FormatVersion::from_magic(&buf10), Some(FormatVersion::V10));
1063    }
1064
1065    #[test]
1066    fn phase_beta_format_version_v12_magic_round_trip() {
1067        let v = FormatVersion::V12;
1068        let bytes = v.magic();
1069        assert_eq!(bytes, MAGIC_BYTES_V12.as_slice());
1070        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
1071    }
1072
1073    #[test]
1074    fn phase1_format_version_copy_eq_debug() {
1075        let v = FormatVersion::V8;
1076        let copied = v;
1077        assert_eq!(v, copied);
1078        assert_eq!(format!("{v:?}"), "V8");
1079    }
1080
1081    #[test]
1082    fn phase2_format_version_v9_copy_eq_debug() {
1083        let v = FormatVersion::V9;
1084        let copied = v;
1085        assert_eq!(v, copied);
1086        assert_eq!(format!("{v:?}"), "V9");
1087    }
1088
1089    #[test]
1090    fn test_graph_header_with_provenance_and_plugins() {
1091        let provenance = make_test_provenance();
1092
1093        let mut plugin_versions = HashMap::new();
1094        plugin_versions.insert("rust".to_string(), "3.3.0".to_string());
1095        plugin_versions.insert("python".to_string(), "3.3.0".to_string());
1096
1097        let header = GraphHeader::with_provenance_and_plugins(
1098            100,
1099            50,
1100            200,
1101            10,
1102            provenance,
1103            plugin_versions.clone(),
1104        );
1105
1106        assert_eq!(header.version, VERSION);
1107        assert_eq!(header.node_count, 100);
1108        assert!(header.config_provenance.is_some());
1109        assert_eq!(header.plugin_versions().len(), 2);
1110        assert_eq!(
1111            header.plugin_versions().get("rust"),
1112            Some(&"3.3.0".to_string())
1113        );
1114    }
1115}