vite_manifest/
lib.rs

1//! A Rust library for parsing and working with Vite build manifest files.
2//!
3//! Vite generates a manifest.json file during build that maps original source files
4//! to their corresponding output files, including information about dependencies,
5//! CSS files, and chunk relationships.
6//!
7//! # Example
8//!
9//! ```rust
10//! use vite_manifest::{parse_manifest, ManifestChunk};
11//!
12//! let json = r#"{
13//!     "src/main.js": {
14//!         "file": "assets/main-abc123.js",
15//!         "src": "src/main.js",
16//!         "isEntry": true,
17//!         "css": ["assets/main-def456.css"]
18//!     }
19//! }"#;
20//!
21//! let manifest = parse_manifest(json)?;
22//! let main_chunk = manifest.manifest().get("src/main.js").unwrap();
23//! println!("Output file: {}", main_chunk.file);
24//! # Ok::<(), serde_json::Error>(())
25//! ```
26
27use serde::{Deserialize, Serialize};
28use std::collections::{HashMap, HashSet};
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct Manifest(HashMap<String, ManifestChunk>);
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34pub struct ManifestChunk {
35    /// Source file path (optional)
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub src: Option<String>,
38
39    /// Output file path
40    pub file: String,
41
42    /// Associated CSS output files (optional)
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub css: Option<Vec<String>>,
45
46    /// Associated asset files (optional)
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub assets: Option<Vec<String>>,
49
50    /// Whether this chunk is an entry point (optional)
51    #[serde(skip_serializing_if = "Option::is_none")]
52    #[serde(rename = "isEntry")]
53    pub is_entry: Option<bool>,
54
55    /// Name of the chunk (optional)
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub name: Option<String>,
58
59    /// Names associated with the chunk (optional)
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub names: Option<Vec<String>>,
62
63    /// Whether this chunk is a dynamic import (optional)
64    #[serde(skip_serializing_if = "Option::is_none")]
65    #[serde(rename = "isDynamicEntry")]
66    pub is_dynamic_entry: Option<bool>,
67
68    /// Other chunks that this chunk depends on (optional)
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub imports: Option<Vec<String>>,
71
72    /// Other chunks this chunk dynamically imports (optional)
73    #[serde(skip_serializing_if = "Option::is_none")]
74    #[serde(rename = "dynamicImports")]
75    pub dynamic_imports: Option<Vec<String>>,
76}
77
78impl std::str::FromStr for Manifest {
79    type Err = serde_json::Error;
80
81    fn from_str(s: &str) -> Result<Self, Self::Err> {
82        serde_json::from_str(s)
83    }
84}
85
86impl Manifest {
87    pub fn manifest(&self) -> &HashMap<String, ManifestChunk> {
88        &self.0
89    }
90
91    pub fn imported_chunks(&self, name: &str) -> Vec<ManifestChunk> {
92        imported_chunks(&self.0, name)
93    }
94}
95
96/// Deserialize manifest from JSON string
97pub fn parse_manifest(json: &str) -> Result<Manifest, serde_json::Error> {
98    serde_json::from_str(json)
99}
100
101/// Serialize manifest to JSON string
102pub fn manifest_to_json(manifest: &Manifest) -> Result<String, serde_json::Error> {
103    serde_json::to_string_pretty(manifest)
104}
105
106fn imported_chunks(manifest: &HashMap<String, ManifestChunk>, name: &str) -> Vec<ManifestChunk> {
107    let mut seen: HashSet<String> = HashSet::new();
108
109    fn get_imported_chunks(
110        chunk: &ManifestChunk,
111        manifest: &HashMap<String, ManifestChunk>,
112        seen: &mut HashSet<String>,
113    ) -> Vec<ManifestChunk> {
114        let mut chunks: Vec<ManifestChunk> = Vec::new();
115        if let Some(imports) = &chunk.imports {
116            for file in imports.iter() {
117                if !seen.insert(file.clone()) {
118                    continue;
119                }
120
121                if let Some(chunk) = manifest.get(file) {
122                    chunks.extend(get_imported_chunks(chunk, manifest, seen));
123                    chunks.push(chunk.clone());
124                }
125            }
126        }
127        chunks
128    }
129
130    match manifest.get(name) {
131        Some(chunk) => get_imported_chunks(chunk, manifest, &mut seen),
132        None => Vec::new(),
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_manifest_chunk_serialization() {
142        let chunk = ManifestChunk {
143            src: Some("src/main.js".to_string()),
144            file: "assets/main-abc123.js".to_string(),
145            css: Some(vec!["assets/main-def456.css".to_string()]),
146            assets: None,
147            is_entry: Some(true),
148            name: None,
149            names: None,
150            is_dynamic_entry: None,
151            imports: None,
152            dynamic_imports: None,
153        };
154
155        let json = serde_json::to_string(&chunk).unwrap();
156        let parsed_chunk: ManifestChunk = serde_json::from_str(&json).unwrap();
157
158        assert_eq!(chunk, parsed_chunk);
159        assert_eq!(chunk.file, "assets/main-abc123.js");
160        assert_eq!(chunk.src, Some("src/main.js".to_string()));
161        assert_eq!(chunk.is_entry, Some(true));
162        assert_eq!(chunk.css, Some(vec!["assets/main-def456.css".to_string()]));
163    }
164
165    #[test]
166    fn test_manifest_serialization() {
167        let json = r#"{
168            "src/main.js": {
169                "file": "assets/main-abc123.js",
170                "src": "src/main.js",
171                "isEntry": true,
172                "css": ["assets/main-def456.css"]
173            },
174            "src/utils.js": {
175                "file": "assets/utils-def456.js"
176            }
177        }"#;
178
179        let manifest = parse_manifest(json).unwrap();
180        let serialized = manifest_to_json(&manifest).unwrap();
181        let parsed_again = parse_manifest(&serialized).unwrap();
182
183        assert_eq!(manifest, parsed_again);
184        assert_eq!(manifest.manifest().len(), 2);
185
186        let main_chunk = manifest.manifest().get("src/main.js").unwrap();
187        assert_eq!(main_chunk.file, "assets/main-abc123.js");
188        assert_eq!(main_chunk.is_entry, Some(true));
189
190        let utils_chunk = manifest.manifest().get("src/utils.js").unwrap();
191        assert_eq!(utils_chunk.file, "assets/utils-def456.js");
192        assert_eq!(utils_chunk.is_entry, None);
193    }
194
195    #[test]
196    fn test_json_field_names() {
197        let json = r#"{
198            "file": "assets/main.js",
199            "isEntry": true,
200            "isDynamicEntry": false,
201            "dynamicImports": ["./lazy.js"]
202        }"#;
203
204        let chunk: ManifestChunk = serde_json::from_str(json).unwrap();
205        assert_eq!(chunk.is_entry, Some(true));
206        assert_eq!(chunk.is_dynamic_entry, Some(false));
207        assert_eq!(chunk.dynamic_imports, Some(vec!["./lazy.js".to_string()]));
208    }
209
210    #[test]
211    fn test_imported_chunks() {
212        let json = r#"{
213            "entry.js": {
214                "file": "entry.mjs",
215                "src": "entry.js",
216                "isEntry": true,
217                "imports": ["utils.js", "components.js"]
218            },
219            "utils.js": {
220                "file": "utils.mjs",
221                "src": "utils.js",
222                "imports": ["helpers.js"]
223            },
224            "components.js": {
225                "file": "components.mjs",
226                "src": "components.js",
227                "imports": ["helpers.js"]
228            },
229            "helpers.js": {
230                "file": "helpers.mjs",
231                "src": "helpers.js"
232            },
233            "standalone.js": {
234                "file": "standalone.mjs",
235                "src": "standalone.js"
236            }
237        }"#;
238
239        let manifest = parse_manifest(json).unwrap();
240
241        // Test getting imported chunks for entry.js
242        let imported = manifest.imported_chunks("entry.js");
243        assert_eq!(imported.len(), 3); // helpers.js, utils.js, components.js
244
245        // Verify the chunks are correct and in dependency order
246        let files: Vec<_> = imported.iter().map(|c| &c.file).collect();
247        assert!(files.contains(&&"helpers.mjs".to_string()));
248        assert!(files.contains(&&"utils.mjs".to_string()));
249        assert!(files.contains(&&"components.mjs".to_string()));
250
251        // Test getting imported chunks for utils.js
252        let utils_imported = manifest.imported_chunks("utils.js");
253        assert_eq!(utils_imported.len(), 1);
254        assert_eq!(utils_imported[0].file, "helpers.mjs");
255
256        // Test getting imported chunks for a chunk with no imports
257        let helpers_imported = manifest.imported_chunks("helpers.js");
258        assert_eq!(helpers_imported.len(), 0);
259
260        // Test getting imported chunks for a standalone chunk
261        let standalone_imported = manifest.imported_chunks("standalone.js");
262        assert_eq!(standalone_imported.len(), 0);
263
264        // Test getting imported chunks for non-existent chunk
265        let missing_imported = manifest.imported_chunks("missing.js");
266        assert_eq!(missing_imported.len(), 0);
267    }
268}