vite-manifest 0.1.0

Types for working with Vite build manifest files
Documentation
//! A Rust library for parsing and working with Vite build manifest files.
//!
//! Vite generates a manifest.json file during build that maps original source files
//! to their corresponding output files, including information about dependencies,
//! CSS files, and chunk relationships.
//!
//! # Example
//!
//! ```rust
//! use vite_manifest::{parse_manifest, ManifestChunk};
//!
//! let json = r#"{
//!     "src/main.js": {
//!         "file": "assets/main-abc123.js",
//!         "src": "src/main.js",
//!         "isEntry": true,
//!         "css": ["assets/main-def456.css"]
//!     }
//! }"#;
//!
//! let manifest = parse_manifest(json)?;
//! let main_chunk = manifest.manifest().get("src/main.js").unwrap();
//! println!("Output file: {}", main_chunk.file);
//! # Ok::<(), serde_json::Error>(())
//! ```

use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Manifest(HashMap<String, ManifestChunk>);

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ManifestChunk {
    /// Source file path (optional)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub src: Option<String>,

    /// Output file path
    pub file: String,

    /// Associated CSS output files (optional)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub css: Option<Vec<String>>,

    /// Associated asset files (optional)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub assets: Option<Vec<String>>,

    /// Whether this chunk is an entry point (optional)
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "isEntry")]
    pub is_entry: Option<bool>,

    /// Name of the chunk (optional)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,

    /// Names associated with the chunk (optional)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub names: Option<Vec<String>>,

    /// Whether this chunk is a dynamic import (optional)
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "isDynamicEntry")]
    pub is_dynamic_entry: Option<bool>,

    /// Other chunks that this chunk depends on (optional)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub imports: Option<Vec<String>>,

    /// Other chunks this chunk dynamically imports (optional)
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "dynamicImports")]
    pub dynamic_imports: Option<Vec<String>>,
}

impl std::str::FromStr for Manifest {
    type Err = serde_json::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        serde_json::from_str(s)
    }
}

impl Manifest {
    pub fn manifest(&self) -> &HashMap<String, ManifestChunk> {
        &self.0
    }

    pub fn imported_chunks(&self, name: &str) -> Vec<ManifestChunk> {
        imported_chunks(&self.0, name)
    }
}

/// Deserialize manifest from JSON string
pub fn parse_manifest(json: &str) -> Result<Manifest, serde_json::Error> {
    serde_json::from_str(json)
}

/// Serialize manifest to JSON string
pub fn manifest_to_json(manifest: &Manifest) -> Result<String, serde_json::Error> {
    serde_json::to_string_pretty(manifest)
}

