Skip to main content

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