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