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;