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: "`SQRY_GRAPH_V6`"
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
20pub const MAGIC_BYTES: &[u8; 13] = b"SQRY_GRAPH_V6";
21
22/// Current format version
23pub const VERSION: u32 = 6;
24
25/// Header for persisted graph files.
26///
27/// The header provides metadata about the graph for validation
28/// and efficient loading.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct GraphHeader {
31    /// Format version (for compatibility checking)
32    pub version: u32,
33
34    /// Number of nodes in the graph
35    pub node_count: usize,
36
37    /// Number of edges in the graph
38    pub edge_count: usize,
39
40    /// Number of interned strings
41    pub string_count: usize,
42
43    /// Number of registered files
44    pub file_count: usize,
45
46    /// Timestamp when graph was saved (unix epoch seconds)
47    pub timestamp: u64,
48
49    /// Configuration provenance - records which config was used to build this graph.
50    #[serde(default)]
51    pub config_provenance: Option<ConfigProvenance>,
52
53    /// Plugin versions used to build this graph (`plugin_id` → version).
54    ///
55    /// Tracks which language plugin versions were active during indexing.
56    /// Used to detect stale indexes when plugin versions change.
57    #[serde(default)]
58    pub plugin_versions: HashMap<String, String>,
59}
60
61impl GraphHeader {
62    /// Creates a new graph header with the given counts.
63    #[must_use]
64    pub fn new(
65        node_count: usize,
66        edge_count: usize,
67        string_count: usize,
68        file_count: usize,
69    ) -> Self {
70        Self {
71            version: VERSION,
72            node_count,
73            edge_count,
74            string_count,
75            file_count,
76            timestamp: std::time::SystemTime::now()
77                .duration_since(std::time::UNIX_EPOCH)
78                .unwrap_or_default()
79                .as_secs(),
80            config_provenance: None,
81            plugin_versions: HashMap::new(),
82        }
83    }
84
85    /// Creates a new graph header with config provenance.
86    #[must_use]
87    pub fn with_provenance(
88        node_count: usize,
89        edge_count: usize,
90        string_count: usize,
91        file_count: usize,
92        provenance: ConfigProvenance,
93    ) -> Self {
94        Self {
95            version: VERSION,
96            node_count,
97            edge_count,
98            string_count,
99            file_count,
100            timestamp: std::time::SystemTime::now()
101                .duration_since(std::time::UNIX_EPOCH)
102                .unwrap_or_default()
103                .as_secs(),
104            config_provenance: Some(provenance),
105            plugin_versions: HashMap::new(),
106        }
107    }
108
109    /// Creates a new graph header with config provenance and plugin versions.
110    #[must_use]
111    pub fn with_provenance_and_plugins(
112        node_count: usize,
113        edge_count: usize,
114        string_count: usize,
115        file_count: usize,
116        provenance: ConfigProvenance,
117        plugin_versions: HashMap<String, String>,
118    ) -> Self {
119        Self {
120            version: VERSION,
121            node_count,
122            edge_count,
123            string_count,
124            file_count,
125            timestamp: std::time::SystemTime::now()
126                .duration_since(std::time::UNIX_EPOCH)
127                .unwrap_or_default()
128                .as_secs(),
129            config_provenance: Some(provenance),
130            plugin_versions,
131        }
132    }
133
134    /// Returns the config provenance if available.
135    #[must_use]
136    pub fn provenance(&self) -> Option<&ConfigProvenance> {
137        self.config_provenance.as_ref()
138    }
139
140    /// Checks if the graph was built with tracked config provenance.
141    #[must_use]
142    pub fn has_provenance(&self) -> bool {
143        self.config_provenance.is_some()
144    }
145
146    /// Returns the plugin versions used to build this graph.
147    #[must_use]
148    pub fn plugin_versions(&self) -> &HashMap<String, String> {
149        &self.plugin_versions
150    }
151
152    /// Sets the plugin versions for this graph header.
153    pub fn set_plugin_versions(&mut self, versions: HashMap<String, String>) {
154        self.plugin_versions = versions;
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use std::collections::HashMap;
162    use std::path::PathBuf;
163
164    fn make_test_provenance() -> ConfigProvenance {
165        ConfigProvenance {
166            config_file: PathBuf::from(".sqry/graph/config/config.json"),
167            config_checksum: "abc123def456".to_string(),
168            schema_version: 1,
169            overrides: HashMap::new(),
170            build_timestamp: std::time::SystemTime::now()
171                .duration_since(std::time::UNIX_EPOCH)
172                .unwrap_or_default()
173                .as_secs(),
174            build_host: Some("test-host".to_string()),
175        }
176    }
177
178    #[test]
179    fn test_magic_bytes() {
180        assert_eq!(MAGIC_BYTES, b"SQRY_GRAPH_V6");
181        assert_eq!(MAGIC_BYTES.len(), 13);
182    }
183
184    #[test]
185    fn test_version() {
186        assert_eq!(VERSION, 6);
187    }
188
189    #[test]
190    fn test_graph_header_new() {
191        let header = GraphHeader::new(100, 50, 200, 10);
192
193        assert_eq!(header.version, VERSION);
194        assert_eq!(header.node_count, 100);
195        assert_eq!(header.edge_count, 50);
196        assert_eq!(header.string_count, 200);
197        assert_eq!(header.file_count, 10);
198        assert!(header.timestamp > 0);
199        assert!(header.config_provenance.is_none());
200    }
201
202    #[test]
203    fn test_graph_header_with_provenance() {
204        let provenance = make_test_provenance();
205        let header = GraphHeader::with_provenance(100, 50, 200, 10, provenance);
206
207        assert_eq!(header.version, VERSION);
208        assert_eq!(header.node_count, 100);
209        assert_eq!(header.edge_count, 50);
210        assert!(header.config_provenance.is_some());
211        assert_eq!(
212            header.config_provenance.as_ref().unwrap().config_checksum,
213            "abc123def456"
214        );
215    }
216
217    #[test]
218    fn test_graph_header_provenance_method() {
219        let header = GraphHeader::new(10, 5, 20, 2);
220        assert!(header.provenance().is_none());
221
222        let provenance = make_test_provenance();
223        let header_with = GraphHeader::with_provenance(10, 5, 20, 2, provenance);
224        assert!(header_with.provenance().is_some());
225        assert_eq!(
226            header_with.provenance().unwrap().config_checksum,
227            "abc123def456"
228        );
229    }
230
231    #[test]
232    fn test_graph_header_has_provenance() {
233        let header = GraphHeader::new(10, 5, 20, 2);
234        assert!(!header.has_provenance());
235
236        let provenance = make_test_provenance();
237        let header_with = GraphHeader::with_provenance(10, 5, 20, 2, provenance);
238        assert!(header_with.has_provenance());
239    }
240
241    #[test]
242    fn test_graph_header_clone() {
243        let header = GraphHeader::new(100, 50, 200, 10);
244        let cloned = header.clone();
245
246        assert_eq!(header.version, cloned.version);
247        assert_eq!(header.node_count, cloned.node_count);
248        assert_eq!(header.edge_count, cloned.edge_count);
249        assert_eq!(header.string_count, cloned.string_count);
250        assert_eq!(header.file_count, cloned.file_count);
251    }
252
253    #[test]
254    fn test_graph_header_debug() {
255        let header = GraphHeader::new(100, 50, 200, 10);
256        let debug_str = format!("{:?}", header);
257
258        assert!(debug_str.contains("GraphHeader"));
259        assert!(debug_str.contains("version"));
260        assert!(debug_str.contains("node_count"));
261    }
262
263    #[test]
264    fn test_graph_header_timestamp_is_recent() {
265        let header = GraphHeader::new(10, 5, 20, 2);
266        let now = std::time::SystemTime::now()
267            .duration_since(std::time::UNIX_EPOCH)
268            .unwrap()
269            .as_secs();
270
271        // Timestamp should be within 1 second of now
272        assert!(header.timestamp <= now);
273        assert!(header.timestamp >= now - 1);
274    }
275
276    #[test]
277    fn test_graph_header_zero_counts() {
278        let header = GraphHeader::new(0, 0, 0, 0);
279
280        assert_eq!(header.node_count, 0);
281        assert_eq!(header.edge_count, 0);
282        assert_eq!(header.string_count, 0);
283        assert_eq!(header.file_count, 0);
284    }
285
286    #[test]
287    fn test_graph_header_large_counts() {
288        let header = GraphHeader::new(1_000_000, 5_000_000, 10_000_000, 100_000);
289
290        assert_eq!(header.node_count, 1_000_000);
291        assert_eq!(header.edge_count, 5_000_000);
292        assert_eq!(header.string_count, 10_000_000);
293        assert_eq!(header.file_count, 100_000);
294    }
295
296    #[test]
297    fn test_graph_header_plugin_versions_empty_by_default() {
298        let header = GraphHeader::new(10, 5, 20, 2);
299        assert!(header.plugin_versions().is_empty());
300    }
301
302    #[test]
303    fn test_graph_header_set_plugin_versions() {
304        let mut header = GraphHeader::new(10, 5, 20, 2);
305
306        let mut versions = HashMap::new();
307        versions.insert("rust".to_string(), "3.3.0".to_string());
308        versions.insert("javascript".to_string(), "3.3.0".to_string());
309
310        header.set_plugin_versions(versions.clone());
311
312        assert_eq!(header.plugin_versions().len(), 2);
313        assert_eq!(
314            header.plugin_versions().get("rust"),
315            Some(&"3.3.0".to_string())
316        );
317        assert_eq!(
318            header.plugin_versions().get("javascript"),
319            Some(&"3.3.0".to_string())
320        );
321    }
322
323    #[test]
324    fn test_graph_header_with_provenance_and_plugins() {
325        let provenance = make_test_provenance();
326
327        let mut plugin_versions = HashMap::new();
328        plugin_versions.insert("rust".to_string(), "3.3.0".to_string());
329        plugin_versions.insert("python".to_string(), "3.3.0".to_string());
330
331        let header = GraphHeader::with_provenance_and_plugins(
332            100,
333            50,
334            200,
335            10,
336            provenance,
337            plugin_versions.clone(),
338        );
339
340        assert_eq!(header.version, VERSION);
341        assert_eq!(header.node_count, 100);
342        assert!(header.config_provenance.is_some());
343        assert_eq!(header.plugin_versions().len(), 2);
344        assert_eq!(
345            header.plugin_versions().get("rust"),
346            Some(&"3.3.0".to_string())
347        );
348    }
349}