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