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 mut concatenated = String::new();
48    for css_file in &css_files {
49        let content = fs::read_to_string(css_file).map_err(|e| {
50            CssBundlerError::ReadSource {
51                path: css_file.clone(),
52                reason: e.to_string(),
53            }
54        })?;
55        concatenated.push_str(&content);
56        concatenated.push('\n');
57    }
58
59    let nanos = std::time::SystemTime::now()
60        .duration_since(std::time::UNIX_EPOCH)
61        .map(|d| d.subsec_nanos())
62        .unwrap_or(0);
63    let temp_file = std::env::temp_dir().join(format!("css_bundle_{}.css", nanos));
64    fs::write(&temp_file, &concatenated).map_err(|e| {
65        CssBundlerError::WriteOutput {
66            path: temp_file.clone(),
67            reason: e.to_string(),
68        }
69    })?;
70
71    let result = bundle::bundle_and_minify_css(&[src_dir_canon.clone()], &temp_file)
72        .await
73        .map_err(|e| CssBundlerError::Bundle(format!("{:?}", e)));
74
75    let _ = fs::remove_file(&temp_file);
76
77    let (bundled_bytes, _deps) = result?;
78
79    if let Some(parent) = output_path.parent() {
80        fs::create_dir_all(parent).map_err(|e| {
81            CssBundlerError::WriteOutput {
82                path: output_path.to_path_buf(),
83                reason: e.to_string(),
84            }
85        })?;
86    }
87
88    fs::write(output_path, &bundled_bytes).map_err(|e| {
89        CssBundlerError::WriteOutput {
90            path: output_path.to_path_buf(),
91            reason: e.to_string(),
92        }
93    })?;
94
95    Ok(())
96}
97
98/// Find all `.css` files in a directory tree.
99fn find_css_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
100    let mut css_files = Vec::new();
101    let mut dirs = vec![dir.to_path_buf()];
102
103    while let Some(current_dir) = dirs.pop() {
104        let entries = fs::read_dir(&current_dir)?;
105        for entry in entries {
106            let entry = entry?;
107            let path = entry.path();
108            let file_type = entry.file_type()?;
109
110            if file_type.is_dir() {
111                dirs.push(path);
112            } else if file_type.is_file() {
113                if path.extension().and_then(|s| s.to_str()) == Some("css") {
114                    css_files.push(path);
115                }
116            }
117        }
118    }
119
120    css_files.sort();
121    Ok(css_files)
122}
123
124/// Errors that can occur during CSS bundling.
125#[derive(Debug)]
126pub enum CssBundlerError {
127    /// Failed to read the source directory.
128    ReadSource { path: PathBuf, reason: String },
129    /// Failed to write the output file.
130    WriteOutput { path: PathBuf, reason: String },
131    /// No CSS files found in the source directory.
132    NoFilesFound(PathBuf),
133    /// CSS bundling/minification failed.
134    Bundle(String),
135}
136
137impl std::fmt::Display for CssBundlerError {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        match self {
140            CssBundlerError::ReadSource { path, reason } => {
141                write!(f, "failed to read source dir {}: {}", path.display(), reason)
142            }
143            CssBundlerError::WriteOutput { path, reason } => {
144                write!(f, "failed to write output file {}: {}", path.display(), reason)
145            }
146            CssBundlerError::NoFilesFound(path) => {
147                write!(f, "no CSS files found in {}", path.display())
148            }
149            CssBundlerError::Bundle(msg) => {
150                write!(f, "CSS bundling failed: {}", msg)
151            }
152        }
153    }
154}
155
156impl std::error::Error for CssBundlerError {}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use tempfile::TempDir;
162    use std::fs;
163
164    #[tokio::test]
165    async fn bundles_single_css_file() {
166        let src = TempDir::new().unwrap();
167        let out = TempDir::new().unwrap();
168        let src_path = src.path();
169        let out_path = out.path().join("bundle.css");
170
171        fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();
172
173        let result = bundle_directory_css(src_path, &out_path).await;
174        assert!(result.is_ok(), "bundling should succeed");
175        assert!(out_path.exists(), "output file should be created");
176        let content = fs::read_to_string(&out_path).unwrap();
177        assert!(!content.is_empty(), "output should not be empty");
178    }
179
180    #[tokio::test]
181    async fn bundles_multiple_css_files() {
182        let src = TempDir::new().unwrap();
183        let out = TempDir::new().unwrap();
184        let src_path = src.path();
185        let out_path = out.path().join("bundle.css");
186
187        fs::write(src_path.join("a.css"), "body { color: red; }").unwrap();
188        fs::write(src_path.join("b.css"), ".class { color: blue; }").unwrap();
189
190        let result = bundle_directory_css(src_path, &out_path).await;
191        assert!(result.is_ok(), "bundling should succeed");
192        assert!(out_path.exists(), "output file should be created");
193    }
194
195    #[tokio::test]
196    async fn errors_on_empty_source_dir() {
197        let src = TempDir::new().unwrap();
198        let out = TempDir::new().unwrap();
199        let src_path = src.path();
200        let out_path = out.path().join("bundle.css");
201
202        let result = bundle_directory_css(src_path, &out_path).await;
203        assert!(matches!(result, Err(CssBundlerError::NoFilesFound(_))), "should error on no files");
204    }
205
206    #[tokio::test]
207    async fn creates_output_directory_if_missing() {
208        let src = TempDir::new().unwrap();
209        let out = TempDir::new().unwrap();
210        let src_path = src.path();
211        let nested_out = out.path().join("nested/deep/bundle.css");
212
213        fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();
214
215        let result = bundle_directory_css(src_path, &nested_out).await;
216        assert!(result.is_ok(), "should create parent directories");
217        assert!(nested_out.exists(), "output file should exist");
218    }
219
220    #[tokio::test]
221    async fn bundles_css_in_subdirectories() {
222        let src = TempDir::new().unwrap();
223        let out = TempDir::new().unwrap();
224        let src_path = src.path();
225        let out_path = out.path().join("bundle.css");
226
227        fs::create_dir(src_path.join("subdir")).unwrap();
228        fs::write(src_path.join("main.css"), "body { margin: 0; }").unwrap();
229        fs::write(src_path.join("subdir/nested.css"), ".nested { color: green; }").unwrap();
230
231        let result = bundle_directory_css(src_path, &out_path).await;
232        assert!(result.is_ok(), "should bundle files in subdirectories");
233        assert!(out_path.exists(), "output file should be created");
234    }
235
236    #[tokio::test]
237    async fn produces_valid_css_output() {
238        let src = TempDir::new().unwrap();
239        let out = TempDir::new().unwrap();
240        let src_path = src.path();
241        let out_path = out.path().join("bundle.css");
242
243        fs::write(src_path.join("style.css"), "body { margin: 0; } .class { padding: 10px; }").unwrap();
244
245        let result = bundle_directory_css(src_path, &out_path).await;
246        assert!(result.is_ok(), "bundling should succeed");
247        let content = fs::read_to_string(&out_path).unwrap();
248        assert!(!content.is_empty(), "output should not be empty");
249        assert!(content.contains("body"), "output should contain CSS rules");
250        assert!(content.contains("margin"), "output should preserve CSS properties");
251    }
252}