Skip to main content

mini_static/
css.rs

1use std::ffi::OsString;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::tool;
6
7/// External tools mini-static knows how to invoke for CSS bundling/minification.
8///
9/// `#[non_exhaustive]` so a future preset (e.g. a second CSS tool) is an additive
10/// variant, not a semver-breaking change for downstream `match` expressions.
11///
12/// # Installation
13///
14/// mini-static does not install or manage these binaries — only looks them up on
15/// `PATH` at server startup and fails loudly if missing (see [`crate::Server::with_css_tool`]).
16#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum CssTool {
19    /// <https://lightningcss.dev>, invoked via its separately-installed `lightningcss`
20    /// CLI (npm package `lightningcss-cli`) — a different artifact from the Rust
21    /// `lightningcss` crate this refactor removes.
22    ///
23    /// # Trust boundary
24    ///
25    /// Bundling delegates `@import` resolution entirely to this CLI process, which
26    /// resolves imports relative to the file being processed with no root boundary
27    /// mini-static can inject. This is an accepted trade-off: CSS source folders are
28    /// developer-authored build inputs, not request-time attacker input (unlike the
29    /// HTTP path resolver in `resolve.rs`, which stays fully guarded). An `@import`
30    /// escaping the intended source tree is a build misconfiguration to catch in
31    /// review, not a runtime exploit surface. [`tool::TOOL_TIMEOUT`] is the bound that
32    /// replaces the old in-process import-depth/file-count ceilings.
33    LightningCss,
34    /// Copies the entry file to the output file unchanged. Test-only: lets
35    /// orchestration (discovery, concatenation, error handling, `SourcePipeline`
36    /// wiring) be exercised against a real subprocess without depending on
37    /// `lightningcss` being installed in CI.
38    #[cfg(test)]
39    TestEcho,
40    /// Always fails with `ToolError::NotFound`. Test-only, for exercising the
41    /// startup PATH-probe and hard-fail-on-missing-binary paths.
42    #[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/// Configuration for [`crate::Server::with_css_tool`]: independent `bundle`/`minify`
92/// toggles, all four combinations valid.
93#[derive(Debug, Clone)]
94pub struct CssOptions {
95    bundle: bool,
96    minify: bool,
97    bundle_output_name: String,
98}
99
100impl CssOptions {
101    /// Neither bundle nor minify — CSS is copied through unchanged.
102    pub fn new() -> Self {
103        CssOptions {
104            bundle: false,
105            minify: false,
106            bundle_output_name: "styles.css".to_string(),
107        }
108    }
109
110    /// Bundle every `.css` under the source folders into a single output file,
111    /// resolving `@import` via the configured [`CssTool`].
112    pub fn bundle(mut self, bundle: bool) -> Self {
113        self.bundle = bundle;
114        self
115    }
116
117    /// Minify CSS via the configured [`CssTool`].
118    pub fn minify(mut self, minify: bool) -> Self {
119        self.minify = minify;
120        self
121    }
122
123    /// Output file name for bundle mode, under the server's output dir (default
124    /// `styles.css`). Ignored when `bundle` is `false`.
125    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/// Errors from CSS tool orchestration: discovery, subprocess execution, and output
150/// writing.
151#[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
182/// Bundle every `.css` file discovered under `source_dirs` into a single file at
183/// `output_path`, per `options`. Each discovered file is run through `css_tool`
184/// (bundle/minify per `options`) into a scratch file, then concatenated in sorted
185/// path order — same shape as the old in-process bundler, just delegated per-file.
186///
187/// # Errors
188///
189/// Returns `Err` without touching `output_path` if discovery, any tool invocation, or
190/// the final write fails — a broken rebuild leaves the previous good output in place
191/// rather than serving a partial or corrupt bundle.
192pub(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
231/// Process a single CSS file (bundle disabled) into its mirrored `output` path.
232///
233/// A malformed source degrades to a raw copy rather than failing the pipeline — the
234/// server must still serve the file and keep the browser in sync. The degradation is
235/// logged, not silent.
236pub(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
278/// True if `path`'s filename indicates it's already minified (`*.min.css`). Such
279/// files should be served as-is — running a minifier on already-minified input is
280/// wasted work at best and a correctness risk at worst.
281fn 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
322/// Find every `.css` file under `source_dirs`, recursively, sorted by path.
323fn 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(&current_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        // TestMissing would error if invoked; success proves the tool was skipped.
472        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}