Skip to main content

mini_static/
css_bundler.rs

1use std::path::{Path, PathBuf};
2use std::fs;
3
4use crate::bundle;
5
6/// Bundle all CSS files from a source directory into a single output file.
7///
8/// Discovers all `.css` files in `src_dir`, reads and concatenates them (in sorted order),
9/// follows `@import` statements within that directory, minifies the result, and writes it
10/// to `output_path`.
11///
12/// The bundling process:
13/// 1. Finds all `.css` files in src_dir (recursively, sorted by path)
14/// 2. Reads and concatenates all CSS file contents
15/// 3. Writes concatenated content to a temporary file
16/// 4. Runs that through the bundler to resolve imports and minify
17/// 5. Writes the final bytes to output_path
18///
19/// # Errors
20///
21/// Returns `Err` if:
22/// - src_dir cannot be read
23/// - output_path cannot be written to
24/// - CSS parsing or bundling fails
25pub async fn bundle_directory_css(
26    src_dir: &Path,
27    output_path: &Path,
28) -> Result<(), CssBundlerError> {
29    let src_dir_canon = src_dir.canonicalize().map_err(|e| {
30        CssBundlerError::ReadSource {
31            path: src_dir.to_path_buf(),
32            reason: e.to_string(),
33        }
34    })?;
35
36    let css_files = find_css_files(&src_dir_canon).map_err(|e| {
37        CssBundlerError::ReadSource {
38            path: src_dir.to_path_buf(),
39            reason: e.to_string(),
40        }
41    })?;
42
43    if css_files.is_empty() {
44        return Err(CssBundlerError::NoFilesFound(src_dir.to_path_buf()));
45    }
46
47    let nanos = std::time::SystemTime::now()
48        .duration_since(std::time::UNIX_EPOCH)
49        .map(|d| d.subsec_nanos())
50        .unwrap_or(0);
51    let synthetic_entry = src_dir_canon.join(format!(".css_bundle_entry_{}.css", nanos));
52
53    let mut import_statements = String::new();
54    for css_file in &css_files {
55        if let Ok(relative) = css_file.strip_prefix(&src_dir_canon) {
56            if let Some(rel_str) = relative.to_str() {
57                import_statements.push_str(&format!("@import \"{}\";\n", rel_str));
58            }
59        }
60    }
61
62    fs::write(&synthetic_entry, &import_statements).map_err(|e| {
63        CssBundlerError::WriteOutput {
64            path: synthetic_entry.clone(),
65            reason: e.to_string(),
66        }
67    })?;
68
69    let result = bundle::bundle_and_minify_css(&[src_dir_canon.clone()], &synthetic_entry)
70        .await
71        .map_err(|e| CssBundlerError::Bundle(format!("{:?}", e)));
72
73    let _ = fs::remove_file(&synthetic_entry);
74
75    let (bundled_bytes, _deps) = result?;
76
77    if let Some(parent) = output_path.parent() {
78        fs::create_dir_all(parent).map_err(|e| {
79            CssBundlerError::WriteOutput {
80                path: output_path.to_path_buf(),
81                reason: e.to_string(),
82            }
83        })?;
84    }
85
86    fs::write(output_path, &bundled_bytes).map_err(|e| {
87        CssBundlerError::WriteOutput {
88            path: output_path.to_path_buf(),
89            reason: e.to_string(),
90        }
91    })?;
92
93    Ok(())
94}
95
96/// Find all `.css` files in a directory tree.
97fn find_css_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
98    let mut css_files = Vec::new();
99    let mut dirs = vec![dir.to_path_buf()];
100
101    while let Some(current_dir) = dirs.pop() {
102        let entries = fs::read_dir(&current_dir)?;
103        for entry in entries {
104            let entry = entry?;
105            let path = entry.path();
106            let file_type = entry.file_type()?;
107
108            if file_type.is_dir() {
109                dirs.push(path);
110            } else if file_type.is_file() {
111                if path.extension().and_then(|s| s.to_str()) == Some("css") {
112                    css_files.push(path);
113                }
114            }
115        }
116    }
117
118    css_files.sort();
119    Ok(css_files)
120}
121
122/// Errors that can occur during CSS bundling.
123#[derive(Debug)]
124pub enum CssBundlerError {
125    /// Failed to read the source directory.
126    ReadSource { path: PathBuf, reason: String },
127    /// Failed to write the output file.
128    WriteOutput { path: PathBuf, reason: String },
129    /// No CSS files found in the source directory.
130    NoFilesFound(PathBuf),
131    /// CSS bundling/minification failed.
132    Bundle(String),
133}
134
135impl std::fmt::Display for CssBundlerError {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        match self {
138            CssBundlerError::ReadSource { path, reason } => {
139                write!(f, "failed to read source dir {}: {}", path.display(), reason)
140            }
141            CssBundlerError::WriteOutput { path, reason } => {
142                write!(f, "failed to write output file {}: {}", path.display(), reason)
143            }
144            CssBundlerError::NoFilesFound(path) => {
145                write!(f, "no CSS files found in {}", path.display())
146            }
147            CssBundlerError::Bundle(msg) => {
148                write!(f, "CSS bundling failed: {}", msg)
149            }
150        }
151    }
152}
153
154impl std::error::Error for CssBundlerError {}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use tempfile::TempDir;
160    use std::fs;
161
162    #[tokio::test]
163    async fn bundles_single_css_file() {
164        let src = TempDir::new().unwrap();
165        let out = TempDir::new().unwrap();
166        let src_path = src.path();
167        let out_path = out.path().join("bundle.css");
168
169        fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();
170
171        let result = bundle_directory_css(src_path, &out_path).await;
172        assert!(result.is_ok(), "bundling should succeed");
173        assert!(out_path.exists(), "output file should be created");
174        let content = fs::read_to_string(&out_path).unwrap();
175        assert!(!content.is_empty(), "output should not be empty");
176    }
177
178    #[tokio::test]
179    async fn bundles_multiple_css_files() {
180        let src = TempDir::new().unwrap();
181        let out = TempDir::new().unwrap();
182        let src_path = src.path();
183        let out_path = out.path().join("bundle.css");
184
185        fs::write(src_path.join("a.css"), "body { color: red; }").unwrap();
186        fs::write(src_path.join("b.css"), ".class { color: blue; }").unwrap();
187
188        let result = bundle_directory_css(src_path, &out_path).await;
189        assert!(result.is_ok(), "bundling should succeed");
190        assert!(out_path.exists(), "output file should be created");
191    }
192
193    #[tokio::test]
194    async fn errors_on_empty_source_dir() {
195        let src = TempDir::new().unwrap();
196        let out = TempDir::new().unwrap();
197        let src_path = src.path();
198        let out_path = out.path().join("bundle.css");
199
200        let result = bundle_directory_css(src_path, &out_path).await;
201        assert!(matches!(result, Err(CssBundlerError::NoFilesFound(_))), "should error on no files");
202    }
203
204    #[tokio::test]
205    async fn creates_output_directory_if_missing() {
206        let src = TempDir::new().unwrap();
207        let out = TempDir::new().unwrap();
208        let src_path = src.path();
209        let nested_out = out.path().join("nested/deep/bundle.css");
210
211        fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();
212
213        let result = bundle_directory_css(src_path, &nested_out).await;
214        assert!(result.is_ok(), "should create parent directories");
215        assert!(nested_out.exists(), "output file should exist");
216    }
217
218    #[tokio::test]
219    async fn bundles_css_in_subdirectories() {
220        let src = TempDir::new().unwrap();
221        let out = TempDir::new().unwrap();
222        let src_path = src.path();
223        let out_path = out.path().join("bundle.css");
224
225        fs::create_dir(src_path.join("subdir")).unwrap();
226        fs::write(src_path.join("main.css"), "body { margin: 0; }").unwrap();
227        fs::write(src_path.join("subdir/nested.css"), ".nested { color: green; }").unwrap();
228
229        let result = bundle_directory_css(src_path, &out_path).await;
230        assert!(result.is_ok(), "should bundle files in subdirectories");
231        assert!(out_path.exists(), "output file should be created");
232    }
233
234    #[tokio::test]
235    async fn produces_valid_css_output() {
236        let src = TempDir::new().unwrap();
237        let out = TempDir::new().unwrap();
238        let src_path = src.path();
239        let out_path = out.path().join("bundle.css");
240
241        fs::write(src_path.join("style.css"), "body { margin: 0; } .class { padding: 10px; }").unwrap();
242
243        let result = bundle_directory_css(src_path, &out_path).await;
244        assert!(result.is_ok(), "bundling should succeed");
245        let content = fs::read_to_string(&out_path).unwrap();
246        assert!(!content.is_empty(), "output should not be empty");
247        assert!(content.contains("body"), "output should contain CSS rules");
248        assert!(content.contains("margin"), "output should preserve CSS properties");
249    }
250}