Skip to main content

mini_build/
css.rs

1use std::collections::HashMap;
2use std::ffi::OsString;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::tool::{self, group_by_parent};
7
8/// External tools this crate knows how to invoke for CSS bundling/minification.
9///
10/// `#[non_exhaustive]` so a future preset (e.g. a second CSS tool) is an additive
11/// variant, not a semver-breaking change for downstream `match` expressions.
12///
13/// # Installation
14///
15/// This crate does not install or manage these binaries — only looks them up on
16/// `PATH` before a build starts and fails loudly if missing (see [`crate::Builder::css_tool`]).
17#[non_exhaustive]
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum CssTool {
20    /// <https://lightningcss.dev>, invoked via its separately-installed `lightningcss`
21    /// CLI (npm package `lightningcss-cli`) — a different artifact from the Rust
22    /// `lightningcss` crate this refactor removes.
23    ///
24    /// # Trust boundary
25    ///
26    /// Bundling delegates `@import` resolution entirely to this CLI process, which
27    /// resolves imports relative to the file being processed with no root boundary
28    /// this crate can inject. This is an accepted trade-off: CSS source folders are
29    /// developer-authored build inputs, not request-time attacker input (unlike a
30    /// static server's HTTP path resolver, which is guarded separately). An `@import`
31    /// escaping the intended source tree is a build misconfiguration to catch in
32    /// review, not a runtime exploit surface. [`tool::TOOL_TIMEOUT`] is the bound that
33    /// replaces the old in-process import-depth/file-count ceilings.
34    LightningCss,
35    /// Copies the entry file to the output file unchanged. Test-only: lets
36    /// orchestration (discovery, concatenation, error handling, `SourcePipeline`
37    /// wiring) be exercised against a real subprocess without depending on
38    /// `lightningcss` being installed in CI.
39    #[cfg(test)]
40    TestEcho,
41    /// Always fails with `ToolError::NotFound`. Test-only, for exercising the
42    /// startup PATH-probe and hard-fail-on-missing-binary paths.
43    #[cfg(test)]
44    TestMissing,
45}
46
47impl CssTool {
48    pub(crate) fn binary_name(&self) -> &'static str {
49        match self {
50            CssTool::LightningCss => "lightningcss",
51            #[cfg(test)]
52            CssTool::TestEcho => "cp",
53            #[cfg(test)]
54            CssTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
55        }
56    }
57
58    pub(crate) fn install_hint(&self) -> &'static str {
59        match self {
60            CssTool::LightningCss => {
61                "install via `npm install -g lightningcss-cli` (or add it as a project \
62                 devDependency and put its bin/ on PATH)"
63            }
64            #[cfg(test)]
65            CssTool::TestEcho | CssTool::TestMissing => "test-only tool, not installable",
66        }
67    }
68
69    fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
70        match self {
71            CssTool::LightningCss => {
72                let mut args = Vec::new();
73                if bundle {
74                    args.push(OsString::from("--bundle"));
75                }
76                if minify {
77                    args.push(OsString::from("--minify"));
78                }
79                args.push(OsString::from("-o"));
80                args.push(output.into());
81                args.push(entry.into());
82                args
83            }
84            #[cfg(test)]
85            CssTool::TestEcho => vec![entry.into(), output.into()],
86            #[cfg(test)]
87            CssTool::TestMissing => vec![],
88        }
89    }
90
91    /// Arguments for transforming many inputs in **one** invocation, writing each result
92    /// into `out_dir`.
93    ///
94    /// This is where nearly all of a real build's time goes: process startup, paid once
95    /// per file. `lightningcss` accepts `[INPUT_FILE]...` with `--output-dir`, so N files
96    /// cost one spawn instead of N.
97    ///
98    /// `--output-dir` **flattens** — `src/a.css` and `src/sub/a.css` would both land on
99    /// `out/a.css`, silently losing one. Callers must therefore batch only files that
100    /// share a parent directory, where basenames are unique by construction. See
101    /// `group_by_parent`.
102    fn batch_args(
103        &self,
104        bundle: bool,
105        minify: bool,
106        inputs: &[PathBuf],
107        out_dir: &Path,
108    ) -> Vec<OsString> {
109        match self {
110            CssTool::LightningCss => {
111                let mut args = Vec::new();
112                if bundle {
113                    args.push(OsString::from("--bundle"));
114                }
115                if minify {
116                    args.push(OsString::from("--minify"));
117                }
118                args.push(OsString::from("--output-dir"));
119                args.push(out_dir.into());
120                args.extend(inputs.iter().map(OsString::from));
121                args
122            }
123            // `cp file... dir/` is the batch form of `cp file dir/file`.
124            #[cfg(test)]
125            CssTool::TestEcho => {
126                let mut args: Vec<OsString> = inputs.iter().map(OsString::from).collect();
127                args.push(out_dir.into());
128                args
129            }
130            #[cfg(test)]
131            CssTool::TestMissing => vec![],
132        }
133    }
134}
135
136/// A directory removed when this value is dropped.
137///
138/// The scratch dir holds per-file tool output on its way into the bundle. Cleanup has to
139/// survive the `?` on every fallible step between creating it and finishing with it,
140/// which a `remove_dir_all` at the end of the function does not.
141struct ScratchDir {
142    path: PathBuf,
143}
144
145impl ScratchDir {
146    fn new(path: PathBuf) -> Result<Self, CssError> {
147        fs::create_dir_all(&path).map_err(|e| CssError::WriteOutput {
148            path: path.clone(),
149            reason: e.to_string(),
150        })?;
151        Ok(ScratchDir { path })
152    }
153}
154
155impl Drop for ScratchDir {
156    fn drop(&mut self) {
157        let _ = fs::remove_dir_all(&self.path);
158    }
159}
160
161/// Configuration for [`crate::Builder::css_tool`]: independent `bundle`/`minify`
162/// toggles, all four combinations valid.
163#[derive(Debug, Clone)]
164pub struct CssOptions {
165    bundle: bool,
166    minify: bool,
167    bundle_output_name: String,
168}
169
170impl CssOptions {
171    /// Neither bundle nor minify — CSS is copied through unchanged.
172    pub fn new() -> Self {
173        CssOptions {
174            bundle: false,
175            minify: false,
176            bundle_output_name: "styles.css".to_string(),
177        }
178    }
179
180    /// Bundle every `.css` under the source folders into a single output file,
181    /// resolving `@import` via the configured [`CssTool`].
182    pub fn bundle(mut self, bundle: bool) -> Self {
183        self.bundle = bundle;
184        self
185    }
186
187    /// Minify CSS via the configured [`CssTool`].
188    pub fn minify(mut self, minify: bool) -> Self {
189        self.minify = minify;
190        self
191    }
192
193    /// Output file name for bundle mode, under the server's output dir (default
194    /// `styles.css`). Ignored when `bundle` is `false`.
195    pub fn bundle_output_name(mut self, name: impl Into<String>) -> Self {
196        self.bundle_output_name = name.into();
197        self
198    }
199
200    pub(crate) fn is_bundle(&self) -> bool {
201        self.bundle
202    }
203
204    pub(crate) fn is_minify(&self) -> bool {
205        self.minify
206    }
207
208    pub(crate) fn output_file_name(&self) -> &str {
209        &self.bundle_output_name
210    }
211}
212
213impl Default for CssOptions {
214    fn default() -> Self {
215        Self::new()
216    }
217}
218
219/// Errors from CSS tool orchestration: discovery, subprocess execution, and output
220/// writing.
221#[derive(Debug)]
222pub enum CssError {
223    ReadSource { path: PathBuf, reason: String },
224    WriteOutput { path: PathBuf, reason: String },
225    NoFilesFound(PathBuf),
226    Tool(tool::ToolError),
227}
228
229impl std::fmt::Display for CssError {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        match self {
232            CssError::ReadSource { path, reason } => {
233                write!(f, "failed to read {}: {reason}", path.display())
234            }
235            CssError::WriteOutput { path, reason } => {
236                write!(f, "failed to write {}: {reason}", path.display())
237            }
238            CssError::NoFilesFound(path) => write!(f, "no CSS files found in {}", path.display()),
239            CssError::Tool(e) => write!(f, "{e}"),
240        }
241    }
242}
243
244impl std::error::Error for CssError {}
245
246impl From<tool::ToolError> for CssError {
247    fn from(e: tool::ToolError) -> Self {
248        CssError::Tool(e)
249    }
250}
251
252/// Bundle every `.css` file discovered under `source_dirs` into a single file at
253/// `output_path`, per `options`. Each discovered file is run through `css_tool`
254/// (bundle/minify per `options`) into a scratch file, then concatenated in sorted
255/// path order — same shape as the old in-process bundler, just delegated per-file.
256///
257/// # Errors
258///
259/// Returns `Err` without touching `output_path` if discovery, any tool invocation, or
260/// the final write fails — a broken rebuild leaves the previous good output in place
261/// rather than serving a partial or corrupt bundle.
262pub(crate) fn build_css_bundle(
263    css_tool: CssTool,
264    options: &CssOptions,
265    source_dirs: &[PathBuf],
266    output_path: &Path,
267) -> Result<(), CssError> {
268    let css_files = find_css_files(source_dirs)?;
269    if css_files.is_empty() {
270        let first = source_dirs
271            .first()
272            .cloned()
273            .unwrap_or_else(|| PathBuf::from("."));
274        return Err(CssError::NoFilesFound(first));
275    }
276
277    // Passthrough: no tool runs at all, so there is nothing to batch.
278    if !options.bundle && !options.minify {
279        let mut combined = Vec::new();
280        for file in &css_files {
281            let bytes = fs::read(file).map_err(|e| CssError::ReadSource {
282                path: file.clone(),
283                reason: e.to_string(),
284            })?;
285            combined.extend_from_slice(&bytes);
286        }
287        return write_output(output_path, &combined);
288    }
289
290    // One invocation per source directory rather than per file. Process startup, not the
291    // work itself, is what a real build spends its time on — measured at roughly 19 ms a
292    // spawn against about 5 ms of actual transformation — so collapsing N spawns into one
293    // per directory is the difference between a second and a few tens of milliseconds on
294    // a realistic tree. Grouping by directory rather than batching everything at once is
295    // what keeps it correct: `--output-dir` distinguishes results by basename only.
296    let scratch = ScratchDir::new(scratch_dir_path(output_path))?;
297    let mut produced: HashMap<PathBuf, PathBuf> = HashMap::new();
298
299    for (index, (_parent, files)) in group_by_parent(&css_files).into_iter().enumerate() {
300        let group_dir = scratch.path.join(index.to_string());
301        fs::create_dir_all(&group_dir).map_err(|e| CssError::WriteOutput {
302            path: group_dir.clone(),
303            reason: e.to_string(),
304        })?;
305
306        let outputs: Vec<PathBuf> = files
307            .iter()
308            .map(|file| group_dir.join(file.file_name().unwrap_or_default()))
309            .collect();
310
311        run_tool_batch(
312            css_tool,
313            options.bundle,
314            options.minify,
315            &files,
316            &group_dir,
317            &outputs,
318        )?;
319
320        for (file, output) in files.into_iter().zip(outputs) {
321            produced.insert(file, output);
322        }
323    }
324
325    // Concatenated in the original sorted order, not group order: the bundle's contents
326    // must not depend on how the work happened to be batched.
327    let mut combined = Vec::new();
328    for file in &css_files {
329        let output = produced.get(file).ok_or_else(|| CssError::ReadSource {
330            path: file.clone(),
331            reason: "the tool produced no output for this file".to_string(),
332        })?;
333        let bytes = fs::read(output).map_err(|e| CssError::ReadSource {
334            path: output.clone(),
335            reason: e.to_string(),
336        })?;
337        combined.extend_from_slice(&bytes);
338    }
339
340    write_output(output_path, &combined)
341}
342
343/// Process a single CSS file (bundle disabled) into its mirrored `output` path.
344///
345/// A malformed source degrades to a raw copy rather than failing the pipeline — the
346/// server must still serve the file and keep the browser in sync. The degradation is
347/// logged, not silent.
348/// Transform many files in as few invocations as possible, each into its mirrored
349/// `output`.
350///
351/// Files the tool would not touch anyway — passthrough mode, or `*.min.css` — are copied
352/// without a process. The rest are grouped by output directory and sent one invocation
353/// per group, because `--output-dir` tells results apart by basename and basenames are
354/// unique only within a directory.
355///
356/// A failed batch **falls back to building its files one at a time** rather than failing.
357/// Per-file mode's contract is that one malformed source degrades to a raw copy and the
358/// build carries on; batching must not quietly upgrade that to "one bad file breaks the
359/// whole directory". The fallback costs a spawn per file, but only in the case that was
360/// already going wrong.
361pub(crate) fn build_css_files(
362    css_tool: CssTool,
363    options: &CssOptions,
364    pairs: &[(PathBuf, PathBuf)],
365) -> Result<(), CssError> {
366    let (transform, bypass): (Vec<_>, Vec<_>) = pairs
367        .iter()
368        .partition(|(source, _)| options.minify && !is_already_minified(source));
369
370    for (source, output) in bypass {
371        copy_file(source, output)?;
372    }
373    if transform.is_empty() {
374        return Ok(());
375    }
376
377    let by_output_dir = group_by_parent(
378        &transform
379            .iter()
380            .map(|(_, output)| output.clone())
381            .collect::<Vec<_>>(),
382    );
383
384    for (out_dir, outputs) in by_output_dir {
385        fs::create_dir_all(&out_dir).map_err(|e| CssError::WriteOutput {
386            path: out_dir.clone(),
387            reason: e.to_string(),
388        })?;
389
390        let group: Vec<&(PathBuf, PathBuf)> = transform
391            .iter()
392            .copied()
393            .filter(|(_, output)| outputs.contains(output))
394            .collect();
395        let inputs: Vec<PathBuf> = group.iter().map(|(source, _)| source.clone()).collect();
396
397        if run_tool_batch(css_tool, false, true, &inputs, &out_dir, &outputs).is_err() {
398            for (source, output) in group {
399                build_css_file(css_tool, options, source, output)?;
400            }
401        }
402    }
403
404    Ok(())
405}
406
407pub(crate) fn build_css_file(
408    css_tool: CssTool,
409    options: &CssOptions,
410    source: &Path,
411    output: &Path,
412) -> Result<(), CssError> {
413    if !options.minify || is_already_minified(source) {
414        return copy_file(source, output);
415    }
416
417    if let Err(e) = run_tool(css_tool, false, true, source, output) {
418        eprintln!(
419            "css tool: minify failed for {}, serving raw bytes: {e}",
420            source.display()
421        );
422        return copy_file(source, output);
423    }
424    Ok(())
425}
426
427fn run_tool(
428    css_tool: CssTool,
429    bundle: bool,
430    minify: bool,
431    entry: &Path,
432    output: &Path,
433) -> Result<(), tool::ToolError> {
434    if let Some(parent) = output.parent() {
435        let _ = fs::create_dir_all(parent);
436    }
437    let args = css_tool.args(bundle, minify, entry, output);
438    tool::execute(
439        css_tool.binary_name(),
440        css_tool.install_hint(),
441        css_tool.binary_name(),
442        &args,
443        &[output],
444        tool::TOOL_TIMEOUT,
445    )
446}
447
448/// Transform every path in `inputs` in a single invocation, writing into `out_dir`.
449///
450/// `expected` is what the caller believes will be produced; `execute` verifies every one
451/// of them exists and is non-empty, so a tool that quietly skipped an input fails here
452/// rather than leaving a hole in the bundle.
453fn run_tool_batch(
454    css_tool: CssTool,
455    bundle: bool,
456    minify: bool,
457    inputs: &[PathBuf],
458    out_dir: &Path,
459    expected: &[PathBuf],
460) -> Result<(), tool::ToolError> {
461    let args = css_tool.batch_args(bundle, minify, inputs, out_dir);
462    let expected: Vec<&Path> = expected.iter().map(PathBuf::as_path).collect();
463    tool::execute(
464        css_tool.binary_name(),
465        css_tool.install_hint(),
466        css_tool.binary_name(),
467        &args,
468        &expected,
469        tool::TOOL_TIMEOUT,
470    )
471}
472
473/// True if `path`'s filename indicates it's already minified (`*.min.css`). Such
474/// files should be served as-is — running a minifier on already-minified input is
475/// wasted work at best and a correctness risk at worst.
476fn is_already_minified(path: &Path) -> bool {
477    path.file_name()
478        .and_then(|name| name.to_str())
479        .is_some_and(|name| name.ends_with(".min.css"))
480}
481
482/// Where per-file tool output is staged before being concatenated into the bundle.
483///
484/// A sibling of the bundle rather than a system temp dir: it must be on the same
485/// filesystem, and it is visible next to the output when a build is interrupted, which
486/// makes a leak obvious rather than mysterious.
487fn scratch_dir_path(output_path: &Path) -> PathBuf {
488    let file_name = output_path
489        .file_name()
490        .and_then(|n| n.to_str())
491        .unwrap_or("output");
492    output_path.with_file_name(format!(".{file_name}.building"))
493}
494
495fn write_output(output_path: &Path, bytes: &[u8]) -> Result<(), CssError> {
496    if let Some(parent) = output_path.parent() {
497        fs::create_dir_all(parent).map_err(|e| CssError::WriteOutput {
498            path: output_path.to_path_buf(),
499            reason: e.to_string(),
500        })?;
501    }
502    fs::write(output_path, bytes).map_err(|e| CssError::WriteOutput {
503        path: output_path.to_path_buf(),
504        reason: e.to_string(),
505    })
506}
507
508fn copy_file(source: &Path, output: &Path) -> Result<(), CssError> {
509    if let Some(parent) = output.parent() {
510        fs::create_dir_all(parent).map_err(|e| CssError::WriteOutput {
511            path: output.to_path_buf(),
512            reason: e.to_string(),
513        })?;
514    }
515    fs::copy(source, output).map_err(|e| CssError::WriteOutput {
516        path: output.to_path_buf(),
517        reason: e.to_string(),
518    })?;
519    Ok(())
520}
521
522/// Find every `.css` file under `source_dirs`, recursively, sorted by path.
523fn find_css_files(source_dirs: &[PathBuf]) -> Result<Vec<PathBuf>, CssError> {
524    let mut files = Vec::new();
525    for dir in source_dirs {
526        let found = walk_for_extension(dir, "css").map_err(|e| CssError::ReadSource {
527            path: dir.clone(),
528            reason: e.to_string(),
529        })?;
530        files.extend(found);
531    }
532    files.sort();
533    Ok(files)
534}
535
536fn walk_for_extension(dir: &Path, ext: &str) -> std::io::Result<Vec<PathBuf>> {
537    let mut files = Vec::new();
538    let mut dirs = vec![dir.to_path_buf()];
539
540    while let Some(current_dir) = dirs.pop() {
541        for entry in fs::read_dir(&current_dir)? {
542            let entry = entry?;
543            let path = entry.path();
544            let file_type = entry.file_type()?;
545
546            if file_type.is_dir() {
547                dirs.push(path);
548            } else if file_type.is_file() && path.extension().and_then(|s| s.to_str()) == Some(ext)
549            {
550                files.push(path);
551            }
552        }
553    }
554
555    Ok(files)
556}
557
558#[cfg(test)]
559#[path = "../tests/unit/css.rs"]
560mod tests;