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"));
+ }
+}