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