From bbce70a64075aba72efd42e7800a7c5b8d6cfee4 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Fri, 21 Aug 2026 19:13:20 -0400 Subject: [PATCH] Add initial implementation --- .gitignore | 1 + Cargo.lock | 171 +++++++++++++++++++ Cargo.toml | 14 ++ README.md | 41 +++++ src/lib.rs | 487 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 714 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..500bd21 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,171 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tangled-workflow" +version = "0.1.0" +dependencies = [ + "indexmap", + "indoc", + "pretty_assertions", + "serde", + "serde_yaml", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1b16bcf --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "tangled-workflow" +version = "0.1.0" +edition = "2021" +description = "A Rust eDSL for defining Tangled workflows" + +[dependencies] +indexmap = { version = "2", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" + +[dev-dependencies] +indoc = "2" +pretty_assertions = "1" diff --git a/README.md b/README.md index 5b4a05e..87aba8a 100644 --- a/README.md +++ b/README.md @@ -1 +1,42 @@ # tangled-workflow + +A Rust eDSL for defining [Tangled](https://tangled.org) [workflows](https://docs.tangled.org/spindles). + +## Usage + +```rust +use tangled_workflow::{Constraint, Event, Microvm, Step, Workflow}; + +let workflow = Workflow::new( + Microvm::new() + .image("nixos") + .dependencies(["cargo", "rustc", "clippy"]), +) +.when(Constraint::event([Event::Push, Event::PullRequest]).branch(["main"])) +.step(Step::run("Clippy", "cargo clippy --all-targets -- -D warnings")) +.step(Step::run("Test", "cargo test --all")); + +println!("{}", workflow.to_yaml()?); +``` + +This emits YAML suitable for `.tangled/workflows/`: + +```yaml +when: + - event: + - push + - pull_request + branch: + - main +engine: microvm +image: nixos +dependencies: + - cargo + - rustc + - clippy +steps: + - name: Clippy + command: cargo clippy --all-targets -- -D warnings + - name: Test + command: cargo test --all +``` diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..234c77c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,487 @@ +//! A Rust eDSL for defining [Tangled](https://tangled.org) workflows. + +use indexmap::IndexMap; +use serde::Serialize; + +pub use serde_yaml::Value; + +/// A single workflow, corresponding to one file in `.tangled/workflows`. +#[derive(Debug, Clone, Serialize)] +pub struct Workflow { + #[serde(skip_serializing_if = "Vec::is_empty")] + when: Vec, + + #[serde(flatten)] + engine: Engine, + + #[serde(skip_serializing_if = "Vec::is_empty")] + runs_on: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + clone: Option, + + #[serde(skip_serializing_if = "IndexMap::is_empty")] + environment: IndexMap, + + #[serde(skip_serializing_if = "Vec::is_empty")] + steps: Vec, +} + +impl Workflow { + pub fn new(engine: impl Into) -> Self { + Self { + when: Vec::new(), + engine: engine.into(), + runs_on: Vec::new(), + clone: None, + environment: IndexMap::new(), + steps: Vec::new(), + } + } + + /// Adds a trigger constraint. The workflow runs if any constraint matches. + pub fn when(mut self, constraint: Constraint) -> Self { + self.when.push(constraint); + self + } + + pub fn runs_on(mut self, runners: impl IntoIterator>) -> Self { + self.runs_on.extend(runners.into_iter().map(Into::into)); + self + } + + pub fn clone_opts(mut self, clone: CloneOpts) -> Self { + self.clone = Some(clone); + self + } + + /// Sets a workflow-wide environment variable. + /// + /// Do not put secrets here; these values are visible to anyone viewing + /// the repository. + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.environment.insert(key.into(), value.into()); + self + } + + pub fn step(mut self, step: Step) -> Self { + self.steps.push(step); + self + } + + pub fn to_yaml(&self) -> serde_yaml::Result { + serde_yaml::to_string(self) + } +} + +/// The engine a workflow runs on, along with its engine-specific fields. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "engine", rename_all = "lowercase")] +pub enum Engine { + Nixery(Nixery), + Microvm(Microvm), +} + +/// The Nixery engine: each step runs in a fresh container built from a +/// Nixery image containing the requested dependencies. +#[derive(Debug, Clone, Default, Serialize)] +pub struct Nixery { + #[serde(skip_serializing_if = "IndexMap::is_empty")] + dependencies: IndexMap>, +} + +impl Nixery { + pub fn new() -> Self { + Self::default() + } + + /// Adds dependencies from a registry (e.g. `nixpkgs`, or a flake URL like + /// `git+https://tangled.org/@example.com/my_pkg`). + pub fn dependencies( + mut self, + registry: impl Into, + packages: impl IntoIterator>, + ) -> Self { + self.dependencies + .entry(registry.into()) + .or_default() + .extend(packages.into_iter().map(Into::into)); + self + } +} + +impl From for Engine { + fn from(nixery: Nixery) -> Self { + Engine::Nixery(nixery) + } +} + +/// The microVM engine: the whole workflow runs inside its own microVM. +#[derive(Debug, Clone, Default, Serialize)] +pub struct Microvm { + #[serde(skip_serializing_if = "Option::is_none")] + image: Option, + + #[serde(skip_serializing_if = "Vec::is_empty")] + dependencies: Vec, + + #[serde(skip_serializing_if = "IndexMap::is_empty")] + registry: IndexMap, + + #[serde(skip_serializing_if = "IndexMap::is_empty")] + caches: IndexMap, + + #[serde(skip_serializing_if = "IndexMap::is_empty")] + services: IndexMap, + + #[serde(rename = "virtualisation", skip_serializing_if = "IndexMap::is_empty")] + virtualization: IndexMap, +} + +impl Microvm { + pub fn new() -> Self { + Self::default() + } + + /// Sets the image to boot (e.g. `nixos`, `alpine`). Falls back to the + /// spindle's default image when unset. + pub fn image(mut self, image: impl Into) -> Self { + self.image = Some(image.into()); + self + } + + /// Adds a package available to every step. A bare name like `go` is + /// looked up in nixpkgs; `flakeref#attr` syntax works too. + pub fn dependency(mut self, package: impl Into) -> Self { + self.dependencies.push(package.into()); + self + } + + pub fn dependencies(mut self, packages: impl IntoIterator>) -> Self { + self.dependencies + .extend(packages.into_iter().map(Into::into)); + self + } + + /// Remaps a flake reference, the same way `nix registry` does. + pub fn registry(mut self, name: impl Into, flake_ref: impl Into) -> Self { + self.registry.insert(name.into(), flake_ref.into()); + self + } + + /// Adds a Nix binary cache URL and its trusted public key. + pub fn cache(mut self, url: impl Into, public_key: impl Into) -> Self { + self.caches.insert(url.into(), public_key.into()); + self + } + + /// Configures a NixOS service (anything under `services.*`). + /// + /// `true` works as shorthand for `.enable = true`. + pub fn service(mut self, name: impl Into, config: impl Into) -> Self { + self.services.insert(name.into(), config.into()); + self + } + + /// Configures NixOS virtualization (anything under `virtualisation.*`). + /// + /// `true` works as shorthand for `.enable = true`. + pub fn virtualization(mut self, name: impl Into, config: impl Into) -> Self { + self.virtualization.insert(name.into(), config.into()); + self + } +} + +impl From for Engine { + fn from(microvm: Microvm) -> Self { + Engine::Microvm(microvm) + } +} + +/// A trigger constraint under the `when` field. +#[derive(Debug, Clone, Serialize)] +pub struct Constraint { + event: Vec, + + #[serde(skip_serializing_if = "Vec::is_empty")] + types: Vec, + + #[serde(skip_serializing_if = "Vec::is_empty")] + branch: Vec, + + #[serde(skip_serializing_if = "Vec::is_empty")] + tag: Vec, + + #[serde(skip_serializing_if = "Vec::is_empty")] + paths: Vec, +} + +impl Constraint { + pub fn event(events: impl IntoIterator) -> Self { + Self { + event: events.into_iter().collect(), + types: Vec::new(), + branch: Vec::new(), + tag: Vec::new(), + paths: Vec::new(), + } + } + + /// Restricts which pull request actions trigger the workflow. Only + /// applies to [`Event::PullRequest`]. Defaults to opened, reopened, and + /// synchronize. + pub fn types(mut self, types: impl IntoIterator) -> Self { + self.types.extend(types); + self + } + + /// Branch patterns to match. Supports globs using `*` and `**`. + pub fn branch(mut self, branches: impl IntoIterator>) -> Self { + self.branch.extend(branches.into_iter().map(Into::into)); + self + } + + /// Tag patterns to match. Only applies to [`Event::Push`]. Supports globs + /// using `*` and `**`. + pub fn tag(mut self, tags: impl IntoIterator>) -> Self { + self.tag.extend(tags.into_iter().map(Into::into)); + self + } + + /// Only run if any changed file matches one of these glob patterns. + pub fn paths(mut self, paths: impl IntoIterator>) -> Self { + self.paths.extend(paths.into_iter().map(Into::into)); + self + } +} + +/// An event that can trigger a workflow. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Event { + Push, + PullRequest, + Manual, +} + +/// A pull request lifecycle action, matched against a constraint's `types`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PullRequestAction { + Opened, + Reopened, + Closed, + Merged, + Synchronize, +} + +/// Options for how the repository is cloned before the workflow runs. +#[derive(Debug, Clone, Default, Serialize)] +pub struct CloneOpts { + #[serde(skip_serializing_if = "Option::is_none")] + skip: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + depth: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + submodules: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + tags: Option, +} + +impl CloneOpts { + pub fn new() -> Self { + Self::default() + } + + /// Skips cloning the repository entirely. + pub fn skip(mut self, skip: bool) -> Self { + self.skip = Some(skip); + self + } + + /// Sets the clone depth. Defaults to 1. + pub fn depth(mut self, depth: u32) -> Self { + self.depth = Some(depth); + self + } + + /// Recursively fetches all submodules. + pub fn submodules(mut self, submodules: bool) -> Self { + self.submodules = Some(submodules); + self + } + + /// Whether to fetch tags. + pub fn tags(mut self, tags: bool) -> Self { + self.tags = Some(tags); + self + } +} + +/// A single step in a workflow. Steps run serially in a Bash shell. +#[derive(Debug, Clone, Serialize)] +pub struct Step { + name: String, + command: String, + + #[serde(skip_serializing_if = "IndexMap::is_empty")] + environment: IndexMap, +} + +impl Step { + pub fn run(name: impl Into, command: impl Into) -> Self { + Self { + name: name.into(), + command: command.into(), + environment: IndexMap::new(), + } + } + + /// Sets an environment variable for this step. + /// + /// Do not put secrets here; these values are visible to anyone viewing + /// the repository. + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.environment.insert(key.into(), value.into()); + self + } +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_nixery_workflow() { + let workflow = Workflow::new( + Nixery::new() + .dependencies("nixpkgs", ["nodejs", "go"]) + .dependencies("git+https://tangled.org/@example.com/my_pkg", ["my_pkg"]), + ) + .when(Constraint::event([Event::Push, Event::Manual]).branch(["main", "develop"])) + .when(Constraint::event([Event::PullRequest]).branch(["main"])) + .clone_opts(CloneOpts::new().skip(false).depth(1).submodules(false)) + .env("GOOS", "linux") + .env("GOARCH", "arm64") + .step(Step::run("Build backend", "go build").env("GOOS", "darwin")) + .step(Step::run("Build frontend", "npm run build").env("NODE_ENV", "production")); + + let expected = indoc! {r#" + when: + - event: + - push + - manual + branch: + - main + - develop + - event: + - pull_request + branch: + - main + engine: nixery + dependencies: + nixpkgs: + - nodejs + - go + git+https://tangled.org/@example.com/my_pkg: + - my_pkg + clone: + skip: false + depth: 1 + submodules: false + environment: + GOOS: linux + GOARCH: arm64 + steps: + - name: Build backend + command: go build + environment: + GOOS: darwin + - name: Build frontend + command: npm run build + environment: + NODE_ENV: production + "#}; + + assert_eq!(workflow.to_yaml().unwrap(), expected); + } + + #[test] + fn test_microvm_workflow() { + let workflow = Workflow::new( + Microvm::new() + .image("nixos") + .dependencies(["gcc", "cargo", "rustc", "pkg-config", "openssl"]) + .registry("nixpkgs", "github:nixos/nixpkgs/nixos-unstable") + .cache( + "https://nix-community.cachix.org", + "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=", + ) + .virtualization("docker", true), + ) + .when(Constraint::event([Event::Push]).tag(["v*"])) + .step(Step::run("Test", "cargo test --all")); + + let expected = indoc! {r#" + when: + - event: + - push + tag: + - v* + engine: microvm + image: nixos + dependencies: + - gcc + - cargo + - rustc + - pkg-config + - openssl + registry: + nixpkgs: github:nixos/nixpkgs/nixos-unstable + caches: + https://nix-community.cachix.org: nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= + virtualisation: + docker: true + steps: + - name: Test + command: cargo test --all + "#}; + + assert_eq!(workflow.to_yaml().unwrap(), expected); + } + + #[test] + fn test_microvm_service_with_structured_config() { + let config: Value = serde_yaml::from_str(indoc! {r#" + enable: true + ensureDatabases: ["spindle-workflow"] + "#}) + .unwrap(); + + let workflow = Workflow::new(Microvm::new().image("nixos").service("postgresql", config)) + .step(Step::run("Migrate", "sqlx migrate run")); + + let yaml = workflow.to_yaml().unwrap(); + assert!(yaml.contains("postgresql:")); + assert!(yaml.contains("enable: true")); + assert!(yaml.contains("ensureDatabases:")); + } + + #[test] + fn test_pull_request_types() { + let workflow = Workflow::new(Nixery::new()).when( + Constraint::event([Event::PullRequest]) + .types([PullRequestAction::Merged]) + .branch(["main"]), + ); + + let yaml = workflow.to_yaml().unwrap(); + assert!(yaml.contains("types:\n - merged")); + } +} -- knotmirror 0.0.1