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 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
98fn 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(¤t_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#[derive(Debug)]
126pub enum CssBundlerError {
127 ReadSource { path: PathBuf, reason: String },
129 WriteOutput { path: PathBuf, reason: String },
131 NoFilesFound(PathBuf),
133 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}