Skip to main content

fob_bundler/cache/
serialize.rs

1//! Serialization layer for build cache.
2//!
3//! Rolldown types (`OutputChunk`, `OutputAsset`, etc.) don't implement serde
4//! traits. This module provides serializable wrapper types and conversions.
5
6use std::sync::Arc;
7
8use serde::{Deserialize, Serialize};
9
10use crate::BundleOutput;
11use crate::builders::asset_registry::{AssetInfo, AssetRegistry};
12use fob_graph::{CacheAnalysis, TransformationTrace};
13
14/// Current cache format version. Increment when format changes.
15pub const CACHE_FORMAT_VERSION: u32 = 1;
16
17/// Cached build data - serializable representation of `AnalyzedBundle`.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct CachedBuild {
20    /// Cache metadata for validation.
21    pub metadata: CacheMetadata,
22
23    /// Serialized bundle outputs (chunks and assets).
24    pub outputs: Vec<SerializedOutput>,
25
26    /// Serialized module graph (JSON).
27    pub graph_json: String,
28
29    /// Entry point module IDs.
30    pub entry_points: Vec<String>,
31
32    /// Warnings from analysis.
33    pub warnings: Vec<String>,
34
35    /// Errors from analysis.
36    pub errors: Vec<String>,
37
38    /// Cache metrics.
39    pub cache: CacheAnalysis,
40
41    /// Optional transformation trace.
42    pub trace: Option<TransformationTrace>,
43
44    /// Serialized asset registry.
45    pub assets: Vec<SerializedAssetInfo>,
46}
47
48/// Cache metadata for validation.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct CacheMetadata {
51    /// fob-bundler version that created this cache.
52    pub fob_version: String,
53
54    /// Cache format version.
55    pub format_version: u32,
56
57    /// Unix timestamp when cache was created.
58    pub created_at: u64,
59}
60
61impl CacheMetadata {
62    /// Create new metadata with current version and timestamp.
63    pub fn new() -> Self {
64        Self {
65            fob_version: env!("CARGO_PKG_VERSION").to_string(),
66            format_version: CACHE_FORMAT_VERSION,
67            created_at: std::time::SystemTime::now()
68                .duration_since(std::time::UNIX_EPOCH)
69                .map(|d| d.as_secs())
70                .unwrap_or(0),
71        }
72    }
73
74    /// Check if this metadata is compatible with current version.
75    pub fn is_compatible(&self) -> bool {
76        self.format_version == CACHE_FORMAT_VERSION
77    }
78}
79
80impl Default for CacheMetadata {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86/// Serialized output (chunk or asset).
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub enum SerializedOutput {
89    Chunk(SerializedChunk),
90    Asset(SerializedAsset),
91}
92
93/// Serialized chunk from Rolldown.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct SerializedChunk {
96    pub name: String,
97    pub filename: String,
98    pub code: String,
99    pub map_json: Option<String>,
100    pub sourcemap_filename: Option<String>,
101    pub preliminary_filename: String,
102    pub is_entry: bool,
103    pub is_dynamic_entry: bool,
104    pub facade_module_id: Option<String>,
105    pub module_ids: Vec<String>,
106    pub imports: Vec<String>,
107    pub dynamic_imports: Vec<String>,
108    pub exports: Vec<String>,
109    pub modules: Vec<SerializedRenderedModule>,
110}
111
112/// Serialized rendered module.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct SerializedRenderedModule {
115    pub id: String,
116    pub code: Option<String>,
117    pub rendered_exports: Vec<String>,
118    pub exec_order: u32,
119}
120
121/// Serialized asset from Rolldown.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct SerializedAsset {
124    pub names: Vec<String>,
125    pub original_file_names: Vec<String>,
126    pub filename: String,
127    pub source: SerializedStrOrBytes,
128}
129
130/// Serialized string or bytes.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub enum SerializedStrOrBytes {
133    Str(String),
134    Bytes(Vec<u8>),
135}
136
137/// Serialized asset info from registry.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SerializedAssetInfo {
140    pub source_path: String,
141    pub referrer: String,
142    pub specifier: String,
143    pub content_type: String,
144    pub size: Option<u64>,
145    pub url_path: Option<String>,
146    pub content_hash: Option<String>,
147}
148
149// ============================================================================
150// Conversion: Rolldown types -> Serialized types
151// ============================================================================
152
153impl SerializedChunk {
154    /// Convert from Rolldown OutputChunk.
155    pub fn from_rolldown(chunk: &rolldown_common::OutputChunk) -> Self {
156        Self {
157            name: chunk.name.to_string(),
158            filename: chunk.filename.to_string(),
159            code: chunk.code.clone(),
160            map_json: chunk.map.as_ref().map(|m| m.to_json_string()),
161            sourcemap_filename: chunk.sourcemap_filename.clone(),
162            preliminary_filename: chunk.preliminary_filename.clone(),
163            is_entry: chunk.is_entry,
164            is_dynamic_entry: chunk.is_dynamic_entry,
165            facade_module_id: chunk.facade_module_id.as_ref().map(|id| id.to_string()),
166            module_ids: chunk.module_ids.iter().map(|id| id.to_string()).collect(),
167            imports: chunk.imports.iter().map(|s| s.to_string()).collect(),
168            dynamic_imports: chunk
169                .dynamic_imports
170                .iter()
171                .map(|s| s.to_string())
172                .collect(),
173            exports: chunk.exports.iter().map(|s| s.to_string()).collect(),
174            modules: chunk
175                .modules
176                .keys
177                .iter()
178                .zip(chunk.modules.values.iter())
179                .map(|(id, module)| SerializedRenderedModule {
180                    id: id.to_string(),
181                    code: module.code(),
182                    rendered_exports: module
183                        .rendered_exports
184                        .iter()
185                        .map(|s| s.to_string())
186                        .collect(),
187                    exec_order: module.exec_order,
188                })
189                .collect(),
190        }
191    }
192}
193
194impl SerializedAsset {
195    /// Convert from Rolldown OutputAsset.
196    pub fn from_rolldown(asset: &rolldown_common::OutputAsset) -> Self {
197        Self {
198            names: asset.names.clone(),
199            original_file_names: asset.original_file_names.clone(),
200            filename: asset.filename.to_string(),
201            source: match &asset.source {
202                rolldown_common::StrOrBytes::Str(s) => SerializedStrOrBytes::Str(s.clone()),
203                rolldown_common::StrOrBytes::Bytes(b) => SerializedStrOrBytes::Bytes(b.clone()),
204            },
205        }
206    }
207}
208
209impl SerializedOutput {
210    /// Convert from Rolldown Output.
211    pub fn from_rolldown(output: &rolldown_common::Output) -> Self {
212        match output {
213            rolldown_common::Output::Chunk(chunk) => {
214                SerializedOutput::Chunk(SerializedChunk::from_rolldown(chunk))
215            }
216            rolldown_common::Output::Asset(asset) => {
217                SerializedOutput::Asset(SerializedAsset::from_rolldown(asset))
218            }
219        }
220    }
221}
222
223impl SerializedAssetInfo {
224    /// Convert from AssetInfo.
225    pub fn from_asset_info(info: &AssetInfo) -> Self {
226        Self {
227            source_path: info.source_path.to_string_lossy().to_string(),
228            referrer: info.referrer.clone(),
229            specifier: info.specifier.clone(),
230            content_type: info.content_type.clone(),
231            size: info.size,
232            url_path: info.url_path.clone(),
233            content_hash: info.content_hash.clone(),
234        }
235    }
236}
237
238// ============================================================================
239// Conversion: Serialized types -> Rolldown types
240// ============================================================================
241
242impl SerializedChunk {
243    /// Convert back to Rolldown OutputChunk.
244    pub fn into_rolldown(self) -> rolldown_common::OutputChunk {
245        use arcstr::ArcStr;
246
247        // Build Modules struct
248        let mut keys = Vec::with_capacity(self.modules.len());
249        let mut values = Vec::with_capacity(self.modules.len());
250
251        for module in self.modules {
252            keys.push(rolldown_common::ModuleId::new(module.id));
253            // Convert via String to avoid oxc_span version mismatch issues
254            let rendered_exports = module
255                .rendered_exports
256                .into_iter()
257                .map(|s| s.into())
258                .collect();
259            values.push(Arc::new(rolldown_common::RenderedModule::new(
260                None, // We can't reconstruct the inner_code Sources
261                rendered_exports,
262                module.exec_order,
263            )));
264        }
265
266        rolldown_common::OutputChunk {
267            name: ArcStr::from(self.name),
268            filename: ArcStr::from(self.filename),
269            code: self.code,
270            // Note: We store the sourcemap as JSON and return None here.
271            // The JSON is preserved in case we need to reconstruct it later.
272            map: None,
273            sourcemap_filename: self.sourcemap_filename,
274            preliminary_filename: self.preliminary_filename,
275            is_entry: self.is_entry,
276            is_dynamic_entry: self.is_dynamic_entry,
277            facade_module_id: self.facade_module_id.map(rolldown_common::ModuleId::new),
278            module_ids: self
279                .module_ids
280                .into_iter()
281                .map(rolldown_common::ModuleId::new)
282                .collect(),
283            imports: self.imports.into_iter().map(ArcStr::from).collect(),
284            dynamic_imports: self.dynamic_imports.into_iter().map(ArcStr::from).collect(),
285            exports: self.exports.into_iter().map(|s| s.into()).collect(),
286            modules: rolldown_common::Modules { keys, values },
287        }
288    }
289}
290
291impl SerializedAsset {
292    /// Convert back to Rolldown OutputAsset.
293    pub fn into_rolldown(self) -> rolldown_common::OutputAsset {
294        use arcstr::ArcStr;
295
296        rolldown_common::OutputAsset {
297            names: self.names,
298            original_file_names: self.original_file_names,
299            filename: ArcStr::from(self.filename),
300            source: match self.source {
301                SerializedStrOrBytes::Str(s) => rolldown_common::StrOrBytes::Str(s),
302                SerializedStrOrBytes::Bytes(b) => rolldown_common::StrOrBytes::Bytes(b),
303            },
304        }
305    }
306}
307
308impl SerializedOutput {
309    /// Convert back to Rolldown Output.
310    pub fn into_rolldown(self) -> rolldown_common::Output {
311        match self {
312            SerializedOutput::Chunk(chunk) => {
313                rolldown_common::Output::Chunk(Arc::new(chunk.into_rolldown()))
314            }
315            SerializedOutput::Asset(asset) => {
316                rolldown_common::Output::Asset(Arc::new(asset.into_rolldown()))
317            }
318        }
319    }
320}
321
322// ============================================================================
323// CachedBuild conversion
324// ============================================================================
325
326/// Components needed to create a cached build.
327pub struct BuildComponents<'a> {
328    pub bundle: &'a BundleOutput,
329    pub graph_json: String,
330    pub entry_points: Vec<String>,
331    pub warnings: Vec<String>,
332    pub errors: Vec<String>,
333    pub cache: &'a CacheAnalysis,
334    pub trace: Option<&'a TransformationTrace>,
335    pub asset_registry: Option<&'a Arc<AssetRegistry>>,
336}
337
338impl CachedBuild {
339    /// Create from build components.
340    pub fn from_components(components: BuildComponents<'_>) -> Self {
341        // Serialize outputs
342        let outputs: Vec<SerializedOutput> = components
343            .bundle
344            .assets
345            .iter()
346            .map(SerializedOutput::from_rolldown)
347            .collect();
348
349        // Serialize asset registry
350        let assets = components
351            .asset_registry
352            .map(|registry| {
353                registry
354                    .all_assets()
355                    .iter()
356                    .map(SerializedAssetInfo::from_asset_info)
357                    .collect()
358            })
359            .unwrap_or_default();
360
361        Self {
362            metadata: CacheMetadata::new(),
363            outputs,
364            graph_json: components.graph_json,
365            entry_points: components.entry_points,
366            warnings: components.warnings,
367            errors: components.errors,
368            cache: components.cache.clone(),
369            trace: components.trace.cloned(),
370            assets,
371        }
372    }
373
374    /// Convert to BundleOutput.
375    pub fn into_bundle_output(self) -> BundleOutput {
376        BundleOutput {
377            assets: self
378                .outputs
379                .into_iter()
380                .map(|o| o.into_rolldown())
381                .collect(),
382            warnings: vec![], // Warnings are stored separately
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_cache_metadata_compatibility() {
393        let meta = CacheMetadata::new();
394        assert!(meta.is_compatible());
395        assert_eq!(meta.format_version, CACHE_FORMAT_VERSION);
396    }
397
398    #[test]
399    fn test_serialized_str_or_bytes_roundtrip() {
400        let str_val = SerializedStrOrBytes::Str("hello".to_string());
401        let bytes_val = SerializedStrOrBytes::Bytes(vec![1, 2, 3]);
402
403        // Test bincode serialization roundtrip
404        let str_bytes = bincode::serialize(&str_val).unwrap();
405        let str_back: SerializedStrOrBytes = bincode::deserialize(&str_bytes).unwrap();
406        assert!(matches!(str_back, SerializedStrOrBytes::Str(s) if s == "hello"));
407
408        let bytes_bytes = bincode::serialize(&bytes_val).unwrap();
409        let bytes_back: SerializedStrOrBytes = bincode::deserialize(&bytes_bytes).unwrap();
410        assert!(matches!(bytes_back, SerializedStrOrBytes::Bytes(b) if b == vec![1, 2, 3]));
411    }
412}