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