fn imported_chunks(manifest: &HashMap<String, ManifestChunk>, name: &str) -> Vec<ManifestChunk> {
    let mut seen: HashSet<String> = HashSet::new();

    fn get_imported_chunks(
        chunk: &ManifestChunk,
        manifest: &HashMap<String, ManifestChunk>,
        seen: &mut HashSet<String>,
    ) -> Vec<ManifestChunk> {
        let mut chunks: Vec<ManifestChunk> = Vec::new();
        if let Some(imports) = &chunk.imports {
            for file in imports.iter() {
                if !seen.insert(file.clone()) {
                    continue;
                }

                if let Some(chunk) = manifest.get(file) {
                    chunks.extend(get_imported_chunks(chunk, manifest, seen));
                    chunks.push(chunk.clone());
                }
            }
        }
        chunks
    }

    match manifest.get(name) {
        Some(chunk) => get_imported_chunks(chunk, manifest, &mut seen),
        None => Vec::new(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_manifest_chunk_serialization() {
        let chunk = ManifestChunk {
            src: Some("src/main.js".to_string()),
            file: "assets/main-abc123.js".to_string(),
            css: Some(vec!["assets/main-def456.css".to_string()]),
            assets: None,
            is_entry: Some(true),
            name: None,
            names: None,
            is_dynamic_entry: None,
            imports: None,
            dynamic_imports: None,
        };

        let json = serde_json::to_string(&chunk).unwrap();
        let parsed_chunk: ManifestChunk = serde_json::from_str(&json).unwrap();

        assert_eq!(chunk, parsed_chunk);
        assert_eq!(chunk.file, "assets/main-abc123.js");
        assert_eq!(chunk.src, Some("src/main.js".to_string()));
        assert_eq!(chunk.is_entry, Some(true));
        assert_eq!(chunk.css, Some(vec!["assets/main-def456.css".to_string()]));
    }

    #[test]
    fn test_manifest_serialization() {
        let json = r#"{
            "src/main.js": {
                "file": "assets/main-abc123.js",
                "src": "src/main.js",
                "isEntry": true,
                "css": ["assets/main-def456.css"]
            },
            "src/utils.js": {
                "file": "assets/utils-def456.js"
            }
        }"#;

        let manifest = parse_manifest(json).unwrap();
        let serialized = manifest_to_json(&manifest).unwrap();
        let parsed_again = parse_manifest(&serialized).unwrap();

        assert_eq!(manifest, parsed_again);
        assert_eq!(manifest.manifest().len(), 2);

        let main_chunk = manifest.manifest().get("src/main.js").unwrap();
        assert_eq!(main_chunk.file, "assets/main-abc123.js");
        assert_eq!(main_chunk.is_entry, Some(true));

        let utils_chunk = manifest.manifest().get("src/utils.js").unwrap();
        assert_eq!(utils_chunk.file, "assets/utils-def456.js");
        assert_eq!(utils_chunk.is_entry, None);
    }

    #[test]
    fn test_json_field_names() {
        let json = r#"{
            "file": "assets/main.js",
            "isEntry": true,
            "isDynamicEntry": false,
            "dynamicImports": ["./lazy.js"]
        }"#;

        let chunk: ManifestChunk = serde_json::from_str(json).unwrap();
        assert_eq!(chunk.is_entry, Some(true));
        assert_eq!(chunk.is_dynamic_entry, Some(false));
        assert_eq!(chunk.dynamic_imports, Some(vec!["./lazy.js".to_string()]));
    }

    #[test]
    fn test_imported_chunks() {
        let json = r#"{
            "entry.js": {
                "file": "entry.mjs",
                "src": "entry.js",
                "isEntry": true,
                "imports": ["utils.js", "components.js"]
            },
            "utils.js": {
                "file": "utils.mjs",
                "src": "utils.js",
                "imports": ["helpers.js"]
            },
            "components.js": {
                "file": "components.mjs",
                "src": "components.js",
                "imports": ["helpers.js"]
            },
            "helpers.js": {
                "file": "helpers.mjs",
                "src": "helpers.js"
            },
            "standalone.js": {
                "file": "standalone.mjs",
                "src": "standalone.js"
            }
        }"#;

        let manifest = parse_manifest(json).unwrap();

        // Test getting imported chunks for entry.js
        let imported = manifest.imported_chunks("entry.js");
        assert_eq!(imported.len(), 3); // helpers.js, utils.js, components.js

        // Verify the chunks are correct and in dependency order
        let files: Vec<_> = imported.iter().map(|c| &c.file).collect();
        assert!(files.contains(&&"helpers.mjs".to_string()));
        assert!(files.contains(&&"utils.mjs".to_string()));
        assert!(files.contains(&&"components.mjs".to_string()));

        // Test getting imported chunks for utils.js
        let utils_imported = manifest.imported_chunks("utils.js");
        assert_eq!(utils_imported.len(), 1);
        assert_eq!(utils_imported[0].file, "helpers.mjs");

        // Test getting imported chunks for a chunk with no imports
        let helpers_imported = manifest.imported_chunks("helpers.js");
        assert_eq!(helpers_imported.len(), 0);

        // Test getting imported chunks for a standalone chunk
        let standalone_imported = manifest.imported_chunks("standalone.js");
        assert_eq!(standalone_imported.len(), 0);

        // Test getting imported chunks for non-existent chunk
        let missing_imported = manifest.imported_chunks("missing.js");
        assert_eq!(missing_imported.len(), 0);
    }
}