1use std::path::{Path, PathBuf};
2
3use crate::change::{Broadcaster, ChangeEvent, ChangeType};
4use crate::css::{self, CssOptions, CssTool};
5use crate::js::{self, JsOptions, JsTool};
6
7pub struct SourcePipeline {
26 source_folders: Vec<PathBuf>,
27 bundle_roots: Vec<PathBuf>,
28 asset_folders: Vec<PathBuf>,
29 output_dir: PathBuf,
30 css_tool: Option<(CssTool, CssOptions)>,
31 js_tool: Option<(JsTool, JsOptions)>,
32 prune_output: bool,
33 broadcaster: Broadcaster,
34}
35
36impl SourcePipeline {
37 #[allow(clippy::too_many_arguments)]
38 pub fn new(
39 source_folders: Vec<PathBuf>,
40 bundle_roots: Vec<PathBuf>,
41 asset_folders: Vec<PathBuf>,
42 output_dir: PathBuf,
43 css_tool: Option<(CssTool, CssOptions)>,
44 js_tool: Option<(JsTool, JsOptions)>,
45 prune_output: bool,
46 broadcaster: Broadcaster,
47 ) -> Self {
48 SourcePipeline {
49 source_folders,
50 bundle_roots,
51 asset_folders,
52 output_dir,
53 css_tool,
54 js_tool,
55 prune_output,
56 broadcaster,
57 }
58 }
59
60 pub fn full_build(&self) -> Result<(), SourceError> {
64 let css_written = self.build_css_bundle()?;
65 self.build_js_bundle()?;
66 self.build_all_per_file()?;
67 self.build_all_assets()?;
68
69 if self.prune_output {
70 self.prune_stale_output(css_written.as_deref())?;
71 }
72
73 Ok(())
74 }
75
76 pub fn process_change(&self, path: &Path, change_type: &ChangeType) -> Result<(), SourceError> {
80 if let Some(folder) = self.containing_asset_folder(path) {
85 let output = self.mirror_output(folder, path)?;
86 copy_asset(path, &output)?;
87 self.broadcast_change(&output);
88 return Ok(());
89 }
90
91 if change_type == &ChangeType::Css && self.is_input(path) {
92 if let Some((_, options)) = &self.css_tool {
93 let written = if options.is_bundle() {
94 self.build_css_bundle()?
95 } else {
96 self.rebuild_css_file(path)?
97 };
98 if let Some(output) = written {
99 self.broadcast_change(&output);
100 }
101 return Ok(());
102 }
103 }
104
105 if change_type == &ChangeType::Script && self.is_input(path) {
106 if let Some((_, options)) = &self.js_tool {
107 let written = if options.is_bundle() {
108 self.build_js_bundle()?
109 } else {
110 self.rebuild_js_file(path)?
111 };
112 if let Some(output) = written {
113 self.broadcast_change(&output);
114 }
115 return Ok(());
116 }
117 }
118
119 if self.is_input(path) {
126 self.broadcaster.broadcast(ChangeEvent {
127 path: path.to_path_buf(),
128 change_type: change_type.clone(),
129 });
130 }
131 Ok(())
132 }
133
134 fn build_css_bundle(&self) -> Result<Option<PathBuf>, SourceError> {
138 let Some((css_tool, options)) = &self.css_tool else {
139 return Ok(None);
140 };
141 if !options.is_bundle() || !self.has_css_sources() {
142 return Ok(None);
143 }
144
145 let output = self.output_dir.join(options.output_file_name());
146 css::build_css_bundle(*css_tool, options, &self.source_folders, &output)
147 .map_err(SourceError::Css)?;
148
149 Ok(Some(output))
150 }
151
152 fn build_js_bundle(&self) -> Result<Option<PathBuf>, SourceError> {
156 let Some((js_tool, options)) = &self.js_tool else {
157 return Ok(None);
158 };
159 let (true, Some(entry)) = (options.is_bundle(), options.entry()) else {
160 return Ok(None);
161 };
162
163 let name = options.output_file_name().unwrap_or("bundle.js");
164 let output = self.output_dir.join(name);
165 js::build_js_bundle(*js_tool, options, entry, &output).map_err(SourceError::Js)?;
166
167 Ok(Some(output))
168 }
169
170 fn build_all_per_file(&self) -> Result<(), SourceError> {
181 let mut css_pairs: Vec<(PathBuf, PathBuf)> = Vec::new();
182 let mut js_pairs: Vec<(PathBuf, PathBuf)> = Vec::new();
183
184 for folder in &self.source_folders {
185 let files = list_files(folder).map_err(SourceError::Io)?;
186 for file in files {
187 if is_css(&file) {
188 if let Some((_, options)) = &self.css_tool {
189 if !options.is_bundle() {
190 let output = self.mirror_output(folder, &file)?;
191 css_pairs.push((file, output));
192 }
193 }
194 } else if is_script(&file) {
195 if let Some((_, options)) = &self.js_tool {
196 if !options.is_bundle() {
197 let output = self.mirror_output(folder, &file)?;
198 js_pairs.push((file, output));
199 }
200 }
201 }
202 }
203 }
204
205 if let Some((css_tool, options)) = &self.css_tool {
206 css::build_css_files(*css_tool, options, &css_pairs).map_err(SourceError::Css)?;
207 }
208 if let Some((js_tool, options)) = &self.js_tool {
209 js::build_js_files(*js_tool, options, &js_pairs).map_err(SourceError::Js)?;
210 }
211
212 Ok(())
213 }
214
215 fn rebuild_css_file(&self, source: &Path) -> Result<Option<PathBuf>, SourceError> {
218 let Some(folder) = self.containing_source_folder(source) else {
219 return Ok(None);
220 };
221 if !is_css(source) {
222 return Ok(None);
223 }
224 let Some((css_tool, options)) = &self.css_tool else {
225 return Ok(None);
226 };
227
228 let output = self.mirror_output(folder, source)?;
229 css::build_css_file(*css_tool, options, source, &output).map_err(SourceError::Css)?;
230 Ok(Some(output))
231 }
232
233 fn rebuild_js_file(&self, source: &Path) -> Result<Option<PathBuf>, SourceError> {
236 let Some(folder) = self.containing_source_folder(source) else {
237 return Ok(None);
238 };
239 if !is_script(source) {
240 return Ok(None);
241 }
242 let Some((js_tool, options)) = &self.js_tool else {
243 return Ok(None);
244 };
245
246 let output = self.mirror_output(folder, source)?;
247 js::build_js_file(*js_tool, options, source, &output).map_err(SourceError::Js)?;
248 Ok(Some(output))
249 }
250
251 fn build_all_assets(&self) -> Result<(), SourceError> {
254 for folder in &self.asset_folders {
255 let files = list_files(folder).map_err(SourceError::Io)?;
256 for file in files {
257 let output = self.mirror_output(folder, &file)?;
258 copy_asset(&file, &output)?;
259 }
260 }
261 Ok(())
262 }
263
264 fn containing_asset_folder(&self, path: &Path) -> Option<&PathBuf> {
266 self.asset_folders
267 .iter()
268 .find(|folder| path.starts_with(folder))
269 }
270
271 fn mirror_output(&self, folder: &Path, source: &Path) -> Result<PathBuf, SourceError> {
274 let relative = source
275 .strip_prefix(folder)
276 .map_err(|_| SourceError::NotUnderSource(source.to_path_buf()))?;
277 Ok(self.output_dir.join(relative))
278 }
279
280 fn broadcast_change(&self, output: &Path) {
283 self.broadcaster.broadcast(ChangeEvent {
284 path: output.to_path_buf(),
285 change_type: ChangeType::from_path(output),
286 });
287 }
288
289 fn is_input(&self, path: &Path) -> bool {
294 self.source_folders
295 .iter()
296 .chain(self.bundle_roots.iter())
297 .chain(self.asset_folders.iter())
298 .any(|root| path.starts_with(root))
299 }
300
301 fn containing_source_folder(&self, path: &Path) -> Option<&PathBuf> {
303 self.source_folders
304 .iter()
305 .find(|folder| path.starts_with(folder))
306 }
307
308 fn has_css_sources(&self) -> bool {
310 for folder in &self.source_folders {
311 if walk_dir(folder).any(|path| is_css(&path)) {
312 return true;
313 }
314 }
315 false
316 }
317
318 fn css_bundle_output_path(&self) -> Option<PathBuf> {
320 let (_, options) = self.css_tool.as_ref()?;
321 if !options.is_bundle() {
322 return None;
323 }
324 Some(self.output_dir.join(options.output_file_name()))
325 }
326
327 fn prune_stale_output(&self, css_written: Option<&Path>) -> Result<(), SourceError> {
336 let Some(bundle) = self.css_bundle_output_path() else {
337 return Ok(());
338 };
339
340 let wrote_bundle = css_written.is_some_and(|written| written == bundle);
341 if wrote_bundle {
342 return Ok(());
343 }
344
345 if std::fs::metadata(&bundle).is_err() {
346 return Ok(());
347 }
348
349 std::fs::remove_file(&bundle).map_err(SourceError::Io)?;
350 eprintln!("pruned stale css bundle output: {}", bundle.display());
351 Ok(())
352 }
353}
354
355#[derive(Debug)]
357pub enum SourceError {
358 Css(css::CssError),
360 Js(js::JsError),
362 Io(std::io::Error),
364 NotUnderSource(PathBuf),
366}
367
368impl std::fmt::Display for SourceError {
369 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370 match self {
371 SourceError::Css(e) => write!(f, "css tool step failed: {e}"),
372 SourceError::Js(e) => write!(f, "js tool step failed: {e}"),
373 SourceError::Io(e) => write!(f, "io error: {e}"),
374 SourceError::NotUnderSource(p) => {
375 write!(f, "path not under any source folder: {}", p.display())
376 }
377 }
378 }
379}
380
381impl std::error::Error for SourceError {}
382
383fn is_css(path: &Path) -> bool {
385 path.extension().and_then(|e| e.to_str()) == Some("css")
386}
387
388fn is_script(path: &Path) -> bool {
390 matches!(
391 path.extension().and_then(|e| e.to_str()),
392 Some("js" | "mjs")
393 )
394}
395
396fn copy_asset(source: &Path, output: &Path) -> Result<(), SourceError> {
399 if let Some(parent) = output.parent() {
400 std::fs::create_dir_all(parent).map_err(SourceError::Io)?;
401 }
402 std::fs::copy(source, output).map_err(SourceError::Io)?;
403 Ok(())
404}
405
406fn list_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
411 let mut files = Vec::new();
412 let mut dirs = vec![dir.to_path_buf()];
413
414 while let Some(current) = dirs.pop() {
415 for entry in std::fs::read_dir(¤t)? {
416 let entry = entry?;
417 let path = entry.path();
418 if entry.file_type()?.is_dir() {
419 dirs.push(path);
420 } else {
421 files.push(path);
422 }
423 }
424 }
425
426 Ok(files)
427}
428
429fn walk_dir(dir: &Path) -> impl Iterator<Item = PathBuf> {
432 let mut dirs = vec![dir.to_path_buf()];
433 std::iter::from_fn(move || {
434 while let Some(current) = dirs.pop() {
435 let Ok(entries) = std::fs::read_dir(¤t) else {
436 continue;
437 };
438 for entry in entries.flatten() {
439 let path = entry.path();
440 if entry.file_type().is_ok_and(|t| t.is_dir()) {
441 dirs.push(path);
442 } else {
443 return Some(path);
444 }
445 }
446 }
447 None
448 })
449}
450
451#[cfg(test)]
452#[path = "../tests/unit/source.rs"]
453mod tests;