abbaye/builders/mod.rs
1//! Builder types, the `Builder` trait, and the parallel execution model.
2//!
3//! Each `[[builders]]` entry in `abbaye.toml` deserialises into a
4//! [`BuilderEntry`] and is run as an independent Tokio task. This module
5//! owns the [`AnyBuilder`] enum (the TOML type-tag dispatch), the
6//! `LogEvent` channel used for progress-bar updates, and the `Builder`
7//! trait that every concrete builder must implement.
8//!
9//! ---
10//!
11//! # Parallel Builder Execution
12//!
13//! This section explains how Abbaye runs the `[[builders]]` entries defined in
14//! `abbaye.toml` in parallel, how inter-builder dependencies are enforced, and
15//! how the progress UI is kept in sync with the running tasks.
16//!
17//! ## Overview
18//!
19//! Every `[[builders]]` entry is executed as an independent Tokio async task.
20//! Tasks are launched at the same time (via a shared `JoinSet`) and may
21//! therefore run fully concurrently. When a builder declares `depends_on`,
22//! its task simply waits - without blocking any thread - until all named
23//! prerequisites have completed successfully before invoking the underlying
24//! build logic.
25//!
26//! ## Configuration Model
27//!
28//! ### `BuilderEntry`
29//!
30//! Each `[[builders]]` table in the TOML file deserialises into a
31//! [`BuilderEntry`]:
32//!
33//! | Field | Type | Purpose |
34//! |--------------|-------------------|-------------------------------------------------------------------|
35//! | `builder` | [`AnyBuilder`] | Type-tagged union holding the builder's own config (flattened). |
36//! | `id` | `Option<String>` | Stable name that other builders can reference in `depends_on`. |
37//! | `depends_on` | `Vec<String>` | IDs of builders that must succeed before this one starts. |
38//! | `name` | `Option<String>` | Display name shown as a heading on the release page. |
39//! | `comment` | `Option<String>` | Description displayed alongside the artifacts on the release page. |
40//!
41//! Example:
42//!
43//! ```toml
44//! [[builders]]
45//! type = "cargo"
46//! id = "compile"
47//!
48//! [[builders]]
49//! type = "script"
50//! script = ["strip target/release/mybin"]
51//! outputs = ["target/release/mybin"]
52//! depends_on = ["compile"] # waits for the cargo builder above
53//! name = "Pre-built binary" # optional display heading on the release page
54//! comment = "Stripped release binary for Linux x86_64"
55//! ```
56//!
57//! ### `AnyBuilder` variants
58//!
59//! | TOML `type` | Rust variant | What it does |
60//! |--------------|---------------|-----------------------------------------------------------|
61//! | `archive` | `Archive` | Creates a `.tar.gz` of the source tree. |
62//! | `cargo` | `Cargo` | `cargo build --release` (or `cross build --release`), for multiple targets and/or feature sets. Supports `features`, `no_default_features`, and a custom artifact `suffix`. |
63//! | `cargo_doc` | `CargoDoc` | `cargo doc`. |
64//! | `markdown` | `Markdown` | Renders a directory of `.md` files to HTML. |
65//! | `script` | `Script` | Runs an arbitrary sequence of `sh -c` commands. |
66//!
67//! ## Orchestration in `build_site` (`src/site.rs`)
68//!
69//! The entire builder pipeline lives inside `build_site`, in three phases.
70//!
71//! ### Phase 1 - Validation
72//!
73//! Before any task is spawned, two checks are performed:
74//!
75//! 1. **Reference check** - every string in `depends_on` must match an `id`
76//! of some other builder in the same config. Unknown IDs are reported as a
77//! hard error immediately.
78//!
79//! 2. **Cycle detection** - a depth-first search visits the dependency graph
80//! and returns an error if a cycle is found.
81//!
82//! The DFS tracks three states per node: `0 = unvisited`, `1 = in the current
83//! stack`, `2 = fully processed`. Encountering a node in state `1` means
84//! there is a back-edge, i.e. a cycle.
85//!
86//! ### Phase 2 - Completion channels
87//!
88//! For each builder that carries an `id`, a `tokio::sync::watch` channel is
89//! created:
90//!
91//! ```text
92//! watch::channel(None::<bool>)
93//! ↑
94//! └─ initial value: None (pending)
95//! ```
96//!
97//! The channel is later sent `Some(true)` (success) or `Some(false)`
98//! (failure). Dependents hold a cloned `Receiver` and call
99//! `wait_for(|v| v.is_some())`, which suspends the task cooperatively until
100//! the value changes.
101//!
102//! If a dependency's `watch::Sender` is dropped without ever sending a value
103//! (e.g. the task panicked), `wait_for` returns an `Err`, which is treated as
104//! a failure and the dependent is skipped.
105//!
106//! ### Phase 3 - Task spawning and collection
107//!
108//! All tasks are submitted to a single `tokio::task::JoinSet`. Every task
109//! follows the same lifecycle:
110//!
111//! - **Spawned** → waits for all declared dependencies.
112//! - **Running** → all deps succeeded; the builder executes.
113//! - **Skipped** → a dependency failed; returns `Ok(vec![])` and signals
114//! `Some(false)` so its own dependents also skip.
115//! - **Done / Failed** → signals `Some(true)` or `Some(false)`.
116//!
117//! After all tasks finish, collected `ArtifactPath` values are classified:
118//!
119//! - **File** artifacts → `dist/` (distribution binaries, archives, etc.).
120//! - **Directory** artifacts → `docs/` (rustdoc output, rendered Markdown, …).
121//!
122//! If any builder returned an error, the first error is propagated to the
123//! caller after all spinners have settled, so the full UI is always rendered
124//! to completion.
125//!
126//! ## Progress UI
127//!
128//! The UI is built with the `indicatif` crate. Each builder task owns:
129//!
130//! - A **parent spinner** inserted *above* a shared summary bar.
131//! - An **mpsc log channel** (`LogSender`) over which the builder streams
132//! events.
133//! - A **log-consumer task** that receives `LogEvent` values and updates the
134//! spinner.
135//!
136//! ### `LogEvent` variants
137//!
138//! | Variant | Meaning |
139//! |------------------------------|---------------------------------------------------|
140//! | `Line(String)` | Update the parent spinner message. |
141//! | `ChildStart { id, label }` | Create a sub-spinner below the parent. |
142//! | `ChildLine { id, line }` | Update a child spinner's message. |
143//! | `ChildFinish { id, … }` | Close a child spinner with ✓ or ✗. |
144//!
145//! The `ChildStart` / `ChildLine` / `ChildFinish` events are used only by the
146//! `cargo` builder when it compiles for multiple targets in parallel (see
147//! below).
148//!
149//! ## Inner Parallelism: `CargoBuilder`
150//!
151//! When a `cargo` builder lists multiple `targets`, it spawns one Tokio task
152//! per target inside its own inner `JoinSet` - a second level of concurrency
153//! nested inside the outer builder task. Feature flags (`features`,
154//! `no_default_features`) apply uniformly to all targets in the entry; use
155//! separate `[[builders]]` entries with `depends_on` for different feature
156//! sets per target.
157//!
158//! Each inner task:
159//!
160//! 1. Creates a [`tempfile::TempDir`] and passes it as `--target-dir` so the
161//! `cargo build` process does not contend with sibling builds on cargo's
162//! file lock.
163//! 2. Emits a `ChildStart` event so the UI creates a dedicated sub-spinner.
164//! 3. Uses `line_bridge` to adapt the plain-string stderr stream from
165//! `run_cargo_build` into `ChildLine` events on the parent `LogSender`.
166//! 4. After the build, copies artifacts to stable `target/<triple>/release/`
167//! paths before the `TempDir` is dropped.
168//! 5. Emits a `ChildFinish` event when done.
169//!
170//! Artifact names include a feature-derived suffix (e.g.
171//! `myapp-0.10.0-x86_64-full`) so binaries built with different feature sets
172//! do not collide in the distribution directory. See the `suffix` field for
173//! custom overrides.
174//!
175//! Within `run_cargo_build` itself, stderr and the JSON stdout are consumed
176//! concurrently in separate `tokio::spawn` tasks so neither stream blocks the
177//! other.
178//!
179//! ## Other Builders
180//!
181//! | Builder | Async strategy |
182//! |-------------|---------------------------------------------------------------------------------|
183//! | `archive` | `tokio::task::spawn_blocking` for the CPU-bound tar walk; sends `Line` events. |
184//! | `cargo_doc` | Spawns one `tokio::spawn` to stream stderr; awaits `cargo doc` exit. |
185//! | `markdown` | Sequential per-file render loop; `spawn_blocking` for the directory walk. |
186//! | `script` | Runs commands sequentially; streams stdout **and** stderr in parallel tasks. |
187
188use miette::Result;
189use schemars::JsonSchema;
190use serde::{Deserialize, Serialize};
191use std::path::PathBuf;
192use tokio::sync::mpsc::UnboundedSender;
193
194pub mod archive;
195pub mod cargo;
196pub mod markdown;
197pub(crate) mod orchestrator;
198pub mod script;
199
200use archive::{ArchiveBuilder, ArchiveBuilderConfig};
201use cargo::{CargoBuilder, CargoBuilderConfig, CargoDocBuilder, CargoDocBuilderConfig};
202use markdown::{MarkdownBuilder, MarkdownBuilderConfig};
203use script::{ScriptBuilder, ScriptBuilderConfig};
204
205/// Events emitted by builders and forwarded to the progress-bar manager in
206/// [`crate::site`].
207///
208/// Most builders only ever send [`LogEvent::Line`]. Builders that fan out
209/// into parallel sub-tasks (e.g. [`crate::builders::cargo::CargoBuilder`]
210/// compiling for multiple targets simultaneously) additionally use the
211/// `Child*` variants so the UI can show a dedicated spinner per sub-task.
212#[derive(Debug)]
213pub enum LogEvent {
214 /// A plain output line from the top-level builder task.
215 Line(String),
216 /// Register a new child task. The receiver will create a sub-spinner
217 /// immediately below the parent spinner.
218 ChildStart {
219 /// Unique key used to route subsequent [`LogEvent::ChildLine`] and
220 /// [`LogEvent::ChildFinish`] messages.
221 id: String,
222 /// Human-readable label shown in the sub-spinner's prefix.
223 label: String,
224 },
225 /// A log line produced by a specific child task.
226 ChildLine { id: String, line: String },
227 /// Signal that a child task has finished.
228 ChildFinish {
229 id: String,
230 success: bool,
231 /// Short one-line summary displayed as the spinner's final message.
232 summary: String,
233 },
234}
235
236/// A sender used to stream [`LogEvent`]s from a builder back to the
237/// progress-bar manager in [`crate::site`]. Errors sending are silently
238/// ignored because the receiver may already be gone when a builder finishes.
239pub type LogSender = UnboundedSender<LogEvent>;
240
241/// A single `[[builders]]` entry: the builder itself plus optional dependency
242/// metadata.
243///
244/// # Dependency ordering
245///
246/// By default every builder runs concurrently with all others. When a builder
247/// must only start *after* another one has finished successfully, give the
248/// prerequisite an `id` and list that id in the dependent's `depends_on`:
249///
250/// ```toml
251/// [[builders]]
252/// type = "cargo"
253/// id = "compile" # ← give this builder a name
254///
255/// [[builders]]
256/// type = "script"
257/// script = ["strip target/release/my_bin"]
258/// outputs = ["target/release/my_bin"]
259/// depends_on = ["compile"] # ← wait for the builder above
260/// ```
261///
262/// Circular dependencies are detected before any builder starts and reported
263/// as an error.
264#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
265pub struct BuilderEntry {
266 /// The builder's type-specific configuration (type tag + all builder
267 /// fields), serialised flat into the same TOML table.
268 #[serde(flatten)]
269 pub builder: AnyBuilder,
270
271 /// Optional identifier for this builder. Other builders reference this
272 /// string in their `depends_on` list.
273 #[serde(skip_serializing_if = "Option::is_none")]
274 pub id: Option<String>,
275
276 /// IDs of builders that must complete successfully before this one starts.
277 #[serde(default, skip_serializing_if = "Vec::is_empty")]
278 pub depends_on: Vec<String>,
279
280 /// Optional category for grouping artifacts on the release page.
281 /// Artifacts produced by builders with the same `category` are listed
282 /// together under a shared heading.
283 #[serde(skip_serializing_if = "Option::is_none")]
284 pub category: Option<String>,
285
286 /// Optional display name for the artifacts this builder produces.
287 /// When set, it is shown as a heading on the release page, grouping all
288 /// outputs from this builder entry together.
289 #[serde(skip_serializing_if = "Option::is_none")]
290 pub name: Option<String>,
291
292 /// Optional comment or description shown alongside the artifacts on the
293 /// release page. Useful for adding build architecture notes or usage
294 /// instructions.
295 #[serde(skip_serializing_if = "Option::is_none")]
296 pub comment: Option<String>,
297}
298
299impl BuilderEntry {
300 /// Short human-readable label forwarded from the inner builder.
301 pub fn label(&self) -> &'static str {
302 self.builder.label()
303 }
304
305 /// Run the inner builder, forwarding the version string and log sender.
306 /// Each returned [`ArtifactPath`] inherits this entry's `category`,
307 /// `name`, and `comment`.
308 pub async fn build(&self, version: &str, log: LogSender) -> Result<Vec<ArtifactPath>> {
309 let mut artifacts = self.builder.build(version, log).await?;
310 for artifact in &mut artifacts {
311 artifact.category = self.category.clone();
312 artifact.group_name = self.name.clone();
313 artifact.group_comment = self.comment.clone();
314 }
315 Ok(artifacts)
316 }
317}
318
319#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
320#[serde(tag = "type", rename_all = "snake_case")]
321pub enum AnyBuilder {
322 /// Creates a `.tar.gz` archive of the source tree, automatically excluding
323 /// files and directories matched by any `.gitignore` found in the hierarchy.
324 ///
325 /// ```toml
326 /// [[builders]]
327 /// type = "archive"
328 /// source_dir = "." # optional, defaults to CWD
329 /// output = "myproject-1.0.0.tar.gz" # optional, defaults to source.tar.gz
330 /// prefix = "myproject-1.0.0" # optional, defaults to source_dir name
331 /// ```
332 Archive(ArchiveBuilderConfig),
333
334 /// Compiles the crate in release mode with `cargo build --release`.
335 /// One or more target triples can be specified for cross-compilation;
336 /// omitting `targets` builds for the host platform.
337 ///
338 /// Feature flags can be passed with `features` (optionally combined with
339 /// `no_default_features`). The artifact name is suffixed with the feature
340 /// set (e.g. `myapp-1.0-x86_64-full`); customise the suffix with `suffix`.
341 ///
342 /// ```toml
343 /// [[builders]]
344 /// type = "cargo"
345 /// targets = ["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl"]
346 /// manifest_path = "Cargo.toml" # optional
347 /// features = ["full"] # optional
348 /// no_default_features = true # optional
349 /// suffix = "production" # optional, overrides auto suffix
350 /// ```
351 Cargo(CargoBuilderConfig),
352
353 /// Generates API documentation with `cargo doc`.
354 /// Returns the per-crate doc directory (e.g. `target/doc/my_crate`) as an
355 /// artifact so it can be published or archived by a later pipeline step.
356 ///
357 /// ```toml
358 /// [[builders]]
359 /// type = "cargo_doc"
360 /// no_deps = true # optional, skip dependency docs
361 /// manifest_path = "Cargo.toml" # optional
362 /// ```
363 CargoDoc(CargoDocBuilderConfig),
364
365 /// Runs an arbitrary sequence of shell commands and collects declared
366 /// output paths as release artifacts.
367 ///
368 /// Each script line is passed to `sh -c`; the build fails immediately if
369 /// any command exits with a non-zero status.
370 ///
371 /// ```toml
372 /// [[builders]]
373 /// type = "script"
374 /// script = [
375 /// "make release",
376 /// "strip target/mybin",
377 /// ]
378 /// outputs = ["target/mybin"]
379 /// ```
380 Script(ScriptBuilderConfig),
381
382 /// Renders a directory of Markdown files to standalone HTML5 documents.
383 ///
384 /// Every `.md` file is rendered to a mirrored `.html` file in the output
385 /// directory. Non-Markdown files referenced by local links or image
386 /// embeds are copied to the output directory so that relative URLs remain
387 /// valid. Files excluded by `.gitignore` rules are automatically skipped.
388 ///
389 /// ```toml
390 /// [[builders]]
391 /// type = "markdown"
392 /// input = "docs/"
393 /// output = "docs-html/" # optional
394 /// recursive = true # optional, default true
395 /// ```
396 Markdown(MarkdownBuilderConfig),
397}
398
399impl AnyBuilder {
400 /// A short human-readable label used in progress-bar prefixes.
401 pub fn label(&self) -> &'static str {
402 match self {
403 Self::Archive(_) => "archive",
404 Self::Cargo(_) => "cargo-build",
405 Self::CargoDoc(_) => "cargo-doc",
406 Self::Markdown(_) => "markdown",
407 Self::Script(_) => "script",
408 }
409 }
410
411 pub async fn build(&self, version: &str, log: LogSender) -> Result<Vec<ArtifactPath>> {
412 match self {
413 Self::Archive(config) => ArchiveBuilder.build(config.clone(), version, log).await,
414 Self::Cargo(config) => CargoBuilder.build(config.clone(), version, log).await,
415 Self::CargoDoc(config) => CargoDocBuilder.build(config.clone(), version, log).await,
416 Self::Markdown(config) => MarkdownBuilder.build(config.clone(), version, log).await,
417 Self::Script(config) => ScriptBuilder.build(config.clone(), version, log).await,
418 }
419 }
420}
421
422pub struct ArtifactPath {
423 pub path: PathBuf,
424 pub name: String,
425 /// Lowercase hexadecimal SHA1 digest of the artifact's contents, if computed.
426 pub hash: Option<String>,
427 /// Optional category for grouping on the release page, carried over from
428 /// the builder entry that produced this artifact.
429 pub category: Option<String>,
430 /// Optional display name, propagated from the builder entry.
431 pub group_name: Option<String>,
432 /// Optional comment, propagated from the builder entry.
433 pub group_comment: Option<String>,
434}
435
436/// # Implementing a New Builder
437///
438/// This section walks you through adding a brand-new builder type to Abbaye,
439/// from an empty file to a fully working `[[builders]]` entry that users can
440/// drop into their `abbaye.toml`.
441///
442/// The worked example is a **`make` builder** that runs one or more `make`
443/// targets and collects declared output paths as release artifacts.
444///
445/// ## How Builders Plug In
446///
447/// Every builder type is one variant of the [`AnyBuilder`] enum. The enum
448/// acts as the TOML-visible type tag (`type = "make"`) and routes calls down
449/// to the concrete implementation. The [`Builder`] trait is the interface
450/// every implementation must satisfy:
451///
452/// ```text
453/// abbaye.toml ──► AnyBuilder enum ──► BuilderEntry
454/// │
455/// ▼
456/// Builder trait
457/// │
458/// ▼
459/// src/builders/make.rs
460/// ```
461///
462/// ## Step 1 - Create the builder file
463///
464/// Create `src/builders/make.rs`. Start with imports that mirror what the
465/// existing builders use:
466///
467/// ```rust,ignore
468/// use std::{path::PathBuf, process::Stdio};
469///
470/// use miette::{IntoDiagnostic, Result, miette};
471/// use schemars::JsonSchema;
472/// use serde::{Deserialize, Serialize};
473/// use tokio::io::{AsyncBufReadExt, BufReader};
474/// use tokio::process::Command;
475///
476/// use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender};
477/// ```
478///
479/// | Import | Purpose |
480/// |-----------------|-------------------------------------------------------------------|
481/// | `ArtifactPath` | The return type: a path plus display name and optional hash. |
482/// | `Builder` | The trait your struct must implement. |
483/// | `LogEvent` | The structured event type sent over the log channel. |
484/// | `LogSender` | An `mpsc::UnboundedSender<LogEvent>` - your handle to the UI. |
485///
486/// ## Step 2 - Write the config struct
487///
488/// The config struct holds every field that can appear in the TOML table.
489/// It must derive exactly these traits (all are required for the builder
490/// machinery):
491///
492/// ```rust,ignore
493/// /// Configuration for [`MakeBuilder`].
494/// #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
495/// pub struct MakeBuilderConfig {
496/// /// `make` target to build. Defaults to the default target when absent.
497/// pub target: Option<String>,
498///
499/// /// Path to the Makefile. Passed as `-f <path>`.
500/// pub makefile: Option<PathBuf>,
501///
502/// /// Paths of the files produced by `make` to treat as release artifacts.
503/// #[serde(default)]
504/// pub outputs: Vec<PathBuf>,
505/// }
506/// ```
507///
508/// | Derive | Why it is required |
509/// |---------------|-------------------------------------------------------------------|
510/// | `Debug` | Diagnostic printing. |
511/// | `Default` | The [`Builder`] trait bound requires it for the associated type. |
512/// | `Clone` | [`BuilderEntry::clone`] is used when spawning the builder task. |
513/// | `Deserialize` | Figment deserialises the TOML table into this struct. |
514/// | `Serialize` | Needed for `abbaye init` and JSON Schema generation. |
515/// | `JsonSchema` | Powers `abbaye dump-schema` so editors get completion/validation. |
516///
517/// Every public field should have a doc comment - these become the field
518/// descriptions in the generated JSON Schema and appear in editor tooltips.
519///
520/// If `Default` cannot be derived because a field has a non-false/zero/empty
521/// default (e.g. a `bool` that defaults to `true`), implement `Default`
522/// manually and provide a `fn default_fieldname() -> T` free function for the
523/// `#[serde(default = "...")]` attribute. See [`crate::builders::cargo`] for
524/// an example.
525///
526/// ## Step 3 - Implement the `Builder` trait
527///
528/// Define a zero-sized marker struct and implement [`Builder`] for it:
529///
530/// ```rust,ignore
531/// pub struct MakeBuilder;
532///
533/// impl Builder for MakeBuilder {
534/// type ConfigType = MakeBuilderConfig;
535///
536/// async fn build(
537/// &self,
538/// config: Self::ConfigType,
539/// version: &str,
540/// log: LogSender,
541/// ) -> Result<Vec<ArtifactPath>> {
542/// let mut cmd = Command::new("make");
543///
544/// // Always expose the version being built.
545/// cmd.env("ABBAYE_BUILDING_VERSION", version);
546///
547/// if let Some(ref makefile) = config.makefile {
548/// cmd.arg("-f").arg(makefile);
549/// }
550/// if let Some(ref target) = config.target {
551/// cmd.arg(target);
552/// }
553///
554/// let mut child = cmd
555/// .stdout(Stdio::piped())
556/// .stderr(Stdio::piped())
557/// .spawn()
558/// .into_diagnostic()?;
559///
560/// // Stream stdout and stderr concurrently so neither pipe fills up
561/// // and deadlocks the child process.
562/// let stdout = child.stdout.take().expect("stdout was piped");
563/// let stderr = child.stderr.take().expect("stderr was piped");
564/// let log_out = log.clone();
565/// let log_err = log.clone();
566/// let stdout_task = tokio::spawn(async move {
567/// let mut lines = BufReader::new(stdout).lines();
568/// while let Ok(Some(line)) = lines.next_line().await {
569/// let _ = log_out.send(LogEvent::Line(line));
570/// }
571/// });
572/// let stderr_task = tokio::spawn(async move {
573/// let mut lines = BufReader::new(stderr).lines();
574/// while let Ok(Some(line)) = lines.next_line().await {
575/// let _ = log_err.send(LogEvent::Line(line));
576/// }
577/// });
578///
579/// // Drain I/O tasks *before* checking the exit code to avoid
580/// // losing the last lines of output.
581/// let status = child.wait().await.into_diagnostic()?;
582/// let _ = tokio::join!(stdout_task, stderr_task);
583///
584/// if !status.success() {
585/// return Err(miette!(
586/// "make failed with exit status: {}",
587/// status.code().unwrap_or(-1)
588/// ));
589/// }
590///
591/// // Collect declared outputs as artifacts.
592/// let mut artifacts = Vec::new();
593/// for output in &config.outputs {
594/// if !output.exists() {
595/// return Err(miette!(
596/// "declared output does not exist after make: {}",
597/// output.display()
598/// ));
599/// }
600/// let name = output
601/// .file_name()
602/// .ok_or_else(|| miette!("output path has no file name"))?
603/// .to_string_lossy()
604/// .into_owned();
605/// artifacts.push(ArtifactPath { path: output.clone(), name, hash: None });
606/// }
607/// Ok(artifacts)
608/// }
609/// }
610/// ```
611///
612/// ### Key conventions
613///
614/// **Always set `ABBAYE_BUILDING_VERSION`.** Every builder must expose the
615/// version being built through this environment variable so that build scripts
616/// can embed it without extra plumbing.
617///
618/// **Always drain I/O tasks before checking the exit status.** If you
619/// `.await` the child first, the subprocess's pipes may fill up and cause it
620/// to block forever before it can exit.
621///
622/// **Errors from `log.send(…)` are silently ignored** (`let _ = …`). The
623/// receiver may already be gone by the time a builder finishes - that is
624/// expected and harmless.
625///
626/// **File artifacts → `dist/`, directory artifacts → `docs/`.** The
627/// orchestrator in `site.rs` distinguishes them by `path.is_dir()`. Return
628/// a directory path only for documentation trees.
629///
630/// **CPU-bound or blocking I/O must use `spawn_blocking`.** Tokio runs on a
631/// small thread pool; blocking it starves other tasks.
632///
633/// ## Step 4 - Register in `mod.rs`
634///
635/// Open `src/builders/mod.rs` and make four additions.
636///
637/// **4a - Declare the module:**
638///
639/// ```rust,ignore
640/// pub mod make;
641/// ```
642///
643/// **4b - Import the types:**
644///
645/// ```rust,ignore
646/// use make::{MakeBuilder, MakeBuilderConfig};
647/// ```
648///
649/// **4c - Add a variant to [`AnyBuilder`]** with a doc comment containing a
650/// canonical TOML usage example (this becomes the JSON Schema description):
651///
652/// ```rust,ignore
653/// /// Runs `make [target]` and collects declared output paths as artifacts.
654/// ///
655/// /// ```toml
656/// /// [[builders]]
657/// /// type = "make"
658/// /// target = "release" # optional
659/// /// outputs = ["dist/mybin"]
660/// /// ```
661/// Make(MakeBuilderConfig),
662/// ```
663///
664/// The variant name is lowercased by `rename_all = "snake_case"`, so `Make`
665/// becomes `type = "make"` in TOML. Multi-word names use `CamelCase` in
666/// Rust and become `snake_case` in TOML (e.g. `WasmPack` → `wasm_pack`).
667///
668/// **4d - Wire up `label()` and `build()`:**
669///
670/// ```rust,ignore
671/// Self::Make(_) => "make", // in label()
672/// Self::Make(c) => MakeBuilder.build(c.clone(), version, log).await, // in build()
673/// ```
674///
675/// `label()` provides the default spinner prefix when the user hasn't given
676/// the builder an `id`. Keep it short and lowercase-kebab.
677///
678/// ## Step 5 - Verify
679///
680/// ```sh
681/// cargo build
682/// cargo run -- dump-schema | python3 -m json.tool | grep -A 20 '"make"'
683/// ```
684///
685/// ## Step 6 - Use it in `abbaye.toml`
686///
687/// ```toml
688/// [[builders]]
689/// type = "make"
690/// target = "release"
691/// outputs = ["build/myprogram"]
692///
693/// # With dependency ordering:
694/// [[builders]]
695/// type = "make"
696/// id = "make-release"
697/// target = "release"
698/// outputs = ["build/myprogram"]
699///
700/// [[builders]]
701/// type = "script"
702/// script = ["upx build/myprogram"]
703/// outputs = ["build/myprogram"]
704/// depends_on = ["make-release"]
705/// ```
706///
707/// ## Advanced - Parallel sub-tasks with child spinners
708///
709/// If your builder naturally fans out into independent sub-tasks (like the
710/// `cargo` builder running one `cargo build` per target triple), you can run
711/// them in parallel and give each its own sub-spinner in the UI.
712///
713/// The pattern is always the same:
714///
715/// 1. Send [`LogEvent::ChildStart`] `{ id, label }` to create the sub-spinner.
716/// 2. Forward output lines as [`LogEvent::ChildLine`] `{ id, line }`.
717/// 3. Send [`LogEvent::ChildFinish`] `{ id, success, summary }` to close it.
718///
719/// ```rust,ignore
720/// join_set.spawn(async move {
721/// let _ = log.send(LogEvent::ChildStart {
722/// id: target.clone(),
723/// label: target.clone(),
724/// });
725///
726/// // ... do work, sending ChildLine events ...
727///
728/// let _ = log.send(LogEvent::ChildFinish {
729/// id: target.clone(),
730/// success: result.is_ok(),
731/// summary: "done".to_owned(),
732/// });
733/// });
734/// ```
735///
736/// The `line_bridge` helper in [`crate::builders::cargo`] is a reusable
737/// pattern for adapting a plain-string sender to structured [`LogEvent`]s -
738/// feel free to copy or generalise it.
739///
740/// ## Reviewer checklist
741///
742/// - Config struct derives `Debug`, `Default` (or has a manual impl),
743/// `Clone`, `Deserialize`, `Serialize`, `JsonSchema`.
744/// - Every public config field has a doc comment.
745/// - The builder sets `ABBAYE_BUILDING_VERSION` in every subprocess
746/// environment.
747/// - Stdout and stderr are consumed concurrently to prevent pipe deadlocks.
748/// - Log send errors are silently ignored (`let _ = log.send(…)`).
749/// - Blocking I/O uses `tokio::task::spawn_blocking`.
750/// - Both `label()` and `build()` arms are added to [`AnyBuilder`].
751/// - `pub mod` declaration and `use` imports are present in `mod.rs`.
752/// - `cargo build` succeeds with no new warnings.
753/// - `abbaye dump-schema` shows the new variant and all its fields.
754#[allow(async_fn_in_trait)]
755pub trait Builder {
756 type ConfigType: Default + for<'de> Deserialize<'de> + Clone;
757
758 /// Run the builder and return the produced artifacts.
759 ///
760 /// `version` is the abbaye release version currently being built (e.g.
761 /// `"1.2.3"`). Implementations that spawn subprocesses must expose it as
762 /// the `ABBAYE_BUILDING_VERSION` environment variable.
763 ///
764 /// `log` receives one-line status/output messages from the builder's
765 /// subprocesses. Lines are sent without a trailing newline. The caller
766 /// displays these in the UI (e.g. as a spinner message); errors sending
767 /// are ignored because the receiver may already be closed.
768 async fn build(
769 &self,
770 config: Self::ConfigType,
771 version: &str,
772 log: LogSender,
773 ) -> Result<Vec<ArtifactPath>>;
774}