pub trait Builder {
type ConfigType: Default + for<'de> Deserialize<'de> + Clone;
// Required method
async fn build(
&self,
config: Self::ConfigType,
version: &str,
log: LogSender,
) -> Result<Vec<ArtifactPath>>;
}Expand description
§Implementing a New Builder
This section walks you through adding a brand-new builder type to Abbaye,
from an empty file to a fully working [[builders]] entry that users can
drop into their abbaye.toml.
The worked example is a make builder that runs one or more make
targets and collects declared output paths as release artifacts.
§How Builders Plug In
Every builder type is one variant of the AnyBuilder enum. The enum
acts as the TOML-visible type tag (type = "make") and routes calls down
to the concrete implementation. The Builder trait is the interface
every implementation must satisfy:
abbaye.toml ──► AnyBuilder enum ──► BuilderEntry
│
▼
Builder trait
│
▼
src/builders/make.rs§Step 1 — Create the builder file
Create src/builders/make.rs. Start with imports that mirror what the
existing builders use:
use std::{path::PathBuf, process::Stdio};
use miette::{IntoDiagnostic, Result, miette};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender};| Import | Purpose |
|---|---|
ArtifactPath | The return type: a path plus display name and optional hash. |
Builder | The trait your struct must implement. |
LogEvent | The structured event type sent over the log channel. |
LogSender | An mpsc::UnboundedSender<LogEvent> — your handle to the UI. |
§Step 2 — Write the config struct
The config struct holds every field that can appear in the TOML table. It must derive exactly these traits (all are required for the builder machinery):
/// Configuration for [`MakeBuilder`].
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
pub struct MakeBuilderConfig {
/// `make` target to build. Defaults to the default target when absent.
pub target: Option<String>,
/// Path to the Makefile. Passed as `-f <path>`.
pub makefile: Option<PathBuf>,
/// Paths of the files produced by `make` to treat as release artifacts.
#[serde(default)]
pub outputs: Vec<PathBuf>,
}| Derive | Why it is required |
|---|---|
Debug | Diagnostic printing. |
Default | The Builder trait bound requires it for the associated type. |
Clone | BuilderEntry::clone is used when spawning the builder task. |
Deserialize | Figment deserialises the TOML table into this struct. |
Serialize | Needed for abbaye init and JSON Schema generation. |
JsonSchema | Powers abbaye dump-schema so editors get completion/validation. |
Every public field should have a doc comment — these become the field descriptions in the generated JSON Schema and appear in editor tooltips.
If Default cannot be derived because a field has a non-false/zero/empty
default (e.g. a bool that defaults to true), implement Default
manually and provide a fn default_fieldname() -> T free function for the
#[serde(default = "...")] attribute. See crate::builders::cargo for
an example.
§Step 3 — Implement the Builder trait
Define a zero-sized marker struct and implement Builder for it:
pub struct MakeBuilder;
impl Builder for MakeBuilder {
type ConfigType = MakeBuilderConfig;
async fn build(
&self,
config: Self::ConfigType,
version: &str,
log: LogSender,
) -> Result<Vec<ArtifactPath>> {
let mut cmd = Command::new("make");
// Always expose the version being built.
cmd.env("ABBAYE_BUILDING_VERSION", version);
if let Some(ref makefile) = config.makefile {
cmd.arg("-f").arg(makefile);
}
if let Some(ref target) = config.target {
cmd.arg(target);
}
let mut child = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.into_diagnostic()?;
// Stream stdout and stderr concurrently so neither pipe fills up
// and deadlocks the child process.
let stdout = child.stdout.take().expect("stdout was piped");
let stderr = child.stderr.take().expect("stderr was piped");
let log_out = log.clone();
let log_err = log.clone();
let stdout_task = tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = log_out.send(LogEvent::Line(line));
}
});
let stderr_task = tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = log_err.send(LogEvent::Line(line));
}
});
// Drain I/O tasks *before* checking the exit code to avoid
// losing the last lines of output.
let status = child.wait().await.into_diagnostic()?;
let _ = tokio::join!(stdout_task, stderr_task);
if !status.success() {
return Err(miette!(
"make failed with exit status: {}",
status.code().unwrap_or(-1)
));
}
// Collect declared outputs as artifacts.
let mut artifacts = Vec::new();
for output in &config.outputs {
if !output.exists() {
return Err(miette!(
"declared output does not exist after make: {}",
output.display()
));
}
let name = output
.file_name()
.ok_or_else(|| miette!("output path has no file name"))?
.to_string_lossy()
.into_owned();
artifacts.push(ArtifactPath { path: output.clone(), name, hash: None });
}
Ok(artifacts)
}
}§Key conventions
Always set ABBAYE_BUILDING_VERSION. Every builder must expose the
version being built through this environment variable so that build scripts
can embed it without extra plumbing.
Always drain I/O tasks before checking the exit status. If you
.await the child first, the subprocess’s pipes may fill up and cause it
to block forever before it can exit.
Errors from log.send(…) are silently ignored (let _ = …). The
receiver may already be gone by the time a builder finishes — that is
expected and harmless.
File artifacts → dist/, directory artifacts → docs/. The
orchestrator in site.rs distinguishes them by path.is_dir(). Return
a directory path only for documentation trees.
CPU-bound or blocking I/O must use spawn_blocking. Tokio runs on a
small thread pool; blocking it starves other tasks.
§Step 4 — Register in mod.rs
Open src/builders/mod.rs and make four additions.
4a — Declare the module:
pub mod make;4b — Import the types:
use make::{MakeBuilder, MakeBuilderConfig};4c — Add a variant to AnyBuilder with a doc comment containing a
canonical TOML usage example (this becomes the JSON Schema description):
/// Runs `make [target]` and collects declared output paths as artifacts.
///
/// ```toml
/// [[builders]]
/// type = "make"
/// target = "release" # optional
/// outputs = ["dist/mybin"]
/// ```
Make(MakeBuilderConfig),The variant name is lowercased by rename_all = "snake_case", so Make
becomes type = "make" in TOML. Multi-word names use CamelCase in
Rust and become snake_case in TOML (e.g. WasmPack → wasm_pack).
4d — Wire up label() and build():
Self::Make(_) => "make", // in label()
Self::Make(c) => MakeBuilder.build(c.clone(), version, log).await, // in build()label() provides the default spinner prefix when the user hasn’t given
the builder an id. Keep it short and lowercase-kebab.
§Step 5 — Verify
cargo build
cargo run -- dump-schema | python3 -m json.tool | grep -A 20 '"make"'§Step 6 — Use it in abbaye.toml
[[builders]]
type = "make"
target = "release"
outputs = ["build/myprogram"]
# With dependency ordering:
[[builders]]
type = "make"
id = "make-release"
target = "release"
outputs = ["build/myprogram"]
[[builders]]
type = "script"
script = ["upx build/myprogram"]
outputs = ["build/myprogram"]
depends_on = ["make-release"]§Advanced — Parallel sub-tasks with child spinners
If your builder naturally fans out into independent sub-tasks (like the
cargo builder running one cargo build per target triple), you can run
them in parallel and give each its own sub-spinner in the UI.
The pattern is always the same:
- Send
LogEvent::ChildStart{ id, label }to create the sub-spinner. - Forward output lines as
LogEvent::ChildLine{ id, line }. - Send
LogEvent::ChildFinish{ id, success, summary }to close it.
join_set.spawn(async move {
let _ = log.send(LogEvent::ChildStart {
id: target.clone(),
label: target.clone(),
});
// ... do work, sending ChildLine events ...
let _ = log.send(LogEvent::ChildFinish {
id: target.clone(),
success: result.is_ok(),
summary: "done".to_owned(),
});
});The line_bridge helper in crate::builders::cargo is a reusable
pattern for adapting a plain-string sender to structured LogEvents —
feel free to copy or generalise it.
§Reviewer checklist
- Config struct derives
Debug,Default(or has a manual impl),Clone,Deserialize,Serialize,JsonSchema. - Every public config field has a doc comment.
- The builder sets
ABBAYE_BUILDING_VERSIONin every subprocess environment. - Stdout and stderr are consumed concurrently to prevent pipe deadlocks.
- Log send errors are silently ignored (
let _ = log.send(…)). - Blocking I/O uses
tokio::task::spawn_blocking. - Both
label()andbuild()arms are added toAnyBuilder. pub moddeclaration anduseimports are present inmod.rs.cargo buildsucceeds with no new warnings.abbaye dump-schemashows the new variant and all its fields.
Required Associated Types§
type ConfigType: Default + for<'de> Deserialize<'de> + Clone
Required Methods§
Sourceasync fn build(
&self,
config: Self::ConfigType,
version: &str,
log: LogSender,
) -> Result<Vec<ArtifactPath>>
async fn build( &self, config: Self::ConfigType, version: &str, log: LogSender, ) -> Result<Vec<ArtifactPath>>
Run the builder and return the produced artifacts.
version is the abbaye release version currently being built (e.g.
"1.2.3"). Implementations that spawn subprocesses must expose it as
the ABBAYE_BUILDING_VERSION environment variable.
log receives one-line status/output messages from the builder’s
subprocesses. Lines are sent without a trailing newline. The caller
displays these in the UI (e.g. as a spinner message); errors sending
are ignored because the receiver may already be closed.
Dyn Compatibility§
This trait is not dyn compatible.
In older versions of Rust, dyn compatibility was called "object safety", so this trait is not object safe.