1use std::ffi::OsString;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::tool;
6
7#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum JsTool {
19 Esbuild,
22 #[cfg(test)]
24 TestEcho,
25 #[cfg(test)]
27 TestMissing,
28}
29
30impl JsTool {
31 pub(crate) fn binary_name(&self) -> &'static str {
32 match self {
33 JsTool::Esbuild => "esbuild",
34 #[cfg(test)]
35 JsTool::TestEcho => "cp",
36 #[cfg(test)]
37 JsTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
38 }
39 }
40
41 pub(crate) fn install_hint(&self) -> &'static str {
42 match self {
43 JsTool::Esbuild => {
44 "install via `npm install -g esbuild` (or add it as a project \
45 devDependency and put its bin/ on PATH)"
46 }
47 #[cfg(test)]
48 JsTool::TestEcho | JsTool::TestMissing => "test-only tool, not installable",
49 }
50 }
51
52 fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
53 match self {
54 JsTool::Esbuild => {
55 let mut args = vec![OsString::from(entry)];
56 if bundle {
57 args.push(OsString::from("--bundle"));
58 }
59 if minify {
60 args.push(OsString::from("--minify"));
61 }
62 let mut outfile = OsString::from("--outfile=");
63 outfile.push(output);
64 args.push(outfile);
65 args
66 }
67 #[cfg(test)]
68 JsTool::TestEcho => vec![entry.into(), output.into()],
69 #[cfg(test)]
70 JsTool::TestMissing => vec![],
71 }
72 }
73}
74
75#[derive(Debug, Clone, Default)]
85pub struct JsOptions {
86 bundle: bool,
87 minify: bool,
88 entry: Option<PathBuf>,
89 bundle_output_name: Option<String>,
90}
91
92impl JsOptions {
93 pub fn new() -> Self {
95 JsOptions::default()
96 }
97
98 pub fn minify(mut self, minify: bool) -> Self {
101 self.minify = minify;
102 self
103 }
104
105 pub fn bundle_entry(mut self, entry: &Path, output_name: impl Into<String>) -> Self {
110 self.bundle = true;
111 self.entry = Some(entry.to_path_buf());
112 self.bundle_output_name = Some(output_name.into());
113 self
114 }
115
116 pub(crate) fn is_bundle(&self) -> bool {
117 self.bundle
118 }
119
120 pub(crate) fn is_minify(&self) -> bool {
121 self.minify
122 }
123
124 pub(crate) fn entry(&self) -> Option<&Path> {
125 self.entry.as_deref()
126 }
127
128 pub(crate) fn output_file_name(&self) -> Option<&str> {
129 self.bundle_output_name.as_deref()
130 }
131}
132
133#[derive(Debug)]
135pub(crate) enum JsError {
136 WriteOutput { path: PathBuf, reason: String },
137 Tool(tool::ToolError),
138}
139
140impl std::fmt::Display for JsError {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 match self {
143 JsError::WriteOutput { path, reason } => {
144 write!(f, "failed to write {}: {reason}", path.display())
145 }
146 JsError::Tool(e) => write!(f, "{e}"),
147 }
148 }
149}
150
151impl std::error::Error for JsError {}
152
153impl From<tool::ToolError> for JsError {
154 fn from(e: tool::ToolError) -> Self {
155 JsError::Tool(e)
156 }
157}
158
159pub(crate) async fn build_js_bundle(
166 js_tool: JsTool,
167 options: &JsOptions,
168 entry: &Path,
169 output_path: &Path,
170) -> Result<(), JsError> {
171 if !options.bundle && !options.minify {
172 return copy_file(entry, output_path);
173 }
174 Ok(run_tool(js_tool, true, options.minify, entry, output_path).await?)
175}
176
177pub(crate) async fn build_js_file(
183 js_tool: JsTool,
184 options: &JsOptions,
185 source: &Path,
186 output: &Path,
187) -> Result<(), JsError> {
188 if !options.minify || is_already_minified(source) {
189 return copy_file(source, output);
190 }
191
192 if let Err(e) = run_tool(js_tool, false, true, source, output).await {
193 eprintln!(
194 "js tool: minify failed for {}, serving raw bytes: {e}",
195 source.display()
196 );
197 return copy_file(source, output);
198 }
199 Ok(())
200}
201
202async fn run_tool(
203 js_tool: JsTool,
204 bundle: bool,
205 minify: bool,
206 entry: &Path,
207 output: &Path,
208) -> Result<(), tool::ToolError> {
209 if let Some(parent) = output.parent() {
210 let _ = fs::create_dir_all(parent);
211 }
212 let args = js_tool.args(bundle, minify, entry, output);
213 tool::execute(
214 js_tool.binary_name(),
215 js_tool.install_hint(),
216 js_tool.binary_name(),
217 &args,
218 output,
219 tool::TOOL_TIMEOUT,
220 )
221 .await
222}
223
224fn is_already_minified(path: &Path) -> bool {
228 path.file_name()
229 .and_then(|name| name.to_str())
230 .is_some_and(|name| name.ends_with(".min.js"))
231}
232
233fn copy_file(source: &Path, output: &Path) -> Result<(), JsError> {
234 if let Some(parent) = output.parent() {
235 fs::create_dir_all(parent).map_err(|e| JsError::WriteOutput {
236 path: output.to_path_buf(),
237 reason: e.to_string(),
238 })?;
239 }
240 fs::copy(source, output).map_err(|e| JsError::WriteOutput {
241 path: output.to_path_buf(),
242 reason: e.to_string(),
243 })?;
244 Ok(())
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use tempfile::TempDir;
251
252 #[tokio::test]
253 async fn bundle_mode_runs_the_tool_against_the_entry() {
254 let src = TempDir::new().unwrap();
255 let out = TempDir::new().unwrap();
256 let entry = src.path().join("main.js");
257 let output = out.path().join("bundle.js");
258 fs::write(&entry, "const x = 1;").unwrap();
259
260 build_js_bundle(
261 JsTool::TestEcho,
262 &JsOptions::new().bundle_entry(&entry, "bundle.js"),
263 &entry,
264 &output,
265 )
266 .await
267 .unwrap();
268
269 assert_eq!(fs::read_to_string(&output).unwrap(), "const x = 1;");
270 }
271
272 #[tokio::test]
273 async fn bundle_false_minify_false_is_a_passthrough_copy() {
274 let src = TempDir::new().unwrap();
275 let out = TempDir::new().unwrap();
276 let entry = src.path().join("main.js");
277 let output = out.path().join("main.js");
278 fs::write(&entry, "const x = 1;").unwrap();
279
280 build_js_bundle(JsTool::TestMissing, &JsOptions::new(), &entry, &output)
281 .await
282 .unwrap();
283
284 assert_eq!(fs::read_to_string(&output).unwrap(), "const x = 1;");
285 }
286
287 #[tokio::test]
288 async fn a_failing_bundle_tool_leaves_previous_output_untouched() {
289 let src = TempDir::new().unwrap();
290 let out = TempDir::new().unwrap();
291 let entry = src.path().join("main.js");
292 let output = out.path().join("bundle.js");
293 fs::write(&entry, "const x = 1;").unwrap();
294 fs::write(&output, "/* previous good build */").unwrap();
295
296 let result = build_js_bundle(
297 JsTool::TestMissing,
298 &JsOptions::new()
299 .bundle_entry(&entry, "bundle.js")
300 .minify(true),
301 &entry,
302 &output,
303 )
304 .await;
305
306 assert!(result.is_err());
307 assert_eq!(
308 fs::read_to_string(&output).unwrap(),
309 "/* previous good build */"
310 );
311 }
312
313 #[tokio::test]
314 async fn per_file_mode_with_minify_false_copies_through_unchanged() {
315 let src = TempDir::new().unwrap();
316 let out = TempDir::new().unwrap();
317 let source = src.path().join("app.js");
318 let output = out.path().join("app.js");
319 fs::write(&source, "const x = 1;").unwrap();
320
321 build_js_file(JsTool::TestMissing, &JsOptions::new(), &source, &output)
322 .await
323 .unwrap();
324
325 assert_eq!(fs::read_to_string(&output).unwrap(), "const x = 1;");
326 }
327
328 #[tokio::test]
329 async fn per_file_mode_already_minified_skips_the_tool() {
330 let src = TempDir::new().unwrap();
331 let out = TempDir::new().unwrap();
332 let source = src.path().join("app.min.js");
333 let output = out.path().join("app.min.js");
334 fs::write(&source, "const x=1;").unwrap();
335
336 build_js_file(
337 JsTool::TestMissing,
338 &JsOptions::new().minify(true),
339 &source,
340 &output,
341 )
342 .await
343 .unwrap();
344
345 assert_eq!(fs::read_to_string(&output).unwrap(), "const x=1;");
346 }
347
348 #[tokio::test]
349 async fn per_file_mode_degrades_to_raw_copy_when_the_tool_fails() {
350 let src = TempDir::new().unwrap();
351 let out = TempDir::new().unwrap();
352 let source = src.path().join("app.js");
353 let output = out.path().join("app.js");
354 fs::write(&source, "const x = 1;").unwrap();
355
356 build_js_file(
357 JsTool::TestMissing,
358 &JsOptions::new().minify(true),
359 &source,
360 &output,
361 )
362 .await
363 .unwrap();
364
365 assert_eq!(
366 fs::read_to_string(&output).unwrap(),
367 "const x = 1;",
368 "a failing tool must degrade to serving the raw source, not fail the pipeline"
369 );
370 }
371}