Skip to main content

mini_build/
source.rs

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
7/// Routes source-folder file changes to the pipeline that turns them into served output.
8///
9/// The watcher layer runs over *source folders only*. The designated output dir is never
10/// watched and never a trigger: every pipeline below writes its own output, and a pipeline
11/// that listened to its own output would re-trigger itself forever (the feedback-loop bug
12/// this design fixes). Instead, after a build finishes, the pipeline broadcasts the reload
13/// event for the output it wrote, so the browser still hot-swaps/reloads.
14///
15/// CSS and JS are each independently configured via an optional `(tool, options)` pair.
16/// `options.bundle()` selects between two disjoint modes per language: bundle (a single
17/// discovered/entry-driven output, rebuilt in full on any relevant change) or per-file
18/// (every source file mirrored independently into the output dir).
19///
20/// `asset_folders` are a third, simpler kind of source: every file under one (any
21/// extension, no transformation) is mirrored byte-identical into the output dir,
22/// preserving its path relative to the asset folder — for hand-authored static files
23/// (`index.html`, images) that should live outside the served/output dir as source,
24/// same separation the CSS/JS pipelines already have, without needing a CSS/JS tool.
25pub 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    /// Rebuild every enabled output once, then prune stale output if configured.
61    ///
62    /// Runs at server startup. Never during live-reload — see [`Self::prune_stale_output`].
63    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    /// Handle a single change event for `path` (which must be under a watched source
77    /// folder or import root), rebuilding output as needed and broadcasting the reload
78    /// event for whatever was written.
79    pub fn process_change(&self, path: &Path, change_type: &ChangeType) -> Result<(), SourceError> {
80        // Asset folders own any extension, so this check runs before the CSS/JS
81        // dispatch below (which only fires for `ChangeType::Css`/`Script`) — an
82        // asset folder file with one of those extensions is still a plain copy, not
83        // a CSS/JS build input.
84        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        // No pipeline owns this file kind. Re-broadcast only genuine watched-input
120        // changes so the client reloads and re-fetches whatever external builder produced
121        // the output. The watcher never watches the output dir, so the ONLY paths that
122        // reach here without being under a watched root are this pipeline's own broadcast
123        // echoes (e.g. a rebuilt bundle) — re-broadcasting those would make the pipeline
124        // loop on its own output forever, flooding the SSE stream (the `_mr` cycling bug).
125        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    /// Rebuild the single CSS bundle from every source folder, if CSS bundle mode is
135    /// configured. Returns the output path it wrote, or `None` when bundle mode isn't
136    /// configured or no CSS sources exist (nothing to produce).
137    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    /// Rebuild the JS bundle from its configured entry point, if JS bundle mode is
153    /// configured. Returns the output path it wrote, or `None` when bundle mode isn't
154    /// configured.
155    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    /// Process every file under every source folder through whichever per-file (i.e.
171    /// non-bundle) pipeline claims its extension. Bundle-mode CSS/JS is handled
172    /// separately by [`Self::build_css_bundle`]/[`Self::build_js_bundle`].
173    /// Build every per-file-mode source, batching tool invocations rather than spawning
174    /// one process per file.
175    ///
176    /// Sources are collected first and built after, which is the whole point: a process
177    /// spawn costs roughly nineteen milliseconds against about five of actual
178    /// transformation, so the number of invocations — not the number of files — is what a
179    /// build's wall clock tracks.
180    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    /// Rebuild a single changed CSS file into its mirrored output path (per-file mode
216    /// only — bundle mode always rebuilds the whole bundle via [`Self::build_css_bundle`]).
217    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    /// Rebuild a single changed JS file into its mirrored output path (per-file mode
234    /// only — bundle mode always rebuilds the whole bundle via [`Self::build_js_bundle`]).
235    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    /// Mirror every file under every asset folder into the output dir, byte-identical,
252    /// preserving each file's path relative to its asset folder.
253    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    /// The asset folder containing `path`, if any.
265    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    /// The output path for `source`, mirroring its path relative to `folder` under the
272    /// output dir.
273    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    /// Broadcast a reload event for `output`, deriving its change type from the output's
281    /// own extension so the browser hot-swaps/reloads exactly as it would for that file.
282    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    /// True when `path` lives under any watched root (a source folder, a CSS `@import`
290    /// root, or an asset folder). These are the only paths the watcher emits, so an
291    /// event whose path fails this check must be the pipeline's own output echo and
292    /// must not be re-broadcast.
293    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    /// The source folder containing `path`, if any.
302    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    /// True when at least one `.css` file exists under the source folders.
309    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    /// The CSS bundle's output path, if CSS bundle mode is configured.
319    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    /// Remove stale output at build time, never during live-reload.
328    ///
329    /// The only output this server can own *by identity* is the CSS bundle file — a single
330    /// exact path that no hand-written file shares. If bundling is enabled but no CSS
331    /// sources remain, the leftover bundle is removed. Per-file outputs are deliberately
332    /// NOT auto-pruned: their mirrored paths can coincide with hand-written files, and
333    /// deleting files the server doesn't own is a surprise (A1) the caller can't opt into
334    /// by accident.
335    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/// Why [`SourcePipeline`] could not produce output for a change or build.
356#[derive(Debug)]
357pub enum SourceError {
358    /// The CSS tool step failed.
359    Css(css::CssError),
360    /// The JS tool step failed.
361    Js(js::JsError),
362    /// A filesystem operation failed.
363    Io(std::io::Error),
364    /// A changed path was not under the source folder claimed to contain it.
365    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
383/// True if `path` names a CSS file (by extension).
384fn is_css(path: &Path) -> bool {
385    path.extension().and_then(|e| e.to_str()) == Some("css")
386}
387
388/// True if `path` names a script the tool accepts (`js`/`mjs`).
389fn is_script(path: &Path) -> bool {
390    matches!(
391        path.extension().and_then(|e| e.to_str()),
392        Some("js" | "mjs")
393    )
394}
395
396/// Copy `source` to `output` byte-identical, creating `output`'s parent directory if
397/// needed — the flat-mirror operation asset folders use, with no transformation.
398fn 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
406/// Recursively list every file under `dir`.
407///
408/// Bounded by the filesystem: `walk_dir` pushes directories onto a stack and terminates
409/// when none remain — a directory tree is finite, so this loop always ends.
410fn 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(&current)? {
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
429/// Synchronously walk `dir` and yield every file path. Used by the cheap existence check
430/// in [`SourcePipeline::has_css_sources`] (runs at build boundaries, not per request).
431fn 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(&current) 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;