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