Skip to main content

sqlitegraph/backend/native/v2/migration/
detect.rs

1//! Version detection for file format migration
2//!
3//! Provides functions to detect the format version of a graph database file
4//! and determine whether migration is required.
5
6use crate::backend::native::{
7    constants::MAGIC_BYTES,
8    types::{NativeBackendError, NativeResult},
9};
10use std::fs::File;
11use std::io::Read;
12use std::path::Path;
13
14/// File format version enum
15///
16/// Represents the different file format versions that SQLiteGraph has used.
17/// V1 is unsupported, V2 can be migrated to V3, V3 is current.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum FormatVersion {
20    /// V1 format - unsupported, cannot be migrated
21    V1,
22    /// V2 format - has 8-byte schema_version field at offset 32-39
23    /// Can be migrated to V3 (4-byte schema_version + 4-byte reserved)
24    V2,
25    /// V3 format - current version with 4-byte schema_version + 4-byte reserved
26    V3,
27    /// Unknown format version
28    Unknown(u32),
29}
30
31impl FormatVersion {
32    /// Check if this format version is supported
33    ///
34    /// V2 and V3 are supported. V1 and unknown versions are not.
35    pub fn is_supported(&self) -> bool {
36        matches!(self, FormatVersion::V2 | FormatVersion::V3)
37    }
38
39    /// Check if this format needs migration to current version
40    ///
41    /// V2 needs migration to V3. V3 is current.
42    pub fn needs_migration_to_current(&self) -> bool {
43        matches!(self, FormatVersion::V2)
44    }
45
46    /// Get the numeric version value
47    pub fn as_u32(&self) -> u32 {
48        match self {
49            FormatVersion::V1 => 1,
50            FormatVersion::V2 => 2,
51            FormatVersion::V3 => 3,
52            FormatVersion::Unknown(v) => *v,
53        }
54    }
55}
56
57/// Detect the format version of a graph database file
58///
59/// Reads the file header and extracts the format version.
60/// Returns an error if:
61/// - File doesn't exist or can't be opened
62/// - File is too small to contain a valid header
63/// - Magic bytes don't match
64///
65/// # Arguments
66///
67/// * `path` - Path to the graph database file
68///
69/// # Returns
70///
71/// * `Ok(FormatVersion)` - The detected format version
72/// * `Err(NativeBackendError)` - Error reading file or invalid format
73pub fn detect_format_version(path: &Path) -> NativeResult<FormatVersion> {
74    // Open file
75    let mut file = File::open(path).map_err(|e| NativeBackendError::Io(e))?;
76
77    // Read header (first 80 bytes)
78    let mut header = [0u8; 80];
79    file.read_exact(&mut header)
80        .map_err(|e| NativeBackendError::Io(e))?;
81
82    // Verify magic bytes
83    let magic = &header[0..8];
84    if magic != MAGIC_BYTES {
85        return Err(NativeBackendError::InvalidMagic {
86            expected: u64::from_be_bytes(MAGIC_BYTES),
87            found: u64::from_be_bytes(magic.try_into().unwrap_or([0u8; 8])),
88        });
89    }
90
91    // Read version field (offset 8-11) as u32 big-endian
92    let version_bytes = [header[8], header[9], header[10], header[11]];
93    let version = u32::from_be_bytes(version_bytes);
94
95    Ok(match version {
96        1 => FormatVersion::V1,
97        2 => FormatVersion::V2,
98        3 => FormatVersion::V3,
99        v => FormatVersion::Unknown(v),
100    })
101}
102
103/// Check if a file needs migration to the current format version
104///
105/// Returns `true` if the file is in V2 format and needs migration to V3.
106/// Returns `false` if the file is already in V3 format.
107/// Returns an error for:
108/// - V1 format (unsupported, cannot be migrated)
109/// - Unknown format versions
110/// - Read/detection errors
111///
112/// # Arguments
113///
114/// * `path` - Path to the graph database file
115///
116/// # Returns
117///
118/// * `Ok(true)` - File needs migration (V2 -> V3)
119/// * `Ok(false)` - File is current version (V3)
120/// * `Err(NativeBackendError)` - Unsupported version or detection error
121pub fn needs_migration(path: &Path) -> NativeResult<bool> {
122    let version = detect_format_version(path)?;
123
124    match version {
125        FormatVersion::V1 => Err(NativeBackendError::UnsupportedVersion {
126            version: 1,
127            supported_version: 3,
128        }),
129        FormatVersion::V2 => Ok(true),  // V2 needs migration to V3
130        FormatVersion::V3 => Ok(false), // V3 is current
131        FormatVersion::Unknown(v) => Err(NativeBackendError::UnsupportedVersion {
132            version: v,
133            supported_version: 3,
134        }),
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::backend::native::{
142        constants::DEFAULT_FEATURE_FLAGS,
143        v2::{V2_FORMAT_VERSION, V2_MAGIC},
144    };
145    use std::io::Write;
146    use tempfile::NamedTempFile;
147
148    /// Helper to create a graph file with specific version
149    fn create_test_file(version: u32) -> NamedTempFile {
150        let mut file = NamedTempFile::new().unwrap();
151
152        // Write header (80 bytes)
153        let mut header = [0u8; 80];
154
155        // Magic bytes (0-7)
156        header[0..8].copy_from_slice(&V2_MAGIC);
157
158        // Version (8-11)
159        header[8..12].copy_from_slice(&version.to_be_bytes());
160
161        // Flags (12-15)
162        header[12..16].copy_from_slice(&DEFAULT_FEATURE_FLAGS.to_be_bytes());
163
164        // Rest of header is zero-filled for this test
165        file.as_file_mut().write_all(&header).unwrap();
166        file.as_file_mut().flush().unwrap();
167        file.as_file_mut().sync_all().unwrap();
168
169        file
170    }
171
172    #[test]
173    fn test_detect_format_version_v2() {
174        let file = create_test_file(2);
175        let version = detect_format_version(file.path()).unwrap();
176        assert_eq!(version, FormatVersion::V2);
177        assert!(version.is_supported());
178        assert!(version.needs_migration_to_current());
179    }
180
181    #[test]
182    fn test_detect_format_version_v3() {
183        let file = create_test_file(3);
184        let version = detect_format_version(file.path()).unwrap();
185        assert_eq!(version, FormatVersion::V3);
186        assert!(version.is_supported());
187        assert!(!version.needs_migration_to_current());
188    }
189
190    #[test]
191    fn test_detect_format_version_v1_unsupported() {
192        let file = create_test_file(1);
193        let version = detect_format_version(file.path()).unwrap();
194        assert_eq!(version, FormatVersion::V1);
195        assert!(!version.is_supported());
196        // V1 can't be migrated to current (would return error in needs_migration)
197    }
198
199    #[test]
200    fn test_detect_format_version_unknown() {
201        let file = create_test_file(99);
202        let version = detect_format_version(file.path()).unwrap();
203        assert_eq!(version, FormatVersion::Unknown(99));
204        assert!(!version.is_supported());
205    }
206
207    #[test]
208    fn test_needs_migration_v2_returns_true() {
209        let file = create_test_file(2);
210        let needs = needs_migration(file.path()).unwrap();
211        assert!(needs);
212    }
213
214    #[test]
215    fn test_needs_migration_v3_returns_false() {
216        let file = create_test_file(3);
217        let needs = needs_migration(file.path()).unwrap();
218        assert!(!needs);
219    }
220
221    #[test]
222    fn test_needs_migration_v1_returns_error() {
223        let file = create_test_file(1);
224        let result = needs_migration(file.path());
225        assert!(result.is_err());
226        match result.unwrap_err() {
227            NativeBackendError::UnsupportedVersion { version, .. } => {
228                assert_eq!(version, 1);
229            }
230            _ => panic!("Expected UnsupportedVersion error"),
231        }
232    }
233
234    #[test]
235    fn test_detect_format_version_invalid_magic() {
236        let mut file = NamedTempFile::new().unwrap();
237
238        // Write invalid magic (need 8 bytes)
239        let mut header = [0u8; 80];
240        header[0..7].copy_from_slice(b"INVALID");
241        file.as_file_mut().write_all(&header).unwrap();
242
243        let result = detect_format_version(file.path());
244        assert!(result.is_err());
245    }
246
247    #[test]
248    fn test_detect_format_version_missing_file() {
249        let result = detect_format_version(Path::new("/nonexistent/file.db"));
250        assert!(result.is_err());
251    }
252
253    #[test]
254    fn test_format_version_as_u32() {
255        assert_eq!(FormatVersion::V1.as_u32(), 1);
256        assert_eq!(FormatVersion::V2.as_u32(), 2);
257        assert_eq!(FormatVersion::V3.as_u32(), 3);
258        assert_eq!(FormatVersion::Unknown(99).as_u32(), 99);
259    }
260}