Commit
Message
Changed Files (11)
-
modified Cargo.lock
diff --git a/Cargo.lock b/Cargo.lock index 36abd60..2bda91f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,7 @@ dependencies = [ "globset", "human-panic", "ignore", + "indicatif", "miette", "pulldown-cmark", "schemars", @@ -122,7 +123,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -133,7 +134,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -324,6 +325,19 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -405,6 +419,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -418,7 +438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -668,6 +688,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "tokio", + "unicode-width 0.2.2", + "web-time", +] + [[package]] name = "inlinable_string" version = "0.1.15" @@ -813,7 +847,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -831,7 +865,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -843,6 +877,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -1027,6 +1067,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1206,7 +1252,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1410,7 +1456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1504,7 +1550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1560,7 +1606,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1940,6 +1986,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1962,7 +2018,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2083,6 +2139,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" -
modified Cargo.toml
diff --git a/Cargo.toml b/Cargo.toml index ddf5f40..2b1d4cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ flate2 = "1" globset = "0.4" human-panic = "2.0.5" ignore = "0.4" +indicatif = { version = "0.17", features = ["tokio"] } miette = { version = "7.6.0", features = ["fancy", "serde"] } pulldown-cmark = "0.12" schemars = "1" -
modified abbaye.schema.json
diff --git a/abbaye.schema.json b/abbaye.schema.json index c17f0cd..08a42be 100644 --- a/abbaye.schema.json +++ b/abbaye.schema.json @@ -8,7 +8,7 @@ "description": "Builders to run during the build process.", "type": "array", "items": { - "$ref": "#/definitions/AnyBuilder" + "$ref": "#/definitions/BuilderEntry" } }, "changelog": { @@ -51,20 +51,20 @@ "builders" ], "definitions": { - "AnyBuilder": { + "AnyVersionExtractor": { "oneOf": [ { - "description": "Creates a `.tar.gz` archive of the source tree, automatically excluding\nfiles and directories matched by any `.gitignore` found in the hierarchy.\n\n```toml\n[[builders]]\ntype = \"archive\"\nsource_dir = \".\" # optional, defaults to CWD\noutput = \"myproject-1.0.0.tar.gz\" # optional, defaults to source.tar.gz\nprefix = \"myproject-1.0.0\" # optional, defaults to source_dir name\n```", + "description": "Reads the version from the `version` field in the `[package]` section\nof a `Cargo.toml` file.\n\n```toml\n[version_extractor]\ntype = \"cargo\"\nmanifest_path = \"Cargo.toml\" # optional, defaults to ./Cargo.toml\n```", "type": "object", "properties": { "type": { "type": "string", - "const": "archive" + "const": "cargo" } }, "allOf": [ { - "$ref": "#/definitions/ArchiveBuilderConfig" + "$ref": "#/definitions/CargoVersionConfig" } ], "required": [ @@ -72,35 +72,95 @@ ] }, { - "description": "Compiles the crate in release mode with `cargo build --release`.\nOne or more target triples can be specified for cross-compilation;\nomitting `targets` builds for the host platform.\n\n```toml\n[[builders]]\ntype = \"cargo\"\ntargets = [\"x86_64-unknown-linux-musl\", \"aarch64-unknown-linux-musl\"]\nmanifest_path = \"Cargo.toml\" # optional\n```", + "description": "Derives the version from the most recent Git tag using\n`git describe --tags --always`.\nSupports stripping a tag prefix (e.g. `\"v\"`) and customising the\nsuffix appended when the working tree has uncommitted changes.\n\n```toml\n[version_extractor]\ntype = \"git\"\ntag_prefix = \"v\" # optional, strips leading \"v\"\ndirty_suffix = \"-dev\" # optional, defaults to \"-dirty\"\n```", "type": "object", "properties": { "type": { "type": "string", - "const": "cargo" + "const": "git" } }, "allOf": [ { - "$ref": "#/definitions/CargoBuilderConfig" + "$ref": "#/definitions/GitVersionConfig" } ], "required": [ "type" ] + } + ] + }, + "ArchiveBuilderConfig": { + "description": "Configuration for [`ArchiveBuilder`].", + "type": "object", + "properties": { + "ignore_patterns": { + "description": "Glob patterns for files and directories to exclude from the archive.\nEach pattern is matched against every component of a path, so a pattern\nlike `\".git\"` excludes the `.git` directory and all its contents, and\n`\"*.local\"` excludes any entry whose name ends with `.local`.\nDefaults to `[\".git\", \"*.local\"]`.", + "type": "array", + "default": [ + ".git", + "*.local" + ], + "items": { + "type": "string" + } + }, + "output": { + "description": "Output path for the generated `.tar.gz` archive.\nDefaults to `source.tar.gz` in the current working directory.", + "type": [ + "string", + "null" + ] + }, + "prefix": { + "description": "Prefix applied to every entry path inside the archive.\nFor example, `\"myproject-1.0.0\"` produces entries like\n`myproject-1.0.0/src/main.rs`.\nDefaults to the source directory's name.", + "type": [ + "string", + "null" + ] + }, + "source_dir": { + "description": "Root directory to archive. Defaults to the current working directory.", + "type": [ + "string", + "null" + ] + } + } + }, + "BuilderEntry": { + "description": "A single `[[builders]]` entry: the builder itself plus optional dependency\nmetadata.\n\n# Dependency ordering\n\nBy default every builder runs concurrently with all others. When a builder\nmust only start *after* another one has finished successfully, give the\nprerequisite an `id` and list that id in the dependent's `depends_on`:\n\n```toml\n[[builders]]\ntype = \"cargo\"\nid = \"compile\" # ← give this builder a name\n\n[[builders]]\ntype = \"script\"\nscript = [\"strip target/release/my_bin\"]\noutputs = [\"target/release/my_bin\"]\ndepends_on = [\"compile\"] # ← wait for the builder above\n```\n\nCircular dependencies are detected before any builder starts and reported\nas an error.", + "type": "object", + "properties": { + "depends_on": { + "description": "IDs of builders that must complete successfully before this one starts.", + "type": "array", + "items": { + "type": "string" + } }, + "id": { + "description": "Optional identifier for this builder. Other builders reference this\nstring in their `depends_on` list.", + "type": [ + "string", + "null" + ] + } + }, + "oneOf": [ { - "description": "Generates API documentation with `cargo doc`.\nReturns the per-crate doc directory (e.g. `target/doc/my_crate`) as an\nartifact so it can be published or archived by a later pipeline step.\n\n```toml\n[[builders]]\ntype = \"cargo_doc\"\nno_deps = true # optional, skip dependency docs\nmanifest_path = \"Cargo.toml\" # optional\n```", + "description": "Creates a `.tar.gz` archive of the source tree, automatically excluding\nfiles and directories matched by any `.gitignore` found in the hierarchy.\n\n```toml\n[[builders]]\ntype = \"archive\"\nsource_dir = \".\" # optional, defaults to CWD\noutput = \"myproject-1.0.0.tar.gz\" # optional, defaults to source.tar.gz\nprefix = \"myproject-1.0.0\" # optional, defaults to source_dir name\n```", "type": "object", "properties": { "type": { "type": "string", - "const": "cargo_doc" + "const": "archive" } }, "allOf": [ { - "$ref": "#/definitions/CargoDocBuilderConfig" + "$ref": "#/definitions/ArchiveBuilderConfig" } ], "required": [ @@ -108,39 +168,35 @@ ] }, { - "description": "Runs an arbitrary sequence of shell commands and collects declared\noutput paths as release artifacts.\n\nEach script line is passed to `sh -c`; the build fails immediately if\nany command exits with a non-zero status.\n\n```toml\n[[builders]]\ntype = \"script\"\nscript = [\n \"make release\",\n \"strip target/mybin\",\n]\noutputs = [\"target/mybin\"]\n```", + "description": "Compiles the crate in release mode with `cargo build --release`.\nOne or more target triples can be specified for cross-compilation;\nomitting `targets` builds for the host platform.\n\n```toml\n[[builders]]\ntype = \"cargo\"\ntargets = [\"x86_64-unknown-linux-musl\", \"aarch64-unknown-linux-musl\"]\nmanifest_path = \"Cargo.toml\" # optional\n```", "type": "object", "properties": { "type": { "type": "string", - "const": "script" + "const": "cargo" } }, "allOf": [ { - "$ref": "#/definitions/ScriptBuilderConfig" + "$ref": "#/definitions/CargoBuilderConfig" } ], "required": [ "type" ] - } - ] - }, - "AnyVersionExtractor": { - "oneOf": [ + }, { - "description": "Reads the version from the `version` field in the `[package]` section\nof a `Cargo.toml` file.\n\n```toml\n[version_extractor]\ntype = \"cargo\"\nmanifest_path = \"Cargo.toml\" # optional, defaults to ./Cargo.toml\n```", + "description": "Generates API documentation with `cargo doc`.\nReturns the per-crate doc directory (e.g. `target/doc/my_crate`) as an\nartifact so it can be published or archived by a later pipeline step.\n\n```toml\n[[builders]]\ntype = \"cargo_doc\"\nno_deps = true # optional, skip dependency docs\nmanifest_path = \"Cargo.toml\" # optional\n```", "type": "object", "properties": { "type": { "type": "string", - "const": "cargo" + "const": "cargo_doc" } }, "allOf": [ { - "$ref": "#/definitions/CargoVersionConfig" + "$ref": "#/definitions/CargoDocBuilderConfig" } ], "required": [ @@ -148,17 +204,17 @@ ] }, { - "description": "Derives the version from the most recent Git tag using\n`git describe --tags --always`.\nSupports stripping a tag prefix (e.g. `\"v\"`) and customising the\nsuffix appended when the working tree has uncommitted changes.\n\n```toml\n[version_extractor]\ntype = \"git\"\ntag_prefix = \"v\" # optional, strips leading \"v\"\ndirty_suffix = \"-dev\" # optional, defaults to \"-dirty\"\n```", + "description": "Runs an arbitrary sequence of shell commands and collects declared\noutput paths as release artifacts.\n\nEach script line is passed to `sh -c`; the build fails immediately if\nany command exits with a non-zero status.\n\n```toml\n[[builders]]\ntype = \"script\"\nscript = [\n \"make release\",\n \"strip target/mybin\",\n]\noutputs = [\"target/mybin\"]\n```", "type": "object", "properties": { "type": { "type": "string", - "const": "git" + "const": "script" } }, "allOf": [ { - "$ref": "#/definitions/GitVersionConfig" + "$ref": "#/definitions/ScriptBuilderConfig" } ], "required": [ @@ -167,44 +223,6 @@ } ] }, - "ArchiveBuilderConfig": { - "description": "Configuration for [`ArchiveBuilder`].", - "type": "object", - "properties": { - "ignore_patterns": { - "description": "Glob patterns for files and directories to exclude from the archive.\nEach pattern is matched against every component of a path, so a pattern\nlike `\".git\"` excludes the `.git` directory and all its contents, and\n`\"*.local\"` excludes any entry whose name ends with `.local`.\nDefaults to `[\".git\", \"*.local\"]`.", - "type": "array", - "default": [ - ".git", - "*.local" - ], - "items": { - "type": "string" - } - }, - "output": { - "description": "Output path for the generated `.tar.gz` archive.\nDefaults to `source.tar.gz` in the current working directory.", - "type": [ - "string", - "null" - ] - }, - "prefix": { - "description": "Prefix applied to every entry path inside the archive.\nFor example, `\"myproject-1.0.0\"` produces entries like\n`myproject-1.0.0/src/main.rs`.\nDefaults to the source directory's name.", - "type": [ - "string", - "null" - ] - }, - "source_dir": { - "description": "Root directory to archive. Defaults to the current working directory.", - "type": [ - "string", - "null" - ] - } - } - }, "CargoBuilderConfig": { "description": "Configuration for [`CargoBuilder`].", "type": "object", -
modified abbaye.toml
diff --git a/abbaye.toml b/abbaye.toml index 7532457..01c83f9 100644 --- a/abbaye.toml +++ b/abbaye.toml @@ -25,6 +25,7 @@ type = "cargo_doc" no_deps = true [[builders]] +id = "generate schema" type = "script" script = [ "mkdir -p target", @@ -33,4 +34,6 @@ script = [ outputs = ["target/abbaye.schema.json"] [[builders]] +id = "archive source" type = "archive" # creates a compressed tarball of the source code (can be of anything, really) +output = "target/abbaye-source.tar.gz" -
modified src/builders/archive.rs
diff --git a/src/builders/archive.rs b/src/builders/archive.rs index 19b3c70..84f82f3 100644 --- a/src/builders/archive.rs +++ b/src/builders/archive.rs @@ -10,7 +10,7 @@ use miette::{IntoDiagnostic, Result}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::builders::{ArtifactPath, Builder}; +use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender}; fn default_ignore_patterns() -> Vec<String> { vec![".git".to_owned(), "*.local".to_owned()] @@ -59,7 +59,12 @@ pub struct ArchiveBuilder; impl Builder for ArchiveBuilder { type ConfigType = ArchiveBuilderConfig; - async fn build(&self, config: Self::ConfigType, _version: &str) -> Result<Vec<ArtifactPath>> { + async fn build( + &self, + config: Self::ConfigType, + _version: &str, + log: LogSender, + ) -> Result<Vec<ArtifactPath>> { let source_dir = config .source_dir .unwrap_or_else(|| PathBuf::from(".")) @@ -79,11 +84,20 @@ impl Builder for ArchiveBuilder { let ignore_set = build_ignore_set(&config.ignore_patterns)?; + let _ = log.send(LogEvent::Line(format!( + "archiving {} → {}", + source_dir.display(), + output.display() + ))); let archive_path = tokio::task::spawn_blocking(move || { create_archive(&source_dir, &output, &prefix, &ignore_set) }) .await .into_diagnostic()??; + let _ = log.send(LogEvent::Line(format!( + "archive written: {}", + archive_path.display() + ))); let name = archive_path .file_name() -
modified src/builders/cargo.rs
diff --git a/src/builders/cargo.rs b/src/builders/cargo.rs index db94d87..4454e82 100644 --- a/src/builders/cargo.rs +++ b/src/builders/cargo.rs @@ -8,8 +8,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; +use tokio::sync::mpsc::UnboundedSender; -use crate::builders::{ArtifactPath, Builder}; +use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender}; /// Configuration for [`CargoBuilder`]. #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)] @@ -52,30 +53,99 @@ impl Builder for CargoBuilder { &self, config: Self::ConfigType, abbaye_version: &str, + log: LogSender, ) -> Result<Vec<ArtifactPath>> { let crate_version = read_crate_version(config.manifest_path.as_deref()).await?; if config.targets.is_empty() { + // Single host target: forward stderr lines as plain LogEvent::Line events. let host = get_host_target().await?; - run_cargo_build(&config, None, &host, &crate_version, abbaye_version).await + let line_tx = line_bridge(log, LogEvent::Line); + run_cargo_build( + &config, + None, + &host, + &crate_version, + abbaye_version, + line_tx, + ) + .await } else { - let mut all_artifacts = Vec::new(); + // Multiple targets: each runs in its own task and gets its own + // child spinner in the UI via ChildStart / ChildLine / ChildFinish. + let mut join_set = tokio::task::JoinSet::new(); + for target in &config.targets { - let artifacts = run_cargo_build( - &config, - Some(target.as_str()), - target, - &crate_version, - abbaye_version, - ) - .await?; - all_artifacts.extend(artifacts); + let config = config.clone(); + let crate_version = crate_version.clone(); + let abbaye_version = abbaye_version.to_owned(); + let target = target.clone(); + let log = log.clone(); + + join_set.spawn(async move { + // Announce this target as a child task. + let _ = log.send(LogEvent::ChildStart { + id: target.clone(), + label: target.clone(), + }); + + // Bridge: run_cargo_build emits plain Strings; forward + // them as ChildLine events on the parent LogSender. + let target_id = target.clone(); + let line_tx = line_bridge(log.clone(), move |l| LogEvent::ChildLine { + id: target_id.clone(), + line: l, + }); + + let result = run_cargo_build( + &config, + Some(target.as_str()), + &target, + &crate_version, + &abbaye_version, + line_tx, + ) + .await; + + let _ = log.send(LogEvent::ChildFinish { + id: target.clone(), + success: result.is_ok(), + summary: match &result { + Ok(artifacts) => format!("{} artifact(s)", artifacts.len()), + Err(e) => e.to_string(), + }, + }); + + result + }); + } + + let mut all_artifacts = Vec::new(); + while let Some(res) = join_set.join_next().await { + all_artifacts.extend(res.into_diagnostic()??); } Ok(all_artifacts) } } } +/// Creates a plain-string sender whose lines are mapped through `f` and +/// forwarded to `log`. This lets `run_cargo_build` (which only knows about +/// strings) feed into the structured [`LogSender`] without depending on +/// [`LogEvent`] directly. +fn line_bridge( + log: LogSender, + f: impl Fn(String) -> LogEvent + Send + 'static, +) -> UnboundedSender<String> { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>(); + tokio::spawn(async move { + while let Some(line) = rx.recv().await { + let _ = log.send(f(line)); + } + }); + tx +} + /// Minimal representation of the JSON messages emitted by /// `cargo build --message-format=json`. #[derive(Deserialize)] @@ -97,12 +167,16 @@ struct CargoMessageTarget { /// Spawn `cargo build --release --message-format=json [--target <triple>] /// [--manifest-path <path>]` and collect every artifact path from the /// `compiler-artifact` messages. +/// +/// Stderr lines are forwarded to `line_tx` as plain strings; the caller is +/// responsible for mapping them to the appropriate [`LogEvent`] variant. async fn run_cargo_build( config: &CargoBuilderConfig, target: Option<&str>, triple: &str, version: &str, abbaye_version: &str, + line_tx: UnboundedSender<String>, ) -> Result<Vec<ArtifactPath>> { let mut cmd = Command::new("cargo"); cmd.args(["build", "--release", "--message-format=json"]); @@ -118,10 +192,20 @@ async fn run_cargo_build( let mut child = cmd .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) // let cargo's human-readable errors reach the terminal + .stderr(Stdio::piped()) .spawn() .into_diagnostic()?; + // Forward stderr lines to the caller's line sender concurrently with + // JSON stdout parsing. + let stderr = child.stderr.take().expect("stderr was piped"); + tokio::spawn(async move { + let mut stderr_lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = stderr_lines.next_line().await { + let _ = line_tx.send(line); + } + }); + let stdout = child.stdout.take().expect("stdout was piped"); let mut lines = BufReader::new(stdout).lines(); @@ -269,6 +353,7 @@ impl Builder for CargoDocBuilder { &self, config: Self::ConfigType, abbaye_version: &str, + log: LogSender, ) -> Result<Vec<ArtifactPath>> { let mut cmd = Command::new("cargo"); cmd.arg("doc"); @@ -282,11 +367,17 @@ impl Builder for CargoDocBuilder { cmd.arg("--manifest-path").arg(manifest); } - let status = cmd - .stderr(Stdio::inherit()) - .status() - .await - .into_diagnostic()?; + let mut child = cmd.stderr(Stdio::piped()).spawn().into_diagnostic()?; + + let stderr = child.stderr.take().expect("stderr was piped"); + tokio::spawn(async move { + let mut stderr_lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = stderr_lines.next_line().await { + let _ = log.send(LogEvent::Line(line)); + } + }); + + let status = child.wait().await.into_diagnostic()?; if !status.success() { return Err(miette!("cargo doc failed with exit status: {status}")); -
modified src/builders/mod.rs
diff --git a/src/builders/mod.rs b/src/builders/mod.rs index a4941ea..b0d2794 100644 --- a/src/builders/mod.rs +++ b/src/builders/mod.rs @@ -2,6 +2,7 @@ use miette::Result; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use tokio::sync::mpsc::UnboundedSender; pub mod archive; pub mod cargo; @@ -11,6 +12,94 @@ use archive::{ArchiveBuilder, ArchiveBuilderConfig}; use cargo::{CargoBuilder, CargoBuilderConfig, CargoDocBuilder, CargoDocBuilderConfig}; use script::{ScriptBuilder, ScriptBuilderConfig}; +/// Events emitted by builders and forwarded to the progress-bar manager in +/// [`crate::site`]. +/// +/// Most builders only ever send [`LogEvent::Line`]. Builders that fan out +/// into parallel sub-tasks (e.g. [`crate::builders::cargo::CargoBuilder`] +/// compiling for multiple targets simultaneously) additionally use the +/// `Child*` variants so the UI can show a dedicated spinner per sub-task. +#[derive(Debug)] +pub enum LogEvent { + /// A plain output line from the top-level builder task. + Line(String), + /// Register a new child task. The receiver will create a sub-spinner + /// immediately below the parent spinner. + ChildStart { + /// Unique key used to route subsequent [`LogEvent::ChildLine`] and + /// [`LogEvent::ChildFinish`] messages. + id: String, + /// Human-readable label shown in the sub-spinner's prefix. + label: String, + }, + /// A log line produced by a specific child task. + ChildLine { id: String, line: String }, + /// Signal that a child task has finished. + ChildFinish { + id: String, + success: bool, + /// Short one-line summary displayed as the spinner's final message. + summary: String, + }, +} + +/// A sender used to stream [`LogEvent`]s from a builder back to the +/// progress-bar manager in [`crate::site`]. Errors sending are silently +/// ignored because the receiver may already be gone when a builder finishes. +pub type LogSender = UnboundedSender<LogEvent>; + +/// A single `[[builders]]` entry: the builder itself plus optional dependency +/// metadata. +/// +/// # Dependency ordering +/// +/// By default every builder runs concurrently with all others. When a builder +/// must only start *after* another one has finished successfully, give the +/// prerequisite an `id` and list that id in the dependent's `depends_on`: +/// +/// ```toml +/// [[builders]] +/// type = "cargo" +/// id = "compile" # ← give this builder a name +/// +/// [[builders]] +/// type = "script" +/// script = ["strip target/release/my_bin"] +/// outputs = ["target/release/my_bin"] +/// depends_on = ["compile"] # ← wait for the builder above +/// ``` +/// +/// Circular dependencies are detected before any builder starts and reported +/// as an error. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct BuilderEntry { + /// The builder's type-specific configuration (type tag + all builder + /// fields), serialised flat into the same TOML table. + #[serde(flatten)] + pub builder: AnyBuilder, + + /// Optional identifier for this builder. Other builders reference this + /// string in their `depends_on` list. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option<String>, + + /// IDs of builders that must complete successfully before this one starts. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub depends_on: Vec<String>, +} + +impl BuilderEntry { + /// Short human-readable label forwarded from the inner builder. + pub fn label(&self) -> &'static str { + self.builder.label() + } + + /// Run the inner builder, forwarding the version string and log sender. + pub async fn build(&self, version: &str, log: LogSender) -> Result<Vec<ArtifactPath>> { + self.builder.build(version, log).await + } +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum AnyBuilder { @@ -69,12 +158,22 @@ pub enum AnyBuilder { } impl AnyBuilder { - pub async fn build(&self, version: &str) -> Result<Vec<ArtifactPath>> { + /// A short human-readable label used in progress-bar prefixes. + pub fn label(&self) -> &'static str { match self { - Self::Archive(config) => ArchiveBuilder.build(config.clone(), version).await, - Self::Cargo(config) => CargoBuilder.build(config.clone(), version).await, - Self::CargoDoc(config) => CargoDocBuilder.build(config.clone(), version).await, - Self::Script(config) => ScriptBuilder.build(config.clone(), version).await, + Self::Archive(_) => "archive", + Self::Cargo(_) => "cargo-build", + Self::CargoDoc(_) => "cargo-doc", + Self::Script(_) => "script", + } + } + + pub async fn build(&self, version: &str, log: LogSender) -> Result<Vec<ArtifactPath>> { + match self { + Self::Archive(config) => ArchiveBuilder.build(config.clone(), version, log).await, + Self::Cargo(config) => CargoBuilder.build(config.clone(), version, log).await, + Self::CargoDoc(config) => CargoDocBuilder.build(config.clone(), version, log).await, + Self::Script(config) => ScriptBuilder.build(config.clone(), version, log).await, } } } @@ -95,5 +194,15 @@ pub trait Builder { /// `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. - async fn build(&self, config: Self::ConfigType, version: &str) -> Result<Vec<ArtifactPath>>; + /// + /// `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. + async fn build( + &self, + config: Self::ConfigType, + version: &str, + log: LogSender, + ) -> Result<Vec<ArtifactPath>>; } -
modified src/builders/script.rs
diff --git a/src/builders/script.rs b/src/builders/script.rs index 92fdec2..645c144 100644 --- a/src/builders/script.rs +++ b/src/builders/script.rs @@ -1,9 +1,11 @@ use std::path::PathBuf; +use std::process::Stdio; -use crate::builders::{ArtifactPath, Builder}; +use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender}; use miette::{IntoDiagnostic, Result, miette}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; /// Configuration for [`ScriptBuilder`]. @@ -53,15 +55,43 @@ pub struct ScriptBuilder; impl Builder for ScriptBuilder { type ConfigType = ScriptBuilderConfig; - async fn build(&self, config: Self::ConfigType, version: &str) -> Result<Vec<ArtifactPath>> { + async fn build( + &self, + config: Self::ConfigType, + version: &str, + log: LogSender, + ) -> Result<Vec<ArtifactPath>> { for line in &config.script { - let status = Command::new("sh") + let mut child = Command::new("sh") .args(["-c", line]) .env("ABBAYE_BUILDING_VERSION", version) - .status() - .await + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() .into_diagnostic()?; + // Merge stdout and stderr into the log channel concurrently. + let stdout = child.stdout.take().expect("stdout piped"); + let stderr = child.stderr.take().expect("stderr 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(l)) = lines.next_line().await { + let _ = log_out.send(LogEvent::Line(l)); + } + }); + let stderr_task = tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(l)) = lines.next_line().await { + let _ = log_err.send(LogEvent::Line(l)); + } + }); + + let status = child.wait().await.into_diagnostic()?; + // Drain I/O tasks before checking the exit code. + let _ = tokio::join!(stdout_task, stderr_task); + if !status.success() { return Err(miette!( "script command failed (exit {}):\n {}", -
modified src/config.rs
diff --git a/src/config.rs b/src/config.rs index ef0340c..4e7c498 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use schemars::JsonSchema; use crate::{ - builders::AnyBuilder, changelog::ChangelogConfig, version_extractors::AnyVersionExtractor, + builders::BuilderEntry, changelog::ChangelogConfig, version_extractors::AnyVersionExtractor, }; /// General website metadata. @@ -89,7 +89,7 @@ pub struct AbbayeConfig { /// Configuration for the changelog extractor. pub changelog: ChangelogConfig, /// Builders to run during the build process. - pub builders: Vec<AnyBuilder>, + pub builders: Vec<BuilderEntry>, /// Where to output generated files. Defaults to `public`. #[serde(default = "abbaye_output_dir")] pub output_dir: Option<PathBuf>, -
modified src/main.rs
diff --git a/src/main.rs b/src/main.rs index 7d74b9d..4d19e58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,7 +129,7 @@ use tokio::fs::create_dir_all; use tracing::{info, warn}; use crate::{ - builders::{AnyBuilder, archive::ArchiveBuilderConfig}, + builders::{AnyBuilder, BuilderEntry, archive::ArchiveBuilderConfig}, changelog::ChangelogConfig, config::{AbbayeConfig, SiteConfig}, version_extractors::{AnyVersionExtractor, git::GitVersionConfig}, @@ -296,12 +296,16 @@ async fn main() -> Result<()> { tag_prefix: Some("v".to_string()), dirty_suffix: "-dirty".to_string(), }), - builders: vec![AnyBuilder::Archive(ArchiveBuilderConfig { - source_dir: None, - output: None, - prefix: None, - ignore_patterns: vec![".git/".to_string(), "*.tar.gz".to_string()], - })], + builders: vec![BuilderEntry { + builder: AnyBuilder::Archive(ArchiveBuilderConfig { + source_dir: None, + output: None, + prefix: None, + ignore_patterns: vec![".git/".to_string(), "*.tar.gz".to_string()], + }), + id: None, + depends_on: vec![], + }], changelog: ChangelogConfig { ..Default::default() }, -
modified src/site.rs
diff --git a/src/site.rs b/src/site.rs index dda6d85..85ffb9d 100644 --- a/src/site.rs +++ b/src/site.rs @@ -2,18 +2,26 @@ use std::{ future::Future, path::{Path, PathBuf}, pin::Pin, + time::Duration, }; use chrono::{DateTime, SecondsFormat, Utc}; use sha2::{Digest, Sha256}; use flate2::{Compression, write::GzEncoder}; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use miette::{IntoDiagnostic, Result}; use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html}; +use std::collections::HashMap; use tera::{Context, Tera}; +use tokio::sync::{mpsc, watch}; +use tokio::task::JoinSet; use tracing::warn; -use crate::{changelog::ChangelogExtractor, config::AbbayeConfig, version_extractors::VersionInfo}; +use crate::{ + builders::LogEvent, changelog::ChangelogExtractor, config::AbbayeConfig, + version_extractors::VersionInfo, +}; pub const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html.j2"); pub const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html.j2"); @@ -109,18 +117,290 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { copy_dir_recursive(theme_path.join("static"), output_dir.join("static")).await?; } - // ── 3. Builders ─────────────────────────────────────────────────────────── + // ── 3. Builders (run in parallel, respecting depends_on ordering) ───────── let mut dist_artifacts = Vec::new(); let mut doc_artifacts = Vec::new(); - for builder in &config.builders { - for artifact in builder.build(&version).await? { - if artifact.path.is_dir() { - doc_artifacts.push(artifact); - } else { - dist_artifacts.push(artifact); + { + // Colour palette: ANSI foreground codes cycled across builders. + const COLORS: &[&str] = &[ + "\x1b[36m", // cyan + "\x1b[32m", // green + "\x1b[33m", // yellow + "\x1b[35m", // magenta + "\x1b[34m", // blue + "\x1b[31m", // red + ]; + const RESET: &str = "\x1b[0m"; + + // ── Dependency validation ───────────────────────────────────────────── + // + // Build a map from id → index so we can reference builders by name. + let id_to_idx: HashMap<&str, usize> = config + .builders + .iter() + .enumerate() + .filter_map(|(i, e)| e.id.as_deref().map(|id| (id, i))) + .collect(); + + // Check that every depends_on reference resolves to a known id. + for (i, entry) in config.builders.iter().enumerate() { + for dep in &entry.depends_on { + if !id_to_idx.contains_key(dep.as_str()) { + return Err(miette::miette!( + "builder #{i} ({}) lists '{}' in depends_on, \ + but no builder has that id", + entry.label(), + dep + )); + } } } + + // Cycle detection via iterative DFS (0=unvisited, 1=in-stack, 2=done). + { + let n = config.builders.len(); + let mut state = vec![0u8; n]; + + fn dfs( + idx: usize, + id_to_idx: &HashMap<&str, usize>, + builders: &[crate::builders::BuilderEntry], + state: &mut Vec<u8>, + ) -> Result<()> { + if state[idx] == 1 { + return Err(miette::miette!( + "dependency cycle detected involving builder #{idx} ({})", + builders[idx].id.as_deref().unwrap_or(builders[idx].label()) + )); + } + if state[idx] == 2 { + return Ok(()); + } + state[idx] = 1; + for dep in &builders[idx].depends_on { + if let Some(&dep_idx) = id_to_idx.get(dep.as_str()) { + dfs(dep_idx, id_to_idx, builders, state)?; + } + } + state[idx] = 2; + Ok(()) + } + + for i in 0..n { + dfs(i, &id_to_idx, &config.builders, &mut state)?; + } + } + + // ── Completion signals ──────────────────────────────────────────────── + // + // For every builder that carries an `id` we open a watch channel. + // Dependents receive a clone of the receiver and wait until the value + // transitions from `None` (pending) to `Some(true)` (success) or + // `Some(false)` (failure / dependency failure). + let mut completion_txs: HashMap<String, watch::Sender<Option<bool>>> = HashMap::new(); + let mut completion_rxs: HashMap<String, watch::Receiver<Option<bool>>> = HashMap::new(); + + for entry in &config.builders { + if let Some(id) = &entry.id { + let (tx, rx) = watch::channel(None::<bool>); + completion_txs.insert(id.clone(), tx); + completion_rxs.insert(id.clone(), rx); + } + } + + // ── Progress bars & task spawning ───────────────────────────────────── + let total = config.builders.len(); + let multi = MultiProgress::new(); + + // Bottom bar: overall completion counter. + let summary = multi.add(ProgressBar::new(total as u64)); + summary.set_style( + ProgressStyle::with_template("{pos}/{len} builders {bar:20.green/white} {msg}") + .expect("valid template"), + ); + summary.set_message("building…"); + + let mut join_set: JoinSet<miette::Result<Vec<crate::builders::ArtifactPath>>> = + JoinSet::new(); + + for (i, entry) in config.builders.iter().enumerate() { + let color = COLORS[i % COLORS.len()]; + let label = entry.id.as_deref().unwrap_or(entry.label()); + let colored_prefix = format!("{color}[{label}]{RESET}"); + + // Spinner inserted above the summary bar. + let pb = multi.insert_before(&summary, ProgressBar::new_spinner()); + pb.set_style( + ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}") + .expect("valid template") + .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "), + ); + pb.set_prefix(colored_prefix); + pb.set_message("starting…"); + pb.enable_steady_tick(Duration::from_millis(100)); + + let (log_tx, mut log_rx) = mpsc::unbounded_channel::<LogEvent>(); + + // Task: receive LogEvents and update spinners. + // ChildStart creates a new sub-spinner inserted right below the + // parent (and below any previously created siblings, via + // `last_child_pb`). ChildLine / ChildFinish update or finish it. + let pb_log = pb.clone(); + let multi_log = multi.clone(); + let parent_color_idx = i; + tokio::spawn(async move { + let mut child_pbs: HashMap<String, ProgressBar> = HashMap::new(); + // Track insertion point so siblings stack in order. + let mut last_child_pb = pb_log.clone(); + let mut child_color_idx = parent_color_idx + 1; + + while let Some(event) = log_rx.recv().await { + match event { + LogEvent::Line(line) => { + pb_log.set_message(line); + } + LogEvent::ChildStart { id, label } => { + let child_color = COLORS[child_color_idx % COLORS.len()]; + child_color_idx += 1; + let child_pb = + multi_log.insert_after(&last_child_pb, ProgressBar::new_spinner()); + child_pb.set_style( + ProgressStyle::with_template(" {spinner:.bold} {prefix} {msg}") + .expect("valid template") + .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "), + ); + child_pb.set_prefix(format!("{child_color}[{label}]{RESET}")); + child_pb.set_message("starting…"); + child_pb.enable_steady_tick(Duration::from_millis(100)); + last_child_pb = child_pb.clone(); + child_pbs.insert(id, child_pb); + } + LogEvent::ChildLine { id, line } => { + if let Some(child_pb) = child_pbs.get(&id) { + child_pb.set_message(line); + } + } + LogEvent::ChildFinish { + id, + success, + summary, + } => { + if let Some(child_pb) = child_pbs.remove(&id) { + if success { + child_pb.finish_with_message(format!( + "\x1b[32m\u{2713}\x1b[0m {summary}" + )); + } else { + child_pb.finish_with_message(format!( + "\x1b[31m\u{2717}\x1b[0m {summary}" + )); + } + } + } + } + } + }); + + // Collect the watch receivers for every declared dependency. + let dep_receivers: Vec<(String, watch::Receiver<Option<bool>>)> = entry + .depends_on + .iter() + .filter_map(|dep_id| { + completion_rxs + .get(dep_id) + .map(|rx| (dep_id.clone(), rx.clone())) + }) + .collect(); + + // Take ownership of the completion sender for this builder's own id + // (if it has one) so the task can signal its outcome. + let my_tx: Option<watch::Sender<Option<bool>>> = + entry.id.as_ref().and_then(|id| completion_txs.remove(id)); + + let entry = entry.clone(); + let version = version.clone(); + let pb_task = pb.clone(); + let summary_task = summary.clone(); + + join_set.spawn(async move { + // ── Wait for dependencies ───────────────────────────────────── + for (dep_id, mut rx) in dep_receivers { + pb_task.set_message(format!("waiting for '{dep_id}'…")); + + // Block until the dependency resolves (Some(_)) or its + // sender is dropped (which we treat as a failure). + let resolved = rx.wait_for(|v| v.is_some()).await; + + let succeeded = match resolved { + Err(_) => false, // sender dropped unexpectedly + Ok(r) => r.unwrap_or(false), + }; + + if !succeeded { + summary_task.inc(1); + pb_task.finish_with_message(format!( + "\x1b[33m\u{29B8} skipped\x1b[0m (dependency '{dep_id}' failed)" + )); + if let Some(tx) = &my_tx { + let _ = tx.send(Some(false)); + } + // Return an empty artifact list; the dependency error + // itself will surface from the dependency's own task. + return Ok(vec![]); + } + } + + // ── Run the builder ─────────────────────────────────────────── + pb_task.set_message("running…"); + let result = entry.build(&version, log_tx).await; + let succeeded = result.is_ok(); + + if let Some(tx) = my_tx { + let _ = tx.send(Some(succeeded)); + } + + summary_task.inc(1); + match &result { + Ok(artifacts) => pb_task.finish_with_message(format!( + "\x1b[32m\u{2713} done\x1b[0m ({} artifact(s))", + artifacts.len() + )), + Err(e) => { + pb_task.finish_with_message(format!("\x1b[31m\u{2717} failed:\x1b[0m {e}")) + } + } + result + }); + } + + // Collect all results; continue even when some builders fail so every + // spinner reaches its final state before we return an error. + let mut errors: Vec<miette::Report> = Vec::new(); + while let Some(res) = join_set.join_next().await { + match res.into_diagnostic()? { + Ok(artifacts) => { + for artifact in artifacts { + if artifact.path.is_dir() { + doc_artifacts.push(artifact); + } else { + dist_artifacts.push(artifact); + } + } + } + Err(e) => errors.push(e), + } + } + + summary.finish_with_message(if errors.is_empty() { + "\x1b[32mall done\x1b[0m" + } else { + "\x1b[31msome builders failed\x1b[0m" + }); + + if let Some(first_err) = errors.into_iter().next() { + return Err(first_err); + } } // ── 4. Lay out dist/ ──────────────────────────────────────────────────────