use std::path::{Path, PathBuf};
use std::fs;
use crate::bundle;
pub async fn bundle_directory_css(
src_dir: &Path,
output_path: &Path,
) -> Result<(), CssBundlerError> {
let src_dir_canon = src_dir.canonicalize().map_err(|e| {
CssBundlerError::ReadSource {
path: src_dir.to_path_buf(),
reason: e.to_string(),
}
})?;
let css_files = find_css_files(&src_dir_canon).map_err(|e| {
CssBundlerError::ReadSource {
path: src_dir.to_path_buf(),
reason: e.to_string(),
}
})?;
if css_files.is_empty() {
return Err(CssBundlerError::NoFilesFound(src_dir.to_path_buf()));
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let synthetic_entry = src_dir_canon.join(format!(".css_bundle_entry_{}.css", nanos));
let mut import_statements = String::new();
for css_file in &css_files {
if let Ok(relative) = css_file.strip_prefix(&src_dir_canon) {
if let Some(rel_str) = relative.to_str() {
import_statements.push_str(&format!("@import \"{}\";\n", rel_str));
}
}
}
fs::write(&synthetic_entry, &import_statements).map_err(|e| {
CssBundlerError::WriteOutput {
path: synthetic_entry.clone(),
reason: e.to_string(),
}
})?;
let result = bundle::bundle_and_minify_css(&[src_dir_canon.clone()], &synthetic_entry)
.await
.map_err(|e| CssBundlerError::Bundle(format!("{:?}", e)));
let _ = fs::remove_file(&synthetic_entry);
let (bundled_bytes, _deps) = result?;
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
CssBundlerError::WriteOutput {
path: output_path.to_path_buf(),
reason: e.to_string(),
}
})?;
}
fs::write(output_path, &bundled_bytes).map_err(|e| {
CssBundlerError::WriteOutput {
path: output_path.to_path_buf(),
reason: e.to_string(),
}
})?;
Ok(())
}
fn find_css_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut css_files = Vec::new();
let mut dirs = vec![dir.to_path_buf()];
while let Some(current_dir) = dirs.pop() {
let entries = fs::read_dir(¤t_dir)?;
for entry in entries {
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() {
if path.extension().and_then(|s| s.to_str()) == Some("css") {
css_files.push(path);
}
}
}
}
css_files.sort();
Ok(css_files)
}
#[derive(Debug)]
pub enum CssBundlerError {
ReadSource { path: PathBuf, reason: String },
WriteOutput { path: PathBuf, reason: String },
NoFilesFound(PathBuf),
Bundle(String),
}
impl std::fmt::Display for CssBundlerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CssBundlerError::ReadSource { path, reason } => {
write!(f, "failed to read source dir {}: {}", path.display(), reason)
}
CssBundlerError::WriteOutput { path, reason } => {
write!(f, "failed to write output file {}: {}", path.display(), reason)
}
CssBundlerError::NoFilesFound(path) => {
write!(f, "no CSS files found in {}", path.display())
}
CssBundlerError::Bundle(msg) => {
write!(f, "CSS bundling failed: {}", msg)
}
}
}
}
impl std::error::Error for CssBundlerError {}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs;
#[tokio::test]
async fn bundles_single_css_file() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let src_path = src.path();
let out_path = out.path().join("bundle.css");
fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();
let result = bundle_directory_css(src_path, &out_path).await;
assert!(result.is_ok(), "bundling should succeed");
assert!(out_path.exists(), "output file should be created");
let content = fs::read_to_string(&out_path).unwrap();
assert!(!content.is_empty(), "output should not be empty");
}
#[tokio::test]
async fn bundles_multiple_css_files() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let src_path = src.path();
let out_path = out.path().join("bundle.css");
fs::write(src_path.join("a.css"), "body { color: red; }").unwrap();
fs::write(src_path.join("b.css"), ".class { color: blue; }").unwrap();
let result = bundle_directory_css(src_path, &out_path).await;
assert!(result.is_ok(), "bundling should succeed");
assert!(out_path.exists(), "output file should be created");
}
#[tokio::test]
async fn errors_on_empty_source_dir() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let src_path = src.path();
let out_path = out.path().join("bundle.css");
let result = bundle_directory_css(src_path, &out_path).await;
assert!(matches!(result, Err(CssBundlerError::NoFilesFound(_))), "should error on no files");
}
#[tokio::test]
async fn creates_output_directory_if_missing() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let src_path = src.path();
let nested_out = out.path().join("nested/deep/bundle.css");
fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();
let result = bundle_directory_css(src_path, &nested_out).await;
assert!(result.is_ok(), "should create parent directories");
assert!(nested_out.exists(), "output file should exist");
}
#[tokio::test]
async fn bundles_css_in_subdirectories() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let src_path = src.path();
let out_path = out.path().join("bundle.css");
fs::create_dir(src_path.join("subdir")).unwrap();
fs::write(src_path.join("main.css"), "body { margin: 0; }").unwrap();
fs::write(src_path.join("subdir/nested.css"), ".nested { color: green; }").unwrap();
let result = bundle_directory_css(src_path, &out_path).await;
assert!(result.is_ok(), "should bundle files in subdirectories");
assert!(out_path.exists(), "output file should be created");
}
#[tokio::test]
async fn produces_valid_css_output() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let src_path = src.path();
let out_path = out.path().join("bundle.css");
fs::write(src_path.join("style.css"), "body { margin: 0; } .class { padding: 10px; }").unwrap();
let result = bundle_directory_css(src_path, &out_path).await;
assert!(result.is_ok(), "bundling should succeed");
let content = fs::read_to_string(&out_path).unwrap();
assert!(!content.is_empty(), "output should not be empty");
assert!(content.contains("body"), "output should contain CSS rules");
assert!(content.contains("margin"), "output should preserve CSS properties");
}
}