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/// Legacy V7 numeric version, exposed with a versioned name so the Phase 1 reader
154/// dispatch can cite it explicitly. Equal to [`VERSION`].
155pub const LEGACY_VERSION_V7: u32 = 7;
156
157/// Typed snapshot format version.
158///
159/// Phase 1 introduces V8 as read/write and preserves V7 as a read-only compatibility
160/// path. Phase 2 introduces V9 as read/write and preserves V8 as an upconvert path.
161/// Later format additions bump the magic bytes (V10, …) rather than relying on any
162/// in-format revision counter.
163#[repr(u32)]
164#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
165pub enum FormatVersion {
166    /// Legacy V7 — read-only after Phase 1 lands.
167    V7 = 7,
168    /// V8 — read/write after Phase 1, upconvert source after Phase 2.
169    V8 = 8,
170    /// V9 — read/write after Phase 2 (binding plane: `ScopeArena`, `AliasTable`,
171    /// `ShadowTable`, `ScopeProvenanceStore`). V8 snapshots are upconverted to V9
172    /// inline on load by running `derive_binding_plane`.
173    V9 = 9,
174    /// V10 — read/write after Phase 3 (derived DB: `FileSegmentTable`). V9
175    /// snapshots are upconverted to V10 inline on load by rebuilding the
176    /// segment table from the node arena.
177    V10 = 10,
178    /// V11 — read/write after Phase A C-icall precision (`StoredEntry`
179    /// metadata wire format + reserved `c_indirect_tables` envelope slot).
180    /// V10 snapshots are upconverted to V11 inline on load by mapping the
181    /// legacy `NodeMetadata` variants into `StoredEntry { typed, flags }`
182    /// and setting `c_indirect_tables` to `None`.
183    V11 = 11,
184    /// V12 — read/write after Phase β joint-stubs. Extends V11 with two new
185    /// envelope slots: `framework_routes` (Plan A) and `dispatch_tables`
186    /// (Plan B). Both are zero-initialised by the V11 → V12 upconvert.
187    V12 = 12,
188    /// V13 — read/write after T3 (error chains: `EdgeKind::Wraps`).
189    /// Shape-identical to V12; the bump exists so V12 readers reject snapshots
190    /// containing the new edge variant. V12 snapshots upconvert to V13 inline
191    /// on load via an identity mapping.
192    V13 = 13,
193    /// V14 — read/write after T2 (Go channel pairing: `NodeKind::Channel`,
194    /// `EdgeKind::ChannelPeer`; generic instantiation: `EdgeKind::Instantiates`).
195    /// The `NodeKind::Channel` insert-before-`Other` shifts `Other`'s positional
196    /// postcard discriminant, so V13 snapshots are upconverted to V14 inline on
197    /// load via `upconvert_v13_to_v14`: the node arena + `kind_index` decode
198    /// through the frozen `NodeKindV13` mirror, the arena is translated so
199    /// legacy `Other` nodes land at their new position, and `kind_index` is
200    /// rebuilt from the translated arena.
201    V14 = 14,
202    /// V15 — read/write after the body-shape-descriptor feature. Shape-identical
203    /// to V14 on the wire plus one appended envelope slot carrying the
204    /// `shape_descriptors` side table (`Vec<(NodeId, ShapeDescriptor)>`). V14
205    /// snapshots upconvert to V15 inline on load via `upconvert_v14_to_v15`
206    /// (every field moves by value, the descriptor table comes up empty), so an
207    /// existing V14 snapshot loads with no descriptors until the next
208    /// `sqry index --force` repopulates them. A V14-or-earlier reader rejects a
209    /// V15 snapshot at the magic gate.
210    V15 = 15,
211    /// V16: read/write after the definition-signal feature. Shape-identical to
212    /// V15 on the wire plus a trailing `NodeEntry.is_definition: bool`
213    /// (`#[serde(default)]`, appended last). V15 snapshots upconvert to V16
214    /// inline on load via `upconvert_v15_to_v16` (a no-op that advances the
215    /// version; every node decodes `is_definition = false`). The
216    /// `definition_signal_present` marker is `true` only for V16+ loads (and
217    /// fresh in-process builds), so a pre-V16 snapshot's all-`false`
218    /// `is_definition` bits are never mistaken for genuine signal. A
219    /// V15-or-earlier reader rejects a V16 snapshot at the magic gate.
220    V16 = 16,
221}
222
223impl FormatVersion {
224    /// Returns the magic-byte sequence identifying this format version.
225    #[must_use]
226    pub const fn magic(self) -> &'static [u8] {
227        match self {
228            Self::V7 => MAGIC_BYTES_V7.as_slice(),
229            Self::V8 => MAGIC_BYTES_V8.as_slice(),
230            Self::V9 => MAGIC_BYTES_V9.as_slice(),
231            Self::V10 => MAGIC_BYTES_V10.as_slice(),
232            Self::V11 => MAGIC_BYTES_V11.as_slice(),
233            Self::V12 => MAGIC_BYTES_V12.as_slice(),
234            Self::V13 => MAGIC_BYTES_V13.as_slice(),
235            Self::V14 => MAGIC_BYTES_V14.as_slice(),
236            Self::V15 => MAGIC_BYTES_V15.as_slice(),
237            Self::V16 => MAGIC_BYTES_V16.as_slice(),
238        }
239    }
240
241    /// Returns the numeric version tag (matches the trailing digit of the magic).
242    #[must_use]
243    pub const fn as_u32(self) -> u32 {
244        self as u32
245    }
246
247    /// Parses a magic-byte prefix into a `FormatVersion`.
248    ///
249    /// Returns `None` if the bytes do not match any known format magic.
250    #[must_use]
251    pub fn from_magic(bytes: &[u8]) -> Option<Self> {
252        // V16, V15, V14, V13, V12, V11, and V10 magics are all 14 bytes. Check
253        // newest first (V16, V15, V14, ..., V10) so a longer / newer magic is
254        // never mis-classified as an older one by a less-strict comparison path.
255        if bytes.len() >= MAGIC_BYTES_V16.len()
256            && bytes[..MAGIC_BYTES_V16.len()] == *MAGIC_BYTES_V16
257        {
258            return Some(Self::V16);
259        }
260        if bytes.len() >= MAGIC_BYTES_V15.len()
261            && bytes[..MAGIC_BYTES_V15.len()] == *MAGIC_BYTES_V15
262        {
263            return Some(Self::V15);
264        }
265        if bytes.len() >= MAGIC_BYTES_V14.len()
266            && bytes[..MAGIC_BYTES_V14.len()] == *MAGIC_BYTES_V14
267        {
268            return Some(Self::V14);
269        }
270        if bytes.len() >= MAGIC_BYTES_V13.len()
271            && bytes[..MAGIC_BYTES_V13.len()] == *MAGIC_BYTES_V13
272        {
273            return Some(Self::V13);
274        }
275        if bytes.len() >= MAGIC_BYTES_V12.len()
276            && bytes[..MAGIC_BYTES_V12.len()] == *MAGIC_BYTES_V12
277        {
278            return Some(Self::V12);
279        }
280        if bytes.len() >= MAGIC_BYTES_V11.len()
281            && bytes[..MAGIC_BYTES_V11.len()] == *MAGIC_BYTES_V11
282        {
283            return Some(Self::V11);
284        }
285        if bytes.len() >= MAGIC_BYTES_V10.len()
286            && bytes[..MAGIC_BYTES_V10.len()] == *MAGIC_BYTES_V10
287        {
288            return Some(Self::V10);
289        }
290        if bytes.len() < MAGIC_BYTES_V7.len() {
291            return None;
292        }
293        let prefix = &bytes[..MAGIC_BYTES_V7.len()];
294        if prefix == MAGIC_BYTES_V7 {
295            Some(Self::V7)
296        } else if prefix == MAGIC_BYTES_V8 {
297            Some(Self::V8)
298        } else if prefix == MAGIC_BYTES_V9 {
299            Some(Self::V9)
300        } else {
301            None
302        }
303    }
304}
305
306/// Current writer format version (definition-signal `NodeEntry.is_definition`: V16).
307pub const CURRENT_VERSION: FormatVersion = FormatVersion::V16;
308
309/// Header for persisted graph files.
310///
311/// The header provides metadata about the graph for validation
312/// and efficient loading.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct GraphHeader {
315    /// Format version (for compatibility checking)
316    pub version: u32,
317
318    /// Number of nodes in the graph
319    pub node_count: usize,
320
321    /// Number of edges in the graph
322    pub edge_count: usize,
323
324    /// Number of interned strings
325    pub string_count: usize,
326
327    /// Number of registered files
328    pub file_count: usize,
329
330    /// Timestamp when graph was saved (unix epoch seconds)
331    pub timestamp: u64,
332
333    /// Configuration provenance - records which config was used to build this graph.
334    #[serde(default)]
335    pub config_provenance: Option<ConfigProvenance>,
336
337    /// Plugin versions used to build this graph (`plugin_id` → version).
338    ///
339    /// Tracks which language plugin versions were active during indexing.
340    /// Used to detect stale indexes when plugin versions change.
341    #[serde(default)]
342    pub plugin_versions: HashMap<String, String>,
343
344    /// Monotonic fact-layer epoch stamped at save time (Phase 1+).
345    ///
346    /// Strictly increases across successive saves of the same snapshot file,
347    /// including across process restarts: the writer reads the existing
348    /// header (if any) before stamping and computes
349    /// `max(prev_epoch + 1, SystemTime::now().as_secs())`.
350    ///
351    /// Defaulted to `0` for V7 snapshots and for `GraphHeader::new` /
352    /// `with_provenance` constructors. The epoch is stamped by the Phase 1
353    /// V8 writer (P1U06); this unit only introduces the field and accessors.
354    ///
355    /// Format: plain `u64`, serde-default `0` so postcard deserialization of
356    /// older headers that did not carry the field continues to succeed.
357    #[serde(default)]
358    pub fact_epoch: u64,
359}
360
361impl GraphHeader {
362    /// Creates a new graph header with the given counts.
363    #[must_use]
364    pub fn new(
365        node_count: usize,
366        edge_count: usize,
367        string_count: usize,
368        file_count: usize,
369    ) -> Self {
370        Self {
371            version: VERSION,
372            node_count,
373            edge_count,
374            string_count,
375            file_count,
376            timestamp: std::time::SystemTime::now()
377                .duration_since(std::time::UNIX_EPOCH)
378                .unwrap_or_default()
379                .as_secs(),
380            config_provenance: None,
381            plugin_versions: HashMap::new(),
382            fact_epoch: 0,
383        }
384    }
385
386    /// Creates a new graph header with config provenance.
387    #[must_use]
388    pub fn with_provenance(
389        node_count: usize,
390        edge_count: usize,
391        string_count: usize,
392        file_count: usize,
393        provenance: ConfigProvenance,
394    ) -> Self {
395        Self {
396            version: VERSION,
397            node_count,
398            edge_count,
399            string_count,
400            file_count,
401            timestamp: std::time::SystemTime::now()
402                .duration_since(std::time::UNIX_EPOCH)
403                .unwrap_or_default()
404                .as_secs(),
405            config_provenance: Some(provenance),
406            plugin_versions: HashMap::new(),
407            fact_epoch: 0,
408        }
409    }
410
411    /// Creates a new graph header with config provenance and plugin versions.
412    #[must_use]
413    pub fn with_provenance_and_plugins(
414        node_count: usize,
415        edge_count: usize,
416        string_count: usize,
417        file_count: usize,
418        provenance: ConfigProvenance,
419        plugin_versions: HashMap<String, String>,
420    ) -> Self {
421        Self {
422            version: VERSION,
423            node_count,
424            edge_count,
425            string_count,
426            file_count,
427            timestamp: std::time::SystemTime::now()
428                .duration_since(std::time::UNIX_EPOCH)
429                .unwrap_or_default()
430                .as_secs(),
431            config_provenance: Some(provenance),
432            plugin_versions,
433            fact_epoch: 0,
434        }
435    }
436
437    /// Returns the config provenance if available.
438    #[must_use]
439    pub fn provenance(&self) -> Option<&ConfigProvenance> {
440        self.config_provenance.as_ref()
441    }
442
443    /// Checks if the graph was built with tracked config provenance.
444    #[must_use]
445    pub fn has_provenance(&self) -> bool {
446        self.config_provenance.is_some()
447    }
448
449    /// Returns the plugin versions used to build this graph.
450    #[must_use]
451    pub fn plugin_versions(&self) -> &HashMap<String, String> {
452        &self.plugin_versions
453    }
454
455    /// Sets the plugin versions for this graph header.
456    pub fn set_plugin_versions(&mut self, versions: HashMap<String, String>) {
457        self.plugin_versions = versions;
458    }
459
460    /// Returns the monotonic fact-layer epoch stamped on this header.
461    ///
462    /// Returns `0` for headers created via `new` / `with_provenance` /
463    /// `with_provenance_and_plugins` before the Phase 1 writer stamps a
464    /// real epoch (P1U06), and for legacy V7 snapshots loaded through the
465    /// backwards-read path (P1U07).
466    #[must_use]
467    pub fn fact_epoch(&self) -> u64 {
468        self.fact_epoch
469    }
470
471    /// Sets the monotonic fact-layer epoch on this header.
472    ///
473    /// Intended for use by the Phase 1 V8 writer (P1U06), which computes
474    /// the epoch via a `FactEpochClock` helper and stamps it immediately
475    /// before serialization. Also used by tests.
476    pub fn set_fact_epoch(&mut self, epoch: u64) {
477        self.fact_epoch = epoch;
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use std::collections::HashMap;
485    use std::path::PathBuf;
486
487    fn make_test_provenance() -> ConfigProvenance {
488        ConfigProvenance {
489            config_file: PathBuf::from(".sqry/graph/config/config.json"),
490            config_checksum: "abc123def456".to_string(),
491            schema_version: 1,
492            overrides: HashMap::new(),
493            build_timestamp: std::time::SystemTime::now()
494                .duration_since(std::time::UNIX_EPOCH)
495                .unwrap_or_default()
496                .as_secs(),
497            build_host: Some("test-host".to_string()),
498        }
499    }
500
501    #[test]
502    fn test_magic_bytes() {
503        assert_eq!(MAGIC_BYTES, b"SQRY_GRAPH_V7");
504        assert_eq!(MAGIC_BYTES.len(), 13);
505    }
506
507    #[test]
508    fn test_version() {
509        assert_eq!(VERSION, 7);
510    }
511
512    #[test]
513    fn test_graph_header_new() {
514        let header = GraphHeader::new(100, 50, 200, 10);
515
516        assert_eq!(header.version, VERSION);
517        assert_eq!(header.node_count, 100);
518        assert_eq!(header.edge_count, 50);
519        assert_eq!(header.string_count, 200);
520        assert_eq!(header.file_count, 10);
521        assert!(header.timestamp > 0);
522        assert!(header.config_provenance.is_none());
523    }
524
525    #[test]
526    fn test_graph_header_with_provenance() {
527        let provenance = make_test_provenance();
528        let header = GraphHeader::with_provenance(100, 50, 200, 10, provenance);
529
530        assert_eq!(header.version, VERSION);
531        assert_eq!(header.node_count, 100);
532        assert_eq!(header.edge_count, 50);
533        assert!(header.config_provenance.is_some());
534        assert_eq!(
535            header.config_provenance.as_ref().unwrap().config_checksum,
536            "abc123def456"
537        );
538    }
539
540    #[test]
541    fn test_graph_header_provenance_method() {
542        let header = GraphHeader::new(10, 5, 20, 2);
543        assert!(header.provenance().is_none());
544
545        let provenance = make_test_provenance();
546        let header_with = GraphHeader::with_provenance(10, 5, 20, 2, provenance);
547        assert!(header_with.provenance().is_some());
548        assert_eq!(
549            header_with.provenance().unwrap().config_checksum,
550            "abc123def456"
551        );
552    }
553
554    #[test]
555    fn test_graph_header_has_provenance() {
556        let header = GraphHeader::new(10, 5, 20, 2);
557        assert!(!header.has_provenance());
558
559        let provenance = make_test_provenance();
560        let header_with = GraphHeader::with_provenance(10, 5, 20, 2, provenance);
561        assert!(header_with.has_provenance());
562    }
563
564    #[test]
565    fn test_graph_header_clone() {
566        let header = GraphHeader::new(100, 50, 200, 10);
567        let cloned = header.clone();
568
569        assert_eq!(header.version, cloned.version);
570        assert_eq!(header.node_count, cloned.node_count);
571        assert_eq!(header.edge_count, cloned.edge_count);
572        assert_eq!(header.string_count, cloned.string_count);
573        assert_eq!(header.file_count, cloned.file_count);
574    }
575
576    #[test]
577    fn test_graph_header_debug() {
578        let header = GraphHeader::new(100, 50, 200, 10);
579        let debug_str = format!("{header:?}");
580
581        assert!(debug_str.contains("GraphHeader"));
582        assert!(debug_str.contains("version"));
583        assert!(debug_str.contains("node_count"));
584    }
585
586    #[test]
587    fn test_graph_header_timestamp_is_recent() {
588        let header = GraphHeader::new(10, 5, 20, 2);
589        let now = std::time::SystemTime::now()
590            .duration_since(std::time::UNIX_EPOCH)
591            .unwrap()
592            .as_secs();
593
594        // Timestamp should be within 1 second of now
595        assert!(header.timestamp <= now);
596        assert!(header.timestamp >= now - 1);
597    }
598
599    #[test]
600    fn test_graph_header_zero_counts() {
601        let header = GraphHeader::new(0, 0, 0, 0);
602
603        assert_eq!(header.node_count, 0);
604        assert_eq!(header.edge_count, 0);
605        assert_eq!(header.string_count, 0);
606        assert_eq!(header.file_count, 0);
607    }
608
609    #[test]
610    fn test_graph_header_large_counts() {
611        let header = GraphHeader::new(1_000_000, 5_000_000, 10_000_000, 100_000);
612
613        assert_eq!(header.node_count, 1_000_000);
614        assert_eq!(header.edge_count, 5_000_000);
615        assert_eq!(header.string_count, 10_000_000);
616        assert_eq!(header.file_count, 100_000);
617    }
618
619    #[test]
620    fn test_graph_header_plugin_versions_empty_by_default() {
621        let header = GraphHeader::new(10, 5, 20, 2);
622        assert!(header.plugin_versions().is_empty());
623    }
624
625    #[test]
626    fn test_graph_header_set_plugin_versions() {
627        let mut header = GraphHeader::new(10, 5, 20, 2);
628
629        let mut versions = HashMap::new();
630        versions.insert("rust".to_string(), "3.3.0".to_string());
631        versions.insert("javascript".to_string(), "3.3.0".to_string());
632
633        header.set_plugin_versions(versions.clone());
634
635        assert_eq!(header.plugin_versions().len(), 2);
636        assert_eq!(
637            header.plugin_versions().get("rust"),
638            Some(&"3.3.0".to_string())
639        );
640        assert_eq!(
641            header.plugin_versions().get("javascript"),
642            Some(&"3.3.0".to_string())
643        );
644    }
645
646    // ------------------------------------------------------------------
647    // Phase 1 P1U02: GraphHeader.fact_epoch (additive u64)
648    // ------------------------------------------------------------------
649
650    #[test]
651    fn phase1_graph_header_new_defaults_fact_epoch_to_zero() {
652        let header = GraphHeader::new(10, 5, 20, 2);
653        assert_eq!(header.fact_epoch, 0);
654        assert_eq!(header.fact_epoch(), 0);
655    }
656
657    #[test]
658    fn phase1_graph_header_with_provenance_defaults_fact_epoch_to_zero() {
659        let header = GraphHeader::with_provenance(10, 5, 20, 2, make_test_provenance());
660        assert_eq!(header.fact_epoch, 0);
661    }
662
663    #[test]
664    fn phase1_graph_header_set_fact_epoch_round_trip() {
665        let mut header = GraphHeader::new(10, 5, 20, 2);
666        header.set_fact_epoch(42);
667        assert_eq!(header.fact_epoch(), 42);
668    }
669
670    #[test]
671    fn phase1_graph_header_postcard_round_trip_with_fact_epoch() {
672        let mut header = GraphHeader::new(100, 50, 200, 10);
673        header.set_fact_epoch(1_234_567);
674
675        let encoded = postcard::to_allocvec(&header).expect("encode");
676        let decoded: GraphHeader = postcard::from_bytes(&encoded).expect("decode");
677
678        assert_eq!(decoded.fact_epoch(), 1_234_567);
679        assert_eq!(decoded.node_count, 100);
680        assert_eq!(decoded.edge_count, 50);
681    }
682
683    #[test]
684    fn phase1_graph_header_fact_epoch_preserved_through_clone() {
685        let mut header = GraphHeader::new(10, 5, 20, 2);
686        header.set_fact_epoch(9_999);
687        let cloned = header.clone();
688        assert_eq!(cloned.fact_epoch(), 9_999);
689    }
690
691    // ------------------------------------------------------------------
692    // Phase 1 P1U01: FormatVersion enum + V7/V8 magic constants
693    // ------------------------------------------------------------------
694
695    #[test]
696    fn phase1_magic_bytes_v7_matches_legacy() {
697        assert_eq!(MAGIC_BYTES_V7, b"SQRY_GRAPH_V7");
698        assert_eq!(MAGIC_BYTES_V7, MAGIC_BYTES);
699        assert_eq!(MAGIC_BYTES_V7.len(), 13);
700    }
701
702    #[test]
703    fn phase1_magic_bytes_v8_is_distinct_and_13_bytes() {
704        assert_eq!(MAGIC_BYTES_V8, b"SQRY_GRAPH_V8");
705        assert_eq!(MAGIC_BYTES_V8.len(), 13);
706        assert_ne!(MAGIC_BYTES_V8, MAGIC_BYTES_V7);
707    }
708
709    #[test]
710    fn phase1_legacy_version_v7_equals_seven() {
711        assert_eq!(LEGACY_VERSION_V7, 7);
712    }
713
714    #[test]
715    fn phase1_format_version_discriminants() {
716        assert_eq!(FormatVersion::V7 as u32, 7);
717        assert_eq!(FormatVersion::V8 as u32, 8);
718        assert_eq!(FormatVersion::V9 as u32, 9);
719        assert_eq!(FormatVersion::V10 as u32, 10);
720        assert_eq!(FormatVersion::V11 as u32, 11);
721        assert_eq!(FormatVersion::V12 as u32, 12);
722        assert_eq!(FormatVersion::V13 as u32, 13);
723        assert_eq!(FormatVersion::V14 as u32, 14);
724        assert_eq!(FormatVersion::V15 as u32, 15);
725        assert_eq!(FormatVersion::V16 as u32, 16);
726    }
727
728    #[test]
729    fn current_version_is_v16() {
730        assert_eq!(CURRENT_VERSION, FormatVersion::V16);
731    }
732
733    #[test]
734    fn definition_magic_bytes_v16_is_distinct_and_14_bytes() {
735        assert_eq!(MAGIC_BYTES_V16, b"SQRY_GRAPH_V16");
736        assert_eq!(MAGIC_BYTES_V16.len(), 14);
737        assert_ne!(MAGIC_BYTES_V16.as_slice(), MAGIC_BYTES_V15.as_slice());
738        assert_ne!(MAGIC_BYTES_V16.as_slice(), MAGIC_BYTES_V14.as_slice());
739    }
740
741    /// V16 must be tried before V15/older so a `SQRY_GRAPH_V16` prefix is never
742    /// mis-classified as an older 14-byte magic.
743    #[test]
744    fn definition_format_version_dispatch_v16_before_older() {
745        let mut buf = MAGIC_BYTES_V16.to_vec();
746        buf.extend_from_slice(&[0u8; 8]);
747        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V16));
748
749        let mut buf15 = MAGIC_BYTES_V15.to_vec();
750        buf15.extend_from_slice(&[0u8; 8]);
751        assert_eq!(FormatVersion::from_magic(&buf15), Some(FormatVersion::V15));
752    }
753
754    #[test]
755    fn definition_format_version_v16_magic_round_trip() {
756        let v = FormatVersion::V16;
757        let bytes = v.magic();
758        assert_eq!(bytes, MAGIC_BYTES_V16.as_slice());
759        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
760    }
761
762    #[test]
763    fn shape_magic_bytes_v15_is_distinct_and_14_bytes() {
764        assert_eq!(MAGIC_BYTES_V15, b"SQRY_GRAPH_V15");
765        assert_eq!(MAGIC_BYTES_V15.len(), 14);
766        assert_ne!(MAGIC_BYTES_V15.as_slice(), MAGIC_BYTES_V14.as_slice());
767        assert_ne!(MAGIC_BYTES_V15.as_slice(), MAGIC_BYTES_V13.as_slice());
768    }
769
770    /// V15 must be tried before V14/older so a `SQRY_GRAPH_V15` prefix is never
771    /// mis-classified as an older 14-byte magic.
772    #[test]
773    fn shape_format_version_dispatch_v15_before_older() {
774        let mut buf = MAGIC_BYTES_V15.to_vec();
775        buf.extend_from_slice(&[0u8; 8]);
776        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V15));
777
778        let mut buf14 = MAGIC_BYTES_V14.to_vec();
779        buf14.extend_from_slice(&[0u8; 8]);
780        assert_eq!(FormatVersion::from_magic(&buf14), Some(FormatVersion::V14));
781    }
782
783    #[test]
784    fn shape_format_version_v15_magic_round_trip() {
785        let v = FormatVersion::V15;
786        let bytes = v.magic();
787        assert_eq!(bytes, MAGIC_BYTES_V15.as_slice());
788        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
789    }
790
791    #[test]
792    fn t2_magic_bytes_v14_is_distinct_and_14_bytes() {
793        assert_eq!(MAGIC_BYTES_V14, b"SQRY_GRAPH_V14");
794        assert_eq!(MAGIC_BYTES_V14.len(), 14);
795        assert_ne!(MAGIC_BYTES_V14.as_slice(), MAGIC_BYTES_V13.as_slice());
796        assert_ne!(MAGIC_BYTES_V14.as_slice(), MAGIC_BYTES_V12.as_slice());
797    }
798
799    #[test]
800    fn t2_format_version_from_magic_v14() {
801        assert_eq!(
802            FormatVersion::from_magic(MAGIC_BYTES_V14),
803            Some(FormatVersion::V14),
804        );
805    }
806
807    /// V14 must be tried before V13/V12/V11/V10 so a `SQRY_GRAPH_V14` prefix
808    /// is never mis-classified as an older 14-byte magic.
809    #[test]
810    fn t2_format_version_dispatch_v14_before_older() {
811        let mut buf = MAGIC_BYTES_V14.to_vec();
812        buf.extend_from_slice(&[0u8; 8]);
813        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V14));
814
815        let mut buf13 = MAGIC_BYTES_V13.to_vec();
816        buf13.extend_from_slice(&[0u8; 8]);
817        assert_eq!(FormatVersion::from_magic(&buf13), Some(FormatVersion::V13));
818    }
819
820    #[test]
821    fn t2_format_version_v14_magic_round_trip() {
822        let v = FormatVersion::V14;
823        let bytes = v.magic();
824        assert_eq!(bytes, MAGIC_BYTES_V14.as_slice());
825        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
826    }
827
828    #[test]
829    fn t3_magic_bytes_v13_is_distinct_and_14_bytes() {
830        assert_eq!(MAGIC_BYTES_V13, b"SQRY_GRAPH_V13");
831        assert_eq!(MAGIC_BYTES_V13.len(), 14);
832        assert_ne!(MAGIC_BYTES_V13.as_slice(), MAGIC_BYTES_V12.as_slice());
833        assert_ne!(MAGIC_BYTES_V13.as_slice(), MAGIC_BYTES_V10.as_slice());
834    }
835
836    #[test]
837    fn t3_format_version_from_magic_v13() {
838        assert_eq!(
839            FormatVersion::from_magic(MAGIC_BYTES_V13),
840            Some(FormatVersion::V13),
841        );
842    }
843
844    /// V13 and the other 14-byte magics (V12/V11/V10) must each resolve to
845    /// their own version. The dispatch tries V13 FIRST, so a buffer starting
846    /// with `SQRY_GRAPH_V13` must resolve to `FormatVersion::V13`. Guards
847    /// against a future refactor that re-orders the comparisons and silently
848    /// routes V13 through an older upconvert path.
849    #[test]
850    fn t3_format_version_dispatch_v13_before_v12_v11_v10() {
851        let mut buf = MAGIC_BYTES_V13.to_vec();
852        buf.extend_from_slice(&[0u8; 8]);
853        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V13));
854
855        let mut buf12 = MAGIC_BYTES_V12.to_vec();
856        buf12.extend_from_slice(&[0u8; 8]);
857        assert_eq!(FormatVersion::from_magic(&buf12), Some(FormatVersion::V12));
858    }
859
860    #[test]
861    fn t3_format_version_v13_magic_round_trip() {
862        let v = FormatVersion::V13;
863        let bytes = v.magic();
864        assert_eq!(bytes, MAGIC_BYTES_V13.as_slice());
865        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
866    }
867
868    #[test]
869    fn phase_a_magic_bytes_v11_is_distinct_and_14_bytes() {
870        assert_eq!(MAGIC_BYTES_V11, b"SQRY_GRAPH_V11");
871        assert_eq!(MAGIC_BYTES_V11.len(), 14);
872        assert_ne!(MAGIC_BYTES_V11, MAGIC_BYTES_V10);
873    }
874
875    #[test]
876    fn phase_a_format_version_from_magic_v11() {
877        assert_eq!(
878            FormatVersion::from_magic(MAGIC_BYTES_V11),
879            Some(FormatVersion::V11),
880        );
881    }
882
883    /// V11 and V10 magics are equal-length (14 bytes). The dispatch tries V11
884    /// before V10, so a buffer starting with `SQRY_GRAPH_V11` must resolve to
885    /// `FormatVersion::V11`, not V10. Guards against a future refactor that
886    /// re-orders the comparisons and silently routes V11 through the V10
887    /// upconvert path.
888    #[test]
889    fn phase_a_format_version_dispatch_v11_before_v10() {
890        let mut buf = MAGIC_BYTES_V11.to_vec();
891        // Append trailing bytes so the dispatch sees a "real-looking" payload.
892        buf.extend_from_slice(&[0u8; 8]);
893        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V11));
894
895        let mut buf10 = MAGIC_BYTES_V10.to_vec();
896        buf10.extend_from_slice(&[0u8; 8]);
897        assert_eq!(FormatVersion::from_magic(&buf10), Some(FormatVersion::V10));
898    }
899
900    #[test]
901    fn phase_a_format_version_v11_magic_round_trip() {
902        let v = FormatVersion::V11;
903        let bytes = v.magic();
904        assert_eq!(bytes, MAGIC_BYTES_V11.as_slice());
905        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
906    }
907
908    #[test]
909    fn phase1_format_version_from_magic_v7() {
910        assert_eq!(
911            FormatVersion::from_magic(MAGIC_BYTES_V7),
912            Some(FormatVersion::V7),
913        );
914    }
915
916    #[test]
917    fn phase1_format_version_from_magic_v8() {
918        assert_eq!(
919            FormatVersion::from_magic(MAGIC_BYTES_V8),
920            Some(FormatVersion::V8),
921        );
922    }
923
924    #[test]
925    fn phase2_magic_bytes_v9_is_distinct_and_13_bytes() {
926        assert_eq!(MAGIC_BYTES_V9, b"SQRY_GRAPH_V9");
927        assert_eq!(MAGIC_BYTES_V9.len(), 13);
928        assert_ne!(MAGIC_BYTES_V9, MAGIC_BYTES_V7);
929        assert_ne!(MAGIC_BYTES_V9, MAGIC_BYTES_V8);
930    }
931
932    #[test]
933    fn phase2_format_version_from_magic_v9() {
934        assert_eq!(
935            FormatVersion::from_magic(MAGIC_BYTES_V9),
936            Some(FormatVersion::V9),
937        );
938    }
939
940    #[test]
941    fn phase1_format_version_from_magic_unknown() {
942        assert_eq!(FormatVersion::from_magic(b"SQRY_GRAPH_V1"), None);
943        assert_eq!(FormatVersion::from_magic(b"NOT_A_GRAPH_!"), None);
944    }
945
946    #[test]
947    fn phase1_format_version_magic_round_trip() {
948        for version in [
949            FormatVersion::V7,
950            FormatVersion::V8,
951            FormatVersion::V9,
952            FormatVersion::V10,
953            FormatVersion::V11,
954            FormatVersion::V12,
955            FormatVersion::V13,
956        ] {
957            let bytes = version.magic();
958            assert_eq!(FormatVersion::from_magic(bytes), Some(version));
959        }
960    }
961
962    // ------------------------------------------------------------------
963    // Phase β joint-stubs: V12 magic + dispatch
964    // ------------------------------------------------------------------
965
966    #[test]
967    fn phase_beta_magic_bytes_v12_is_distinct_and_14_bytes() {
968        assert_eq!(MAGIC_BYTES_V12, b"SQRY_GRAPH_V12");
969        assert_eq!(MAGIC_BYTES_V12.len(), 14);
970        assert_ne!(MAGIC_BYTES_V12, MAGIC_BYTES_V11);
971        assert_ne!(MAGIC_BYTES_V12, MAGIC_BYTES_V10);
972    }
973
974    #[test]
975    fn phase_beta_format_version_from_magic_v12() {
976        assert_eq!(
977            FormatVersion::from_magic(MAGIC_BYTES_V12),
978            Some(FormatVersion::V12),
979        );
980    }
981
982    /// V12, V11, and V10 magics are equal-length (14 bytes). The dispatch
983    /// tries V12 FIRST, then V11, then V10 — so a buffer that starts with
984    /// `SQRY_GRAPH_V12` must resolve to V12, not V11 or V10. Mirrors the
985    /// `phase_a_format_version_dispatch_v11_before_v10` guard.
986    #[test]
987    fn phase_beta_format_version_dispatch_v12_before_v11_v10() {
988        let mut buf = MAGIC_BYTES_V12.to_vec();
989        buf.extend_from_slice(&[0u8; 8]);
990        assert_eq!(FormatVersion::from_magic(&buf), Some(FormatVersion::V12));
991
992        let mut buf11 = MAGIC_BYTES_V11.to_vec();
993        buf11.extend_from_slice(&[0u8; 8]);
994        assert_eq!(FormatVersion::from_magic(&buf11), Some(FormatVersion::V11));
995
996        let mut buf10 = MAGIC_BYTES_V10.to_vec();
997        buf10.extend_from_slice(&[0u8; 8]);
998        assert_eq!(FormatVersion::from_magic(&buf10), Some(FormatVersion::V10));
999    }
1000
1001    #[test]
1002    fn phase_beta_format_version_v12_magic_round_trip() {
1003        let v = FormatVersion::V12;
1004        let bytes = v.magic();
1005        assert_eq!(bytes, MAGIC_BYTES_V12.as_slice());
1006        assert_eq!(FormatVersion::from_magic(bytes), Some(v));
1007    }
1008
1009    #[test]
1010    fn phase1_format_version_copy_eq_debug() {
1011        let v = FormatVersion::V8;
1012        let copied = v;
1013        assert_eq!(v, copied);
1014        assert_eq!(format!("{v:?}"), "V8");
1015    }
1016
1017    #[test]
1018    fn phase2_format_version_v9_copy_eq_debug() {
1019        let v = FormatVersion::V9;
1020        let copied = v;
1021        assert_eq!(v, copied);
1022        assert_eq!(format!("{v:?}"), "V9");
1023    }
1024
1025    #[test]
1026    fn test_graph_header_with_provenance_and_plugins() {
1027        let provenance = make_test_provenance();
1028
1029        let mut plugin_versions = HashMap::new();
1030        plugin_versions.insert("rust".to_string(), "3.3.0".to_string());
1031        plugin_versions.insert("python".to_string(), "3.3.0".to_string());
1032
1033        let header = GraphHeader::with_provenance_and_plugins(
1034            100,
1035            50,
1036            200,
1037            10,
1038            provenance,
1039            plugin_versions.clone(),
1040        );
1041
1042        assert_eq!(header.version, VERSION);
1043        assert_eq!(header.node_count, 100);
1044        assert!(header.config_provenance.is_some());
1045        assert_eq!(header.plugin_versions().len(), 2);
1046        assert_eq!(
1047            header.plugin_versions().get("rust"),
1048            Some(&"3.3.0".to_string())
1049        );
1050    }
1051}