use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use crate::tool;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CssTool {
LightningCss,
#[cfg(test)]
TestEcho,
#[cfg(test)]
TestMissing,
}
impl CssTool {
pub(crate) fn binary_name(&self) -> &'static str {
match self {
CssTool::LightningCss => "lightningcss",
#[cfg(test)]
CssTool::TestEcho => "cp",
#[cfg(test)]
CssTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
}
}
pub(crate) fn install_hint(&self) -> &'static str {
match self {
CssTool::LightningCss => {
"install via `npm install -g lightningcss-cli` (or add it as a project \
devDependency and put its bin/ on PATH)"
}
#[cfg(test)]
CssTool::TestEcho | CssTool::TestMissing => "test-only tool, not installable",
}
}
fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
match self {
CssTool::LightningCss => {
let mut args = Vec::new();
if bundle {
args.push(OsString::from("--bundle"));
}
if minify {
args.push(OsString::from("--minify"));
}
args.push(OsString::from("-o"));
args.push(output.into());
args.push(entry.into());
args
}
#[cfg(test)]
CssTool::TestEcho => vec![entry.into(), output.into()],
#[cfg(test)]
CssTool::TestMissing => vec![],
}
}
}
#[derive(Debug, Clone)]
pub struct CssOptions {
bundle: bool,
minify: bool,
bundle_output_name: String,
}
impl CssOptions {
pub fn new() -> Self {
CssOptions {
bundle: false,
minify: false,
bundle_output_name: "styles.css".to_string(),
}
}
pub fn bundle(mut self, bundle: bool) -> Self {
self.bundle = bundle;
self
}
pub fn minify(mut self, minify: bool) -> Self {
self.minify = minify;
self
}
pub fn bundle_output_name(mut self, name: impl Into<String>) -> Self {
self.bundle_output_name = name.into();
self
}
pub(crate) fn is_bundle(&self) -> bool {
self.bundle
}
pub(crate) fn is_minify(&self) -> bool {
self.minify
}
pub(crate) fn output_file_name(&self) -> &str {
&self.bundle_output_name
}
}
impl Default for CssOptions {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
pub(crate) enum CssError {
ReadSource { path: PathBuf, reason: String },
WriteOutput { path: PathBuf, reason: String },
NoFilesFound(PathBuf),
Tool(tool::ToolError),
}
impl std::fmt::Display for CssError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CssError::ReadSource { path, reason } => {
write!(f, "failed to read {}: {reason}", path.display())
}
CssError::WriteOutput { path, reason } => {
write!(f, "failed to write {}: {reason}", path.display())
}
CssError::NoFilesFound(path) => write!(f, "no CSS files found in {}", path.display()),
CssError::Tool(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for CssError {}
impl From<tool::ToolError> for CssError {
fn from(e: tool::ToolError) -> Self {
CssError::Tool(e)
}
}
pub(crate) async fn build_css_bundle(
css_tool: CssTool,
options: &CssOptions,
source_dirs: &[PathBuf],
output_path: &Path,
) -> Result<(), CssError> {
let css_files = find_css_files(source_dirs)?;
if css_files.is_empty() {
let first = source_dirs
.first()
.cloned()
.unwrap_or_else(|| PathBuf::from("."));
return Err(CssError::NoFilesFound(first));
}
let mut combined = Vec::new();
for (index, file) in css_files.iter().enumerate() {
if !options.bundle && !options.minify {
let bytes = fs::read(file).map_err(|e| CssError::ReadSource {
path: file.clone(),
reason: e.to_string(),
})?;
combined.extend_from_slice(&bytes);
continue;
}
let scratch = scratch_output_path(output_path, index);
run_tool(css_tool, options.bundle, options.minify, file, &scratch).await?;
let bytes = fs::read(&scratch).map_err(|e| CssError::ReadSource {
path: scratch.clone(),
reason: e.to_string(),
})?;
let _ = fs::remove_file(&scratch);
combined.extend_from_slice(&bytes);
}
write_output(output_path, &combined)
}
pub(crate) async fn build_css_file(
css_tool: CssTool,
options: &CssOptions,
source: &Path,
output: &Path,
) -> Result<(), CssError> {
if !options.minify || is_already_minified(source) {
return copy_file(source, output);
}
if let Err(e) = run_tool(css_tool, false, true, source, output).await {
eprintln!(
"css tool: minify failed for {}, serving raw bytes: {e}",
source.display()
);
return copy_file(source, output);
}
Ok(())
}
async fn run_tool(
css_tool: CssTool,
bundle: bool,
minify: bool,
entry: &Path,
output: &Path,
) -> Result<(), tool::ToolError> {
if let Some(parent) = output.parent() {
let _ = fs::create_dir_all(parent);
}
let args = css_tool.args(bundle, minify, entry, output);
tool::execute(
css_tool.binary_name(),
css_tool.install_hint(),
css_tool.binary_name(),
&args,
output,
tool::TOOL_TIMEOUT,
)
.await
}
fn is_already_minified(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".min.css"))
}
fn scratch_output_path(output_path: &Path, index: usize) -> PathBuf {
let file_name = output_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("output");
output_path.with_file_name(format!(".{file_name}.{index}.building"))
}
fn write_output(output_path: &Path, bytes: &[u8]) -> Result<(), CssError> {
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).map_err(|e| CssError::WriteOutput {
path: output_path.to_path_buf(),
reason: e.to_string(),
})?;
}
fs::write(output_path, bytes).map_err(|e| CssError::WriteOutput {
path: output_path.to_path_buf(),
reason: e.to_string(),
})
}
fn copy_file(source: &Path, output: &Path) -> Result<(), CssError> {
if let Some(parent) = output.parent() {
fs::create_dir_all(parent).map_err(|e| CssError::WriteOutput {
path: output.to_path_buf(),
reason: e.to_string(),
})?;
}
fs::copy(source, output).map_err(|e| CssError::WriteOutput {
path: output.to_path_buf(),
reason: e.to_string(),
})?;
Ok(())
}
fn find_css_files(source_dirs: &[PathBuf]) -> Result<Vec<PathBuf>, CssError> {
let mut files = Vec::new();
for dir in source_dirs {
let found = walk_for_extension(dir, "css").map_err(|e| CssError::ReadSource {
path: dir.clone(),
reason: e.to_string(),
})?;
files.extend(found);
}
files.sort();
Ok(files)
}
fn walk_for_extension(dir: &Path, ext: &str) -> std::io::Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut dirs = vec![dir.to_path_buf()];
while let Some(current_dir) = dirs.pop() {
for entry in fs::read_dir(¤t_dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
dirs.push(path);
} else if file_type.is_file() && path.extension().and_then(|s| s.to_str()) == Some(ext)
{
files.push(path);
}
}
}
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn bundle_mode_concatenates_discovered_files_in_sorted_order() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let output = out.path().join("styles.css");
fs::write(src.path().join("a.css"), "A").unwrap();
fs::write(src.path().join("b.css"), "B").unwrap();
let options = CssOptions::new().bundle(true).minify(true);
build_css_bundle(
CssTool::TestEcho,
&options,
&[src.path().to_path_buf()],
&output,
)
.await
.unwrap();
let content = fs::read_to_string(&output).unwrap();
assert_eq!(content, "AB", "files must concatenate in sorted path order");
}
#[tokio::test]
async fn bundle_false_minify_false_is_a_passthrough_copy() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let output = out.path().join("styles.css");
fs::write(src.path().join("only.css"), "body{color:red}").unwrap();
let options = CssOptions::new();
build_css_bundle(
CssTool::TestMissing,
&options,
&[src.path().to_path_buf()],
&output,
)
.await
.unwrap();
assert_eq!(fs::read_to_string(&output).unwrap(), "body{color:red}");
}
#[tokio::test]
async fn no_css_files_is_an_error() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let output = out.path().join("styles.css");
let result = build_css_bundle(
CssTool::TestEcho,
&CssOptions::new().bundle(true),
&[src.path().to_path_buf()],
&output,
)
.await;
assert!(matches!(result, Err(CssError::NoFilesFound(_))));
}
#[tokio::test]
async fn a_failing_tool_leaves_previous_bundle_output_untouched() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let output = out.path().join("styles.css");
fs::write(&output, "/* previous good build */").unwrap();
fs::write(src.path().join("a.css"), "A").unwrap();
let result = build_css_bundle(
CssTool::TestMissing,
&CssOptions::new().bundle(true).minify(true),
&[src.path().to_path_buf()],
&output,
)
.await;
assert!(result.is_err());
assert_eq!(
fs::read_to_string(&output).unwrap(),
"/* previous good build */",
"a failed rebuild must not overwrite the previous good bundle"
);
}
#[tokio::test]
async fn per_file_mode_with_minify_false_copies_through_unchanged() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let source = src.path().join("app.css");
let output = out.path().join("app.css");
fs::write(&source, "body{color:blue}").unwrap();
build_css_file(CssTool::TestMissing, &CssOptions::new(), &source, &output)
.await
.unwrap();
assert_eq!(fs::read_to_string(&output).unwrap(), "body{color:blue}");
}
#[tokio::test]
async fn per_file_mode_already_minified_skips_the_tool() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let source = src.path().join("app.min.css");
let output = out.path().join("app.min.css");
fs::write(&source, "body{color:blue}").unwrap();
build_css_file(
CssTool::TestMissing,
&CssOptions::new().minify(true),
&source,
&output,
)
.await
.unwrap();
assert_eq!(fs::read_to_string(&output).unwrap(), "body{color:blue}");
}
#[tokio::test]
async fn per_file_mode_degrades_to_raw_copy_when_the_tool_fails() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let source = src.path().join("app.css");
let output = out.path().join("app.css");
fs::write(&source, "body{color:blue}").unwrap();
build_css_file(
CssTool::TestMissing,
&CssOptions::new().minify(true),
&source,
&output,
)
.await
.unwrap();
assert_eq!(
fs::read_to_string(&output).unwrap(),
"body{color:blue}",
"a failing tool must degrade to serving the raw source, not fail the pipeline"
);
}
}