Skip to main content

mini_build/
builder.rs

1use std::path::{Path, PathBuf};
2
3use crate::change::Broadcaster;
4use crate::css::{CssOptions, CssTool};
5use crate::error::BuildError;
6use crate::js::{JsOptions, JsTool};
7use crate::source::SourcePipeline;
8use crate::tool;
9use crate::watch::{self, WatchHandle};
10
11/// True when either path contains the other, in either direction.
12///
13/// Containment either way is a problem, which is why this is symmetric: a source folder
14/// inside the output dir would have the build read its own writes, and an output dir
15/// inside a source folder would have it write into its own inputs. Both are the same
16/// feedback loop wearing different clothes.
17fn paths_overlap(a: &Path, b: &Path) -> bool {
18    a.starts_with(b) || b.starts_with(a)
19}
20
21/// Assembles a build: which folders are inputs, which directory receives the output, and
22/// which external tools transform what.
23///
24/// Every path is canonicalized and checked as it is registered, so a misconfiguration is
25/// reported while the builder is being assembled rather than partway through a build that
26/// has already written files. [`Builder::build`] then runs every configured pipeline once.
27///
28/// # Example
29///
30/// ```no_run
31/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
32/// use mini_build::{Builder, CssOptions, CssTool};
33/// use std::path::Path;
34///
35/// Builder::new(Path::new("./public"))?
36///     .source_folder(Path::new("./src/styles"))?
37///     .css_tool(CssTool::LightningCss, CssOptions::default())
38///     .build()?;
39/// # Ok(())
40/// # }
41/// ```
42pub struct Builder {
43    source_folders: Vec<PathBuf>,
44    asset_folders: Vec<PathBuf>,
45    output_dir: PathBuf,
46    css_tool: Option<(CssTool, CssOptions)>,
47    js_tool: Option<(JsTool, JsOptions)>,
48    prune_output: bool,
49    broadcaster: Broadcaster,
50}
51
52impl Builder {
53    /// Start a build that writes into `output_dir`.
54    ///
55    /// # Errors
56    ///
57    /// [`BuildError::Io`] if `output_dir` cannot be canonicalized — it must already exist,
58    /// since a typo that silently creates a directory tree is worse than an error.
59    pub fn new(output_dir: &Path) -> Result<Self, BuildError> {
60        Ok(Builder {
61            source_folders: Vec::new(),
62            asset_folders: Vec::new(),
63            output_dir: output_dir.canonicalize()?,
64            css_tool: None,
65            js_tool: None,
66            prune_output: false,
67            broadcaster: Broadcaster::new(),
68        })
69    }
70
71    /// Register a folder of CSS/JS inputs to be transformed into the output dir.
72    ///
73    /// # Errors
74    ///
75    /// [`BuildError::Io`] if `dir` cannot be canonicalized; [`BuildError::Config`] if it
76    /// overlaps the output dir or an already-registered source or asset folder.
77    pub fn source_folder(mut self, dir: &Path) -> Result<Self, BuildError> {
78        let canon = self.register_input(dir, "source folder")?;
79        self.source_folders.push(canon);
80        Ok(self)
81    }
82
83    /// Register a folder mirrored byte-for-byte into the output dir, whatever the
84    /// extension — images, fonts, `robots.txt`, hand-written HTML.
85    ///
86    /// # Errors
87    ///
88    /// As [`Builder::source_folder`].
89    pub fn asset_folder(mut self, dir: &Path) -> Result<Self, BuildError> {
90        let canon = self.register_input(dir, "asset folder")?;
91        self.asset_folders.push(canon);
92        Ok(self)
93    }
94
95    /// Canonicalize `dir` and reject it if it overlaps the output dir or an existing
96    /// input folder. `kind` names the folder in the error, so a caller registering
97    /// several folders learns which one is wrong.
98    fn register_input(&self, dir: &Path, kind: &str) -> Result<PathBuf, BuildError> {
99        let canon = dir.canonicalize()?;
100
101        if paths_overlap(&canon, &self.output_dir) {
102            return Err(BuildError::Config(format!(
103                "{kind} {} overlaps the output dir {}",
104                canon.display(),
105                self.output_dir.display()
106            )));
107        }
108        if self
109            .source_folders
110            .iter()
111            .chain(self.asset_folders.iter())
112            .any(|existing| paths_overlap(&canon, existing))
113        {
114            return Err(BuildError::Config(format!(
115                "{kind} {} overlaps an already-registered source/asset folder",
116                canon.display()
117            )));
118        }
119
120        Ok(canon)
121    }
122
123    /// Transform CSS with `tool`, per `options`.
124    pub fn css_tool(mut self, tool: CssTool, options: CssOptions) -> Self {
125        self.css_tool = Some((tool, options));
126        self
127    }
128
129    /// Transform JS with `tool`, per `options`.
130    ///
131    /// # Errors
132    ///
133    /// [`BuildError::Io`] if a configured bundle entry cannot be canonicalized;
134    /// [`BuildError::Config`] if it does not lie under a registered source folder — which
135    /// would mean bundling a file this build does not consider an input, so register the
136    /// folder first.
137    pub fn js_tool(mut self, tool: JsTool, options: JsOptions) -> Result<Self, BuildError> {
138        if let Some(entry) = options.entry() {
139            let entry_canon = entry.canonicalize()?;
140            let under_source_folder = self
141                .source_folders
142                .iter()
143                .any(|folder| entry_canon.starts_with(folder));
144            if !under_source_folder {
145                return Err(BuildError::Config(format!(
146                    "js bundle entry {} is not under any registered source folder",
147                    entry_canon.display()
148                )));
149            }
150        }
151
152        self.js_tool = Some((tool, options));
153        Ok(self)
154    }
155
156    /// Delete the CSS bundle from the output dir when no CSS sources remain.
157    ///
158    /// Off by default: pruning deletes files the builder did not necessarily write, and
159    /// an output dir shared with hand-placed files should not lose them to a build.
160    pub fn prune_output(mut self) -> Self {
161        self.prune_output = true;
162        self
163    }
164
165    /// Subscribe to the change events this build emits as it writes outputs.
166    ///
167    /// A one-shot [`Builder::build`] emits these too, but they matter for a caller
168    /// watching for rebuilds — a dev server reloading a browser, say.
169    pub fn subscribe(&self) -> std::sync::mpsc::Receiver<crate::ChangeEvent> {
170        self.broadcaster.subscribe()
171    }
172
173    /// Every external binary this configuration will actually invoke.
174    ///
175    /// A tool configured for neither bundling nor minifying never spawns a process, so it
176    /// is not required to be installed — checking for it would fail a build that was
177    /// never going to run it.
178    fn required_tool_binaries(&self) -> Vec<(&'static str, &'static str)> {
179        let mut required = Vec::new();
180        if let Some((css_tool, options)) = &self.css_tool {
181            if options.is_bundle() || options.is_minify() {
182                required.push((css_tool.binary_name(), css_tool.install_hint()));
183            }
184        }
185        if let Some((js_tool, options)) = &self.js_tool {
186            if options.is_bundle() || options.is_minify() {
187                required.push((js_tool.binary_name(), js_tool.install_hint()));
188            }
189        }
190        required
191    }
192
193    /// Run every configured pipeline once.
194    ///
195    /// Tool availability is checked first, before any file is written: a build that is
196    /// going to fail for want of `esbuild` should fail before it has half-populated the
197    /// output dir.
198    ///
199    /// # Errors
200    ///
201    /// [`BuildError::ToolMissing`] if a configured tool's binary is absent from `PATH`;
202    /// [`BuildError::Build`] if a pipeline ran and failed.
203    pub fn build(&self) -> Result<(), BuildError> {
204        for (binary, install_hint) in self.required_tool_binaries() {
205            if !tool::locate_on_path(binary) {
206                return Err(BuildError::ToolMissing(format!(
207                    "{binary} not found on PATH ({install_hint})"
208                )));
209            }
210        }
211
212        self.pipeline().full_build()?;
213        Ok(())
214    }
215
216    /// Build once, then keep the output dir in sync with the sources until the returned
217    /// handle is dropped.
218    ///
219    /// The initial build is part of the job: the output dir is not in sync with the
220    /// sources until it has run, so watching without it would leave a window where the
221    /// two disagree and nothing was going to correct it. Its failures come back through
222    /// the returned `Result`; failures of later rebuilds go to `on_error`, since by then
223    /// there is no call left to return from.
224    ///
225    /// This is the development half of the split with a static server: this crate watches
226    /// sources and writes the output dir, and the server watches the directory it serves.
227    /// Because the server cannot observe a file before it is written, "reload only after
228    /// the output exists" holds by construction rather than by careful sequencing.
229    ///
230    /// # Errors
231    ///
232    /// As [`Builder::build`] — the initial build runs the same checks.
233    ///
234    /// # Example
235    ///
236    /// ```no_run
237    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
238    /// use mini_build::Builder;
239    /// use std::path::Path;
240    ///
241    /// let watching = Builder::new(Path::new("./public"))?
242    ///     .source_folder(Path::new("./src/styles"))?
243    ///     .watch(|e| eprintln!("rebuild failed: {e}"))?;
244    /// // ... outputs stay current until `watching` is dropped.
245    /// watching.stop();
246    /// # Ok(())
247    /// # }
248    /// ```
249    pub fn watch(
250        self,
251        on_error: impl FnMut(BuildError) + Send + 'static,
252    ) -> Result<WatchHandle, BuildError> {
253        self.build()?;
254
255        let watched = self
256            .source_folders
257            .iter()
258            .chain(self.asset_folders.iter())
259            .cloned()
260            .collect();
261
262        Ok(watch::start(self.pipeline(), watched, on_error))
263    }
264
265    /// The pipeline this configuration describes.
266    ///
267    /// `bundle_roots` is always empty: it existed in `mini-static` as an extra watch
268    /// target for CSS rebuilds, never as an `@import` boundary, and watching is not this
269    /// type's concern.
270    fn pipeline(&self) -> SourcePipeline {
271        SourcePipeline::new(
272            self.source_folders.clone(),
273            Vec::new(),
274            self.asset_folders.clone(),
275            self.output_dir.clone(),
276            self.css_tool.clone(),
277            self.js_tool.clone(),
278            self.prune_output,
279            self.broadcaster.clone(),
280        )
281    }
282}
283
284#[cfg(test)]
285#[path = "../tests/unit/builder.rs"]
286mod tests;