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