mini_static/
css_bundler.rs1use std::path::{Path, PathBuf};
2use std::fs;
3
4use crate::bundle;
5
6pub 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
96fn 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(¤t_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#[derive(Debug)]
124pub enum CssBundlerError {
125 ReadSource { path: PathBuf, reason: String },
127 WriteOutput { path: PathBuf, reason: String },
129 NoFilesFound(PathBuf),
131 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}