Skip to main content

Builder

Trait Builder 

Source
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};
ImportPurpose
ArtifactPathThe return type: a path plus display name and optional hash.
BuilderThe trait your struct must implement.
LogEventThe structured event type sent over the log channel.
LogSenderAn 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>,
}
DeriveWhy it is required
DebugDiagnostic printing.
DefaultThe Builder trait bound requires it for the associated type.
CloneBuilderEntry::clone is used when spawning the builder task.
DeserializeFigment deserialises the TOML table into this struct.
SerializeNeeded for abbaye init and JSON Schema generation.
JsonSchemaPowers 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. WasmPackwasm_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:

  1. Send LogEvent::ChildStart { id, label } to create the sub-spinner.
  2. Forward output lines as LogEvent::ChildLine { id, line }.
  3. 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_VERSION in 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() and build() arms are added to AnyBuilder.
  • pub mod declaration and use imports are present in mod.rs.
  • cargo build succeeds with no new warnings.
  • abbaye dump-schema shows the new variant and all its fields.

Required Associated Types§

Source

type ConfigType: Default + for<'de> Deserialize<'de> + Clone

Required Methods§

Source

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.

Implementors§