1use std::ffi::OsString;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::tool;
6
7#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum CssTool {
19 LightningCss,
34 #[cfg(test)]
39 TestEcho,
40 #[cfg(test)]
43 TestMissing,
44}
45
46impl CssTool {
47 pub(crate) fn binary_name(&self) -> &'static str {
48 match self {
49 CssTool::LightningCss => "lightningcss",
50 #[cfg(test)]
51 CssTool::TestEcho => "cp",
52 #[cfg(test)]
53 CssTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
54 }
55 }
56
57 pub(crate) fn install_hint(&self) -> &'static str {
58 match self {
59 CssTool::LightningCss => {
60 "install via `npm install -g lightningcss-cli` (or add it as a project \
61 devDependency and put its bin/ on PATH)"
62 }
63 #[cfg(test)]
64 CssTool::TestEcho | CssTool::TestMissing => "test-only tool, not installable",
65 }
66 }
67
68 fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
69 match self {
70 CssTool::LightningCss => {
71 let mut args = Vec::new();
72 if bundle {
73 args.push(OsString::from("--bundle"));
74 }
75 if minify {
76 args.push(OsString::from("--minify"));
77 }
78 args.push(OsString::from("-o"));
79 args.push(output.into());
80 args.push(entry.into());
81 args
82 }
83 #[cfg(test)]
84 CssTool::TestEcho => vec![entry.into(), output.into()],
85 #[cfg(test)]
86 CssTool::TestMissing => vec![],
87 }
88 }
89}
90
91#[derive(Debug, Clone)]
94pub struct CssOptions {
95 bundle: bool,
96 minify: bool,
97 bundle_output_name: String,
98}
99
100impl CssOptions {
101 pub fn new() -> Self {
103 CssOptions {
104 bundle: false,
105 minify: false,
106 bundle_output_name: "styles.css".to_string(),
107 }
108 }
109
110 pub fn bundle(mut self, bundle: bool) -> Self {
113 self.bundle = bundle;
114 self
115 }
116
117 pub fn minify(mut self, minify: bool) -> Self {
119 self.minify = minify;
120 self
121 }
122
123 pub fn bundle_output_name(mut self, name: impl Into<String>) -> Self {
126 self.bundle_output_name = name.into();
127 self
128 }
129
130 pub(crate) fn is_bundle(&self) -> bool {
131 self.bundle
132 }
133
134 pub(crate) fn is_minify(&self) -> bool {
135 self.minify
136 }
137
138 pub(crate) fn output_file_name(&self) -> &str {
139 &self.bundle_output_name
140 }
141}
142
143impl Default for CssOptions {
144 fn default() -> Self {
145 Self::new()
146 }
147}
148
149#[derive(Debug)]
152pub(crate) enum CssError {
153 ReadSource { path: PathBuf, reason: String },
154 WriteOutput { path: PathBuf, reason: String },
155 NoFilesFound(PathBuf),
156 Tool(tool::ToolError),
157}
158
159impl std::fmt::Display for CssError {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 match self {
162 CssError::ReadSource { path, reason } => {
163 write!(f, "failed to read {}: {reason}", path.display())
164 }
165 CssError::WriteOutput { path, reason } => {
166 write!(f, "failed to write {}: {reason}", path.display())
167 }
168 CssError::NoFilesFound(path) => write!(f, "no CSS files found in {}", path.display()),
169 CssError::Tool(e) => write!(f, "{e}"),
170 }
171 }
172}
173
174impl std::error::Error for CssError {}
175
176impl From<tool::ToolError> for CssError {
177 fn from(e: tool::ToolError) -> Self {
178 CssError::Tool(e)
179 }
180}
181
182pub(crate) async fn build_css_bundle(
193 css_tool: CssTool,
194 options: &CssOptions,
195 source_dirs: &[PathBuf],
196 output_path: &Path,
197) -> Result<(), CssError> {
198 let css_files = find_css_files(source_dirs)?;
199 if css_files.is_empty() {
200 let first = source_dirs
201 .first()
202 .cloned()
203 .unwrap_or_else(|| PathBuf::from("."));
204 return Err(CssError::NoFilesFound(first));
205 }
206
207 let mut combined = Vec::new();
208 for (index, file) in css_files.iter().enumerate() {
209 if !options.bundle && !options.minify {
210 let bytes = fs::read(file).map_err(|e| CssError::ReadSource {
211 path: file.clone(),
212 reason: e.to_string(),
213 })?;
214 combined.extend_from_slice(&bytes);
215 continue;
216 }
217
218 let scratch = scratch_output_path(output_path, index);
219 run_tool(css_tool, options.bundle, options.minify, file, &scratch).await?;
220 let bytes = fs::read(&scratch).map_err(|e| CssError::ReadSource {
221 path: scratch.clone(),
222 reason: e.to_string(),
223 })?;
224 let _ = fs::remove_file(&scratch);
225 combined.extend_from_slice(&bytes);
226 }
227
228 write_output(output_path, &combined)
229}
230
231pub(crate) async fn build_css_file(
237 css_tool: CssTool,
238 options: &CssOptions,
239 source: &Path,
240 output: &Path,
241) -> Result<(), CssError> {
242 if !options.minify || is_already_minified(source) {
243 return copy_file(source, output);
244 }
245
246 if let Err(e) = run_tool(css_tool, false, true, source, output).await {
247 eprintln!(
248 "css tool: minify failed for {}, serving raw bytes: {e}",
249 source.display()
250 );
251 return copy_file(source, output);
252 }
253 Ok(())
254}
255
256async fn run_tool(
257 css_tool: CssTool,
258 bundle: bool,
259 minify: bool,
260 entry: &Path,
261 output: &Path,
262) -> Result<(), tool::ToolError> {
263 if let Some(parent) = output.parent() {
264 let _ = fs::create_dir_all(parent);
265 }
266 let args = css_tool.args(bundle, minify, entry, output);
267 tool::execute(
268 css_tool.binary_name(),
269 css_tool.install_hint(),
270 css_tool.binary_name(),
271 &args,
272 output,
273 tool::TOOL_TIMEOUT,
274 )
275 .await
276}
277
278fn is_already_minified(path: &Path) -> bool {
282 path.file_name()
283 .and_then(|name| name.to_str())
284 .is_some_and(|name| name.ends_with(".min.css"))
285}
286
287fn scratch_output_path(output_path: &Path, index: usize) -> PathBuf {
288 let file_name = output_path
289 .file_name()
290 .and_then(|n| n.to_str())
291 .unwrap_or("output");
292 output_path.with_file_name(format!(".{file_name}.{index}.building"))
293}
294
295fn write_output(output_path: &Path, bytes: &[u8]) -> Result<(), CssError> {
296 if let Some(parent) = output_path.parent() {
297 fs::create_dir_all(parent).map_err(|e| CssError::WriteOutput {
298 path: output_path.to_path_buf(),
299 reason: e.to_string(),
300 })?;
301 }
302 fs::write(output_path, bytes).map_err(|e| CssError::WriteOutput {
303 path: output_path.to_path_buf(),
304 reason: e.to_string(),
305 })
306}
307
308fn copy_file(source: &Path, output: &Path) -> Result<(), CssError> {
309 if let Some(parent) = output.parent() {
310 fs::create_dir_all(parent).map_err(|e| CssError::WriteOutput {
311 path: output.to_path_buf(),
312 reason: e.to_string(),
313 })?;
314 }
315 fs::copy(source, output).map_err(|e| CssError::WriteOutput {
316 path: output.to_path_buf(),
317 reason: e.to_string(),
318 })?;
319 Ok(())
320}
321
322fn find_css_files(source_dirs: &[PathBuf]) -> Result<Vec<PathBuf>, CssError> {
324 let mut files = Vec::new();
325 for dir in source_dirs {
326 let found = walk_for_extension(dir, "css").map_err(|e| CssError::ReadSource {
327 path: dir.clone(),
328 reason: e.to_string(),
329 })?;
330 files.extend(found);
331 }
332 files.sort();
333 Ok(files)
334}
335
336fn walk_for_extension(dir: &Path, ext: &str) -> std::io::Result<Vec<PathBuf>> {
337 let mut files = Vec::new();
338 let mut dirs = vec![dir.to_path_buf()];
339
340 while let Some(current_dir) = dirs.pop() {
341 for entry in fs::read_dir(¤t_dir)? {
342 let entry = entry?;
343 let path = entry.path();
344 let file_type = entry.file_type()?;
345
346 if file_type.is_dir() {
347 dirs.push(path);
348 } else if file_type.is_file() && path.extension().and_then(|s| s.to_str()) == Some(ext)
349 {
350 files.push(path);
351 }
352 }
353 }
354
355 Ok(files)
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use tempfile::TempDir;
362
363 #[tokio::test]
364 async fn bundle_mode_concatenates_discovered_files_in_sorted_order() {
365 let src = TempDir::new().unwrap();
366 let out = TempDir::new().unwrap();
367 let output = out.path().join("styles.css");
368
369 fs::write(src.path().join("a.css"), "A").unwrap();
370 fs::write(src.path().join("b.css"), "B").unwrap();
371
372 let options = CssOptions::new().bundle(true).minify(true);
373 build_css_bundle(
374 CssTool::TestEcho,
375 &options,
376 &[src.path().to_path_buf()],
377 &output,
378 )
379 .await
380 .unwrap();
381
382 let content = fs::read_to_string(&output).unwrap();
383 assert_eq!(content, "AB", "files must concatenate in sorted path order");
384 }
385
386 #[tokio::test]
387 async fn bundle_false_minify_false_is_a_passthrough_copy() {
388 let src = TempDir::new().unwrap();
389 let out = TempDir::new().unwrap();
390 let output = out.path().join("styles.css");
391
392 fs::write(src.path().join("only.css"), "body{color:red}").unwrap();
393
394 let options = CssOptions::new();
395 build_css_bundle(
396 CssTool::TestMissing,
397 &options,
398 &[src.path().to_path_buf()],
399 &output,
400 )
401 .await
402 .unwrap();
403
404 assert_eq!(fs::read_to_string(&output).unwrap(), "body{color:red}");
405 }
406
407 #[tokio::test]
408 async fn no_css_files_is_an_error() {
409 let src = TempDir::new().unwrap();
410 let out = TempDir::new().unwrap();
411 let output = out.path().join("styles.css");
412
413 let result = build_css_bundle(
414 CssTool::TestEcho,
415 &CssOptions::new().bundle(true),
416 &[src.path().to_path_buf()],
417 &output,
418 )
419 .await;
420
421 assert!(matches!(result, Err(CssError::NoFilesFound(_))));
422 }
423
424 #[tokio::test]
425 async fn a_failing_tool_leaves_previous_bundle_output_untouched() {
426 let src = TempDir::new().unwrap();
427 let out = TempDir::new().unwrap();
428 let output = out.path().join("styles.css");
429 fs::write(&output, "/* previous good build */").unwrap();
430 fs::write(src.path().join("a.css"), "A").unwrap();
431
432 let result = build_css_bundle(
433 CssTool::TestMissing,
434 &CssOptions::new().bundle(true).minify(true),
435 &[src.path().to_path_buf()],
436 &output,
437 )
438 .await;
439
440 assert!(result.is_err());
441 assert_eq!(
442 fs::read_to_string(&output).unwrap(),
443 "/* previous good build */",
444 "a failed rebuild must not overwrite the previous good bundle"
445 );
446 }
447
448 #[tokio::test]
449 async fn per_file_mode_with_minify_false_copies_through_unchanged() {
450 let src = TempDir::new().unwrap();
451 let out = TempDir::new().unwrap();
452 let source = src.path().join("app.css");
453 let output = out.path().join("app.css");
454 fs::write(&source, "body{color:blue}").unwrap();
455
456 build_css_file(CssTool::TestMissing, &CssOptions::new(), &source, &output)
457 .await
458 .unwrap();
459
460 assert_eq!(fs::read_to_string(&output).unwrap(), "body{color:blue}");
461 }
462
463 #[tokio::test]
464 async fn per_file_mode_already_minified_skips_the_tool() {
465 let src = TempDir::new().unwrap();
466 let out = TempDir::new().unwrap();
467 let source = src.path().join("app.min.css");
468 let output = out.path().join("app.min.css");
469 fs::write(&source, "body{color:blue}").unwrap();
470
471 build_css_file(
473 CssTool::TestMissing,
474 &CssOptions::new().minify(true),
475 &source,
476 &output,
477 )
478 .await
479 .unwrap();
480
481 assert_eq!(fs::read_to_string(&output).unwrap(), "body{color:blue}");
482 }
483
484 #[tokio::test]
485 async fn per_file_mode_degrades_to_raw_copy_when_the_tool_fails() {
486 let src = TempDir::new().unwrap();
487 let out = TempDir::new().unwrap();
488 let source = src.path().join("app.css");
489 let output = out.path().join("app.css");
490 fs::write(&source, "body{color:blue}").unwrap();
491
492 build_css_file(
493 CssTool::TestMissing,
494 &CssOptions::new().minify(true),
495 &source,
496 &output,
497 )
498 .await
499 .unwrap();
500
501 assert_eq!(
502 fs::read_to_string(&output).unwrap(),
503 "body{color:blue}",
504 "a failing tool must degrade to serving the raw source, not fail the pipeline"
505 );
506 }
507}