Skip to main content

serializer/
binary_output.rs

1//! DX Binary Output (.sr) Module
2//!
3//! Handles binary serialization output with proper path hashing
4//! for the `.dx/serializer` folder structure.
5//!
6//! ## Path Hashing Strategy
7//!
8//! Files with the same name in different directories get unique subfolders:
9//! ```text
10//! project/
11//!   config.dx          -> .dx/serializer/config.sr
12//!   src/config.dx      -> .dx/serializer/src/config.sr
13//!   lib/config.dx      -> .dx/serializer/lib/config.sr
14//! ```
15//!
16//! The hash is derived from the relative path to ensure deterministic output.
17
18use std::fs;
19use std::io::Write;
20use std::path::{Path, PathBuf};
21
22/// Binary output configuration
23#[derive(Debug, Clone)]
24pub struct BinaryConfig {
25    /// Project root directory
26    pub project_root: PathBuf,
27    /// Output directory (default: .dx/serializer)
28    pub output_dir: PathBuf,
29}
30
31impl Default for BinaryConfig {
32    fn default() -> Self {
33        Self {
34            project_root: PathBuf::from("."),
35            output_dir: PathBuf::from(".dx/serializer"),
36        }
37    }
38}
39
40impl BinaryConfig {
41    /// Create config with custom project root
42    pub fn with_root<P: AsRef<Path>>(root: P) -> Self {
43        let root = root.as_ref().to_path_buf();
44        Self {
45            output_dir: root.join(".dx/serializer"),
46            project_root: root,
47        }
48    }
49}
50
51/// Compute hash for a file path (8 hex characters)
52///
53/// Uses FNV-1a for speed and good distribution
54pub fn hash_path(relative_path: &str) -> String {
55    // FNV-1a 64-bit
56    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
57    const FNV_PRIME: u64 = 0x100000001b3;
58
59    let mut hash = FNV_OFFSET;
60    for byte in relative_path.bytes() {
61        hash ^= byte as u64;
62        hash = hash.wrapping_mul(FNV_PRIME);
63    }
64
65    // Take lower 32 bits for 8 hex chars
66    format!("{:08x}", hash as u32)
67}
68
69/// Get the output path for a .sr file
70///
71/// # Arguments
72/// * `source_path` - Path to the source .dx file
73/// * `config` - Binary output configuration
74///
75/// # Returns
76/// The full path where the .sr file should be written
77///
78/// # Example
79///
80/// ```rust
81/// use serializer::binary_output::{BinaryConfig, get_binary_path};
82/// use std::path::PathBuf;
83///
84/// let config = BinaryConfig::with_root("/project");
85/// let output = get_binary_path("/project/src/config.dx", &config);
86///
87/// // Output path contains the hash directory and .dx extension
88/// assert!(output.to_string_lossy().contains(".dx/serializer"));
89/// assert!(output.to_string_lossy().ends_with(".dx"));
90/// ```
91pub fn get_binary_path<P: AsRef<Path>>(source_path: P, config: &BinaryConfig) -> PathBuf {
92    let source = source_path.as_ref();
93
94    // Get relative path from project root
95    let relative = source
96        .strip_prefix(&config.project_root)
97        .unwrap_or(source)
98        .to_string_lossy()
99        .replace('\\', "/"); // Normalize path separators
100
101    // Hash the relative path (including directory)
102    let hash = hash_path(&relative);
103
104    // Get filename and change extension to .sr
105    let filename = source
106        .file_stem()
107        .map(|s| s.to_string_lossy().to_string())
108        .unwrap_or_else(|| "dx".to_string());
109
110    // Build output path: .dx/serializer/{hash}/{filename}.dx
111    config
112        .output_dir
113        .join(&hash)
114        .join(format!("{}.dx", filename))
115}
116
117/// Write binary data to the correct .dx path
118///
119/// Creates directories as needed and writes the binary data.
120pub fn write_binary<P: AsRef<Path>>(
121    source_path: P,
122    binary_data: &[u8],
123    config: &BinaryConfig,
124) -> std::io::Result<PathBuf> {
125    let output_path = get_binary_path(&source_path, config);
126
127    // Create parent directories
128    if let Some(parent) = output_path.parent() {
129        fs::create_dir_all(parent)?;
130    }
131
132    // Write binary data
133    let mut file = fs::File::create(&output_path)?;
134    file.write_all(binary_data)?;
135
136    Ok(output_path)
137}
138
139/// Read binary data from .dx file
140pub fn read_binary<P: AsRef<Path>>(
141    source_path: P,
142    config: &BinaryConfig,
143) -> std::io::Result<Vec<u8>> {
144    let binary_path = get_binary_path(&source_path, config);
145    fs::read(binary_path)
146}
147
148/// Check if binary cache exists and is newer than source
149pub fn is_cache_valid<P: AsRef<Path>>(source_path: P, config: &BinaryConfig) -> bool {
150    let source = source_path.as_ref();
151    let binary_path = get_binary_path(source, config);
152
153    // Check if binary exists
154    let binary_meta = match fs::metadata(&binary_path) {
155        Ok(m) => m,
156        Err(_) => return false,
157    };
158
159    // Check if source exists
160    let source_meta = match fs::metadata(source) {
161        Ok(m) => m,
162        Err(_) => return false,
163    };
164
165    // Compare modification times
166    match (source_meta.modified(), binary_meta.modified()) {
167        (Ok(src_time), Ok(bin_time)) => bin_time >= src_time,
168        _ => false,
169    }
170}
171
172/// Get manifest of all .dx files in a project
173pub fn get_manifest(config: &BinaryConfig) -> std::io::Result<Vec<(String, PathBuf)>> {
174    let mut manifest = Vec::new();
175
176    if !config.output_dir.exists() {
177        return Ok(manifest);
178    }
179
180    // Walk the serializer directory
181    for entry in fs::read_dir(&config.output_dir)? {
182        let entry = entry?;
183        let path = entry.path();
184
185        if path.is_dir() {
186            // This is a hash directory
187            let hash = path.file_name().map(|s| s.to_string_lossy().to_string());
188
189            for file_entry in fs::read_dir(&path)? {
190                let file_entry = file_entry?;
191                let file_path = file_entry.path();
192
193                if file_path.extension().map(|e| e == "dx").unwrap_or(false) {
194                    if let Some(h) = &hash {
195                        manifest.push((h.clone(), file_path));
196                    }
197                }
198            }
199        }
200    }
201
202    Ok(manifest)
203}
204
205/// Clean stale binary files (where source no longer exists)
206pub fn clean_stale(config: &BinaryConfig) -> std::io::Result<usize> {
207    let mut cleaned = 0;
208
209    if !config.output_dir.exists() {
210        return Ok(0);
211    }
212
213    // Walk hash directories
214    for entry in fs::read_dir(&config.output_dir)? {
215        let entry = entry?;
216        let path = entry.path();
217
218        if path.is_dir() {
219            // Check if directory is empty after potential cleanup
220            let mut is_empty = true;
221
222            for file_entry in fs::read_dir(&path)? {
223                let file_entry = file_entry?;
224                let file_path = file_entry.path();
225
226                if file_path.extension().map(|e| e == "dx").unwrap_or(false) {
227                    // We don't have source mapping here, so we can't validate
228                    // In production, we'd store a manifest with source paths
229                    is_empty = false;
230                }
231            }
232
233            if is_empty {
234                fs::remove_dir(&path)?;
235                cleaned += 1;
236            }
237        }
238    }
239
240    Ok(cleaned)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_hash_path_consistency() {
249        let hash1 = hash_path("config.dx");
250        let hash2 = hash_path("config.dx");
251        assert_eq!(hash1, hash2);
252    }
253
254    #[test]
255    fn test_hash_path_uniqueness() {
256        let hash1 = hash_path("config.dx");
257        let hash2 = hash_path("src/config.dx");
258        let hash3 = hash_path("lib/config.dx");
259
260        assert_ne!(hash1, hash2);
261        assert_ne!(hash2, hash3);
262        assert_ne!(hash1, hash3);
263    }
264
265    #[test]
266    fn test_get_binary_path() {
267        let config = BinaryConfig::with_root("/project");
268        let path = get_binary_path("/project/config.dx", &config);
269
270        assert!(path.to_string_lossy().contains(".dx/serializer"));
271        assert!(path.to_string_lossy().ends_with(".dx"));
272    }
273
274    #[test]
275    fn test_binary_path_different_dirs() {
276        let config = BinaryConfig::with_root("/project");
277
278        let path1 = get_binary_path("/project/config.dx", &config);
279        let path2 = get_binary_path("/project/src/config.dx", &config);
280
281        // Same filename, different hashes
282        assert_ne!(path1, path2);
283        assert!(path1.file_name() == path2.file_name()); // Both are config.dx
284    }
285}