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 JsTool {
Esbuild,
#[cfg(test)]
TestEcho,
#[cfg(test)]
TestMissing,
}
impl JsTool {
pub(crate) fn binary_name(&self) -> &'static str {
match self {
JsTool::Esbuild => "esbuild",
#[cfg(test)]
JsTool::TestEcho => "cp",
#[cfg(test)]
JsTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
}
}
pub(crate) fn install_hint(&self) -> &'static str {
match self {
JsTool::Esbuild => {
"install via `npm install -g esbuild` (or add it as a project \
devDependency and put its bin/ on PATH)"
}
#[cfg(test)]
JsTool::TestEcho | JsTool::TestMissing => "test-only tool, not installable",
}
}
fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
match self {
JsTool::Esbuild => {
let mut args = vec![OsString::from(entry)];
if bundle {
args.push(OsString::from("--bundle"));
}
if minify {
args.push(OsString::from("--minify"));
}
let mut outfile = OsString::from("--outfile=");
outfile.push(output);
args.push(outfile);
args
}
#[cfg(test)]
JsTool::TestEcho => vec![entry.into(), output.into()],
#[cfg(test)]
JsTool::TestMissing => vec![],
}
}
}
#[derive(Debug, Clone, Default)]
pub struct JsOptions {
bundle: bool,
minify: bool,
entry: Option<PathBuf>,
bundle_output_name: Option<String>,
}
impl JsOptions {
pub fn new() -> Self {
JsOptions::default()
}
pub fn minify(mut self, minify: bool) -> Self {
self.minify = minify;
self
}
pub fn bundle_entry(mut self, entry: &Path, output_name: impl Into<String>) -> Self {
self.bundle = true;
self.entry = Some(entry.to_path_buf());
self.bundle_output_name = Some(output_name.into());
self
}
pub(crate) fn is_bundle(&self) -> bool {
self.bundle
}
pub(crate) fn is_minify(&self) -> bool {
self.minify
}
pub(crate) fn entry(&self) -> Option<&Path> {
self.entry.as_deref()
}
pub(crate) fn output_file_name(&self) -> Option<&str> {
self.bundle_output_name.as_deref()
}
}
#[derive(Debug)]
pub(crate) enum JsError {
WriteOutput { path: PathBuf, reason: String },
Tool(tool::ToolError),
}
impl std::fmt::Display for JsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JsError::WriteOutput { path, reason } => {
write!(f, "failed to write {}: {reason}", path.display())
}
JsError::Tool(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for JsError {}
impl From<tool::ToolError> for JsError {
fn from(e: tool::ToolError) -> Self {
JsError::Tool(e)
}
}
pub(crate) async fn build_js_bundle(
js_tool: JsTool,
options: &JsOptions,
entry: &Path,
output_path: &Path,
) -> Result<(), JsError> {
if !options.bundle && !options.minify {
return copy_file(entry, output_path);
}
Ok(run_tool(js_tool, true, options.minify, entry, output_path).await?)
}
pub(crate) async fn build_js_file(
js_tool: JsTool,
options: &JsOptions,
source: &Path,
output: &Path,
) -> Result<(), JsError> {
if !options.minify || is_already_minified(source) {
return copy_file(source, output);
}
if let Err(e) = run_tool(js_tool, false, true, source, output).await {
eprintln!(
"js tool: minify failed for {}, serving raw bytes: {e}",
source.display()
);
return copy_file(source, output);
}
Ok(())
}
async fn run_tool(
js_tool: JsTool,
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 = js_tool.args(bundle, minify, entry, output);
tool::execute(
js_tool.binary_name(),
js_tool.install_hint(),
js_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.js"))
}
fn copy_file(source: &Path, output: &Path) -> Result<(), JsError> {
if let Some(parent) = output.parent() {
fs::create_dir_all(parent).map_err(|e| JsError::WriteOutput {
path: output.to_path_buf(),
reason: e.to_string(),
})?;
}
fs::copy(source, output).map_err(|e| JsError::WriteOutput {
path: output.to_path_buf(),
reason: e.to_string(),
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn bundle_mode_runs_the_tool_against_the_entry() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let entry = src.path().join("main.js");
let output = out.path().join("bundle.js");
fs::write(&entry, "const x = 1;").unwrap();
build_js_bundle(
JsTool::TestEcho,
&JsOptions::new().bundle_entry(&entry, "bundle.js"),
&entry,
&output,
)
.await
.unwrap();
assert_eq!(fs::read_to_string(&output).unwrap(), "const x = 1;");
}
#[tokio::test]
async fn bundle_false_minify_false_is_a_passthrough_copy() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let entry = src.path().join("main.js");
let output = out.path().join("main.js");
fs::write(&entry, "const x = 1;").unwrap();
build_js_bundle(JsTool::TestMissing, &JsOptions::new(), &entry, &output)
.await
.unwrap();
assert_eq!(fs::read_to_string(&output).unwrap(), "const x = 1;");
}
#[tokio::test]
async fn a_failing_bundle_tool_leaves_previous_output_untouched() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let entry = src.path().join("main.js");
let output = out.path().join("bundle.js");
fs::write(&entry, "const x = 1;").unwrap();
fs::write(&output, "/* previous good build */").unwrap();
let result = build_js_bundle(
JsTool::TestMissing,
&JsOptions::new()
.bundle_entry(&entry, "bundle.js")
.minify(true),
&entry,
&output,
)
.await;
assert!(result.is_err());
assert_eq!(
fs::read_to_string(&output).unwrap(),
"/* previous good build */"
);
}
#[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.js");
let output = out.path().join("app.js");
fs::write(&source, "const x = 1;").unwrap();
build_js_file(JsTool::TestMissing, &JsOptions::new(), &source, &output)
.await
.unwrap();
assert_eq!(fs::read_to_string(&output).unwrap(), "const x = 1;");
}
#[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.js");
let output = out.path().join("app.min.js");
fs::write(&source, "const x=1;").unwrap();
build_js_file(
JsTool::TestMissing,
&JsOptions::new().minify(true),
&source,
&output,
)
.await
.unwrap();
assert_eq!(fs::read_to_string(&output).unwrap(), "const x=1;");
}
#[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.js");
let output = out.path().join("app.js");
fs::write(&source, "const x = 1;").unwrap();
build_js_file(
JsTool::TestMissing,
&JsOptions::new().minify(true),
&source,
&output,
)
.await
.unwrap();
assert_eq!(
fs::read_to_string(&output).unwrap(),
"const x = 1;",
"a failing tool must degrade to serving the raw source, not fail the pipeline"
);
}
}