From 602c99b2e030ef878c78ff057cab5343c6d466d0 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 18 Jun 2026 11:48:59 +0100 Subject: [PATCH 1/2] refactor(ir): replace task helpers with typed builder structs Replace the free-function ADO task helpers in src/compile/ir/tasks.rs (positional required args + untyped .with_input("camelKey","str") for optionals) with a tasks/ directory module of typed builder structs, one file per task. Each builder exposes new() + typed chained setters + into_step() -> TaskStep; only set fields emit inputs, with enums for constrained values and Option for bool-string inputs. Command/mode-dispatch tasks (Docker@2, DotNetCoreCLI@2, NuGetCommand@2, PowerShell@2) use a command enum with per-variant data so invalid input/command combinations are unrepresentable; tasks/docker.rs is the canonical template. Migrate the docker_installer call sites, update the ado-task-ir-contributor workflow + docs to the new convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ado-task-ir-contributor.lock.yml | 6 +- .github/workflows/ado-task-ir-contributor.md | 170 +- AGENTS.md | 2 +- docs/extending.md | 9 +- docs/ir.md | 4 +- src/compile/agentic_pipeline.rs | 6 +- src/compile/ir/tasks.rs | 1471 ----------------- src/compile/ir/tasks/archive_files.rs | 209 +++ src/compile/ir/tasks/cmd_line.rs | 90 + src/compile/ir/tasks/common.rs | 33 + src/compile/ir/tasks/copy_files.rs | 177 ++ src/compile/ir/tasks/delete_files.rs | 102 ++ src/compile/ir/tasks/docker.rs | 365 ++++ src/compile/ir/tasks/docker_installer.rs | 93 ++ src/compile/ir/tasks/dotnet_core_cli.rs | 398 +++++ .../ir/tasks/download_pipeline_artifact.rs | 254 +++ src/compile/ir/tasks/extract_files.rs | 112 ++ src/compile/ir/tasks/mod.rs | 34 + src/compile/ir/tasks/nuget_command.rs | 430 +++++ src/compile/ir/tasks/powershell.rs | 216 +++ .../ir/tasks/publish_pipeline_artifact.rs | 153 ++ src/compile/ir/tasks/publish_test_results.rs | 152 ++ 22 files changed, 2952 insertions(+), 1534 deletions(-) delete mode 100644 src/compile/ir/tasks.rs create mode 100644 src/compile/ir/tasks/archive_files.rs create mode 100644 src/compile/ir/tasks/cmd_line.rs create mode 100644 src/compile/ir/tasks/common.rs create mode 100644 src/compile/ir/tasks/copy_files.rs create mode 100644 src/compile/ir/tasks/delete_files.rs create mode 100644 src/compile/ir/tasks/docker.rs create mode 100644 src/compile/ir/tasks/docker_installer.rs create mode 100644 src/compile/ir/tasks/dotnet_core_cli.rs create mode 100644 src/compile/ir/tasks/download_pipeline_artifact.rs create mode 100644 src/compile/ir/tasks/extract_files.rs create mode 100644 src/compile/ir/tasks/mod.rs create mode 100644 src/compile/ir/tasks/nuget_command.rs create mode 100644 src/compile/ir/tasks/powershell.rs create mode 100644 src/compile/ir/tasks/publish_pipeline_artifact.rs create mode 100644 src/compile/ir/tasks/publish_test_results.rs diff --git a/.github/workflows/ado-task-ir-contributor.lock.yml b/.github/workflows/ado-task-ir-contributor.lock.yml index 1d31fbe3..2ff3a7ef 100644 --- a/.github/workflows/ado-task-ir-contributor.lock.yml +++ b/.github/workflows/ado-task-ir-contributor.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"73de03018cd220dd27a7cbcec6235243d6acff214a0b8f94650069508713f944","body_hash":"488a38d28e28a493ecf9c7bc1802e9456e2bc57e98876a8f03f0cb046157ff5a","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6c352fa2f0dd13545e07993df4561ced762f9b1bf1c074eeeceb3ab0597f338c","body_hash":"f0a80b67e66dedf9ed3a5f95591787c5d77c2ec596f0538e1b15e623e81572aa","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"5c2fe865bb4dc46e1450f6ee0d0541d759aea73a","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} # ___ _ _ # / _ \ | | (_) @@ -22,7 +22,7 @@ # # For more information: https://github.github.com/gh-aw/introduction/overview/ # -# Crawls Azure DevOps built-in task docs, contributes typed IR helper functions for uncovered tasks, converts compiler-generated Step::RawYaml usages to typed steps, and opens a focused PR per run. +# Crawls Azure DevOps built-in task docs, contributes typed IR builder structs for uncovered tasks, converts compiler-generated Step::RawYaml usages to typed steps, and opens a focused PR per run. # # Secrets used: # - GH_AW_CI_TRIGGER_TOKEN @@ -1282,7 +1282,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: WORKFLOW_NAME: "ADO Task IR Contributor" - WORKFLOW_DESCRIPTION: "Crawls Azure DevOps built-in task docs, contributes typed IR helper functions for uncovered tasks, converts compiler-generated Step::RawYaml usages to typed steps, and opens a focused PR per run." + WORKFLOW_DESCRIPTION: "Crawls Azure DevOps built-in task docs, contributes typed IR builder structs for uncovered tasks, converts compiler-generated Step::RawYaml usages to typed steps, and opens a focused PR per run." HAS_PATCH: ${{ needs.agent.outputs.has_patch }} with: script: | diff --git a/.github/workflows/ado-task-ir-contributor.md b/.github/workflows/ado-task-ir-contributor.md index c2dfac9d..4b69e4f0 100644 --- a/.github/workflows/ado-task-ir-contributor.md +++ b/.github/workflows/ado-task-ir-contributor.md @@ -2,7 +2,7 @@ on: schedule: every 4h workflow_dispatch: {} -description: Crawls Azure DevOps built-in task docs, contributes typed IR helper functions for uncovered tasks, converts compiler-generated Step::RawYaml usages to typed steps, and opens a focused PR per run. +description: Crawls Azure DevOps built-in task docs, contributes typed IR builder structs for uncovered tasks, converts compiler-generated Step::RawYaml usages to typed steps, and opens a focused PR per run. permissions: contents: read issues: read @@ -42,9 +42,9 @@ The `ado-aw` compiler transforms agent markdown into Azure DevOps YAML through a - `Step::Checkout` / `Step::Download` / `Step::Publish` — special-purpose steps - `Step::RawYaml` — **escape hatch** for user-authored YAML that the IR cannot model -The `TaskStep` struct is already fully generic — it accepts any `task: String` and an `inputs: IndexMap`. The gap is: -1. **No typed factory functions** for most ADO built-in tasks — every place that uses a known ADO task must hand-craft `TaskStep::new("Foo@1", "display").with_input(...)`. -2. **Compiler-generated `Step::RawYaml`** — occasionally, extension/runtime code emits `Step::RawYaml` for steps that could be expressed as `Step::Task` with a typed helper; these should be migrated. +The `TaskStep` struct is fully generic — it accepts any `task: String` and an `inputs: IndexMap`. Typed coverage of specific ADO tasks lives in `src/compile/ir/tasks/` as **builder structs** (one submodule per task). Each builder exposes `new()`, one typed chained setter per optional input, and `into_step() -> TaskStep`; only inputs that were set are emitted. Constrained input values are typed enums (each with `as_ado_str()`), bool-string inputs take a Rust `bool`, and command/mode-dispatch tasks (`Docker@2`, `DotNetCoreCLI@2`, `NuGetCommand@2`, `PowerShell@2`) use a command enum with per-variant data so invalid input/command combinations are unrepresentable. `src/compile/ir/tasks/docker.rs` is the canonical template. The gaps are: +1. **No typed builder** for most ADO built-in tasks — code that uses an uncovered task must hand-craft `TaskStep::new("Foo@1", "display").with_input(...)` with raw string keys. +2. **Compiler-generated `Step::RawYaml`** — occasionally, extension/runtime code emits `Step::RawYaml` for steps that could be expressed as `Step::Task` with a typed builder; these should be migrated. ## Step 1 — Load Previous State @@ -67,10 +67,13 @@ If the prior PR is still open, emit `noop` with "Waiting on open PR #N before ad ## Step 2 — Audit Existing Typed Coverage -Examine what typed factory functions already exist: +Examine which tasks already have a typed builder under `src/compile/ir/tasks/`: ```bash -# Existing TaskStep factory functions in runtimes and extensions +# One submodule per covered task; the struct + into_step are the builder. +ls src/compile/ir/tasks/ +grep -rn "pub struct \|pub fn into_step" src/compile/ir/tasks/ --include="*.rs" +# Other typed task factories that live outside tasks/ (runtimes / extensions) grep -rn "TaskStep::new\|fn.*task_step" src/runtimes src/compile/extensions --include="*.rs" ``` @@ -87,7 +90,7 @@ Focus on: Ignore `push_raw_yaml_if_nonempty`, `step_to_raw_yaml_string`, and any function that explicitly handles user-authored YAML (these are legitimately `Step::RawYaml`). -Build a set of **already-typed tasks**: task identifiers that already have typed factory functions (e.g. `UseNode@1`, `UseDotNet@2`, `UsePythonVersion@0`, `NpmAuthenticate@0`, `PipAuthenticate@1`, `NuGetAuthenticate@1`). +Build the set of **already-typed tasks**: the union of the task identifiers covered by `src/compile/ir/tasks/` builder structs and the runtime/extension factories (e.g. `UseNode@1`, `UseDotNet@2`, `UsePythonVersion@0`, `NpmAuthenticate@0`, `PipAuthenticate@1`, `NuGetAuthenticate@1`). ## Step 3 — Fetch ADO Built-In Task Catalog @@ -121,10 +124,12 @@ grep -rh "task:\|TaskStep::new" src tests --include="*.rs" --include="*.yml" --i From the catalog, select **one** task that: 1. Is **not** already in `completed_tasks` from state -2. Is **not** already typed (no existing factory function) +2. Is **not** already typed (no existing builder struct under `src/compile/ir/tasks/` and no runtime/extension factory) 3. Has a stable, widely-used task identifier (prefer tasks in the `Utility`, `Build`, or `Test` categories) 4. Has clear documentation on `learn.microsoft.com` +The chosen task will become a new submodule `src/compile/ir/tasks/.rs`. + Priority order (highest first): 1. A `Step::RawYaml` in compiler-generated code that maps to a known ADO task — converting it to `Step::Task` removes tech debt directly. 2. A task used frequently in the `tests/` fixtures as raw YAML strings. @@ -149,42 +154,91 @@ Document the task: - Optional inputs with defaults - Relevant use cases in ado-aw -## Step 5 — Implement the Typed Helper +## Step 5 — Implement the Typed Builder -Decide where to place the factory function: +Decide where to place the builder: -- **If the task is tied to a specific runtime** (language install, package auth): add to `src/runtimes//mod.rs`. -- **If the task is a general-purpose ADO built-in**: add to `src/compile/ir/step.rs` as a standalone constructor function alongside the existing step types, or create a new `src/compile/ir/tasks.rs` module if multiple new tasks are being added. +- **If the task is tied to a specific runtime** (language install, package auth): add to `src/runtimes//mod.rs` (these remain free factory functions). +- **If the task is a general-purpose ADO built-in**: create a **new submodule** `src/compile/ir/tasks/.rs` and declare it in `src/compile/ir/tasks/mod.rs` with `pub mod ;` (alphabetical order). One file per task. -For a new `tasks.rs` module, create it at `src/compile/ir/tasks.rs` and add `pub mod tasks;` to `src/compile/ir/mod.rs`. +### Builder struct shape -### Helper function shape +Model after `src/compile/ir/tasks/copy_files.rs` (a single-mode task) and, for command/mode-dispatch tasks, `src/compile/ir/tasks/docker.rs` (the canonical command-enum template). Shared helpers (`bool_input`, `push_opt`, `push_bool`) live in `src/compile/ir/tasks/common.rs`. -Model after existing patterns in `src/runtimes/`: +A builder is a struct with the required inputs as fields, each optional input as an `Option<…>` field, and a `display_name: Option` override: ```rust -/// Returns a [`TaskStep`] for `CopyFiles@2`. -/// -/// Copies files matching `contents` from `source_folder` to `target_folder`. -/// All parameters map directly to the ADO task inputs. -pub fn copy_files_step( - source_folder: impl Into, - contents: impl Into, - target_folder: impl Into, -) -> TaskStep { - TaskStep::new("CopyFiles@2", "Copy Files") - .with_input("SourceFolder", source_folder) - .with_input("Contents", contents) - .with_input("TargetFolder", target_folder) +use super::common::bool_input; +use crate::compile::ir::step::TaskStep; + +/// Builder for a [`TaskStep`] invoking `CopyFiles@2`. +#[derive(Debug, Clone)] +pub struct CopyFiles { + contents: String, + target_folder: String, + source_folder: Option, + clean_target_folder: Option, + display_name: Option, +} + +impl CopyFiles { + /// Required inputs are positional parameters of `new`. + pub fn new(contents: impl Into, target_folder: impl Into) -> Self { + Self { + contents: contents.into(), + target_folder: target_folder.into(), + source_folder: None, + clean_target_folder: None, + display_name: None, + } + } + + /// One typed chained setter per optional input. + pub fn source_folder(mut self, value: impl Into) -> Self { + self.source_folder = Some(value.into()); + self + } + + /// Bool-string inputs take a Rust `bool`. + pub fn clean_target_folder(mut self, value: bool) -> Self { + self.clean_target_folder = Some(value); + self + } + + /// Always provide a `displayName` override. + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// `into_step` emits required inputs always, optionals only when set. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "CopyFiles@2", + self.display_name.unwrap_or_else(|| "Copy Files".into()), + ) + .with_input("Contents", self.contents) + .with_input("TargetFolder", self.target_folder); + if let Some(v) = self.source_folder { + t = t.with_input("SourceFolder", v); + } + if let Some(v) = self.clean_target_folder { + t = t.with_input("CleanTargetFolder", bool_input(v)); + } + t + } } ``` Guidelines: -- Function name: snake_case, derived from the task display name, without the version suffix (e.g. `copy_files_step`, `publish_test_results_step`). -- Required inputs: positional parameters. -- Optional inputs with common defaults: keyword-style builder on the returned `TaskStep` (use `.with_input` after the initial construction). -- Include a doc comment with the task identifier and a one-line description. -- Do NOT add a new `Step` enum variant for standard tasks — `Step::Task(TaskStep)` is the correct representation. +- Struct name: PascalCase of the task display name, without the version suffix (e.g. `CopyFiles`, `PublishTestResults`). +- **Required inputs** → positional parameters of `new`. +- **Optional inputs** → `Option<…>` fields with one typed chained setter each; only emit them in `into_step` when set. +- **Constrained values** (a fixed set of string tokens) → a typed enum with `as_ado_str(&self) -> &'static str` returning the exact ADO token. Colocate the enum with the task (reusable shared enums may live in `common.rs`). +- **Bool-string inputs** → `Option`; lower via `bool_input`. +- **Command/mode-dispatch tasks** (each command exposes a different optional-input set) → a command **enum with per-variant data** so invalid input/command combos are unrepresentable. Wrap it in a builder struct (`new()` plus per-command constructors) and match the variant in `into_step`. Model on `src/compile/ir/tasks/docker.rs`. +- Include a doc comment with the task identifier and the ADO task reference URL. +- Do NOT add a new `Step` enum variant for standard tasks — `Step::Task(.into_step())` is the correct representation. ### Convert RawYaml if applicable @@ -192,24 +246,38 @@ If Step 4 identified a `Step::RawYaml` in compiler code that this task covers, r ```rust // Before: -steps.push(Step::RawYaml(format!("- task: CopyFiles@2\n inputs:\n SourceFolder: {src}\n TargetFolder: {dst}"))); +steps.push(Step::RawYaml(format!("- task: CopyFiles@2\n inputs:\n Contents: '**'\n TargetFolder: {dst}"))); // After: -use crate::compile::ir::tasks::copy_files_step; -steps.push(Step::Task(copy_files_step(&src, "**", &dst))); +use crate::compile::ir::tasks::copy_files::CopyFiles; +steps.push(Step::Task(CopyFiles::new("**", &dst).into_step())); ``` ## Step 6 — Add Tests -Add at least one unit test to the same file (or `tests/compiler_tests.rs` for integration tests): +Add at least one `#[cfg(test)] mod tests` unit test to the new task submodule (or `tests/compiler_tests.rs` for integration tests), building via the struct and asserting on `task` / `inputs`: ```rust -#[test] -fn copy_files_step_creates_task_with_inputs() { - let t = copy_files_step("$(Build.SourcesDirectory)", "**/*.rs", "$(Build.ArtifactStagingDirectory)"); - assert_eq!(t.task, "CopyFiles@2"); - assert_eq!(t.inputs.get("SourceFolder").map(|s| s.as_str()), Some("$(Build.SourcesDirectory)")); - assert_eq!(t.inputs.get("TargetFolder").map(|s| s.as_str()), Some("$(Build.ArtifactStagingDirectory)")); +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creates_task_with_inputs() { + let t = CopyFiles::new("**/*.rs", "$(Build.ArtifactStagingDirectory)") + .source_folder("$(Build.SourcesDirectory)") + .into_step(); + assert_eq!(t.task, "CopyFiles@2"); + assert_eq!(t.inputs.get("Contents").map(String::as_str), Some("**/*.rs")); + assert_eq!( + t.inputs.get("TargetFolder").map(String::as_str), + Some("$(Build.ArtifactStagingDirectory)") + ); + assert_eq!( + t.inputs.get("SourceFolder").map(String::as_str), + Some("$(Build.SourcesDirectory)") + ); + } } ``` @@ -260,27 +328,29 @@ jq \ If changes were made, open a PR with: **Title** — conventional commits format: -- `feat(ir): add typed helper for ` — for new factory functions +- `feat(ir): add typed builder for ` — for a new builder struct - `refactor(ir): replace RawYaml with typed TaskStep for ` — for RawYaml conversions -- `feat(ir): add tasks module with typed helpers for and ` — if a new module is created **Body**: ```markdown ## Summary -Adds a typed factory function for `` to the ado-aw IR. +Adds a typed builder struct for `` to the ado-aw IR. ## Motivation Previously, any code that needed to emit this ADO task step had to hand-craft -`TaskStep::new(...)` with raw string inputs. This PR introduces a well-typed -helper that validates required inputs at the call site and provides a clear API. +`TaskStep::new(...)` with raw string input keys. This PR introduces a typed +builder struct (`new()` + typed optional setters + `into_step()`) so +required inputs are positional, optional inputs and their constrained values are +type-checked, and call sites stop using stringly-typed keys. ## Changes -- `src/compile/ir/tasks.rs` (or relevant file): `()` factory function -- `tests/...`: unit tests for the new helper +- `src/compile/ir/tasks/.rs`: new `` builder struct (+ any + typed enums) and its `#[cfg(test)] mod tests` +- `src/compile/ir/tasks/mod.rs`: `pub mod ;` declaration - (if applicable) `src/compile/...`: converted `Step::RawYaml` → `Step::Task` ## ADO Task Reference diff --git a/AGENTS.md b/AGENTS.md index f479285c..9c251721 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ Every compiled pipeline runs as three sequential jobs: │ │ │ ├── mod.rs # IR module entry point and shared types │ │ │ ├── ids.rs # Stable IDs for jobs/steps/outputs in the IR │ │ │ ├── step.rs # Step declarations and typed step variants -│ │ │ ├── tasks.rs # Typed factory helpers for built-in ADO TaskStep creation +│ │ │ ├── tasks/ # Typed builder structs for built-in ADO tasks (one file per task; new()+typed setters+into_step(); command-enum dispatch for Docker/DotNet/NuGet/PowerShell; docker.rs canonical template) │ │ │ ├── job.rs # Job declarations and typed job graph nodes │ │ │ ├── stage.rs # Stage declarations and typed stage graph nodes │ │ │ ├── env.rs # Typed environment and variable modeling diff --git a/docs/extending.md b/docs/extending.md index bd75d152..755856ba 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -117,15 +117,16 @@ let step = Step::Bash( ```rust use crate::compile::ir::step::Step; -use crate::compile::ir::tasks::publish_test_results_step; +use crate::compile::ir::tasks::publish_test_results::{PublishTestResults, TestResultsFormat}; let step = Step::Task( - publish_test_results_step("JUnit", "**/TEST-*.xml") - .with_input("testRunTitle", "Unit Tests"), + PublishTestResults::new(TestResultsFormat::JUnit, "**/TEST-*.xml") + .test_run_title("Unit Tests") + .into_step(), ); ``` -Use `TaskStep` for Azure DevOps built-in tasks. When the task is compiler-generated, add or reuse a typed helper in `src/compile/ir/tasks.rs` so required inputs are explicit and call sites do not hand-construct `TaskStep::new(...)` with raw task/input strings. +Use `TaskStep` for Azure DevOps built-in tasks. When the task is compiler-generated, add or reuse a typed builder struct in `src/compile/ir/tasks/` (one file per task) so required inputs are positional, optional inputs are typed setters, and call sites do not hand-construct `TaskStep::new(...)` with raw task/input strings. Each builder exposes `new()`, typed chained setters, and `into_step() -> TaskStep`; constrained values are typed enums and bool inputs take `bool`. Command/mode-dispatch tasks (e.g. `Docker@2`) use a command enum with per-variant data — model new ones on `src/compile/ir/tasks/docker.rs`. ### Download and publish steps diff --git a/docs/ir.md b/docs/ir.md index 45406e1a..c3bc9d57 100644 --- a/docs/ir.md +++ b/docs/ir.md @@ -19,7 +19,7 @@ Those wrappers are the only place per-target shape (top-level `PipelineShape`, t - `ids.rs` — typed `StageId`, `JobId`, and `StepId` newtypes. Constructors validate the ADO identifier grammar (`^[A-Za-z_][A-Za-z0-9_]*$`) so invalid names fail at compile time. - `step.rs` — `Step` and concrete step structs: `BashStep`, `TaskStep`, `CheckoutStep`, `DownloadStep`, and `PublishStep`. -- `tasks.rs` — typed factory helpers for built-in ADO tasks that return preconfigured `TaskStep` values with required inputs set. Prefer extending these helpers for compiler-generated tasks rather than open-coding `TaskStep::new(...)` with raw string keys at each call site. +- `tasks/` — typed **builder structs** for built-in ADO tasks (one submodule per task). Each builder exposes `new()`, typed chained setters for optional inputs (enums for constrained values, `bool` for bool-string inputs), and `into_step() -> TaskStep`; only set fields are emitted. Command/mode-dispatch tasks (`Docker@2`, `DotNetCoreCLI@2`, `NuGetCommand@2`, `PowerShell@2`) use a command enum with per-variant data so invalid input/command combos are unrepresentable (`tasks/docker.rs` is the canonical template). Prefer these builders for compiler-generated tasks rather than open-coding `TaskStep::new(...)` with raw string keys at each call site. - `job.rs` — `Job`, `Pool`, job variables, 1ES `templateContext` support, and target-job external `dependsOn` / `condition` wrapping. - `stage.rs` — `Stage` plus target-stage external `dependsOn` / `condition` wrapping. - `env.rs` — typed environment values (`EnvValue`) including ADO macros, pipeline variables, secrets, `OutputRef`s, `Coalesce`, and macro-form `Concat`. @@ -86,7 +86,7 @@ pub enum Step { Use the typed structs whenever the compiler owns the step: - `Step::Bash` for inline bash (`BashStep::script` is the raw body, not a YAML block). -- `Step::Task` for ADO task invocations such as `UseNode@1`, `UsePythonVersion@0`, or `UseDotNet@2`. For compiler-generated built-in tasks, prefer `src/compile/ir/tasks.rs` factory helpers over ad-hoc `TaskStep::new(...)` calls. +- `Step::Task` for ADO task invocations such as `UseNode@1`, `UsePythonVersion@0`, or `UseDotNet@2`. For compiler-generated built-in tasks, prefer the typed builder structs in `src/compile/ir/tasks/` (`::Builder::new(...).into_step()`) over ad-hoc `TaskStep::new(...)` calls. - `Step::Checkout` for `checkout:` steps. - `Step::Download` for pipeline-artifact downloads. - `Step::Publish` for pipeline-artifact publishes. Under 1ES, lowering moves publish steps into `templateContext.outputs` so artifacts are published by the 1ES template machinery exactly once. diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index e75497f9..4617b177 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -64,7 +64,7 @@ use super::ir::output::{OutputDecl, OutputRef}; use super::ir::step::{ BashStep, CheckoutRepo, CheckoutStep, DownloadStep, PublishStep, Step, SubmodulesOpt, }; -use super::ir::tasks::docker_installer_step; +use super::ir::tasks::docker_installer::DockerInstaller; use super::ir::{ CiTrigger, Parameter, ParameterDefault, ParameterKind, PipelineResource, PipelineVar, PrTrigger, RepositoryResource, Resources, Schedule, Triggers, @@ -763,7 +763,7 @@ fn build_agent_job( )?)); // 10. DockerInstaller@0 - steps.push(Step::Task(docker_installer_step("26.1.4"))); + steps.push(Step::Task(DockerInstaller::new("26.1.4").into_step())); // 11. Download AWF steps.push(Step::Bash(download_awf_step())); @@ -940,7 +940,7 @@ fn build_detection_job( // Download compiler steps.push(Step::Bash(download_compiler_step(&cfg.compiler_version))); // DockerInstaller - steps.push(Step::Task(docker_installer_step("26.1.4"))); + steps.push(Step::Task(DockerInstaller::new("26.1.4").into_step())); // Download AWF steps.push(Step::Bash(download_awf_step())); // Pre-pull AWF (no MCPG image for detection) diff --git a/src/compile/ir/tasks.rs b/src/compile/ir/tasks.rs deleted file mode 100644 index 15730c53..00000000 --- a/src/compile/ir/tasks.rs +++ /dev/null @@ -1,1471 +0,0 @@ -//! Typed factory helpers for ADO built-in pipeline tasks. -//! -//! Each public function returns a [`TaskStep`] pre-configured for a -//! specific ADO task. Required inputs are positional parameters; -//! optional inputs may be applied via `.with_input(…)` on the -//! returned value. -//! -//! These helpers eliminate hand-crafted `TaskStep::new(…)` + raw -//! string inputs at every call site, making task usage self-documenting -//! and the required/optional input boundary explicit. - -use super::step::TaskStep; - -/// Returns a [`TaskStep`] for `CopyFiles@2`. -/// -/// Copies files matching `contents` into `target_folder`. The optional -/// `source_folder` narrows the root from which the glob is evaluated; -/// when omitted ADO defaults to `$(Build.SourcesDirectory)`. -/// -/// Required inputs are positional parameters. Optional inputs (applied -/// via `.with_input(…)` on the returned value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `SourceFolder` | string | `$(Build.SourcesDirectory)` | Root for glob evaluation. | -/// | `CleanTargetFolder` | bool string | `"false"` | Delete target folder contents before copy. | -/// | `OverWrite` | bool string | `"false"` | Overwrite files in target folder. | -/// | `flattenFolders` | bool string | `"false"` | Flatten directory structure in target. | -/// | `preserveTimestamp` | bool string | `"false"` | Preserve source timestamps. | -/// | `retryCount` | string | `"0"` | Number of retry attempts on failure. | -/// | `delayBetweenRetries` | string | `"1000"` | Milliseconds between retries. | -/// | `ignoreMakeDirErrors` | bool string | `"false"` | Ignore errors when creating target folder. | -/// -/// ADO task reference: -/// -pub fn copy_files_step(contents: impl Into, target_folder: impl Into) -> TaskStep { - TaskStep::new("CopyFiles@2", "Copy Files") - .with_input("Contents", contents) - .with_input("TargetFolder", target_folder) -} - -/// Returns a [`TaskStep`] for `DockerInstaller@0`. -/// -/// Installs a specific version of Docker Engine on the agent. -/// -/// - `docker_version` — the Docker Engine version to install (e.g. -/// `"26.1.4"`). Maps to the `dockerVersion` ADO task input, which -/// is **required** by the task. -/// -/// Optional inputs (applied with `.with_input(…)` on the returned -/// value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `releaseType` | string | `"stable"` | Release channel: `"stable"`, `"edge"`, `"test"`, or `"nightly"`. | -/// -/// ADO task reference: -/// -pub fn docker_installer_step(docker_version: impl Into) -> TaskStep { - TaskStep::new("DockerInstaller@0", "Install Docker").with_input("dockerVersion", docker_version) -} - -/// Returns a [`TaskStep`] for `DotNetCoreCLI@2`. -/// -/// Runs a .NET CLI command against .NET projects or solutions. -/// -/// - `command` — the dotnet CLI sub-command. One of `"build"`, `"test"`, -/// `"publish"`, `"restore"`, `"pack"`, `"run"`, `"push"`, or -/// `"custom"`. This is the only required input; maps to the `command` -/// ADO task input. -/// -/// Optional inputs (applied with `.with_input(…)` on the returned value): -/// -/// | Input key | Applies to | Default | Description | -/// |---|---|---|---| -/// | `projects` | build, test, publish, restore, run, custom | — | Glob for `.csproj`/`.sln` files. | -/// | `arguments` | build, publish, run, test, custom | — | Extra CLI args (e.g. `"--configuration Release"`). | -/// | `workingDirectory` | build, publish, run, test, custom | — | Working directory for the command. | -/// | `publishTestResults` | test | `"true"` | Publish test results to the pipeline. | -/// | `testRunTitle` | test | — | Title shown in the build summary. | -/// | `zipAfterPublish` | publish | `"true"` | Zip output after publish. | -/// | `modifyOutputPath` | publish | `"true"` | Append project folder name to publish path. | -/// | `publishWebProjects` | publish | `"true"` | Publish all web projects. | -/// | `custom` | custom | — | The custom dotnet sub-command word. | -/// | `packagesToPush` | push | `"$(Build.ArtifactStagingDirectory)/*.nupkg"` | NuGet package glob to publish. | -/// | `packagesToPack` | pack | `"**/*.csproj"` | `.csproj`/`.nuspec` glob to pack. | -/// -/// ADO task reference: -/// -pub fn dot_net_core_cli_step(command: impl Into) -> TaskStep { - let cmd: String = command.into(); - TaskStep::new("DotNetCoreCLI@2", format!("dotnet {}", cmd)).with_input("command", cmd) -} - -/// Returns a [`TaskStep`] for `ArchiveFiles@2`. -/// -/// Creates an archive from `root_folder_or_file` and writes it to -/// `archive_file`. The archive type defaults to `zip`; override with -/// `.with_input("archiveType", "7z")` (or `"tar"` / `"wim"`) when needed. -/// -/// Required inputs are positional parameters. Optional inputs (applied -/// via `.with_input(…)` on the returned value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `archiveType` | string | `"zip"` | Archive format: `"zip"`, `"7z"`, `"tar"`, `"wim"`. | -/// | `includeRootFolder` | bool string | `"true"` | Prepend root folder name to archive paths. | -/// | `replaceExistingArchive` | bool string | `"true"` | Replace existing archive. | -/// | `sevenZipCompression` | string | `"normal"` | 7z compression level (when `archiveType = 7z`). | -/// | `tarCompression` | string | `"gz"` | Tar compression (when `archiveType = tar`): `"gz"`, `"bz2"`, `"xz"`, `"none"`. | -/// | `verbose` | bool string | `"false"` | Force verbose output. | -/// | `quiet` | bool string | `"false"` | Force quiet output. | -/// -/// ADO task reference: -/// -pub fn archive_files_step( - root_folder_or_file: impl Into, - archive_file: impl Into, -) -> TaskStep { - TaskStep::new("ArchiveFiles@2", "Archive Files") - .with_input("rootFolderOrFile", root_folder_or_file) - .with_input("archiveFile", archive_file) -} - -/// Returns a [`TaskStep`] for `ExtractFiles@1`. -/// -/// Extracts archives matching `archive_file_patterns` into `destination_folder`. -/// Supports `.zip`, `.tar.gz`, `.tar.bz2`, and 7-Zip formats (`.7z`, `.tar`, -/// `.rar`, etc.) via the 7z utility bundled with the task on Windows agents, -/// or the system 7z on Linux/macOS. -/// -/// - `archive_file_patterns` — glob pattern(s) that match the archives to -/// extract. Patterns are evaluated from the root of the repository -/// (equivalent to `$(Build.SourcesDirectory)`). Multiple patterns can be -/// separated by newlines. Default: `**/*.zip`. -/// - `destination_folder` — path to the folder where files will be extracted. -/// **Required** — the task has no default for this input. -/// -/// Optional inputs (applied with `.with_input(…)` on the returned value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `cleanDestinationFolder` | bool string | `"true"` | Delete destination folder contents before extracting. | -/// | `overwriteExistingFiles` | bool string | `"false"` | Overwrite files that already exist in the destination. | -/// | `pathToSevenZipTool` | string | — | Absolute path to a custom `7z` binary (e.g. `/usr/local/bin/7z`). | -/// -/// ADO task reference: -/// -pub fn extract_files_step( - archive_file_patterns: impl Into, - destination_folder: impl Into, -) -> TaskStep { - TaskStep::new("ExtractFiles@1", "Extract Files") - .with_input("archiveFilePatterns", archive_file_patterns) - .with_input("destinationFolder", destination_folder) -} - -/// Returns a [`TaskStep`] for `PublishTestResults@2`. -/// -/// Publishes test results to the ADO build summary and timeline. -/// -/// - `test_results_format` — the test result format. One of `"JUnit"`, -/// `"NUnit"`, `"VSTest"`, `"XUnit"`, or `"CTest"` (alias: -/// `testRunner`). -/// - `test_results_files` — glob pattern that selects the result files, -/// e.g. `"**/TEST-*.xml"` or `"**/*.trx"`. -/// -/// Optional inputs (applied with `.with_input(…)` on the returned -/// value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `testRunTitle` | string | — | Label shown in the build summary. | -/// | `searchFolder` | string | `$(System.DefaultWorkingDirectory)` | Root for glob expansion. | -/// | `mergeTestResults` | bool string | `"false"` | Combine results into one run. | -/// | `failTaskOnFailedTests` | bool string | `"false"` | Fail the step if tests failed. | -/// | `publishRunAttachments` | bool string | `"true"` | Upload result files. | -/// -/// ADO task reference: -/// -pub fn publish_test_results_step( - test_results_format: impl Into, - test_results_files: impl Into, -) -> TaskStep { - TaskStep::new("PublishTestResults@2", "Publish Test Results") - .with_input("testResultsFormat", test_results_format) - .with_input("testResultsFiles", test_results_files) -} - -/// Returns a [`TaskStep`] for `NuGetCommand@2`. -/// -/// Runs a NuGet command. The `command` parameter selects the operation mode; -/// each mode exposes a different set of optional inputs. -/// -/// - `command` — the NuGet operation: `"restore"`, `"push"`, `"pack"`, or -/// `"custom"`. This is the only required input. -/// -/// **`restore` optional inputs** (applied with `.with_input(…)`): -/// -/// | Input key | Default | Description | -/// |---|---|---| -/// | `solution` | `"**/*.sln"` | Path to solution, `packages.config`, or `project.json`. | -/// | `feedsToUse` | `"select"` | `"select"` (dropdown) or `"config"` (NuGet.config). | -/// | `vstsFeed` | — | Azure Artifacts feed name or ID (when `feedsToUse = select`). | -/// | `includeNuGetOrg` | `"true"` | Include NuGet.org as a package source. | -/// | `nugetConfigPath` | — | Path to `NuGet.config` (when `feedsToUse = config`). | -/// | `externalFeedCredentials` | — | Credentials for external feeds outside the org. | -/// | `noCache` | `"false"` | Disable the local NuGet cache. | -/// | `disableParallelProcessing` | `"false"` | Disable parallel package restore. | -/// | `restoreDirectory` | — | Destination directory for restored packages. | -/// | `verbosityRestore` | `"Detailed"` | Verbosity: `"Quiet"`, `"Normal"`, or `"Detailed"`. | -/// -/// **`push` optional inputs**: -/// -/// | Input key | Default | Description | -/// |---|---|---| -/// | `packagesToPush` | `"$(Build.ArtifactStagingDirectory)/**/*.nupkg;…"` | Glob for `.nupkg` files to publish. | -/// | `nuGetFeedType` | `"internal"` | Feed location: `"internal"` (Azure Artifacts) or `"external"`. | -/// | `publishVstsFeed` | — | Target Azure Artifacts feed (when `nuGetFeedType = internal`). | -/// | `allowPackageConflicts` | `"false"` | Skip duplicate packages instead of failing. | -/// | `publishFeedCredentials` | — | External NuGet server endpoint (when `nuGetFeedType = external`). | -/// | `publishPackageMetadata` | `"true"` | Publish pipeline metadata alongside the package. | -/// | `verbosityPush` | `"Detailed"` | Verbosity: `"Quiet"`, `"Normal"`, or `"Detailed"`. | -/// -/// **`pack` optional inputs**: -/// -/// | Input key | Default | Description | -/// |---|---|---| -/// | `packagesToPack` | `"**/*.csproj"` | Glob for `.csproj` or `.nuspec` files to pack. | -/// | `configuration` | — | Build configuration (e.g. `"Release"`). | -/// | `versioningScheme` | `"off"` | Version strategy: `"off"`, `"byPrereleaseNumber"`, `"byEnvVar"`, `"byBuildNumber"`. | -/// | `verbosityPack` | `"Detailed"` | Verbosity: `"Quiet"`, `"Normal"`, or `"Detailed"`. | -/// -/// **`custom` optional inputs**: -/// -/// | Input key | Default | Description | -/// |---|---|---| -/// | `arguments` | — | Full NuGet command-line arguments (e.g. `"install Foo -Version 1.0 -Source ..."`). **Required** for `custom`. | -/// -/// ADO task reference: -/// -pub fn nuget_command_step(command: impl Into) -> TaskStep { - let cmd: String = command.into(); - TaskStep::new("NuGetCommand@2", format!("NuGet {cmd}")).with_input("command", cmd) -} - -/// Returns a [`TaskStep`] for `PowerShell@2` in file-path mode. -/// -/// Runs the PowerShell script at `file_path` on Linux, macOS, or Windows. -/// `file_path` must be a fully qualified path or relative to -/// `$(System.DefaultWorkingDirectory)`. -/// -/// Optional inputs (applied via `.with_input(…)` on the returned value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `arguments` | string | — | Arguments passed to the script. | -/// | `errorActionPreference` | string | `"stop"` | Non-terminating error behaviour: `"stop"`, `"continue"`, `"silentlyContinue"`. | -/// | `failOnStderr` | bool string | `"false"` | Fail the step if anything is written to stderr. | -/// | `ignoreLASTEXITCODE` | bool string | `"false"` | Do not fail when `$LASTEXITCODE` is non-zero. | -/// | `pwsh` | bool string | `"false"` | Use PowerShell Core (`pwsh`) instead of Windows PowerShell. | -/// | `workingDirectory` | string | — | Working directory for the script. | -/// -/// ADO task reference: -/// -pub fn powershell_file_step(file_path: impl Into) -> TaskStep { - TaskStep::new("PowerShell@2", "PowerShell Script") - .with_input("targetType", "filePath") - .with_input("filePath", file_path) -} - -/// Returns a [`TaskStep`] for `PowerShell@2` in inline mode. -/// -/// Runs `script` as an inline PowerShell block on Linux, macOS, or Windows. -/// -/// Optional inputs (applied via `.with_input(…)` on the returned value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `errorActionPreference` | string | `"stop"` | Non-terminating error behaviour: `"stop"`, `"continue"`, `"silentlyContinue"`. | -/// | `failOnStderr` | bool string | `"false"` | Fail the step if anything is written to stderr. | -/// | `ignoreLASTEXITCODE` | bool string | `"false"` | Do not fail when `$LASTEXITCODE` is non-zero. | -/// | `pwsh` | bool string | `"false"` | Use PowerShell Core (`pwsh`) instead of Windows PowerShell. | -/// | `workingDirectory` | string | — | Working directory for the script. | -/// -/// ADO task reference: -/// -pub fn powershell_inline_step(script: impl Into) -> TaskStep { - TaskStep::new("PowerShell@2", "PowerShell Script") - .with_input("targetType", "inline") - .with_input("script", script) -} - -/// Returns a [`TaskStep`] for `PublishPipelineArtifact@1`. -/// -/// Publishes (uploads) a file or directory as a named artifact for the -/// current pipeline run. The artifact is stored in Azure Pipelines and -/// can be downloaded by subsequent jobs or pipelines via -/// `DownloadPipelineArtifact@2`. -/// -/// - `target_path` — path of the file or directory to publish. Can be -/// absolute or relative to the default working directory. Supports -/// ADO macro variables (e.g. `$(Build.ArtifactStagingDirectory)`), -/// but wildcards are **not** supported. -/// -/// Optional inputs (applied with `.with_input(…)` on the returned -/// value): -/// -/// | Input key | Alias | Type | Default | Description | -/// |---|---|---|---|---| -/// | `artifact` | `artifactName` | string | *(unique job-scoped ID)* | Name of the published artifact (e.g. `"drop"`). May not contain `\`, `/`, `"`, `:`, `<`, `>`, `\|`, `*`, or `?`. | -/// | `publishLocation` | `artifactType` | string | `"pipeline"` | Where to store the artifact: `"pipeline"` (Azure Pipelines) or `"filepath"` (a UNC file share). | -/// | `fileSharePath` | — | string | — | Required when `publishLocation = filepath`. UNC path of the file share. | -/// | `parallel` | — | bool string | `"false"` | Enable multi-threaded copy when `publishLocation = filepath`. | -/// | `parallelCount` | — | string | `"8"` | Thread count for parallel copy (1–128). Applies when `parallel = true`. | -/// | `properties` | — | string | — | JSON string of custom properties to associate with the artifact (keys must start with `user-`). | -/// -/// ADO task reference: -/// -pub fn publish_pipeline_artifact_step(target_path: impl Into) -> TaskStep { - TaskStep::new("PublishPipelineArtifact@1", "Publish Pipeline Artifact") - .with_input("targetPath", target_path) -} - -/// Returns a [`TaskStep`] for `DownloadPipelineArtifact@2`. -/// -/// Downloads artifacts produced by a pipeline run into `target_path`. -/// By default the step downloads from the **current** run; set -/// `source = "specific"` via `.with_input(…)` to pull from a -/// different run or pipeline. -/// -/// - `target_path` — local filesystem path where the artifact will be -/// downloaded. Maps to the `targetPath` ADO task input, which is -/// **required** by the task. -/// -/// Optional inputs (applied with `.with_input(…)` on the returned value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---| -/// | `artifact` | string | — | Name of the artifact to download. Omit to download all artifacts. | -/// | `patterns` | string | `"**"` | Newline-separated glob patterns that filter which files inside the artifact are downloaded. | -/// | `source` | string | `"current"` | `"current"` (this run) or `"specific"` (another run). | -/// | `project` | string | — | ADO project name or ID (`source = "specific"` only). | -/// | `pipeline` | string | — | Pipeline definition ID or name (`source = "specific"` only). | -/// | `runVersion` | string | `"latest"` | Which run to download from: `"latest"`, `"latestFromBranch"`, or `"specific"` (`source = "specific"` only). | -/// | `branchName` | string | — | Branch filter, e.g. `"refs/heads/main"` (`runVersion = "latestFromBranch"` only). | -/// | `runId` | string | — | The build ID to download from (`runVersion = "specific"` only). | -/// | `tags` | string | — | Comma-separated build tags used to filter candidate runs. | -/// | `allowPartiallySucceededBuilds` | bool string | `"false"` | Also consider partially-succeeded runs as download candidates. | -/// | `allowFailedBuilds` | bool string | `"false"` | Also consider failed runs as download candidates. | -/// | `preferTriggeringPipeline` | bool string | `"false"` | Prefer the run that triggered the current pipeline. | -/// | `itemPattern` | string | `"**"` | Minimatch pattern applied after download to select a subset of files. | -/// -/// ADO task reference: -/// -pub fn download_pipeline_artifact_step(target_path: impl Into) -> TaskStep { - TaskStep::new("DownloadPipelineArtifact@2", "Download Pipeline Artifact") - .with_input("targetPath", target_path) -} - -/// Returns a [`TaskStep`] for `DeleteFiles@1`. -/// -/// Deletes files or folders matching one or more patterns from a source folder. -/// -/// - `contents` — newline-separated glob patterns identifying the files or -/// folders to remove (e.g. `"**/*.tmp"` or `"dist\n*.log"`). This is the -/// only required input. -/// -/// Optional inputs (applied with `.with_input(…)` on the returned value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `SourceFolder` | string | working directory | Root folder to delete from. Use `$(Build.ArtifactStagingDirectory)` to clean staging. | -/// | `RemoveSourceFolder` | bool string | `"false"` | Remove the `SourceFolder` itself after deleting its contents. Set to `"true"` and `contents` to `"*"` to wipe the whole folder. | -/// | `RemoveDotFiles` | bool string | `"false"` | Also delete files whose name starts with a dot. Defaults to `"false"` (dot files are preserved). | -/// -/// ADO task reference: -/// -pub fn delete_files_step(contents: impl Into) -> TaskStep { - TaskStep::new("DeleteFiles@1", "Delete Files") - .with_input("Contents", contents) -} - -/// Returns a [`TaskStep`] for `CmdLine@2`. -/// -/// Runs an inline command-line script. On Linux and macOS the script -/// is executed with Bash; on Windows it runs in `cmd.exe`. This makes -/// `CmdLine@2` the cross-platform sibling of the `bash:` step shorthand. -/// -/// - `script` — the inline script text to execute (required). Maps to -/// the `script` ADO task input. -/// -/// Optional inputs (applied via `.with_input(…)` on the returned value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `workingDirectory` | string | — | Working directory in which to run the script. | -/// | `failOnStderr` | bool string | `"false"` | Fail the step if the script writes anything to stderr. | -/// -/// ADO task reference: -/// -pub fn cmd_line_step(script: impl Into) -> TaskStep { - TaskStep::new("CmdLine@2", "Command Line Script").with_input("script", script) -} - -/// Returns a [`TaskStep`] for `Docker@2` in `buildAndPush` mode. -/// -/// Builds a Docker image and pushes it to a container registry in one step. -/// This is the most common Docker@2 use case; it combines `docker build` -/// and `docker push` into a single pipeline step and ensures the pushed image -/// digest matches what was built. -/// -/// All inputs are optional at the Rust API level because the ADO task ships -/// sensible defaults (`Dockerfile = **/Dockerfile`, `tags = $(Build.BuildId)`). -/// Apply them with `.with_input(…)`: -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `containerRegistry` | string | — | Docker registry service connection name. Required in practice to push to a private registry. | -/// | `repository` | string | — | Container repository name (e.g. `"myapp"` or `"username/myapp"` for Docker Hub). | -/// | `Dockerfile` | string | `**/Dockerfile` | Path or glob to the Dockerfile. | -/// | `buildContext` | string | `**` | Build context path relative to the repo root. | -/// | `tags` | string | `$(Build.BuildId)` | Newline-separated list of image tags. | -/// -/// ADO task reference: -/// -pub fn docker_build_and_push_step() -> TaskStep { - TaskStep::new("Docker@2", "Build and Push Docker Image").with_input("command", "buildAndPush") -} - -/// Returns a [`TaskStep`] for `Docker@2` in `build` mode. -/// -/// Builds a Docker image without pushing it to a registry. Use -/// `docker_build_and_push_step()` when you want to build and push in one -/// step; use this when you need to run a scan or test between build and push. -/// -/// Optional inputs (applied via `.with_input(…)` on the returned value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `containerRegistry` | string | — | Docker registry service connection for authentication. | -/// | `repository` | string | — | Image name to tag the build as. | -/// | `Dockerfile` | string | `**/Dockerfile` | Path or glob to the Dockerfile. | -/// | `buildContext` | string | `**` | Build context path relative to the repo root. | -/// | `tags` | string | `$(Build.BuildId)` | Newline-separated list of image tags. | -/// | `arguments` | string | — | Extra arguments appended to the `docker build` command. | -/// -/// ADO task reference: -/// -pub fn docker_build_step() -> TaskStep { - TaskStep::new("Docker@2", "Build Docker Image").with_input("command", "build") -} - -/// Returns a [`TaskStep`] for `Docker@2` in `push` mode. -/// -/// Pushes a previously-built Docker image to a container registry. Use after -/// `docker_build_step()` when the build and push need to be separate steps -/// (e.g. to run a security scan in between). -/// -/// Optional inputs (applied via `.with_input(…)` on the returned value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `containerRegistry` | string | — | Docker registry service connection name. | -/// | `repository` | string | — | Container repository name to push to. | -/// | `tags` | string | `$(Build.BuildId)` | Newline-separated list of tags to push. | -/// | `arguments` | string | — | Extra arguments appended to the `docker push` command. | -/// -/// ADO task reference: -/// -pub fn docker_push_step() -> TaskStep { - TaskStep::new("Docker@2", "Push Docker Image").with_input("command", "push") -} - -/// Returns a [`TaskStep`] for `Docker@2` in `login` mode. -/// -/// Logs in to a Docker container registry. Pair this with -/// `docker_logout_step()` at the end of the job. The service connection is -/// specified via `.with_input("containerRegistry", "")`. -/// -/// Optional inputs (applied via `.with_input(…)` on the returned value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `containerRegistry` | string | — | Docker registry service connection name. When omitted the task logs in to Docker Hub. | -/// -/// ADO task reference: -/// -pub fn docker_login_step() -> TaskStep { - TaskStep::new("Docker@2", "Docker Login").with_input("command", "login") -} - -/// Returns a [`TaskStep`] for `Docker@2` in `logout` mode. -/// -/// Logs out from a Docker container registry. Use after a series of Docker -/// steps to ensure credentials are not left on the agent. -/// -/// Optional inputs (applied via `.with_input(…)` on the returned value): -/// -/// | Input key | Type | Default | Description | -/// |---|---|---|---| -/// | `containerRegistry` | string | — | Docker registry service connection name. | -/// -/// ADO task reference: -/// -pub fn docker_logout_step() -> TaskStep { - TaskStep::new("Docker@2", "Docker Logout").with_input("command", "logout") -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── CopyFiles@2 ────────────────────────────────────────────────────── - - #[test] - fn copy_files_step_sets_task_and_required_inputs() { - let t = copy_files_step("**/*.rs", "$(Build.ArtifactStagingDirectory)"); - assert_eq!(t.task, "CopyFiles@2"); - assert_eq!(t.display_name, "Copy Files"); - assert_eq!( - t.inputs.get("Contents").map(|s| s.as_str()), - Some("**/*.rs") - ); - assert_eq!( - t.inputs.get("TargetFolder").map(|s| s.as_str()), - Some("$(Build.ArtifactStagingDirectory)") - ); - // no optional inputs by default - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn copy_files_step_accepts_source_folder_via_with_input() { - let t = copy_files_step("**", "$(Build.ArtifactStagingDirectory)") - .with_input("SourceFolder", "$(Build.SourcesDirectory)/src"); - assert_eq!(t.task, "CopyFiles@2"); - assert_eq!( - t.inputs.get("SourceFolder").map(|s| s.as_str()), - Some("$(Build.SourcesDirectory)/src") - ); - assert_eq!(t.inputs.len(), 3); - } - - #[test] - fn copy_files_step_accepts_optional_flags() { - let t = copy_files_step("**", "$(Build.ArtifactStagingDirectory)") - .with_input("CleanTargetFolder", "true") - .with_input("OverWrite", "true") - .with_input("flattenFolders", "true"); - assert_eq!( - t.inputs.get("CleanTargetFolder").map(|s| s.as_str()), - Some("true") - ); - assert_eq!(t.inputs.get("OverWrite").map(|s| s.as_str()), Some("true")); - assert_eq!( - t.inputs.get("flattenFolders").map(|s| s.as_str()), - Some("true") - ); - assert_eq!(t.inputs.len(), 5); - } - - // ── DotNetCoreCLI@2 ────────────────────────────────────────────────── - - #[test] - fn dot_net_core_cli_step_build_sets_task_and_command() { - let t = dot_net_core_cli_step("build"); - assert_eq!(t.task, "DotNetCoreCLI@2"); - assert_eq!(t.display_name, "dotnet build"); - assert_eq!(t.inputs.get("command").map(|s| s.as_str()), Some("build")); - // only the required input is set by default - assert_eq!(t.inputs.len(), 1); - } - - #[test] - fn dot_net_core_cli_step_test_command_with_optional_inputs() { - let t = dot_net_core_cli_step("test") - .with_input("projects", "**/*Tests.csproj") - .with_input("arguments", "--configuration Release") - .with_input("publishTestResults", "true") - .with_input("testRunTitle", "Unit Tests"); - assert_eq!(t.task, "DotNetCoreCLI@2"); - assert_eq!(t.display_name, "dotnet test"); - assert_eq!(t.inputs.get("command").map(|s| s.as_str()), Some("test")); - assert_eq!( - t.inputs.get("projects").map(|s| s.as_str()), - Some("**/*Tests.csproj") - ); - assert_eq!( - t.inputs.get("arguments").map(|s| s.as_str()), - Some("--configuration Release") - ); - assert_eq!( - t.inputs.get("publishTestResults").map(|s| s.as_str()), - Some("true") - ); - assert_eq!( - t.inputs.get("testRunTitle").map(|s| s.as_str()), - Some("Unit Tests") - ); - assert_eq!(t.inputs.len(), 5); - } - - #[test] - fn dot_net_core_cli_step_accepts_all_supported_commands() { - for cmd in &[ - "build", "test", "publish", "restore", "pack", "run", "push", "custom", - ] { - let t = dot_net_core_cli_step(*cmd); - assert_eq!(t.task, "DotNetCoreCLI@2"); - assert_eq!(t.display_name, format!("dotnet {}", cmd)); - assert_eq!(t.inputs.get("command").map(|s| s.as_str()), Some(*cmd)); - } - } - - #[test] - fn dot_net_core_cli_step_publish_optional_inputs() { - let t = dot_net_core_cli_step("publish") - .with_input("projects", "src/MyApp/MyApp.csproj") - .with_input( - "arguments", - "--configuration Release --output $(Build.ArtifactStagingDirectory)", - ) - .with_input("zipAfterPublish", "false") - .with_input("modifyOutputPath", "false"); - assert_eq!(t.inputs.get("command").map(|s| s.as_str()), Some("publish")); - assert_eq!( - t.inputs.get("zipAfterPublish").map(|s| s.as_str()), - Some("false") - ); - assert_eq!( - t.inputs.get("modifyOutputPath").map(|s| s.as_str()), - Some("false") - ); - assert_eq!(t.inputs.len(), 5); - } - - // ── PublishTestResults@2 ───────────────────────────────────────────── - - #[test] - fn publish_test_results_step_sets_task_and_required_inputs() { - let t = publish_test_results_step("JUnit", "**/TEST-*.xml"); - assert_eq!(t.task, "PublishTestResults@2"); - assert_eq!( - t.inputs.get("testResultsFormat").map(|s| s.as_str()), - Some("JUnit") - ); - assert_eq!( - t.inputs.get("testResultsFiles").map(|s| s.as_str()), - Some("**/TEST-*.xml") - ); - // display name follows ADO convention - assert_eq!(t.display_name, "Publish Test Results"); - // no optional inputs by default - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn publish_test_results_step_accepts_all_supported_formats() { - for format in &["JUnit", "NUnit", "VSTest", "XUnit", "CTest"] { - let t = publish_test_results_step(*format, "**/results.xml"); - assert_eq!(t.task, "PublishTestResults@2"); - assert_eq!( - t.inputs.get("testResultsFormat").map(|s| s.as_str()), - Some(*format) - ); - } - } - - #[test] - fn publish_test_results_step_optional_inputs_via_with_input() { - let t = publish_test_results_step("VSTest", "**/*.trx") - .with_input("testRunTitle", "Unit Tests") - .with_input("mergeTestResults", "true") - .with_input("searchFolder", "$(System.DefaultWorkingDirectory)"); - assert_eq!(t.task, "PublishTestResults@2"); - assert_eq!( - t.inputs.get("testRunTitle").map(|s| s.as_str()), - Some("Unit Tests") - ); - assert_eq!( - t.inputs.get("mergeTestResults").map(|s| s.as_str()), - Some("true") - ); - assert_eq!( - t.inputs.get("searchFolder").map(|s| s.as_str()), - Some("$(System.DefaultWorkingDirectory)") - ); - assert_eq!(t.inputs.len(), 5); - } - - #[test] - fn docker_installer_step_sets_task_and_required_input() { - let t = docker_installer_step("26.1.4"); - assert_eq!(t.task, "DockerInstaller@0"); - assert_eq!(t.display_name, "Install Docker"); - assert_eq!( - t.inputs.get("dockerVersion").map(|s| s.as_str()), - Some("26.1.4") - ); - // only the required input is set by default - assert_eq!(t.inputs.len(), 1); - } - - #[test] - fn docker_installer_step_optional_release_type_via_with_input() { - let t = docker_installer_step("26.1.4").with_input("releaseType", "edge"); - assert_eq!(t.task, "DockerInstaller@0"); - assert_eq!( - t.inputs.get("dockerVersion").map(|s| s.as_str()), - Some("26.1.4") - ); - assert_eq!( - t.inputs.get("releaseType").map(|s| s.as_str()), - Some("edge") - ); - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn docker_installer_step_accepts_different_versions() { - for version in &["17.09.0-ce", "20.10.0", "26.1.4"] { - let t = docker_installer_step(*version); - assert_eq!( - t.inputs.get("dockerVersion").map(|s| s.as_str()), - Some(*version) - ); - } - } - - // ── ArchiveFiles@2 ─────────────────────────────────────────────────── - - #[test] - fn archive_files_step_sets_task_and_required_inputs() { - let t = archive_files_step( - "$(Build.BinariesDirectory)", - "$(Build.ArtifactStagingDirectory)/output.zip", - ); - assert_eq!(t.task, "ArchiveFiles@2"); - assert_eq!(t.display_name, "Archive Files"); - assert_eq!( - t.inputs.get("rootFolderOrFile").map(|s| s.as_str()), - Some("$(Build.BinariesDirectory)") - ); - assert_eq!( - t.inputs.get("archiveFile").map(|s| s.as_str()), - Some("$(Build.ArtifactStagingDirectory)/output.zip") - ); - // no optional inputs by default - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn archive_files_step_accepts_archive_type_override() { - let t = archive_files_step("$(Build.BinariesDirectory)", "$(Build.ArtifactStagingDirectory)/output.tar.gz") - .with_input("archiveType", "tar") - .with_input("tarCompression", "gz"); - assert_eq!(t.task, "ArchiveFiles@2"); - assert_eq!( - t.inputs.get("archiveType").map(|s| s.as_str()), - Some("tar") - ); - assert_eq!( - t.inputs.get("tarCompression").map(|s| s.as_str()), - Some("gz") - ); - assert_eq!(t.inputs.len(), 4); - } - - #[test] - fn archive_files_step_accepts_optional_flags() { - let t = archive_files_step("$(Build.SourcesDirectory)", "$(Build.ArtifactStagingDirectory)/src.zip") - .with_input("includeRootFolder", "false") - .with_input("replaceExistingArchive", "true"); - assert_eq!( - t.inputs.get("includeRootFolder").map(|s| s.as_str()), - Some("false") - ); - assert_eq!( - t.inputs.get("replaceExistingArchive").map(|s| s.as_str()), - Some("true") - ); - assert_eq!(t.inputs.len(), 4); - } - - #[test] - fn archive_files_step_seven_zip_compression() { - let t = archive_files_step("$(Build.BinariesDirectory)", "$(Build.ArtifactStagingDirectory)/output.7z") - .with_input("archiveType", "7z") - .with_input("sevenZipCompression", "maximum"); - assert_eq!(t.task, "ArchiveFiles@2"); - assert_eq!( - t.inputs.get("archiveType").map(|s| s.as_str()), - Some("7z") - ); - assert_eq!( - t.inputs.get("sevenZipCompression").map(|s| s.as_str()), - Some("maximum") - ); - assert_eq!(t.inputs.len(), 4); - } - - // ── ExtractFiles@1 ─────────────────────────────────────────────────── - - #[test] - fn extract_files_step_sets_task_and_required_inputs() { - let t = extract_files_step("**/*.zip", "$(Build.BinariesDirectory)"); - assert_eq!(t.task, "ExtractFiles@1"); - assert_eq!(t.display_name, "Extract Files"); - assert_eq!( - t.inputs.get("archiveFilePatterns").map(|s| s.as_str()), - Some("**/*.zip") - ); - assert_eq!( - t.inputs.get("destinationFolder").map(|s| s.as_str()), - Some("$(Build.BinariesDirectory)") - ); - // no optional inputs by default - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn extract_files_step_accepts_optional_clean_and_overwrite() { - let t = extract_files_step("**/*.tar.gz", "$(Agent.TempDirectory)/extracted") - .with_input("cleanDestinationFolder", "false") - .with_input("overwriteExistingFiles", "true"); - assert_eq!(t.task, "ExtractFiles@1"); - assert_eq!( - t.inputs.get("cleanDestinationFolder").map(|s| s.as_str()), - Some("false") - ); - assert_eq!( - t.inputs.get("overwriteExistingFiles").map(|s| s.as_str()), - Some("true") - ); - assert_eq!(t.inputs.len(), 4); - } - - #[test] - fn extract_files_step_accepts_custom_seven_zip_path() { - let t = extract_files_step("artifacts/**/*.7z", "$(Build.BinariesDirectory)") - .with_input("pathToSevenZipTool", "/usr/local/bin/7z"); - assert_eq!(t.task, "ExtractFiles@1"); - assert_eq!( - t.inputs.get("pathToSevenZipTool").map(|s| s.as_str()), - Some("/usr/local/bin/7z") - ); - assert_eq!(t.inputs.len(), 3); - } - - #[test] - fn extract_files_step_multiline_patterns() { - let patterns = "**/*.zip\n**/*.tar.gz"; - let t = extract_files_step(patterns, "$(Build.BinariesDirectory)"); - assert_eq!( - t.inputs.get("archiveFilePatterns").map(|s| s.as_str()), - Some("**/*.zip\n**/*.tar.gz") - ); - } - - // ── NuGetCommand@2 ─────────────────────────────────────────────────── - - #[test] - fn nuget_command_step_restore_sets_task_and_command() { - let t = nuget_command_step("restore"); - assert_eq!(t.task, "NuGetCommand@2"); - assert_eq!(t.display_name, "NuGet restore"); - assert_eq!(t.inputs.get("command").map(|s| s.as_str()), Some("restore")); - // only the required input is set by default - assert_eq!(t.inputs.len(), 1); - } - - #[test] - fn nuget_command_step_custom_with_arguments() { - let t = nuget_command_step("custom").with_input( - "arguments", - "install My.Package -Version 1.0.0 -Source https://example.com/nuget -NonInteractive", - ); - assert_eq!(t.task, "NuGetCommand@2"); - assert_eq!(t.display_name, "NuGet custom"); - assert_eq!(t.inputs.get("command").map(|s| s.as_str()), Some("custom")); - assert_eq!( - t.inputs.get("arguments").map(|s| s.as_str()), - Some("install My.Package -Version 1.0.0 -Source https://example.com/nuget -NonInteractive") - ); - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn nuget_command_step_push_with_feed() { - let t = nuget_command_step("push") - .with_input("nuGetFeedType", "internal") - .with_input( - "packagesToPush", - "$(Build.ArtifactStagingDirectory)/**/*.nupkg", - ) - .with_input("publishVstsFeed", "myorg/myfeed") - .with_input("allowPackageConflicts", "true"); - assert_eq!(t.task, "NuGetCommand@2"); - assert_eq!(t.inputs.get("command").map(|s| s.as_str()), Some("push")); - assert_eq!( - t.inputs.get("nuGetFeedType").map(|s| s.as_str()), - Some("internal") - ); - assert_eq!( - t.inputs.get("publishVstsFeed").map(|s| s.as_str()), - Some("myorg/myfeed") - ); - assert_eq!( - t.inputs.get("allowPackageConflicts").map(|s| s.as_str()), - Some("true") - ); - assert_eq!(t.inputs.len(), 5); - } - - #[test] - fn nuget_command_step_restore_with_vsts_feed() { - let t = nuget_command_step("restore") - .with_input("solution", "src/MyApp.sln") - .with_input("feedsToUse", "select") - .with_input("vstsFeed", "myorg/myproject/myfeed") - .with_input("includeNuGetOrg", "false"); - assert_eq!(t.task, "NuGetCommand@2"); - assert_eq!( - t.inputs.get("solution").map(|s| s.as_str()), - Some("src/MyApp.sln") - ); - assert_eq!( - t.inputs.get("vstsFeed").map(|s| s.as_str()), - Some("myorg/myproject/myfeed") - ); - assert_eq!( - t.inputs.get("includeNuGetOrg").map(|s| s.as_str()), - Some("false") - ); - assert_eq!(t.inputs.len(), 5); - } - - #[test] - fn nuget_command_step_accepts_all_supported_commands() { - for cmd in &["restore", "push", "pack", "custom"] { - let t = nuget_command_step(*cmd); - assert_eq!(t.task, "NuGetCommand@2"); - assert_eq!(t.display_name, format!("NuGet {cmd}")); - assert_eq!(t.inputs.get("command").map(|s| s.as_str()), Some(*cmd)); - } - } - - // ── PowerShell@2 ───────────────────────────────────────────────────── - - #[test] - fn powershell_file_step_sets_task_and_required_inputs() { - let t = powershell_file_step("scripts/deploy.ps1"); - assert_eq!(t.task, "PowerShell@2"); - assert_eq!(t.display_name, "PowerShell Script"); - assert_eq!( - t.inputs.get("targetType").map(|s| s.as_str()), - Some("filePath") - ); - assert_eq!( - t.inputs.get("filePath").map(|s| s.as_str()), - Some("scripts/deploy.ps1") - ); - // only the required inputs are set by default - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn powershell_file_step_accepts_optional_arguments() { - let t = powershell_file_step("$(System.DefaultWorkingDirectory)/scripts/build.ps1") - .with_input("arguments", "-Configuration Release -OutputDir $(Build.ArtifactStagingDirectory)") - .with_input("workingDirectory", "$(Build.SourcesDirectory)"); - assert_eq!(t.task, "PowerShell@2"); - assert_eq!( - t.inputs.get("filePath").map(|s| s.as_str()), - Some("$(System.DefaultWorkingDirectory)/scripts/build.ps1") - ); - assert_eq!( - t.inputs.get("arguments").map(|s| s.as_str()), - Some("-Configuration Release -OutputDir $(Build.ArtifactStagingDirectory)") - ); - assert_eq!( - t.inputs.get("workingDirectory").map(|s| s.as_str()), - Some("$(Build.SourcesDirectory)") - ); - assert_eq!(t.inputs.len(), 4); - } - - #[test] - fn powershell_file_step_accepts_error_and_exit_flags() { - let t = powershell_file_step("scripts/test.ps1") - .with_input("errorActionPreference", "continue") - .with_input("failOnStderr", "true") - .with_input("ignoreLASTEXITCODE", "true") - .with_input("pwsh", "true"); - assert_eq!(t.task, "PowerShell@2"); - assert_eq!( - t.inputs.get("errorActionPreference").map(|s| s.as_str()), - Some("continue") - ); - assert_eq!( - t.inputs.get("failOnStderr").map(|s| s.as_str()), - Some("true") - ); - assert_eq!( - t.inputs.get("ignoreLASTEXITCODE").map(|s| s.as_str()), - Some("true") - ); - assert_eq!(t.inputs.get("pwsh").map(|s| s.as_str()), Some("true")); - assert_eq!(t.inputs.len(), 6); - } - - #[test] - fn powershell_inline_step_sets_task_and_required_inputs() { - let script = "Write-Host 'Hello, World!'"; - let t = powershell_inline_step(script); - assert_eq!(t.task, "PowerShell@2"); - assert_eq!(t.display_name, "PowerShell Script"); - assert_eq!( - t.inputs.get("targetType").map(|s| s.as_str()), - Some("inline") - ); - assert_eq!( - t.inputs.get("script").map(|s| s.as_str()), - Some("Write-Host 'Hello, World!'") - ); - // only the required inputs are set by default - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn powershell_inline_step_accepts_optional_flags() { - let t = powershell_inline_step("Get-Date") - .with_input("pwsh", "true") - .with_input("errorActionPreference", "silentlyContinue") - .with_input("workingDirectory", "$(Build.SourcesDirectory)"); - assert_eq!(t.task, "PowerShell@2"); - assert_eq!(t.inputs.get("pwsh").map(|s| s.as_str()), Some("true")); - assert_eq!( - t.inputs.get("errorActionPreference").map(|s| s.as_str()), - Some("silentlyContinue") - ); - assert_eq!( - t.inputs.get("workingDirectory").map(|s| s.as_str()), - Some("$(Build.SourcesDirectory)") - ); - assert_eq!(t.inputs.len(), 5); - } - - #[test] - fn powershell_inline_step_multiline_script() { - let script = "$version = Get-Content VERSION\nWrite-Host \"Building version $version\""; - let t = powershell_inline_step(script); - assert_eq!(t.task, "PowerShell@2"); - assert_eq!( - t.inputs.get("script").map(|s| s.as_str()), - Some("$version = Get-Content VERSION\nWrite-Host \"Building version $version\"") - ); - } - - // ── DeleteFiles@1 ──────────────────────────────────────────────────── - - #[test] - fn delete_files_step_sets_task_and_required_input() { - let t = delete_files_step("**/*.tmp"); - assert_eq!(t.task, "DeleteFiles@1"); - assert_eq!(t.display_name, "Delete Files"); - assert_eq!( - t.inputs.get("Contents").map(|s| s.as_str()), - Some("**/*.tmp") - ); - // only the required input is set by default - assert_eq!(t.inputs.len(), 1); - } - - #[test] - fn delete_files_step_accepts_source_folder() { - let t = - delete_files_step("**/*.log").with_input("SourceFolder", "$(Build.ArtifactStagingDirectory)"); - assert_eq!(t.task, "DeleteFiles@1"); - assert_eq!( - t.inputs.get("SourceFolder").map(|s| s.as_str()), - Some("$(Build.ArtifactStagingDirectory)") - ); - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn delete_files_step_accepts_remove_source_folder_flag() { - let t = delete_files_step("*") - .with_input("SourceFolder", "$(Build.ArtifactStagingDirectory)") - .with_input("RemoveSourceFolder", "true"); - assert_eq!(t.task, "DeleteFiles@1"); - assert_eq!( - t.inputs.get("RemoveSourceFolder").map(|s| s.as_str()), - Some("true") - ); - assert_eq!(t.inputs.len(), 3); - } - - #[test] - fn delete_files_step_accepts_remove_dot_files_flag() { - let t = delete_files_step("**").with_input("RemoveDotFiles", "true"); - assert_eq!(t.task, "DeleteFiles@1"); - assert_eq!( - t.inputs.get("RemoveDotFiles").map(|s| s.as_str()), - Some("true") - ); - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn delete_files_step_multiline_contents() { - let t = delete_files_step("**/*.tmp\n**/*.log\ndist/"); - assert_eq!(t.task, "DeleteFiles@1"); - assert_eq!( - t.inputs.get("Contents").map(|s| s.as_str()), - Some("**/*.tmp\n**/*.log\ndist/") - ); - } - - // ── CmdLine@2 ──────────────────────────────────────────────────────── - - #[test] - fn cmd_line_step_sets_task_and_required_input() { - let t = cmd_line_step("echo hello"); - assert_eq!(t.task, "CmdLine@2"); - assert_eq!(t.display_name, "Command Line Script"); - assert_eq!( - t.inputs.get("script").map(|s| s.as_str()), - Some("echo hello") - ); - // only the required input is set by default - assert_eq!(t.inputs.len(), 1); - } - - #[test] - fn cmd_line_step_accepts_working_directory() { - let t = cmd_line_step("dir /b") - .with_input("workingDirectory", "$(Build.SourcesDirectory)"); - assert_eq!(t.task, "CmdLine@2"); - assert_eq!( - t.inputs.get("workingDirectory").map(|s| s.as_str()), - Some("$(Build.SourcesDirectory)") - ); - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn cmd_line_step_accepts_fail_on_stderr() { - let t = cmd_line_step("my-tool --verbose").with_input("failOnStderr", "true"); - assert_eq!(t.task, "CmdLine@2"); - assert_eq!( - t.inputs.get("failOnStderr").map(|s| s.as_str()), - Some("true") - ); - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn cmd_line_step_accepts_multiline_script() { - let script = "echo step 1\necho step 2\necho step 3"; - let t = cmd_line_step(script); - assert_eq!(t.task, "CmdLine@2"); - assert_eq!(t.inputs.get("script").map(|s| s.as_str()), Some(script)); - assert_eq!(t.inputs.len(), 1); - } - - // ── PublishPipelineArtifact@1 ───────────────────────────────────────── - - #[test] - fn publish_pipeline_artifact_step_sets_task_and_target_path() { - let t = publish_pipeline_artifact_step("$(Build.ArtifactStagingDirectory)"); - assert_eq!(t.task, "PublishPipelineArtifact@1"); - assert_eq!(t.display_name, "Publish Pipeline Artifact"); - assert_eq!( - t.inputs.get("targetPath").map(|s| s.as_str()), - Some("$(Build.ArtifactStagingDirectory)") - ); - // only the required input is set by default - assert_eq!(t.inputs.len(), 1); - } - - // ── DownloadPipelineArtifact@2 ─────────────────────────────────────── - - #[test] - fn download_pipeline_artifact_step_sets_task_and_required_input() { - let t = download_pipeline_artifact_step("$(Pipeline.Workspace)/drop"); - assert_eq!(t.task, "DownloadPipelineArtifact@2"); - assert_eq!(t.display_name, "Download Pipeline Artifact"); - assert_eq!( - t.inputs.get("targetPath").map(|s| s.as_str()), - Some("$(Pipeline.Workspace)/drop") - ); - // only the required input is set by default - assert_eq!(t.inputs.len(), 1); - } - - #[test] - fn publish_pipeline_artifact_step_accepts_artifact_name() { - let t = publish_pipeline_artifact_step("$(Build.ArtifactStagingDirectory)/output") - .with_input("artifact", "drop"); - assert_eq!(t.task, "PublishPipelineArtifact@1"); - assert_eq!( - t.inputs.get("artifact").map(|s| s.as_str()), - Some("drop") - ); - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn download_pipeline_artifact_step_filters_by_artifact_name() { - let t = download_pipeline_artifact_step("$(Agent.TempDirectory)/out") - .with_input("artifact", "drop"); - assert_eq!(t.task, "DownloadPipelineArtifact@2"); - assert_eq!( - t.inputs.get("artifact").map(|s| s.as_str()), - Some("drop") - ); - assert_eq!( - t.inputs.get("targetPath").map(|s| s.as_str()), - Some("$(Agent.TempDirectory)/out") - ); - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn publish_pipeline_artifact_step_accepts_publish_location() { - let t = publish_pipeline_artifact_step("$(Build.ArtifactStagingDirectory)") - .with_input("artifact", "binaries") - .with_input("publishLocation", "pipeline"); - assert_eq!(t.task, "PublishPipelineArtifact@1"); - assert_eq!( - t.inputs.get("publishLocation").map(|s| s.as_str()), - Some("pipeline") - ); - assert_eq!(t.inputs.len(), 3); - } - - #[test] - fn publish_pipeline_artifact_step_accepts_file_share_path() { - let t = publish_pipeline_artifact_step("$(Build.ArtifactStagingDirectory)") - .with_input("publishLocation", "filepath") - .with_input("fileSharePath", "\\\\myserver\\share\\$(Build.DefinitionName)"); - assert_eq!(t.task, "PublishPipelineArtifact@1"); - assert_eq!( - t.inputs.get("publishLocation").map(|s| s.as_str()), - Some("filepath") - ); - assert_eq!( - t.inputs.get("fileSharePath").map(|s| s.as_str()), - Some("\\\\myserver\\share\\$(Build.DefinitionName)") - ); - assert_eq!(t.inputs.len(), 3); - } - - #[test] - fn download_pipeline_artifact_step_specific_source_with_branch() { - let t = download_pipeline_artifact_step("$(Agent.TempDirectory)/prev") - .with_input("source", "specific") - .with_input("project", "$(System.TeamProject)") - .with_input("pipeline", "$(System.DefinitionId)") - .with_input("runVersion", "latestFromBranch") - .with_input("branchName", "$(Build.SourceBranch)") - .with_input("artifact", "safe_outputs") - .with_input("allowPartiallySucceededBuilds", "true"); - assert_eq!(t.task, "DownloadPipelineArtifact@2"); - assert_eq!( - t.inputs.get("source").map(|s| s.as_str()), - Some("specific") - ); - assert_eq!( - t.inputs.get("runVersion").map(|s| s.as_str()), - Some("latestFromBranch") - ); - assert_eq!( - t.inputs.get("branchName").map(|s| s.as_str()), - Some("$(Build.SourceBranch)") - ); - assert_eq!( - t.inputs.get("allowPartiallySucceededBuilds").map(|s| s.as_str()), - Some("true") - ); - assert_eq!(t.inputs.len(), 8); - } - - #[test] - fn download_pipeline_artifact_step_accepts_glob_patterns() { - let t = download_pipeline_artifact_step("$(Build.ArtifactStagingDirectory)") - .with_input("patterns", "**/*.zip\n**/*.tar.gz"); - assert_eq!(t.task, "DownloadPipelineArtifact@2"); - assert_eq!( - t.inputs.get("patterns").map(|s| s.as_str()), - Some("**/*.zip\n**/*.tar.gz") - ); - assert_eq!(t.inputs.len(), 2); - } - - // ── Docker@2 ───────────────────────────────────────────────────────── - - #[test] - fn docker_build_and_push_step_sets_task_and_command() { - let t = docker_build_and_push_step(); - assert_eq!(t.task, "Docker@2"); - assert_eq!(t.display_name, "Build and Push Docker Image"); - assert_eq!( - t.inputs.get("command").map(|s| s.as_str()), - Some("buildAndPush") - ); - // only the command input is set by default - assert_eq!(t.inputs.len(), 1); - } - - #[test] - fn docker_build_and_push_step_accepts_registry_and_repository() { - let t = docker_build_and_push_step() - .with_input("containerRegistry", "myRegistryServiceConnection") - .with_input("repository", "myapp"); - assert_eq!(t.task, "Docker@2"); - assert_eq!( - t.inputs.get("containerRegistry").map(|s| s.as_str()), - Some("myRegistryServiceConnection") - ); - assert_eq!( - t.inputs.get("repository").map(|s| s.as_str()), - Some("myapp") - ); - assert_eq!(t.inputs.len(), 3); - } - - #[test] - fn docker_build_and_push_step_accepts_dockerfile_and_tags() { - let t = docker_build_and_push_step() - .with_input("Dockerfile", "src/Dockerfile") - .with_input("buildContext", "src/") - .with_input("tags", "latest\n$(Build.BuildId)"); - assert_eq!(t.task, "Docker@2"); - assert_eq!( - t.inputs.get("Dockerfile").map(|s| s.as_str()), - Some("src/Dockerfile") - ); - assert_eq!( - t.inputs.get("buildContext").map(|s| s.as_str()), - Some("src/") - ); - assert_eq!( - t.inputs.get("tags").map(|s| s.as_str()), - Some("latest\n$(Build.BuildId)") - ); - assert_eq!(t.inputs.len(), 4); - } - - #[test] - fn docker_build_step_sets_task_and_command() { - let t = docker_build_step(); - assert_eq!(t.task, "Docker@2"); - assert_eq!(t.display_name, "Build Docker Image"); - assert_eq!( - t.inputs.get("command").map(|s| s.as_str()), - Some("build") - ); - assert_eq!(t.inputs.len(), 1); - } - - #[test] - fn docker_build_step_accepts_optional_inputs() { - let t = docker_build_step() - .with_input("repository", "myapp") - .with_input("Dockerfile", "Dockerfile.prod") - .with_input("arguments", "--no-cache --build-arg ENV=prod"); - assert_eq!(t.task, "Docker@2"); - assert_eq!( - t.inputs.get("repository").map(|s| s.as_str()), - Some("myapp") - ); - assert_eq!( - t.inputs.get("Dockerfile").map(|s| s.as_str()), - Some("Dockerfile.prod") - ); - assert_eq!( - t.inputs.get("arguments").map(|s| s.as_str()), - Some("--no-cache --build-arg ENV=prod") - ); - assert_eq!(t.inputs.len(), 4); - } - - #[test] - fn docker_push_step_sets_task_and_command() { - let t = docker_push_step(); - assert_eq!(t.task, "Docker@2"); - assert_eq!(t.display_name, "Push Docker Image"); - assert_eq!( - t.inputs.get("command").map(|s| s.as_str()), - Some("push") - ); - assert_eq!(t.inputs.len(), 1); - } - - #[test] - fn docker_push_step_accepts_registry_repository_and_tags() { - let t = docker_push_step() - .with_input("containerRegistry", "myRegistry") - .with_input("repository", "myapp") - .with_input("tags", "$(Build.BuildId)"); - assert_eq!(t.task, "Docker@2"); - assert_eq!( - t.inputs.get("containerRegistry").map(|s| s.as_str()), - Some("myRegistry") - ); - assert_eq!(t.inputs.len(), 4); - } - - #[test] - fn docker_login_step_sets_task_and_command() { - let t = docker_login_step(); - assert_eq!(t.task, "Docker@2"); - assert_eq!(t.display_name, "Docker Login"); - assert_eq!( - t.inputs.get("command").map(|s| s.as_str()), - Some("login") - ); - assert_eq!(t.inputs.len(), 1); - } - - #[test] - fn docker_login_step_accepts_container_registry() { - let t = docker_login_step().with_input("containerRegistry", "myPrivateRegistry"); - assert_eq!(t.task, "Docker@2"); - assert_eq!( - t.inputs.get("containerRegistry").map(|s| s.as_str()), - Some("myPrivateRegistry") - ); - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn docker_logout_step_sets_task_and_command() { - let t = docker_logout_step(); - assert_eq!(t.task, "Docker@2"); - assert_eq!(t.display_name, "Docker Logout"); - assert_eq!( - t.inputs.get("command").map(|s| s.as_str()), - Some("logout") - ); - assert_eq!(t.inputs.len(), 1); - } - - #[test] - fn docker_logout_step_accepts_container_registry() { - let t = docker_logout_step().with_input("containerRegistry", "myPrivateRegistry"); - assert_eq!(t.task, "Docker@2"); - assert_eq!( - t.inputs.get("containerRegistry").map(|s| s.as_str()), - Some("myPrivateRegistry") - ); - assert_eq!(t.inputs.len(), 2); - } - - #[test] - fn docker_login_and_logout_use_same_task_name() { - let login = docker_login_step(); - let logout = docker_logout_step(); - assert_eq!(login.task, logout.task); - assert_eq!(login.task, "Docker@2"); - assert_ne!( - login.inputs.get("command"), - logout.inputs.get("command"), - "login and logout must use different command values" - ); - } -} diff --git a/src/compile/ir/tasks/archive_files.rs b/src/compile/ir/tasks/archive_files.rs new file mode 100644 index 00000000..c6cb6aaf --- /dev/null +++ b/src/compile/ir/tasks/archive_files.rs @@ -0,0 +1,209 @@ +//! Typed builder for `ArchiveFiles@2`. + +use super::common::bool_input; +use crate::compile::ir::step::TaskStep; + +/// Archive format for [`ArchiveFiles`] (`archiveType` input). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArchiveType { + Zip, + SevenZip, + Tar, + Wim, +} + +impl ArchiveType { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + ArchiveType::Zip => "zip", + ArchiveType::SevenZip => "7z", + ArchiveType::Tar => "tar", + ArchiveType::Wim => "wim", + } + } +} + +/// Tar compression for [`ArchiveFiles`] (`tarCompression` input, only when +/// `archiveType = tar`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TarCompression { + Gz, + Bz2, + Xz, + None, +} + +impl TarCompression { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + TarCompression::Gz => "gz", + TarCompression::Bz2 => "bz2", + TarCompression::Xz => "xz", + TarCompression::None => "none", + } + } +} + +/// Builder for a [`TaskStep`] invoking `ArchiveFiles@2`. +/// +/// Creates an archive from `root_folder_or_file` and writes it to `archive_file`. +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct ArchiveFiles { + root_folder_or_file: String, + archive_file: String, + archive_type: Option, + include_root_folder: Option, + replace_existing_archive: Option, + seven_zip_compression: Option, + tar_compression: Option, + verbose: Option, + quiet: Option, + display_name: Option, +} + +impl ArchiveFiles { + /// Required inputs: `rootFolderOrFile` and `archiveFile`. + pub fn new( + root_folder_or_file: impl Into, + archive_file: impl Into, + ) -> Self { + Self { + root_folder_or_file: root_folder_or_file.into(), + archive_file: archive_file.into(), + archive_type: None, + include_root_folder: None, + replace_existing_archive: None, + seven_zip_compression: None, + tar_compression: None, + verbose: None, + quiet: None, + display_name: None, + } + } + + /// `archiveType` — archive format (default `zip`). + pub fn archive_type(mut self, value: ArchiveType) -> Self { + self.archive_type = Some(value); + self + } + + /// `includeRootFolder` — prepend the root folder name to archive paths. + pub fn include_root_folder(mut self, value: bool) -> Self { + self.include_root_folder = Some(value); + self + } + + /// `replaceExistingArchive` — replace an existing archive. + pub fn replace_existing_archive(mut self, value: bool) -> Self { + self.replace_existing_archive = Some(value); + self + } + + /// `sevenZipCompression` — 7z compression level (when `archiveType = 7z`). + pub fn seven_zip_compression(mut self, value: impl Into) -> Self { + self.seven_zip_compression = Some(value.into()); + self + } + + /// `tarCompression` — tar compression (when `archiveType = tar`). + pub fn tar_compression(mut self, value: TarCompression) -> Self { + self.tar_compression = Some(value); + self + } + + /// `verbose` — force verbose output. + pub fn verbose(mut self, value: bool) -> Self { + self.verbose = Some(value); + self + } + + /// `quiet` — force quiet output. + pub fn quiet(mut self, value: bool) -> Self { + self.quiet = Some(value); + self + } + + /// Override the default `displayName` (`"Archive Files"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "ArchiveFiles@2", + self.display_name.unwrap_or_else(|| "Archive Files".into()), + ) + .with_input("rootFolderOrFile", self.root_folder_or_file) + .with_input("archiveFile", self.archive_file); + if let Some(v) = self.archive_type { + t = t.with_input("archiveType", v.as_ado_str()); + } + if let Some(v) = self.include_root_folder { + t = t.with_input("includeRootFolder", bool_input(v)); + } + if let Some(v) = self.replace_existing_archive { + t = t.with_input("replaceExistingArchive", bool_input(v)); + } + if let Some(v) = self.seven_zip_compression { + t = t.with_input("sevenZipCompression", v); + } + if let Some(v) = self.tar_compression { + t = t.with_input("tarCompression", v.as_ado_str()); + } + if let Some(v) = self.verbose { + t = t.with_input("verbose", bool_input(v)); + } + if let Some(v) = self.quiet { + t = t.with_input("quiet", bool_input(v)); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_task_and_required_inputs() { + let t = ArchiveFiles::new( + "$(Build.ArtifactStagingDirectory)", + "$(Build.ArtifactStagingDirectory)/drop.zip", + ) + .into_step(); + assert_eq!(t.task, "ArchiveFiles@2"); + assert_eq!( + t.inputs.get("rootFolderOrFile").map(String::as_str), + Some("$(Build.ArtifactStagingDirectory)") + ); + assert_eq!( + t.inputs.get("archiveFile").map(String::as_str), + Some("$(Build.ArtifactStagingDirectory)/drop.zip") + ); + } + + #[test] + fn typed_archive_and_tar_compression() { + let t = ArchiveFiles::new("src", "out.tar.gz") + .archive_type(ArchiveType::Tar) + .tar_compression(TarCompression::Gz) + .include_root_folder(false) + .into_step(); + assert_eq!(t.inputs.get("archiveType").map(String::as_str), Some("tar")); + assert_eq!(t.inputs.get("tarCompression").map(String::as_str), Some("gz")); + assert_eq!(t.inputs.get("includeRootFolder").map(String::as_str), Some("false")); + } + + #[test] + fn seven_zip_token() { + let t = ArchiveFiles::new("src", "out.7z").archive_type(ArchiveType::SevenZip).into_step(); + assert_eq!(t.inputs.get("archiveType").map(String::as_str), Some("7z")); + } +} diff --git a/src/compile/ir/tasks/cmd_line.rs b/src/compile/ir/tasks/cmd_line.rs new file mode 100644 index 00000000..c83454d4 --- /dev/null +++ b/src/compile/ir/tasks/cmd_line.rs @@ -0,0 +1,90 @@ +//! Typed builder for `CmdLine@2`. + +use super::common::bool_input; +use crate::compile::ir::step::TaskStep; + +/// Builder for a [`TaskStep`] invoking `CmdLine@2`. +/// +/// Runs an inline command-line script (Bash on Linux/macOS, `cmd.exe` on +/// Windows). +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct CmdLine { + script: String, + working_directory: Option, + fail_on_stderr: Option, + display_name: Option, +} + +impl CmdLine { + /// Required input: the inline `script` text. + pub fn new(script: impl Into) -> Self { + Self { + script: script.into(), + working_directory: None, + fail_on_stderr: None, + display_name: None, + } + } + + /// `workingDirectory` — working directory for the script. + pub fn working_directory(mut self, value: impl Into) -> Self { + self.working_directory = Some(value.into()); + self + } + + /// `failOnStderr` — fail the step if the script writes to stderr. + pub fn fail_on_stderr(mut self, value: bool) -> Self { + self.fail_on_stderr = Some(value); + self + } + + /// Override the default `displayName` (`"Command Line Script"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "CmdLine@2", + self.display_name.unwrap_or_else(|| "Command Line Script".into()), + ) + .with_input("script", self.script); + if let Some(v) = self.working_directory { + t = t.with_input("workingDirectory", v); + } + if let Some(v) = self.fail_on_stderr { + t = t.with_input("failOnStderr", bool_input(v)); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_task_and_required_script() { + let t = CmdLine::new("echo hello").into_step(); + assert_eq!(t.task, "CmdLine@2"); + assert_eq!(t.inputs.get("script").map(String::as_str), Some("echo hello")); + } + + #[test] + fn optional_inputs() { + let t = CmdLine::new("my-tool --verbose") + .working_directory("$(Build.SourcesDirectory)") + .fail_on_stderr(true) + .into_step(); + assert_eq!( + t.inputs.get("workingDirectory").map(String::as_str), + Some("$(Build.SourcesDirectory)") + ); + assert_eq!(t.inputs.get("failOnStderr").map(String::as_str), Some("true")); + } +} diff --git a/src/compile/ir/tasks/common.rs b/src/compile/ir/tasks/common.rs new file mode 100644 index 00000000..e444280d --- /dev/null +++ b/src/compile/ir/tasks/common.rs @@ -0,0 +1,33 @@ +//! Shared helpers for the typed task builders in this module. +//! +//! Every task in `tasks/` is a **builder struct**: `new()` plus one +//! typed chained setter per optional input plus `into_step(self) -> TaskStep`. +//! Only fields that were explicitly set emit an ADO `inputs:` entry, so the +//! generated YAML stays minimal and matches the task's own defaults. +//! +//! Constrained ADO input values (e.g. `archiveType`, `releaseType`) are modeled +//! as enums colocated with the task that uses them; each enum exposes +//! `as_ado_str()` returning the exact token ADO expects. Bool-string inputs are +//! `Option` and lowered through [`bool_input`]. + +use crate::compile::ir::step::TaskStep; + +/// Lower a Rust `bool` to the `"true"` / `"false"` string ADO task inputs use. +pub(crate) fn bool_input(value: bool) -> &'static str { + if value { "true" } else { "false" } +} + +/// Insert an optional string input only when present. Used by command-dispatch +/// tasks whose `into_step` lowers many per-variant optionals. +pub(crate) fn push_opt(t: &mut TaskStep, key: &str, value: Option) { + if let Some(v) = value { + t.inputs.insert(key.to_string(), v); + } +} + +/// Insert an optional bool-string input only when present. +pub(crate) fn push_bool(t: &mut TaskStep, key: &str, value: Option) { + if let Some(v) = value { + t.inputs.insert(key.to_string(), bool_input(v).to_string()); + } +} diff --git a/src/compile/ir/tasks/copy_files.rs b/src/compile/ir/tasks/copy_files.rs new file mode 100644 index 00000000..1d365995 --- /dev/null +++ b/src/compile/ir/tasks/copy_files.rs @@ -0,0 +1,177 @@ +//! Typed builder for `CopyFiles@2`. + +use super::common::bool_input; +use crate::compile::ir::step::TaskStep; + +/// Builder for a [`TaskStep`] invoking `CopyFiles@2`. +/// +/// Copies files matching `contents` into `target_folder`. Optional inputs are +/// applied through the typed setters; only those that are set are emitted. +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct CopyFiles { + contents: String, + target_folder: String, + source_folder: Option, + clean_target_folder: Option, + over_write: Option, + flatten_folders: Option, + preserve_timestamp: Option, + retry_count: Option, + delay_between_retries: Option, + ignore_make_dir_errors: Option, + display_name: Option, +} + +impl CopyFiles { + /// Required inputs: `Contents` glob and `TargetFolder`. + pub fn new(contents: impl Into, target_folder: impl Into) -> Self { + Self { + contents: contents.into(), + target_folder: target_folder.into(), + source_folder: None, + clean_target_folder: None, + over_write: None, + flatten_folders: None, + preserve_timestamp: None, + retry_count: None, + delay_between_retries: None, + ignore_make_dir_errors: None, + display_name: None, + } + } + + /// `SourceFolder` — root for glob evaluation (default `$(Build.SourcesDirectory)`). + pub fn source_folder(mut self, value: impl Into) -> Self { + self.source_folder = Some(value.into()); + self + } + + /// `CleanTargetFolder` — delete target folder contents before copy. + pub fn clean_target_folder(mut self, value: bool) -> Self { + self.clean_target_folder = Some(value); + self + } + + /// `OverWrite` — overwrite files in the target folder. + pub fn over_write(mut self, value: bool) -> Self { + self.over_write = Some(value); + self + } + + /// `flattenFolders` — flatten directory structure in the target. + pub fn flatten_folders(mut self, value: bool) -> Self { + self.flatten_folders = Some(value); + self + } + + /// `preserveTimestamp` — preserve source timestamps. + pub fn preserve_timestamp(mut self, value: bool) -> Self { + self.preserve_timestamp = Some(value); + self + } + + /// `retryCount` — number of retry attempts on failure. + pub fn retry_count(mut self, value: impl Into) -> Self { + self.retry_count = Some(value.into()); + self + } + + /// `delayBetweenRetries` — milliseconds between retries. + pub fn delay_between_retries(mut self, value: impl Into) -> Self { + self.delay_between_retries = Some(value.into()); + self + } + + /// `ignoreMakeDirErrors` — ignore errors when creating the target folder. + pub fn ignore_make_dir_errors(mut self, value: bool) -> Self { + self.ignore_make_dir_errors = Some(value); + self + } + + /// Override the default `displayName` (`"Copy Files"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "CopyFiles@2", + self.display_name.unwrap_or_else(|| "Copy Files".into()), + ) + .with_input("Contents", self.contents) + .with_input("TargetFolder", self.target_folder); + if let Some(v) = self.source_folder { + t = t.with_input("SourceFolder", v); + } + if let Some(v) = self.clean_target_folder { + t = t.with_input("CleanTargetFolder", bool_input(v)); + } + if let Some(v) = self.over_write { + t = t.with_input("OverWrite", bool_input(v)); + } + if let Some(v) = self.flatten_folders { + t = t.with_input("flattenFolders", bool_input(v)); + } + if let Some(v) = self.preserve_timestamp { + t = t.with_input("preserveTimestamp", bool_input(v)); + } + if let Some(v) = self.retry_count { + t = t.with_input("retryCount", v); + } + if let Some(v) = self.delay_between_retries { + t = t.with_input("delayBetweenRetries", v); + } + if let Some(v) = self.ignore_make_dir_errors { + t = t.with_input("ignoreMakeDirErrors", bool_input(v)); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_task_and_required_inputs() { + let t = CopyFiles::new("**/*.rs", "$(Build.ArtifactStagingDirectory)").into_step(); + assert_eq!(t.task, "CopyFiles@2"); + assert_eq!(t.display_name, "Copy Files"); + assert_eq!(t.inputs.get("Contents").map(String::as_str), Some("**/*.rs")); + assert_eq!( + t.inputs.get("TargetFolder").map(String::as_str), + Some("$(Build.ArtifactStagingDirectory)") + ); + } + + #[test] + fn optional_inputs_emit_only_when_set() { + let t = CopyFiles::new("**", "$(Build.ArtifactStagingDirectory)") + .source_folder("$(Build.SourcesDirectory)/src") + .clean_target_folder(true) + .over_write(true) + .flatten_folders(true) + .into_step(); + assert_eq!( + t.inputs.get("SourceFolder").map(String::as_str), + Some("$(Build.SourcesDirectory)/src") + ); + assert_eq!(t.inputs.get("CleanTargetFolder").map(String::as_str), Some("true")); + assert_eq!(t.inputs.get("OverWrite").map(String::as_str), Some("true")); + assert_eq!(t.inputs.get("flattenFolders").map(String::as_str), Some("true")); + // Untouched optionals are absent. + assert!(t.inputs.get("preserveTimestamp").is_none()); + assert!(t.inputs.get("retryCount").is_none()); + } + + #[test] + fn display_name_override() { + let t = CopyFiles::new("**", "out").with_display_name("Stage build output").into_step(); + assert_eq!(t.display_name, "Stage build output"); + } +} diff --git a/src/compile/ir/tasks/delete_files.rs b/src/compile/ir/tasks/delete_files.rs new file mode 100644 index 00000000..faa3533d --- /dev/null +++ b/src/compile/ir/tasks/delete_files.rs @@ -0,0 +1,102 @@ +//! Typed builder for `DeleteFiles@1`. + +use super::common::bool_input; +use crate::compile::ir::step::TaskStep; + +/// Builder for a [`TaskStep`] invoking `DeleteFiles@1`. +/// +/// Deletes files or folders matching `contents` from a source folder. +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct DeleteFiles { + contents: String, + source_folder: Option, + remove_source_folder: Option, + remove_dot_files: Option, + display_name: Option, +} + +impl DeleteFiles { + /// Required input: `Contents` — newline-separated glob patterns. + pub fn new(contents: impl Into) -> Self { + Self { + contents: contents.into(), + source_folder: None, + remove_source_folder: None, + remove_dot_files: None, + display_name: None, + } + } + + /// `SourceFolder` — root folder to delete from. + pub fn source_folder(mut self, value: impl Into) -> Self { + self.source_folder = Some(value.into()); + self + } + + /// `RemoveSourceFolder` — remove the source folder itself after deletion. + pub fn remove_source_folder(mut self, value: bool) -> Self { + self.remove_source_folder = Some(value); + self + } + + /// `RemoveDotFiles` — also delete files whose name starts with a dot. + pub fn remove_dot_files(mut self, value: bool) -> Self { + self.remove_dot_files = Some(value); + self + } + + /// Override the default `displayName` (`"Delete Files"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "DeleteFiles@1", + self.display_name.unwrap_or_else(|| "Delete Files".into()), + ) + .with_input("Contents", self.contents); + if let Some(v) = self.source_folder { + t = t.with_input("SourceFolder", v); + } + if let Some(v) = self.remove_source_folder { + t = t.with_input("RemoveSourceFolder", bool_input(v)); + } + if let Some(v) = self.remove_dot_files { + t = t.with_input("RemoveDotFiles", bool_input(v)); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_task_and_required_contents() { + let t = DeleteFiles::new("**/*.tmp").into_step(); + assert_eq!(t.task, "DeleteFiles@1"); + assert_eq!(t.inputs.get("Contents").map(String::as_str), Some("**/*.tmp")); + } + + #[test] + fn optional_inputs() { + let t = DeleteFiles::new("*") + .source_folder("$(Build.ArtifactStagingDirectory)") + .remove_source_folder(true) + .remove_dot_files(true) + .into_step(); + assert_eq!( + t.inputs.get("SourceFolder").map(String::as_str), + Some("$(Build.ArtifactStagingDirectory)") + ); + assert_eq!(t.inputs.get("RemoveSourceFolder").map(String::as_str), Some("true")); + assert_eq!(t.inputs.get("RemoveDotFiles").map(String::as_str), Some("true")); + } +} diff --git a/src/compile/ir/tasks/docker.rs b/src/compile/ir/tasks/docker.rs new file mode 100644 index 00000000..6b1744a5 --- /dev/null +++ b/src/compile/ir/tasks/docker.rs @@ -0,0 +1,365 @@ +//! Typed builder for `Docker@2`. +//! +//! This is the **canonical command-dispatch task template**: a single +//! [`Docker`] builder wraps a [`DockerCommand`] enum whose variants carry the +//! per-command optional inputs. Because each command's optionals live inside its +//! own variant, applying an input to the wrong command (e.g. a `build`-only +//! `arguments` to a `login`) is unrepresentable. Model new command/mode-dispatch +//! tasks (e.g. `DotNetCoreCLI@2`, `NuGetCommand@2`) after this file. +//! +//! ADO task reference: +//! + +use crate::compile::ir::step::TaskStep; + +/// `Docker@2` `command` selector, carrying the per-command optional inputs. +#[derive(Debug, Clone)] +pub enum DockerCommand { + BuildAndPush(DockerBuildAndPush), + Build(DockerBuild), + Push(DockerPush), + Login(DockerLogin), + Logout(DockerLogout), +} + +/// Optionals for `Docker@2` `command: buildAndPush`. +#[derive(Debug, Clone, Default)] +pub struct DockerBuildAndPush { + container_registry: Option, + repository: Option, + dockerfile: Option, + build_context: Option, + tags: Option, +} + +impl DockerBuildAndPush { + pub fn new() -> Self { + Self::default() + } + /// `containerRegistry` — Docker registry service connection. + pub fn container_registry(mut self, value: impl Into) -> Self { + self.container_registry = Some(value.into()); + self + } + /// `repository` — container repository name. + pub fn repository(mut self, value: impl Into) -> Self { + self.repository = Some(value.into()); + self + } + /// `Dockerfile` — path or glob to the Dockerfile. + pub fn dockerfile(mut self, value: impl Into) -> Self { + self.dockerfile = Some(value.into()); + self + } + /// `buildContext` — build context path. + pub fn build_context(mut self, value: impl Into) -> Self { + self.build_context = Some(value.into()); + self + } + /// `tags` — newline-separated image tags. + pub fn tags(mut self, value: impl Into) -> Self { + self.tags = Some(value.into()); + self + } +} + +/// Optionals for `Docker@2` `command: build`. +#[derive(Debug, Clone, Default)] +pub struct DockerBuild { + container_registry: Option, + repository: Option, + dockerfile: Option, + build_context: Option, + tags: Option, + arguments: Option, +} + +impl DockerBuild { + pub fn new() -> Self { + Self::default() + } + /// `containerRegistry` — Docker registry service connection. + pub fn container_registry(mut self, value: impl Into) -> Self { + self.container_registry = Some(value.into()); + self + } + /// `repository` — image name to tag the build as. + pub fn repository(mut self, value: impl Into) -> Self { + self.repository = Some(value.into()); + self + } + /// `Dockerfile` — path or glob to the Dockerfile. + pub fn dockerfile(mut self, value: impl Into) -> Self { + self.dockerfile = Some(value.into()); + self + } + /// `buildContext` — build context path. + pub fn build_context(mut self, value: impl Into) -> Self { + self.build_context = Some(value.into()); + self + } + /// `tags` — newline-separated image tags. + pub fn tags(mut self, value: impl Into) -> Self { + self.tags = Some(value.into()); + self + } + /// `arguments` — extra arguments for `docker build`. + pub fn arguments(mut self, value: impl Into) -> Self { + self.arguments = Some(value.into()); + self + } +} + +/// Optionals for `Docker@2` `command: push`. +#[derive(Debug, Clone, Default)] +pub struct DockerPush { + container_registry: Option, + repository: Option, + tags: Option, + arguments: Option, +} + +impl DockerPush { + pub fn new() -> Self { + Self::default() + } + /// `containerRegistry` — Docker registry service connection. + pub fn container_registry(mut self, value: impl Into) -> Self { + self.container_registry = Some(value.into()); + self + } + /// `repository` — container repository to push to. + pub fn repository(mut self, value: impl Into) -> Self { + self.repository = Some(value.into()); + self + } + /// `tags` — newline-separated tags to push. + pub fn tags(mut self, value: impl Into) -> Self { + self.tags = Some(value.into()); + self + } + /// `arguments` — extra arguments for `docker push`. + pub fn arguments(mut self, value: impl Into) -> Self { + self.arguments = Some(value.into()); + self + } +} + +/// Optionals for `Docker@2` `command: login`. +#[derive(Debug, Clone, Default)] +pub struct DockerLogin { + container_registry: Option, +} + +impl DockerLogin { + pub fn new() -> Self { + Self::default() + } + /// `containerRegistry` — Docker registry service connection. + pub fn container_registry(mut self, value: impl Into) -> Self { + self.container_registry = Some(value.into()); + self + } +} + +/// Optionals for `Docker@2` `command: logout`. +#[derive(Debug, Clone, Default)] +pub struct DockerLogout { + container_registry: Option, +} + +impl DockerLogout { + pub fn new() -> Self { + Self::default() + } + /// `containerRegistry` — Docker registry service connection. + pub fn container_registry(mut self, value: impl Into) -> Self { + self.container_registry = Some(value.into()); + self + } +} + +/// Builder for a [`TaskStep`] invoking `Docker@2`. +#[derive(Debug, Clone)] +pub struct Docker { + command: DockerCommand, + display_name: Option, +} + +impl Docker { + /// Construct from an explicit [`DockerCommand`]. + pub fn new(command: DockerCommand) -> Self { + Self { + command, + display_name: None, + } + } + + /// `command: buildAndPush`. + pub fn build_and_push(spec: DockerBuildAndPush) -> Self { + Self::new(DockerCommand::BuildAndPush(spec)) + } + + /// `command: build`. + pub fn build(spec: DockerBuild) -> Self { + Self::new(DockerCommand::Build(spec)) + } + + /// `command: push`. + pub fn push(spec: DockerPush) -> Self { + Self::new(DockerCommand::Push(spec)) + } + + /// `command: login`. + pub fn login(spec: DockerLogin) -> Self { + Self::new(DockerCommand::Login(spec)) + } + + /// `command: logout`. + pub fn logout(spec: DockerLogout) -> Self { + Self::new(DockerCommand::Logout(spec)) + } + + /// Override the default per-command `displayName`. + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let (command, default_display): (&str, &str) = match &self.command { + DockerCommand::BuildAndPush(_) => ("buildAndPush", "Build and Push Docker Image"), + DockerCommand::Build(_) => ("build", "Build Docker Image"), + DockerCommand::Push(_) => ("push", "Push Docker Image"), + DockerCommand::Login(_) => ("login", "Docker Login"), + DockerCommand::Logout(_) => ("logout", "Docker Logout"), + }; + let mut t = TaskStep::new( + "Docker@2", + self.display_name.unwrap_or_else(|| default_display.into()), + ) + .with_input("command", command); + match self.command { + DockerCommand::BuildAndPush(s) => { + if let Some(v) = s.container_registry { + t = t.with_input("containerRegistry", v); + } + if let Some(v) = s.repository { + t = t.with_input("repository", v); + } + if let Some(v) = s.dockerfile { + t = t.with_input("Dockerfile", v); + } + if let Some(v) = s.build_context { + t = t.with_input("buildContext", v); + } + if let Some(v) = s.tags { + t = t.with_input("tags", v); + } + } + DockerCommand::Build(s) => { + if let Some(v) = s.container_registry { + t = t.with_input("containerRegistry", v); + } + if let Some(v) = s.repository { + t = t.with_input("repository", v); + } + if let Some(v) = s.dockerfile { + t = t.with_input("Dockerfile", v); + } + if let Some(v) = s.build_context { + t = t.with_input("buildContext", v); + } + if let Some(v) = s.tags { + t = t.with_input("tags", v); + } + if let Some(v) = s.arguments { + t = t.with_input("arguments", v); + } + } + DockerCommand::Push(s) => { + if let Some(v) = s.container_registry { + t = t.with_input("containerRegistry", v); + } + if let Some(v) = s.repository { + t = t.with_input("repository", v); + } + if let Some(v) = s.tags { + t = t.with_input("tags", v); + } + if let Some(v) = s.arguments { + t = t.with_input("arguments", v); + } + } + DockerCommand::Login(s) => { + if let Some(v) = s.container_registry { + t = t.with_input("containerRegistry", v); + } + } + DockerCommand::Logout(s) => { + if let Some(v) = s.container_registry { + t = t.with_input("containerRegistry", v); + } + } + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_and_push_defaults() { + let t = Docker::build_and_push(DockerBuildAndPush::new()).into_step(); + assert_eq!(t.task, "Docker@2"); + assert_eq!(t.display_name, "Build and Push Docker Image"); + assert_eq!(t.inputs.get("command").map(String::as_str), Some("buildAndPush")); + } + + #[test] + fn build_and_push_inputs() { + let t = Docker::build_and_push( + DockerBuildAndPush::new() + .container_registry("myRegistryServiceConnection") + .repository("myapp") + .dockerfile("src/Dockerfile") + .build_context("src/") + .tags("latest\n$(Build.BuildId)"), + ) + .into_step(); + assert_eq!( + t.inputs.get("containerRegistry").map(String::as_str), + Some("myRegistryServiceConnection") + ); + assert_eq!(t.inputs.get("repository").map(String::as_str), Some("myapp")); + assert_eq!(t.inputs.get("Dockerfile").map(String::as_str), Some("src/Dockerfile")); + assert_eq!(t.inputs.get("buildContext").map(String::as_str), Some("src/")); + assert_eq!(t.inputs.get("tags").map(String::as_str), Some("latest\n$(Build.BuildId)")); + } + + #[test] + fn build_command_has_arguments() { + let t = Docker::build(DockerBuild::new().arguments("--no-cache")).into_step(); + assert_eq!(t.inputs.get("command").map(String::as_str), Some("build")); + assert_eq!(t.inputs.get("arguments").map(String::as_str), Some("--no-cache")); + } + + #[test] + fn login_logout_only_registry() { + let login = + Docker::login(DockerLogin::new().container_registry("myPrivateRegistry")).into_step(); + assert_eq!(login.inputs.get("command").map(String::as_str), Some("login")); + assert_eq!( + login.inputs.get("containerRegistry").map(String::as_str), + Some("myPrivateRegistry") + ); + assert!(login.inputs.get("repository").is_none()); + + let logout = Docker::logout(DockerLogout::new()).into_step(); + assert_eq!(logout.inputs.get("command").map(String::as_str), Some("logout")); + assert_eq!(logout.display_name, "Docker Logout"); + } +} diff --git a/src/compile/ir/tasks/docker_installer.rs b/src/compile/ir/tasks/docker_installer.rs new file mode 100644 index 00000000..7cab5f49 --- /dev/null +++ b/src/compile/ir/tasks/docker_installer.rs @@ -0,0 +1,93 @@ +//! Typed builder for `DockerInstaller@0`. + +use crate::compile::ir::step::TaskStep; + +/// Docker release channel for [`DockerInstaller`] (`releaseType` input). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseType { + Stable, + Edge, + Test, + Nightly, +} + +impl ReleaseType { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + ReleaseType::Stable => "stable", + ReleaseType::Edge => "edge", + ReleaseType::Test => "test", + ReleaseType::Nightly => "nightly", + } + } +} + +/// Builder for a [`TaskStep`] invoking `DockerInstaller@0`. +/// +/// Installs a specific version of Docker Engine on the agent. +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct DockerInstaller { + docker_version: String, + release_type: Option, + display_name: Option, +} + +impl DockerInstaller { + /// Required input: `dockerVersion` (e.g. `"26.1.4"`). + pub fn new(docker_version: impl Into) -> Self { + Self { + docker_version: docker_version.into(), + release_type: None, + display_name: None, + } + } + + /// `releaseType` — release channel (default `stable`). + pub fn release_type(mut self, value: ReleaseType) -> Self { + self.release_type = Some(value); + self + } + + /// Override the default `displayName` (`"Install Docker"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "DockerInstaller@0", + self.display_name.unwrap_or_else(|| "Install Docker".into()), + ) + .with_input("dockerVersion", self.docker_version); + if let Some(v) = self.release_type { + t = t.with_input("releaseType", v.as_ado_str()); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_task_and_required_version() { + let t = DockerInstaller::new("26.1.4").into_step(); + assert_eq!(t.task, "DockerInstaller@0"); + assert_eq!(t.display_name, "Install Docker"); + assert_eq!(t.inputs.get("dockerVersion").map(String::as_str), Some("26.1.4")); + assert!(t.inputs.get("releaseType").is_none()); + } + + #[test] + fn release_type_is_typed() { + let t = DockerInstaller::new("26.1.4").release_type(ReleaseType::Edge).into_step(); + assert_eq!(t.inputs.get("releaseType").map(String::as_str), Some("edge")); + } +} diff --git a/src/compile/ir/tasks/dotnet_core_cli.rs b/src/compile/ir/tasks/dotnet_core_cli.rs new file mode 100644 index 00000000..39c603f7 --- /dev/null +++ b/src/compile/ir/tasks/dotnet_core_cli.rs @@ -0,0 +1,398 @@ +//! Typed builder for `DotNetCoreCLI@2`. +//! +//! Command-dispatch task modeled after [`super::docker`]: a [`DotNetCoreCli`] +//! builder wraps a [`DotNetCommand`] enum whose variants carry each command's +//! optional inputs, so applying an input to the wrong command is +//! unrepresentable. +//! +//! ADO task reference: +//! + +use super::common::{push_bool, push_opt}; +use crate::compile::ir::step::TaskStep; + +/// `DotNetCoreCLI@2` `command` selector, carrying per-command optional inputs. +#[derive(Debug, Clone)] +pub enum DotNetCommand { + Build(DotNetBuild), + Test(DotNetTest), + Publish(DotNetPublish), + Restore(DotNetRestore), + Pack(DotNetPack), + Run(DotNetRun), + Push(DotNetPush), + Custom(DotNetCustom), +} + +/// Optionals for `dotnet build`. +#[derive(Debug, Clone, Default)] +pub struct DotNetBuild { + projects: Option, + arguments: Option, + working_directory: Option, +} + +impl DotNetBuild { + pub fn new() -> Self { + Self::default() + } + /// `projects` — glob for `.csproj`/`.sln` files. + pub fn projects(mut self, value: impl Into) -> Self { + self.projects = Some(value.into()); + self + } + /// `arguments` — extra CLI args. + pub fn arguments(mut self, value: impl Into) -> Self { + self.arguments = Some(value.into()); + self + } + /// `workingDirectory` — working directory for the command. + pub fn working_directory(mut self, value: impl Into) -> Self { + self.working_directory = Some(value.into()); + self + } +} + +/// Optionals for `dotnet test`. +#[derive(Debug, Clone, Default)] +pub struct DotNetTest { + projects: Option, + arguments: Option, + working_directory: Option, + publish_test_results: Option, + test_run_title: Option, +} + +impl DotNetTest { + pub fn new() -> Self { + Self::default() + } + /// `projects` — glob for `.csproj`/`.sln` files. + pub fn projects(mut self, value: impl Into) -> Self { + self.projects = Some(value.into()); + self + } + /// `arguments` — extra CLI args. + pub fn arguments(mut self, value: impl Into) -> Self { + self.arguments = Some(value.into()); + self + } + /// `workingDirectory` — working directory for the command. + pub fn working_directory(mut self, value: impl Into) -> Self { + self.working_directory = Some(value.into()); + self + } + /// `publishTestResults` — publish test results to the pipeline. + pub fn publish_test_results(mut self, value: bool) -> Self { + self.publish_test_results = Some(value); + self + } + /// `testRunTitle` — title shown in the build summary. + pub fn test_run_title(mut self, value: impl Into) -> Self { + self.test_run_title = Some(value.into()); + self + } +} + +/// Optionals for `dotnet publish`. +#[derive(Debug, Clone, Default)] +pub struct DotNetPublish { + projects: Option, + arguments: Option, + working_directory: Option, + zip_after_publish: Option, + modify_output_path: Option, + publish_web_projects: Option, +} + +impl DotNetPublish { + pub fn new() -> Self { + Self::default() + } + /// `projects` — glob for `.csproj`/`.sln` files. + pub fn projects(mut self, value: impl Into) -> Self { + self.projects = Some(value.into()); + self + } + /// `arguments` — extra CLI args. + pub fn arguments(mut self, value: impl Into) -> Self { + self.arguments = Some(value.into()); + self + } + /// `workingDirectory` — working directory for the command. + pub fn working_directory(mut self, value: impl Into) -> Self { + self.working_directory = Some(value.into()); + self + } + /// `zipAfterPublish` — zip output after publish. + pub fn zip_after_publish(mut self, value: bool) -> Self { + self.zip_after_publish = Some(value); + self + } + /// `modifyOutputPath` — append project folder name to the publish path. + pub fn modify_output_path(mut self, value: bool) -> Self { + self.modify_output_path = Some(value); + self + } + /// `publishWebProjects` — publish all web projects. + pub fn publish_web_projects(mut self, value: bool) -> Self { + self.publish_web_projects = Some(value); + self + } +} + +/// Optionals for `dotnet restore`. +#[derive(Debug, Clone, Default)] +pub struct DotNetRestore { + projects: Option, +} + +impl DotNetRestore { + pub fn new() -> Self { + Self::default() + } + /// `projects` — glob for `.csproj`/`.sln` files. + pub fn projects(mut self, value: impl Into) -> Self { + self.projects = Some(value.into()); + self + } +} + +/// Optionals for `dotnet pack`. +#[derive(Debug, Clone, Default)] +pub struct DotNetPack { + packages_to_pack: Option, +} + +impl DotNetPack { + pub fn new() -> Self { + Self::default() + } + /// `packagesToPack` — `.csproj`/`.nuspec` glob to pack. + pub fn packages_to_pack(mut self, value: impl Into) -> Self { + self.packages_to_pack = Some(value.into()); + self + } +} + +/// Optionals for `dotnet run`. +#[derive(Debug, Clone, Default)] +pub struct DotNetRun { + projects: Option, + arguments: Option, + working_directory: Option, +} + +impl DotNetRun { + pub fn new() -> Self { + Self::default() + } + /// `projects` — glob for `.csproj`/`.sln` files. + pub fn projects(mut self, value: impl Into) -> Self { + self.projects = Some(value.into()); + self + } + /// `arguments` — extra CLI args. + pub fn arguments(mut self, value: impl Into) -> Self { + self.arguments = Some(value.into()); + self + } + /// `workingDirectory` — working directory for the command. + pub fn working_directory(mut self, value: impl Into) -> Self { + self.working_directory = Some(value.into()); + self + } +} + +/// Optionals for `dotnet push` (NuGet publish). +#[derive(Debug, Clone, Default)] +pub struct DotNetPush { + packages_to_push: Option, +} + +impl DotNetPush { + pub fn new() -> Self { + Self::default() + } + /// `packagesToPush` — NuGet package glob to publish. + pub fn packages_to_push(mut self, value: impl Into) -> Self { + self.packages_to_push = Some(value.into()); + self + } +} + +/// Inputs for `dotnet custom`. `custom` (the sub-command word) is required. +#[derive(Debug, Clone)] +pub struct DotNetCustom { + custom: String, + arguments: Option, +} + +impl DotNetCustom { + /// Required: the custom dotnet sub-command word (e.g. `"tool"`). + pub fn new(custom: impl Into) -> Self { + Self { + custom: custom.into(), + arguments: None, + } + } + /// `arguments` — extra CLI args. + pub fn arguments(mut self, value: impl Into) -> Self { + self.arguments = Some(value.into()); + self + } +} + +/// Builder for a [`TaskStep`] invoking `DotNetCoreCLI@2`. +#[derive(Debug, Clone)] +pub struct DotNetCoreCli { + command: DotNetCommand, + display_name: Option, +} + +impl DotNetCoreCli { + /// Construct from an explicit [`DotNetCommand`]. + pub fn new(command: DotNetCommand) -> Self { + Self { + command, + display_name: None, + } + } + + /// `command: build`. + pub fn build(spec: DotNetBuild) -> Self { + Self::new(DotNetCommand::Build(spec)) + } + /// `command: test`. + pub fn test(spec: DotNetTest) -> Self { + Self::new(DotNetCommand::Test(spec)) + } + /// `command: publish`. + pub fn publish(spec: DotNetPublish) -> Self { + Self::new(DotNetCommand::Publish(spec)) + } + /// `command: restore`. + pub fn restore(spec: DotNetRestore) -> Self { + Self::new(DotNetCommand::Restore(spec)) + } + /// `command: pack`. + pub fn pack(spec: DotNetPack) -> Self { + Self::new(DotNetCommand::Pack(spec)) + } + /// `command: run`. + pub fn run(spec: DotNetRun) -> Self { + Self::new(DotNetCommand::Run(spec)) + } + /// `command: push`. + pub fn push(spec: DotNetPush) -> Self { + Self::new(DotNetCommand::Push(spec)) + } + /// `command: custom`. + pub fn custom(spec: DotNetCustom) -> Self { + Self::new(DotNetCommand::Custom(spec)) + } + + /// Override the default `displayName` (`"dotnet "`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let command = match &self.command { + DotNetCommand::Build(_) => "build", + DotNetCommand::Test(_) => "test", + DotNetCommand::Publish(_) => "publish", + DotNetCommand::Restore(_) => "restore", + DotNetCommand::Pack(_) => "pack", + DotNetCommand::Run(_) => "run", + DotNetCommand::Push(_) => "push", + DotNetCommand::Custom(_) => "custom", + }; + let mut t = TaskStep::new( + "DotNetCoreCLI@2", + self.display_name.unwrap_or_else(|| format!("dotnet {command}")), + ) + .with_input("command", command); + match self.command { + DotNetCommand::Build(s) => { + push_opt(&mut t, "projects", s.projects); + push_opt(&mut t, "arguments", s.arguments); + push_opt(&mut t, "workingDirectory", s.working_directory); + } + DotNetCommand::Test(s) => { + push_opt(&mut t, "projects", s.projects); + push_opt(&mut t, "arguments", s.arguments); + push_opt(&mut t, "workingDirectory", s.working_directory); + push_bool(&mut t, "publishTestResults", s.publish_test_results); + push_opt(&mut t, "testRunTitle", s.test_run_title); + } + DotNetCommand::Publish(s) => { + push_opt(&mut t, "projects", s.projects); + push_opt(&mut t, "arguments", s.arguments); + push_opt(&mut t, "workingDirectory", s.working_directory); + push_bool(&mut t, "zipAfterPublish", s.zip_after_publish); + push_bool(&mut t, "modifyOutputPath", s.modify_output_path); + push_bool(&mut t, "publishWebProjects", s.publish_web_projects); + } + DotNetCommand::Restore(s) => { + push_opt(&mut t, "projects", s.projects); + } + DotNetCommand::Pack(s) => { + push_opt(&mut t, "packagesToPack", s.packages_to_pack); + } + DotNetCommand::Run(s) => { + push_opt(&mut t, "projects", s.projects); + push_opt(&mut t, "arguments", s.arguments); + push_opt(&mut t, "workingDirectory", s.working_directory); + } + DotNetCommand::Push(s) => { + push_opt(&mut t, "packagesToPush", s.packages_to_push); + } + DotNetCommand::Custom(s) => { + t = t.with_input("custom", s.custom); + push_opt(&mut t, "arguments", s.arguments); + } + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_default_display_and_command() { + let t = DotNetCoreCli::build(DotNetBuild::new()).into_step(); + assert_eq!(t.task, "DotNetCoreCLI@2"); + assert_eq!(t.display_name, "dotnet build"); + assert_eq!(t.inputs.get("command").map(String::as_str), Some("build")); + } + + #[test] + fn test_command_inputs() { + let t = DotNetCoreCli::test( + DotNetTest::new() + .projects("**/*Tests.csproj") + .arguments("--configuration Release") + .publish_test_results(true) + .test_run_title("Unit Tests"), + ) + .into_step(); + assert_eq!(t.inputs.get("command").map(String::as_str), Some("test")); + assert_eq!(t.inputs.get("projects").map(String::as_str), Some("**/*Tests.csproj")); + assert_eq!(t.inputs.get("publishTestResults").map(String::as_str), Some("true")); + assert_eq!(t.inputs.get("testRunTitle").map(String::as_str), Some("Unit Tests")); + } + + #[test] + fn custom_requires_word() { + let t = DotNetCoreCli::custom(DotNetCustom::new("tool").arguments("install -g foo")) + .into_step(); + assert_eq!(t.inputs.get("command").map(String::as_str), Some("custom")); + assert_eq!(t.inputs.get("custom").map(String::as_str), Some("tool")); + assert_eq!(t.inputs.get("arguments").map(String::as_str), Some("install -g foo")); + } +} diff --git a/src/compile/ir/tasks/download_pipeline_artifact.rs b/src/compile/ir/tasks/download_pipeline_artifact.rs new file mode 100644 index 00000000..bded39b2 --- /dev/null +++ b/src/compile/ir/tasks/download_pipeline_artifact.rs @@ -0,0 +1,254 @@ +//! Typed builder for `DownloadPipelineArtifact@2`. + +use super::common::bool_input; +use crate::compile::ir::step::TaskStep; + +/// Run source for [`DownloadPipelineArtifact`] (`source` input). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArtifactSource { + Current, + Specific, +} + +impl ArtifactSource { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + ArtifactSource::Current => "current", + ArtifactSource::Specific => "specific", + } + } +} + +/// Which run to download from for [`DownloadPipelineArtifact`] (`runVersion`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunVersion { + Latest, + LatestFromBranch, + Specific, +} + +impl RunVersion { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + RunVersion::Latest => "latest", + RunVersion::LatestFromBranch => "latestFromBranch", + RunVersion::Specific => "specific", + } + } +} + +/// Builder for a [`TaskStep`] invoking `DownloadPipelineArtifact@2`. +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct DownloadPipelineArtifact { + target_path: String, + artifact: Option, + patterns: Option, + source: Option, + project: Option, + pipeline: Option, + run_version: Option, + branch_name: Option, + run_id: Option, + tags: Option, + allow_partially_succeeded_builds: Option, + allow_failed_builds: Option, + prefer_triggering_pipeline: Option, + item_pattern: Option, + display_name: Option, +} + +impl DownloadPipelineArtifact { + /// Required input: `targetPath` — download destination. + pub fn new(target_path: impl Into) -> Self { + Self { + target_path: target_path.into(), + artifact: None, + patterns: None, + source: None, + project: None, + pipeline: None, + run_version: None, + branch_name: None, + run_id: None, + tags: None, + allow_partially_succeeded_builds: None, + allow_failed_builds: None, + prefer_triggering_pipeline: None, + item_pattern: None, + display_name: None, + } + } + + /// `artifact` — name of the artifact to download (omit to download all). + pub fn artifact(mut self, value: impl Into) -> Self { + self.artifact = Some(value.into()); + self + } + + /// `patterns` — newline-separated glob filters for files to download. + pub fn patterns(mut self, value: impl Into) -> Self { + self.patterns = Some(value.into()); + self + } + + /// `source` — `current` (this run) or `specific` (another run). + pub fn source(mut self, value: ArtifactSource) -> Self { + self.source = Some(value); + self + } + + /// `project` — ADO project name or ID (`source = specific` only). + pub fn project(mut self, value: impl Into) -> Self { + self.project = Some(value.into()); + self + } + + /// `pipeline` — pipeline definition ID or name (`source = specific` only). + pub fn pipeline(mut self, value: impl Into) -> Self { + self.pipeline = Some(value.into()); + self + } + + /// `runVersion` — which run to download from (`source = specific` only). + pub fn run_version(mut self, value: RunVersion) -> Self { + self.run_version = Some(value); + self + } + + /// `branchName` — branch filter (`runVersion = latestFromBranch` only). + pub fn branch_name(mut self, value: impl Into) -> Self { + self.branch_name = Some(value.into()); + self + } + + /// `runId` — build ID to download from (`runVersion = specific` only). + pub fn run_id(mut self, value: impl Into) -> Self { + self.run_id = Some(value.into()); + self + } + + /// `tags` — comma-separated build tags to filter candidate runs. + pub fn tags(mut self, value: impl Into) -> Self { + self.tags = Some(value.into()); + self + } + + /// `allowPartiallySucceededBuilds` — also consider partially-succeeded runs. + pub fn allow_partially_succeeded_builds(mut self, value: bool) -> Self { + self.allow_partially_succeeded_builds = Some(value); + self + } + + /// `allowFailedBuilds` — also consider failed runs. + pub fn allow_failed_builds(mut self, value: bool) -> Self { + self.allow_failed_builds = Some(value); + self + } + + /// `preferTriggeringPipeline` — prefer the run that triggered this pipeline. + pub fn prefer_triggering_pipeline(mut self, value: bool) -> Self { + self.prefer_triggering_pipeline = Some(value); + self + } + + /// `itemPattern` — minimatch pattern applied after download. + pub fn item_pattern(mut self, value: impl Into) -> Self { + self.item_pattern = Some(value.into()); + self + } + + /// Override the default `displayName` (`"Download Pipeline Artifact"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "DownloadPipelineArtifact@2", + self.display_name.unwrap_or_else(|| "Download Pipeline Artifact".into()), + ) + .with_input("targetPath", self.target_path); + if let Some(v) = self.artifact { + t = t.with_input("artifact", v); + } + if let Some(v) = self.patterns { + t = t.with_input("patterns", v); + } + if let Some(v) = self.source { + t = t.with_input("source", v.as_ado_str()); + } + if let Some(v) = self.project { + t = t.with_input("project", v); + } + if let Some(v) = self.pipeline { + t = t.with_input("pipeline", v); + } + if let Some(v) = self.run_version { + t = t.with_input("runVersion", v.as_ado_str()); + } + if let Some(v) = self.branch_name { + t = t.with_input("branchName", v); + } + if let Some(v) = self.run_id { + t = t.with_input("runId", v); + } + if let Some(v) = self.tags { + t = t.with_input("tags", v); + } + if let Some(v) = self.allow_partially_succeeded_builds { + t = t.with_input("allowPartiallySucceededBuilds", bool_input(v)); + } + if let Some(v) = self.allow_failed_builds { + t = t.with_input("allowFailedBuilds", bool_input(v)); + } + if let Some(v) = self.prefer_triggering_pipeline { + t = t.with_input("preferTriggeringPipeline", bool_input(v)); + } + if let Some(v) = self.item_pattern { + t = t.with_input("itemPattern", v); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_task_and_required_target() { + let t = DownloadPipelineArtifact::new("$(Pipeline.Workspace)/drop").into_step(); + assert_eq!(t.task, "DownloadPipelineArtifact@2"); + assert_eq!( + t.inputs.get("targetPath").map(String::as_str), + Some("$(Pipeline.Workspace)/drop") + ); + } + + #[test] + fn specific_run_inputs() { + let t = DownloadPipelineArtifact::new("$(Pipeline.Workspace)/in") + .source(ArtifactSource::Specific) + .project("$(System.TeamProject)") + .pipeline("$(System.DefinitionId)") + .run_version(RunVersion::LatestFromBranch) + .branch_name("$(Build.SourceBranch)") + .artifact("safe_outputs") + .allow_partially_succeeded_builds(true) + .into_step(); + assert_eq!(t.inputs.get("source").map(String::as_str), Some("specific")); + assert_eq!(t.inputs.get("runVersion").map(String::as_str), Some("latestFromBranch")); + assert_eq!(t.inputs.get("artifact").map(String::as_str), Some("safe_outputs")); + assert_eq!( + t.inputs.get("allowPartiallySucceededBuilds").map(String::as_str), + Some("true") + ); + } +} diff --git a/src/compile/ir/tasks/extract_files.rs b/src/compile/ir/tasks/extract_files.rs new file mode 100644 index 00000000..95067750 --- /dev/null +++ b/src/compile/ir/tasks/extract_files.rs @@ -0,0 +1,112 @@ +//! Typed builder for `ExtractFiles@1`. + +use super::common::bool_input; +use crate::compile::ir::step::TaskStep; + +/// Builder for a [`TaskStep`] invoking `ExtractFiles@1`. +/// +/// Extracts archives matching `archive_file_patterns` into `destination_folder`. +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct ExtractFiles { + archive_file_patterns: String, + destination_folder: String, + clean_destination_folder: Option, + overwrite_existing_files: Option, + path_to_seven_zip_tool: Option, + display_name: Option, +} + +impl ExtractFiles { + /// Required inputs: `archiveFilePatterns` glob and `destinationFolder`. + pub fn new( + archive_file_patterns: impl Into, + destination_folder: impl Into, + ) -> Self { + Self { + archive_file_patterns: archive_file_patterns.into(), + destination_folder: destination_folder.into(), + clean_destination_folder: None, + overwrite_existing_files: None, + path_to_seven_zip_tool: None, + display_name: None, + } + } + + /// `cleanDestinationFolder` — delete destination contents before extracting. + pub fn clean_destination_folder(mut self, value: bool) -> Self { + self.clean_destination_folder = Some(value); + self + } + + /// `overwriteExistingFiles` — overwrite files already in the destination. + pub fn overwrite_existing_files(mut self, value: bool) -> Self { + self.overwrite_existing_files = Some(value); + self + } + + /// `pathToSevenZipTool` — absolute path to a custom `7z` binary. + pub fn path_to_seven_zip_tool(mut self, value: impl Into) -> Self { + self.path_to_seven_zip_tool = Some(value.into()); + self + } + + /// Override the default `displayName` (`"Extract Files"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "ExtractFiles@1", + self.display_name.unwrap_or_else(|| "Extract Files".into()), + ) + .with_input("archiveFilePatterns", self.archive_file_patterns) + .with_input("destinationFolder", self.destination_folder); + if let Some(v) = self.clean_destination_folder { + t = t.with_input("cleanDestinationFolder", bool_input(v)); + } + if let Some(v) = self.overwrite_existing_files { + t = t.with_input("overwriteExistingFiles", bool_input(v)); + } + if let Some(v) = self.path_to_seven_zip_tool { + t = t.with_input("pathToSevenZipTool", v); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_task_and_required_inputs() { + let t = ExtractFiles::new("**/*.zip", "$(Build.SourcesDirectory)/unpacked").into_step(); + assert_eq!(t.task, "ExtractFiles@1"); + assert_eq!(t.inputs.get("archiveFilePatterns").map(String::as_str), Some("**/*.zip")); + assert_eq!( + t.inputs.get("destinationFolder").map(String::as_str), + Some("$(Build.SourcesDirectory)/unpacked") + ); + } + + #[test] + fn optional_inputs() { + let t = ExtractFiles::new("**/*.tar.gz", "out") + .clean_destination_folder(false) + .overwrite_existing_files(true) + .path_to_seven_zip_tool("/usr/local/bin/7z") + .into_step(); + assert_eq!(t.inputs.get("cleanDestinationFolder").map(String::as_str), Some("false")); + assert_eq!(t.inputs.get("overwriteExistingFiles").map(String::as_str), Some("true")); + assert_eq!( + t.inputs.get("pathToSevenZipTool").map(String::as_str), + Some("/usr/local/bin/7z") + ); + } +} diff --git a/src/compile/ir/tasks/mod.rs b/src/compile/ir/tasks/mod.rs new file mode 100644 index 00000000..ceae89c4 --- /dev/null +++ b/src/compile/ir/tasks/mod.rs @@ -0,0 +1,34 @@ +//! Typed builder structs for ADO built-in pipeline tasks. +//! +//! Each ADO task is modeled as a **builder struct** with `new()`, one +//! typed chained setter per optional input, and `into_step(self) -> TaskStep`. +//! Only inputs that were explicitly set are emitted, so generated YAML stays +//! minimal and matches the task's own defaults. Constrained input values are +//! typed enums (each with `as_ado_str()`); bool-string inputs are `Option`. +//! +//! Command/mode-dispatch tasks (`Docker@2`, `DotNetCoreCLI@2`, `NuGetCommand@2`, +//! `PowerShell@2`) use a command enum whose variants carry the per-command +//! optional inputs, so applying an input to the wrong command is +//! unrepresentable. [`docker::Docker`] is the canonical template for new such +//! tasks. +//! +//! Each task lives in its own submodule; reference a builder by its module path, +//! e.g. `tasks::copy_files::CopyFiles`. Call sites wrap the result in +//! [`crate::compile::ir::step::Step::Task`], e.g. +//! `Step::Task(copy_files::CopyFiles::new(contents, dst).into_step())`. + +mod common; + +pub mod archive_files; +pub mod cmd_line; +pub mod copy_files; +pub mod delete_files; +pub mod docker; +pub mod docker_installer; +pub mod dotnet_core_cli; +pub mod download_pipeline_artifact; +pub mod extract_files; +pub mod nuget_command; +pub mod powershell; +pub mod publish_pipeline_artifact; +pub mod publish_test_results; diff --git a/src/compile/ir/tasks/nuget_command.rs b/src/compile/ir/tasks/nuget_command.rs new file mode 100644 index 00000000..3a82461f --- /dev/null +++ b/src/compile/ir/tasks/nuget_command.rs @@ -0,0 +1,430 @@ +//! Typed builder for `NuGetCommand@2`. +//! +//! Command-dispatch task modeled after [`super::docker`]: a [`NuGetCommand`] +//! builder wraps a [`NuGetOp`] enum whose variants carry each command's optional +//! inputs, so applying an input to the wrong command is unrepresentable. +//! +//! ADO task reference: +//! + +use super::common::{push_bool, push_opt}; +use crate::compile::ir::step::TaskStep; + +/// NuGet task verbosity (`verbosityRestore` / `verbosityPush` / `verbosityPack`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verbosity { + Quiet, + Normal, + Detailed, +} + +impl Verbosity { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + Verbosity::Quiet => "Quiet", + Verbosity::Normal => "Normal", + Verbosity::Detailed => "Detailed", + } + } +} + +/// `feedsToUse` selector for `nuget restore`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeedsToUse { + Select, + Config, +} + +impl FeedsToUse { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + FeedsToUse::Select => "select", + FeedsToUse::Config => "config", + } + } +} + +/// `nuGetFeedType` selector for `nuget push`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NuGetFeedType { + Internal, + External, +} + +impl NuGetFeedType { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + NuGetFeedType::Internal => "internal", + NuGetFeedType::External => "external", + } + } +} + +/// `versioningScheme` selector for `nuget pack`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VersioningScheme { + Off, + ByPrereleaseNumber, + ByEnvVar, + ByBuildNumber, +} + +impl VersioningScheme { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + VersioningScheme::Off => "off", + VersioningScheme::ByPrereleaseNumber => "byPrereleaseNumber", + VersioningScheme::ByEnvVar => "byEnvVar", + VersioningScheme::ByBuildNumber => "byBuildNumber", + } + } +} + +/// `NuGetCommand@2` `command` selector, carrying per-command optional inputs. +#[derive(Debug, Clone)] +pub enum NuGetOp { + Restore(NuGetRestore), + Push(NuGetPush), + Pack(NuGetPack), + Custom(NuGetCustom), +} + +/// Optionals for `nuget restore`. +#[derive(Debug, Clone, Default)] +pub struct NuGetRestore { + solution: Option, + feeds_to_use: Option, + vsts_feed: Option, + include_nuget_org: Option, + nuget_config_path: Option, + external_feed_credentials: Option, + no_cache: Option, + disable_parallel_processing: Option, + restore_directory: Option, + verbosity_restore: Option, +} + +impl NuGetRestore { + pub fn new() -> Self { + Self::default() + } + /// `solution` — path to solution, `packages.config`, or `project.json`. + pub fn solution(mut self, value: impl Into) -> Self { + self.solution = Some(value.into()); + self + } + /// `feedsToUse` — dropdown vs NuGet.config. + pub fn feeds_to_use(mut self, value: FeedsToUse) -> Self { + self.feeds_to_use = Some(value); + self + } + /// `vstsFeed` — Azure Artifacts feed (when `feedsToUse = select`). + pub fn vsts_feed(mut self, value: impl Into) -> Self { + self.vsts_feed = Some(value.into()); + self + } + /// `includeNuGetOrg` — include NuGet.org as a package source. + pub fn include_nuget_org(mut self, value: bool) -> Self { + self.include_nuget_org = Some(value); + self + } + /// `nugetConfigPath` — path to `NuGet.config` (when `feedsToUse = config`). + pub fn nuget_config_path(mut self, value: impl Into) -> Self { + self.nuget_config_path = Some(value.into()); + self + } + /// `externalFeedCredentials` — credentials for external feeds. + pub fn external_feed_credentials(mut self, value: impl Into) -> Self { + self.external_feed_credentials = Some(value.into()); + self + } + /// `noCache` — disable the local NuGet cache. + pub fn no_cache(mut self, value: bool) -> Self { + self.no_cache = Some(value); + self + } + /// `disableParallelProcessing` — disable parallel package restore. + pub fn disable_parallel_processing(mut self, value: bool) -> Self { + self.disable_parallel_processing = Some(value); + self + } + /// `restoreDirectory` — destination directory for restored packages. + pub fn restore_directory(mut self, value: impl Into) -> Self { + self.restore_directory = Some(value.into()); + self + } + /// `verbosityRestore` — restore verbosity. + pub fn verbosity_restore(mut self, value: Verbosity) -> Self { + self.verbosity_restore = Some(value); + self + } +} + +/// Optionals for `nuget push`. +#[derive(Debug, Clone, Default)] +pub struct NuGetPush { + packages_to_push: Option, + nuget_feed_type: Option, + publish_vsts_feed: Option, + allow_package_conflicts: Option, + publish_feed_credentials: Option, + publish_package_metadata: Option, + verbosity_push: Option, +} + +impl NuGetPush { + pub fn new() -> Self { + Self::default() + } + /// `packagesToPush` — glob for `.nupkg` files to publish. + pub fn packages_to_push(mut self, value: impl Into) -> Self { + self.packages_to_push = Some(value.into()); + self + } + /// `nuGetFeedType` — internal (Azure Artifacts) or external. + pub fn nuget_feed_type(mut self, value: NuGetFeedType) -> Self { + self.nuget_feed_type = Some(value); + self + } + /// `publishVstsFeed` — target Azure Artifacts feed (when internal). + pub fn publish_vsts_feed(mut self, value: impl Into) -> Self { + self.publish_vsts_feed = Some(value.into()); + self + } + /// `allowPackageConflicts` — skip duplicate packages instead of failing. + pub fn allow_package_conflicts(mut self, value: bool) -> Self { + self.allow_package_conflicts = Some(value); + self + } + /// `publishFeedCredentials` — external NuGet server endpoint (when external). + pub fn publish_feed_credentials(mut self, value: impl Into) -> Self { + self.publish_feed_credentials = Some(value.into()); + self + } + /// `publishPackageMetadata` — publish pipeline metadata with the package. + pub fn publish_package_metadata(mut self, value: bool) -> Self { + self.publish_package_metadata = Some(value); + self + } + /// `verbosityPush` — push verbosity. + pub fn verbosity_push(mut self, value: Verbosity) -> Self { + self.verbosity_push = Some(value); + self + } +} + +/// Optionals for `nuget pack`. +#[derive(Debug, Clone, Default)] +pub struct NuGetPack { + packages_to_pack: Option, + configuration: Option, + versioning_scheme: Option, + verbosity_pack: Option, +} + +impl NuGetPack { + pub fn new() -> Self { + Self::default() + } + /// `packagesToPack` — glob for `.csproj`/`.nuspec` files to pack. + pub fn packages_to_pack(mut self, value: impl Into) -> Self { + self.packages_to_pack = Some(value.into()); + self + } + /// `configuration` — build configuration (e.g. `"Release"`). + pub fn configuration(mut self, value: impl Into) -> Self { + self.configuration = Some(value.into()); + self + } + /// `versioningScheme` — version strategy. + pub fn versioning_scheme(mut self, value: VersioningScheme) -> Self { + self.versioning_scheme = Some(value); + self + } + /// `verbosityPack` — pack verbosity. + pub fn verbosity_pack(mut self, value: Verbosity) -> Self { + self.verbosity_pack = Some(value); + self + } +} + +/// Inputs for `nuget custom`. `arguments` is required. +#[derive(Debug, Clone)] +pub struct NuGetCustom { + arguments: String, +} + +impl NuGetCustom { + /// Required: the full NuGet command-line arguments. + pub fn new(arguments: impl Into) -> Self { + Self { + arguments: arguments.into(), + } + } +} + +/// Builder for a [`TaskStep`] invoking `NuGetCommand@2`. +#[derive(Debug, Clone)] +pub struct NuGetCommand { + command: NuGetOp, + display_name: Option, +} + +impl NuGetCommand { + /// Construct from an explicit [`NuGetOp`]. + pub fn new(command: NuGetOp) -> Self { + Self { + command, + display_name: None, + } + } + + /// `command: restore`. + pub fn restore(spec: NuGetRestore) -> Self { + Self::new(NuGetOp::Restore(spec)) + } + /// `command: push`. + pub fn push(spec: NuGetPush) -> Self { + Self::new(NuGetOp::Push(spec)) + } + /// `command: pack`. + pub fn pack(spec: NuGetPack) -> Self { + Self::new(NuGetOp::Pack(spec)) + } + /// `command: custom`. + pub fn custom(spec: NuGetCustom) -> Self { + Self::new(NuGetOp::Custom(spec)) + } + + /// Override the default `displayName` (`"NuGet "`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let command = match &self.command { + NuGetOp::Restore(_) => "restore", + NuGetOp::Push(_) => "push", + NuGetOp::Pack(_) => "pack", + NuGetOp::Custom(_) => "custom", + }; + let mut t = TaskStep::new( + "NuGetCommand@2", + self.display_name.unwrap_or_else(|| format!("NuGet {command}")), + ) + .with_input("command", command); + match self.command { + NuGetOp::Restore(s) => { + push_opt(&mut t, "solution", s.solution); + push_opt(&mut t, "feedsToUse", s.feeds_to_use.map(|v| v.as_ado_str().to_string())); + push_opt(&mut t, "vstsFeed", s.vsts_feed); + push_bool(&mut t, "includeNuGetOrg", s.include_nuget_org); + push_opt(&mut t, "nugetConfigPath", s.nuget_config_path); + push_opt(&mut t, "externalFeedCredentials", s.external_feed_credentials); + push_bool(&mut t, "noCache", s.no_cache); + push_bool(&mut t, "disableParallelProcessing", s.disable_parallel_processing); + push_opt(&mut t, "restoreDirectory", s.restore_directory); + push_opt( + &mut t, + "verbosityRestore", + s.verbosity_restore.map(|v| v.as_ado_str().to_string()), + ); + } + NuGetOp::Push(s) => { + push_opt(&mut t, "packagesToPush", s.packages_to_push); + push_opt( + &mut t, + "nuGetFeedType", + s.nuget_feed_type.map(|v| v.as_ado_str().to_string()), + ); + push_opt(&mut t, "publishVstsFeed", s.publish_vsts_feed); + push_bool(&mut t, "allowPackageConflicts", s.allow_package_conflicts); + push_opt(&mut t, "publishFeedCredentials", s.publish_feed_credentials); + push_bool(&mut t, "publishPackageMetadata", s.publish_package_metadata); + push_opt( + &mut t, + "verbosityPush", + s.verbosity_push.map(|v| v.as_ado_str().to_string()), + ); + } + NuGetOp::Pack(s) => { + push_opt(&mut t, "packagesToPack", s.packages_to_pack); + push_opt(&mut t, "configuration", s.configuration); + push_opt( + &mut t, + "versioningScheme", + s.versioning_scheme.map(|v| v.as_ado_str().to_string()), + ); + push_opt( + &mut t, + "verbosityPack", + s.verbosity_pack.map(|v| v.as_ado_str().to_string()), + ); + } + NuGetOp::Custom(s) => { + t = t.with_input("arguments", s.arguments); + } + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn restore_default_display_and_command() { + let t = NuGetCommand::restore(NuGetRestore::new()).into_step(); + assert_eq!(t.task, "NuGetCommand@2"); + assert_eq!(t.display_name, "NuGet restore"); + assert_eq!(t.inputs.get("command").map(String::as_str), Some("restore")); + } + + #[test] + fn restore_typed_inputs() { + let t = NuGetCommand::restore( + NuGetRestore::new() + .solution("src/MyApp.sln") + .feeds_to_use(FeedsToUse::Select) + .vsts_feed("myorg/myproject/myfeed") + .include_nuget_org(false) + .verbosity_restore(Verbosity::Detailed), + ) + .into_step(); + assert_eq!(t.inputs.get("solution").map(String::as_str), Some("src/MyApp.sln")); + assert_eq!(t.inputs.get("feedsToUse").map(String::as_str), Some("select")); + assert_eq!(t.inputs.get("includeNuGetOrg").map(String::as_str), Some("false")); + assert_eq!(t.inputs.get("verbosityRestore").map(String::as_str), Some("Detailed")); + } + + #[test] + fn push_internal_feed() { + let t = NuGetCommand::push( + NuGetPush::new() + .nuget_feed_type(NuGetFeedType::Internal) + .publish_vsts_feed("myorg/myfeed") + .allow_package_conflicts(true), + ) + .into_step(); + assert_eq!(t.inputs.get("command").map(String::as_str), Some("push")); + assert_eq!(t.inputs.get("nuGetFeedType").map(String::as_str), Some("internal")); + assert_eq!(t.inputs.get("allowPackageConflicts").map(String::as_str), Some("true")); + } + + #[test] + fn custom_requires_arguments() { + let t = NuGetCommand::custom(NuGetCustom::new("install Foo -Version 1.0")).into_step(); + assert_eq!(t.inputs.get("command").map(String::as_str), Some("custom")); + assert_eq!( + t.inputs.get("arguments").map(String::as_str), + Some("install Foo -Version 1.0") + ); + } +} diff --git a/src/compile/ir/tasks/powershell.rs b/src/compile/ir/tasks/powershell.rs new file mode 100644 index 00000000..6ef755e4 --- /dev/null +++ b/src/compile/ir/tasks/powershell.rs @@ -0,0 +1,216 @@ +//! Typed builder for `PowerShell@2`. +//! +//! Collapses the former `powershell_file_step` / `powershell_inline_step` pair +//! into a single builder whose [`PowerShellTarget`] selects file-path vs inline +//! mode. The `arguments` input only exists on the `File` variant, so an +//! `arguments` + inline-script combination is structurally unrepresentable in +//! the emitted YAML. + +use super::common::bool_input; +use crate::compile::ir::step::TaskStep; + +/// Non-terminating error behaviour for [`PowerShell`] (`errorActionPreference`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorActionPreference { + Stop, + Continue, + SilentlyContinue, +} + +impl ErrorActionPreference { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + ErrorActionPreference::Stop => "stop", + ErrorActionPreference::Continue => "continue", + ErrorActionPreference::SilentlyContinue => "silentlyContinue", + } + } +} + +/// Execution target for [`PowerShell`] — `targetType: filePath` vs `inline`. +#[derive(Debug, Clone)] +pub enum PowerShellTarget { + /// `targetType: filePath` — run the script at `file_path`. `arguments` + /// applies only to this variant. + File { + file_path: String, + arguments: Option, + }, + /// `targetType: inline` — run `script` as an inline block. + Inline { script: String }, +} + +/// Builder for a [`TaskStep`] invoking `PowerShell@2`. +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct PowerShell { + target: PowerShellTarget, + error_action_preference: Option, + fail_on_stderr: Option, + ignore_last_exit_code: Option, + pwsh: Option, + working_directory: Option, + display_name: Option, +} + +impl PowerShell { + /// Construct from an explicit [`PowerShellTarget`]. + pub fn new(target: PowerShellTarget) -> Self { + Self { + target, + error_action_preference: None, + fail_on_stderr: None, + ignore_last_exit_code: None, + pwsh: None, + working_directory: None, + display_name: None, + } + } + + /// File-path mode: run the script at `file_path`. + pub fn file(file_path: impl Into) -> Self { + Self::new(PowerShellTarget::File { + file_path: file_path.into(), + arguments: None, + }) + } + + /// Inline mode: run `script` as an inline block. + pub fn inline(script: impl Into) -> Self { + Self::new(PowerShellTarget::Inline { + script: script.into(), + }) + } + + /// `arguments` — script arguments. Applies to the `File` target only; a + /// no-op on an inline target. + pub fn arguments(mut self, value: impl Into) -> Self { + if let PowerShellTarget::File { arguments, .. } = &mut self.target { + *arguments = Some(value.into()); + } + self + } + + /// `errorActionPreference` — non-terminating error behaviour (default `stop`). + pub fn error_action_preference(mut self, value: ErrorActionPreference) -> Self { + self.error_action_preference = Some(value); + self + } + + /// `failOnStderr` — fail the step if anything is written to stderr. + pub fn fail_on_stderr(mut self, value: bool) -> Self { + self.fail_on_stderr = Some(value); + self + } + + /// `ignoreLASTEXITCODE` — do not fail when `$LASTEXITCODE` is non-zero. + pub fn ignore_last_exit_code(mut self, value: bool) -> Self { + self.ignore_last_exit_code = Some(value); + self + } + + /// `pwsh` — use PowerShell Core (`pwsh`) instead of Windows PowerShell. + pub fn pwsh(mut self, value: bool) -> Self { + self.pwsh = Some(value); + self + } + + /// `workingDirectory` — working directory for the script. + pub fn working_directory(mut self, value: impl Into) -> Self { + self.working_directory = Some(value.into()); + self + } + + /// Override the default `displayName` (`"PowerShell Script"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "PowerShell@2", + self.display_name.unwrap_or_else(|| "PowerShell Script".into()), + ); + match self.target { + PowerShellTarget::File { + file_path, + arguments, + } => { + t = t + .with_input("targetType", "filePath") + .with_input("filePath", file_path); + if let Some(v) = arguments { + t = t.with_input("arguments", v); + } + } + PowerShellTarget::Inline { script } => { + t = t + .with_input("targetType", "inline") + .with_input("script", script); + } + } + if let Some(v) = self.error_action_preference { + t = t.with_input("errorActionPreference", v.as_ado_str()); + } + if let Some(v) = self.fail_on_stderr { + t = t.with_input("failOnStderr", bool_input(v)); + } + if let Some(v) = self.ignore_last_exit_code { + t = t.with_input("ignoreLASTEXITCODE", bool_input(v)); + } + if let Some(v) = self.pwsh { + t = t.with_input("pwsh", bool_input(v)); + } + if let Some(v) = self.working_directory { + t = t.with_input("workingDirectory", v); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_mode_sets_target_and_path() { + let t = PowerShell::file("scripts/build.ps1").into_step(); + assert_eq!(t.task, "PowerShell@2"); + assert_eq!(t.inputs.get("targetType").map(String::as_str), Some("filePath")); + assert_eq!(t.inputs.get("filePath").map(String::as_str), Some("scripts/build.ps1")); + } + + #[test] + fn file_mode_arguments_and_options() { + let t = PowerShell::file("scripts/build.ps1") + .arguments("-Configuration Release") + .working_directory("$(Build.SourcesDirectory)") + .pwsh(true) + .error_action_preference(ErrorActionPreference::Continue) + .into_step(); + assert_eq!(t.inputs.get("arguments").map(String::as_str), Some("-Configuration Release")); + assert_eq!(t.inputs.get("pwsh").map(String::as_str), Some("true")); + assert_eq!(t.inputs.get("errorActionPreference").map(String::as_str), Some("continue")); + } + + #[test] + fn inline_mode_sets_script() { + let t = PowerShell::inline("Write-Host hi") + .ignore_last_exit_code(true) + .into_step(); + assert_eq!(t.inputs.get("targetType").map(String::as_str), Some("inline")); + assert_eq!(t.inputs.get("script").map(String::as_str), Some("Write-Host hi")); + assert_eq!(t.inputs.get("ignoreLASTEXITCODE").map(String::as_str), Some("true")); + } + + #[test] + fn arguments_is_noop_on_inline_target() { + let t = PowerShell::inline("Write-Host hi").arguments("-X").into_step(); + assert!(t.inputs.get("arguments").is_none()); + } +} diff --git a/src/compile/ir/tasks/publish_pipeline_artifact.rs b/src/compile/ir/tasks/publish_pipeline_artifact.rs new file mode 100644 index 00000000..c2066977 --- /dev/null +++ b/src/compile/ir/tasks/publish_pipeline_artifact.rs @@ -0,0 +1,153 @@ +//! Typed builder for `PublishPipelineArtifact@1`. + +use super::common::bool_input; +use crate::compile::ir::step::TaskStep; + +/// Storage location for [`PublishPipelineArtifact`] (`publishLocation` input). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PublishLocation { + Pipeline, + Filepath, +} + +impl PublishLocation { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + PublishLocation::Pipeline => "pipeline", + PublishLocation::Filepath => "filepath", + } + } +} + +/// Builder for a [`TaskStep`] invoking `PublishPipelineArtifact@1`. +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct PublishPipelineArtifact { + target_path: String, + artifact: Option, + publish_location: Option, + file_share_path: Option, + parallel: Option, + parallel_count: Option, + properties: Option, + display_name: Option, +} + +impl PublishPipelineArtifact { + /// Required input: `targetPath` — the file or directory to publish. + pub fn new(target_path: impl Into) -> Self { + Self { + target_path: target_path.into(), + artifact: None, + publish_location: None, + file_share_path: None, + parallel: None, + parallel_count: None, + properties: None, + display_name: None, + } + } + + /// `artifact` — name of the published artifact (e.g. `"drop"`). + pub fn artifact(mut self, value: impl Into) -> Self { + self.artifact = Some(value.into()); + self + } + + /// `publishLocation` — where to store the artifact (default `pipeline`). + pub fn publish_location(mut self, value: PublishLocation) -> Self { + self.publish_location = Some(value); + self + } + + /// `fileSharePath` — UNC path (required when `publishLocation = filepath`). + pub fn file_share_path(mut self, value: impl Into) -> Self { + self.file_share_path = Some(value.into()); + self + } + + /// `parallel` — multi-threaded copy when `publishLocation = filepath`. + pub fn parallel(mut self, value: bool) -> Self { + self.parallel = Some(value); + self + } + + /// `parallelCount` — thread count for parallel copy (1–128). + pub fn parallel_count(mut self, value: impl Into) -> Self { + self.parallel_count = Some(value.into()); + self + } + + /// `properties` — JSON string of custom artifact properties. + pub fn properties(mut self, value: impl Into) -> Self { + self.properties = Some(value.into()); + self + } + + /// Override the default `displayName` (`"Publish Pipeline Artifact"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "PublishPipelineArtifact@1", + self.display_name.unwrap_or_else(|| "Publish Pipeline Artifact".into()), + ) + .with_input("targetPath", self.target_path); + if let Some(v) = self.artifact { + t = t.with_input("artifact", v); + } + if let Some(v) = self.publish_location { + t = t.with_input("publishLocation", v.as_ado_str()); + } + if let Some(v) = self.file_share_path { + t = t.with_input("fileSharePath", v); + } + if let Some(v) = self.parallel { + t = t.with_input("parallel", bool_input(v)); + } + if let Some(v) = self.parallel_count { + t = t.with_input("parallelCount", v); + } + if let Some(v) = self.properties { + t = t.with_input("properties", v); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_task_and_required_target() { + let t = PublishPipelineArtifact::new("$(Build.ArtifactStagingDirectory)").into_step(); + assert_eq!(t.task, "PublishPipelineArtifact@1"); + assert_eq!( + t.inputs.get("targetPath").map(String::as_str), + Some("$(Build.ArtifactStagingDirectory)") + ); + } + + #[test] + fn filepath_location() { + let t = PublishPipelineArtifact::new("$(Build.ArtifactStagingDirectory)") + .artifact("binaries") + .publish_location(PublishLocation::Filepath) + .file_share_path("\\\\myserver\\share\\$(Build.DefinitionName)") + .into_step(); + assert_eq!(t.inputs.get("artifact").map(String::as_str), Some("binaries")); + assert_eq!(t.inputs.get("publishLocation").map(String::as_str), Some("filepath")); + assert_eq!( + t.inputs.get("fileSharePath").map(String::as_str), + Some("\\\\myserver\\share\\$(Build.DefinitionName)") + ); + } +} diff --git a/src/compile/ir/tasks/publish_test_results.rs b/src/compile/ir/tasks/publish_test_results.rs new file mode 100644 index 00000000..3e5c3d09 --- /dev/null +++ b/src/compile/ir/tasks/publish_test_results.rs @@ -0,0 +1,152 @@ +//! Typed builder for `PublishTestResults@2`. + +use super::common::bool_input; +use crate::compile::ir::step::TaskStep; + +/// Test result format for [`PublishTestResults`] (`testResultsFormat` input). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TestResultsFormat { + JUnit, + NUnit, + VSTest, + XUnit, + CTest, +} + +impl TestResultsFormat { + /// The exact token the ADO task expects. + pub fn as_ado_str(self) -> &'static str { + match self { + TestResultsFormat::JUnit => "JUnit", + TestResultsFormat::NUnit => "NUnit", + TestResultsFormat::VSTest => "VSTest", + TestResultsFormat::XUnit => "XUnit", + TestResultsFormat::CTest => "CTest", + } + } +} + +/// Builder for a [`TaskStep`] invoking `PublishTestResults@2`. +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct PublishTestResults { + test_results_format: TestResultsFormat, + test_results_files: String, + test_run_title: Option, + search_folder: Option, + merge_test_results: Option, + fail_task_on_failed_tests: Option, + publish_run_attachments: Option, + display_name: Option, +} + +impl PublishTestResults { + /// Required inputs: `testResultsFormat` and `testResultsFiles` glob. + pub fn new( + test_results_format: TestResultsFormat, + test_results_files: impl Into, + ) -> Self { + Self { + test_results_format, + test_results_files: test_results_files.into(), + test_run_title: None, + search_folder: None, + merge_test_results: None, + fail_task_on_failed_tests: None, + publish_run_attachments: None, + display_name: None, + } + } + + /// `testRunTitle` — label shown in the build summary. + pub fn test_run_title(mut self, value: impl Into) -> Self { + self.test_run_title = Some(value.into()); + self + } + + /// `searchFolder` — root for glob expansion. + pub fn search_folder(mut self, value: impl Into) -> Self { + self.search_folder = Some(value.into()); + self + } + + /// `mergeTestResults` — combine results into a single run. + pub fn merge_test_results(mut self, value: bool) -> Self { + self.merge_test_results = Some(value); + self + } + + /// `failTaskOnFailedTests` — fail the step if tests failed. + pub fn fail_task_on_failed_tests(mut self, value: bool) -> Self { + self.fail_task_on_failed_tests = Some(value); + self + } + + /// `publishRunAttachments` — upload result files. + pub fn publish_run_attachments(mut self, value: bool) -> Self { + self.publish_run_attachments = Some(value); + self + } + + /// Override the default `displayName` (`"Publish Test Results"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "PublishTestResults@2", + self.display_name.unwrap_or_else(|| "Publish Test Results".into()), + ) + .with_input("testResultsFormat", self.test_results_format.as_ado_str()) + .with_input("testResultsFiles", self.test_results_files); + if let Some(v) = self.test_run_title { + t = t.with_input("testRunTitle", v); + } + if let Some(v) = self.search_folder { + t = t.with_input("searchFolder", v); + } + if let Some(v) = self.merge_test_results { + t = t.with_input("mergeTestResults", bool_input(v)); + } + if let Some(v) = self.fail_task_on_failed_tests { + t = t.with_input("failTaskOnFailedTests", bool_input(v)); + } + if let Some(v) = self.publish_run_attachments { + t = t.with_input("publishRunAttachments", bool_input(v)); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_task_and_required_inputs() { + let t = PublishTestResults::new(TestResultsFormat::JUnit, "**/TEST-*.xml").into_step(); + assert_eq!(t.task, "PublishTestResults@2"); + assert_eq!(t.inputs.get("testResultsFormat").map(String::as_str), Some("JUnit")); + assert_eq!(t.inputs.get("testResultsFiles").map(String::as_str), Some("**/TEST-*.xml")); + } + + #[test] + fn optional_inputs() { + let t = PublishTestResults::new(TestResultsFormat::VSTest, "**/*.trx") + .test_run_title("Unit Tests") + .merge_test_results(true) + .search_folder("$(System.DefaultWorkingDirectory)") + .into_step(); + assert_eq!(t.inputs.get("testRunTitle").map(String::as_str), Some("Unit Tests")); + assert_eq!(t.inputs.get("mergeTestResults").map(String::as_str), Some("true")); + assert_eq!( + t.inputs.get("searchFolder").map(String::as_str), + Some("$(System.DefaultWorkingDirectory)") + ); + } +} From c30698d14ba88bdb19587dbdaeea5b975e8280dd Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 18 Jun 2026 13:59:15 +0100 Subject: [PATCH 2/2] feat(ir): validate front-matter task steps against typed builders Add a parse direction to the typed task builders: parse_task_step() deserializes an authored ADO task-step mapping and validates it by reusing the builder structs as the schema. Derive Deserialize (keyed on ADO input names, deny_unknown_fields) on CopyFiles and the Docker@2 command variants, plus a flexible bool deserializer accepting both rue and "true". Partial coverage is safe and additive: parse_task_step returns Ok(Some(TaskStep)) for a recognized+valid step, Ok(None) for anything not modeled (unmapped task or non-task step) so the caller keeps the original YAML as today's opaque passthrough, and Err only for a recognized task with invalid inputs (missing required, unknown key, bad value, or an input for the wrong command). Wired up for CopyFiles@2 and Docker@2 as a proof of concept. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/ir/tasks/common.rs | 30 +++ src/compile/ir/tasks/copy_files.rs | 21 +- src/compile/ir/tasks/docker.rs | 33 +++- src/compile/ir/tasks/mod.rs | 1 + src/compile/ir/tasks/parse.rs | 302 +++++++++++++++++++++++++++++ 5 files changed, 380 insertions(+), 7 deletions(-) create mode 100644 src/compile/ir/tasks/parse.rs diff --git a/src/compile/ir/tasks/common.rs b/src/compile/ir/tasks/common.rs index e444280d..66733db7 100644 --- a/src/compile/ir/tasks/common.rs +++ b/src/compile/ir/tasks/common.rs @@ -31,3 +31,33 @@ pub(crate) fn push_bool(t: &mut TaskStep, key: &str, value: Option) { t.inputs.insert(key.to_string(), bool_input(v).to_string()); } } + +/// Deserialize an optional ADO bool-string input, accepting either a native +/// YAML boolean (`true`) or the ADO-canonical string form (`"true"` / +/// `"false"`). Used by the front-matter validation path in [`super::parse`] so +/// authored task inputs match ADO's accepted shapes. +pub(crate) fn de_opt_bool_flex<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + + #[derive(Deserialize)] + #[serde(untagged)] + enum BoolOrStr { + Bool(bool), + Str(String), + } + + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(BoolOrStr::Bool(b)) => Ok(Some(b)), + Some(BoolOrStr::Str(s)) => match s.as_str() { + "true" => Ok(Some(true)), + "false" => Ok(Some(false)), + other => Err(serde::de::Error::custom(format!( + "expected a boolean or \"true\"/\"false\", got {other:?}" + ))), + }, + } +} diff --git a/src/compile/ir/tasks/copy_files.rs b/src/compile/ir/tasks/copy_files.rs index 1d365995..17066b51 100644 --- a/src/compile/ir/tasks/copy_files.rs +++ b/src/compile/ir/tasks/copy_files.rs @@ -1,27 +1,44 @@ //! Typed builder for `CopyFiles@2`. -use super::common::bool_input; +use super::common::{bool_input, de_opt_bool_flex}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Builder for a [`TaskStep`] invoking `CopyFiles@2`. /// /// Copies files matching `contents` into `target_folder`. Optional inputs are /// applied through the typed setters; only those that are set are emitted. /// +/// Also implements [`serde::Deserialize`] keyed on ADO input names so a +/// front-matter task `inputs:` mapping can be parsed and validated via +/// [`super::parse`] (required inputs enforced, unknown inputs rejected). +/// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CopyFiles { + #[serde(rename = "Contents")] contents: String, + #[serde(rename = "TargetFolder")] target_folder: String, + #[serde(rename = "SourceFolder", default)] source_folder: Option, + #[serde(rename = "CleanTargetFolder", default, deserialize_with = "de_opt_bool_flex")] clean_target_folder: Option, + #[serde(rename = "OverWrite", default, deserialize_with = "de_opt_bool_flex")] over_write: Option, + #[serde(rename = "flattenFolders", default, deserialize_with = "de_opt_bool_flex")] flatten_folders: Option, + #[serde(rename = "preserveTimestamp", default, deserialize_with = "de_opt_bool_flex")] preserve_timestamp: Option, + #[serde(rename = "retryCount", default)] retry_count: Option, + #[serde(rename = "delayBetweenRetries", default)] delay_between_retries: Option, + #[serde(rename = "ignoreMakeDirErrors", default, deserialize_with = "de_opt_bool_flex")] ignore_make_dir_errors: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/docker.rs b/src/compile/ir/tasks/docker.rs index 6b1744a5..93a9dd90 100644 --- a/src/compile/ir/tasks/docker.rs +++ b/src/compile/ir/tasks/docker.rs @@ -11,6 +11,7 @@ //! use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// `Docker@2` `command` selector, carrying the per-command optional inputs. #[derive(Debug, Clone)] @@ -23,12 +24,18 @@ pub enum DockerCommand { } /// Optionals for `Docker@2` `command: buildAndPush`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DockerBuildAndPush { + #[serde(rename = "containerRegistry", default)] container_registry: Option, + #[serde(default)] repository: Option, + #[serde(rename = "Dockerfile", default)] dockerfile: Option, + #[serde(rename = "buildContext", default)] build_context: Option, + #[serde(default)] tags: Option, } @@ -64,13 +71,20 @@ impl DockerBuildAndPush { } /// Optionals for `Docker@2` `command: build`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DockerBuild { + #[serde(rename = "containerRegistry", default)] container_registry: Option, + #[serde(default)] repository: Option, + #[serde(rename = "Dockerfile", default)] dockerfile: Option, + #[serde(rename = "buildContext", default)] build_context: Option, + #[serde(default)] tags: Option, + #[serde(default)] arguments: Option, } @@ -111,11 +125,16 @@ impl DockerBuild { } /// Optionals for `Docker@2` `command: push`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DockerPush { + #[serde(rename = "containerRegistry", default)] container_registry: Option, + #[serde(default)] repository: Option, + #[serde(default)] tags: Option, + #[serde(default)] arguments: Option, } @@ -146,8 +165,10 @@ impl DockerPush { } /// Optionals for `Docker@2` `command: login`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DockerLogin { + #[serde(rename = "containerRegistry", default)] container_registry: Option, } @@ -163,8 +184,10 @@ impl DockerLogin { } /// Optionals for `Docker@2` `command: logout`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DockerLogout { + #[serde(rename = "containerRegistry", default)] container_registry: Option, } diff --git a/src/compile/ir/tasks/mod.rs b/src/compile/ir/tasks/mod.rs index ceae89c4..ec0ab538 100644 --- a/src/compile/ir/tasks/mod.rs +++ b/src/compile/ir/tasks/mod.rs @@ -29,6 +29,7 @@ pub mod dotnet_core_cli; pub mod download_pipeline_artifact; pub mod extract_files; pub mod nuget_command; +pub mod parse; pub mod powershell; pub mod publish_pipeline_artifact; pub mod publish_test_results; diff --git a/src/compile/ir/tasks/parse.rs b/src/compile/ir/tasks/parse.rs new file mode 100644 index 00000000..a2230525 --- /dev/null +++ b/src/compile/ir/tasks/parse.rs @@ -0,0 +1,302 @@ +//! Front-matter task-step **validation** (prototype). +//! +//! The builder structs in this module are normally used one-way +//! (`Builder::new(...).into_step()`), but the ones that derive +//! [`serde::Deserialize`] keyed on ADO input names can *also* parse and validate +//! an authored task step. [`parse_task_step`] is the inverse direction of +//! `into_step()` and is designed to sit in front of the front-matter `steps:` +//! passthrough with **partial coverage**, so it has three outcomes: +//! +//! - `Ok(Some(step))` — the `task:` is one we model; its inputs were valid, and +//! a normalized [`TaskStep`] is returned. +//! - `Ok(None)` — this step is **not** something we validate (a task with no +//! typed builder yet, or a non-task step like `bash:`/`script:`). The caller +//! should keep the original YAML untouched (today: `Step::RawYaml`). Coverage +//! is therefore additive: mapping a new task only ever *adds* validation and +//! never rejects a workflow that compiled before. +//! - `Err(e)` — the `task:` is one we model but its inputs are wrong (missing a +//! required input, an unknown input key, a bad constrained value, or an input +//! supplied for the wrong command of a command-dispatch task). This is a real +//! authoring error worth surfacing. +//! +//! Only `CopyFiles@2` and `Docker@2` are wired up here as a proof of concept; +//! extending coverage is a matter of deriving `Deserialize` on the remaining +//! builders and adding a match arm. + +use anyhow::{Context, Result, bail}; +use serde_yaml::Value; + +use super::copy_files::CopyFiles; +use super::docker::{Docker, DockerCommand}; +use crate::compile::ir::step::TaskStep; + +/// Validate a single front-matter step. Returns `Ok(Some(_))` for a recognized +/// and valid task step, `Ok(None)` for anything we don't model (pass it through +/// unchanged), and `Err(_)` for a recognized task with invalid inputs. +pub fn parse_task_step(step: &Value) -> Result> { + // Not a mapping, or not a `task:` step (e.g. `bash:` / `script:` / a + // checkout) → nothing for us to validate; leave it to the existing + // passthrough. + let Some(map) = step.as_mapping() else { + return Ok(None); + }; + let Some(task) = map.get("task").and_then(Value::as_str) else { + return Ok(None); + }; + + let display_name = map + .get("displayName") + .and_then(Value::as_str) + .map(str::to_string); + let inputs = map + .get("inputs") + .cloned() + .unwrap_or_else(|| Value::Mapping(Default::default())); + + let validated = match task { + "CopyFiles@2" => { + let mut builder: CopyFiles = serde_yaml::from_value(inputs) + .with_context(|| format!("invalid inputs for `{task}`"))?; + if let Some(dn) = display_name { + builder = builder.with_display_name(dn); + } + builder.into_step() + } + "Docker@2" => parse_docker(inputs, display_name)?, + // No typed builder for this task (yet) → not an error; the caller keeps + // the original YAML as an opaque passthrough step. + _ => return Ok(None), + }; + Ok(Some(validated)) +} + +/// `Docker@2` selects the command via the `command` input; dispatch on it and +/// validate the remaining inputs against that command's allowed set. +fn parse_docker(inputs: Value, display_name: Option) -> Result { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + _ => bail!("`inputs` must be a mapping"), + }; + let command = map + .remove("command") + .and_then(|v| v.as_str().map(str::to_string)) + .context("Docker@2 requires a `command` input")?; + let rest = Value::Mapping(map); + + let cmd = match command.as_str() { + "buildAndPush" => DockerCommand::BuildAndPush( + serde_yaml::from_value(rest).context("invalid inputs for command `buildAndPush`")?, + ), + "build" => DockerCommand::Build( + serde_yaml::from_value(rest).context("invalid inputs for command `build`")?, + ), + "push" => DockerCommand::Push( + serde_yaml::from_value(rest).context("invalid inputs for command `push`")?, + ), + "login" => DockerCommand::Login( + serde_yaml::from_value(rest).context("invalid inputs for command `login`")?, + ), + "logout" => DockerCommand::Logout( + serde_yaml::from_value(rest).context("invalid inputs for command `logout`")?, + ), + other => bail!("Docker@2: unknown command `{other}`"), + }; + + let mut docker = Docker::new(cmd); + if let Some(dn) = display_name { + docker = docker.with_display_name(dn); + } + Ok(docker.into_step()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn yaml(input: &str) -> Value { + serde_yaml::from_str(input).expect("test YAML should parse") + } + + // ── CopyFiles@2 ────────────────────────────────────────────────────── + + #[test] + fn copy_files_valid_roundtrips_to_task_step() { + let step = yaml( + r#" + task: CopyFiles@2 + displayName: Stage build output + inputs: + Contents: "**/*.dll" + TargetFolder: $(Build.ArtifactStagingDirectory) + SourceFolder: $(Build.SourcesDirectory)/bin + CleanTargetFolder: true + OverWrite: "true" + "#, + ); + let t = parse_task_step(&step).expect("valid CopyFiles step").expect("recognized task"); + assert_eq!(t.task, "CopyFiles@2"); + assert_eq!(t.display_name, "Stage build output"); + assert_eq!(t.inputs.get("Contents").map(String::as_str), Some("**/*.dll")); + assert_eq!( + t.inputs.get("TargetFolder").map(String::as_str), + Some("$(Build.ArtifactStagingDirectory)") + ); + assert_eq!( + t.inputs.get("SourceFolder").map(String::as_str), + Some("$(Build.SourcesDirectory)/bin") + ); + // Native YAML bool and ADO-style string bool both normalize to "true". + assert_eq!(t.inputs.get("CleanTargetFolder").map(String::as_str), Some("true")); + assert_eq!(t.inputs.get("OverWrite").map(String::as_str), Some("true")); + // Untouched optionals are absent. + assert!(t.inputs.get("flattenFolders").is_none()); + } + + #[test] + fn copy_files_missing_required_input_is_rejected() { + let step = yaml( + r#" + task: CopyFiles@2 + inputs: + Contents: "**" + "#, + ); + let err = parse_task_step(&step).unwrap_err().to_string(); + assert!(err.contains("invalid inputs for `CopyFiles@2`"), "got: {err}"); + } + + #[test] + fn copy_files_unknown_input_is_rejected() { + let step = yaml( + r#" + task: CopyFiles@2 + inputs: + Contents: "**" + TargetFolder: out + Bogus: nope + "#, + ); + let err = parse_task_step(&step).unwrap_err().to_string(); + assert!(err.contains("invalid inputs for `CopyFiles@2`"), "got: {err}"); + } + + #[test] + fn copy_files_bad_bool_value_is_rejected() { + let step = yaml( + r#" + task: CopyFiles@2 + inputs: + Contents: "**" + TargetFolder: out + CleanTargetFolder: yesplease + "#, + ); + assert!(parse_task_step(&step).is_err()); + } + + // ── Docker@2 (command-dispatch) ────────────────────────────────────── + + #[test] + fn docker_build_and_push_valid() { + let step = yaml( + r#" + task: Docker@2 + inputs: + command: buildAndPush + repository: myapp + tags: latest + "#, + ); + let t = parse_task_step(&step).expect("valid Docker buildAndPush").expect("recognized"); + assert_eq!(t.task, "Docker@2"); + assert_eq!(t.inputs.get("command").map(String::as_str), Some("buildAndPush")); + assert_eq!(t.inputs.get("repository").map(String::as_str), Some("myapp")); + assert_eq!(t.inputs.get("tags").map(String::as_str), Some("latest")); + } + + #[test] + fn docker_login_valid() { + let step = yaml( + r#" + task: Docker@2 + inputs: + command: login + containerRegistry: myRegistry + "#, + ); + let t = parse_task_step(&step).expect("valid Docker login").expect("recognized"); + assert_eq!(t.inputs.get("command").map(String::as_str), Some("login")); + assert_eq!(t.inputs.get("containerRegistry").map(String::as_str), Some("myRegistry")); + } + + #[test] + fn docker_input_for_wrong_command_is_rejected() { + // `repository` is not valid for `command: login`. + let step = yaml( + r#" + task: Docker@2 + inputs: + command: login + repository: myapp + "#, + ); + let err = parse_task_step(&step).unwrap_err().to_string(); + assert!(err.contains("command `login`"), "got: {err}"); + } + + #[test] + fn docker_missing_command_is_rejected() { + let step = yaml( + r#" + task: Docker@2 + inputs: + repository: myapp + "#, + ); + let err = parse_task_step(&step).unwrap_err().to_string(); + assert!(err.contains("requires a `command`"), "got: {err}"); + } + + #[test] + fn docker_unknown_command_is_rejected() { + let step = yaml( + r#" + task: Docker@2 + inputs: + command: teleport + "#, + ); + let err = parse_task_step(&step).unwrap_err().to_string(); + assert!(err.contains("unknown command `teleport`"), "got: {err}"); + } + + // ── Dispatch / partial coverage ────────────────────────────────────── + + #[test] + fn unmapped_task_passes_through_unvalidated() { + // A task we don't model is NOT an error — it returns None so the caller + // keeps the original YAML (today: Step::RawYaml). Note the bogus input: + // we deliberately don't validate it. + let step = yaml( + r#" + task: SomeRandomTask@9 + inputs: + whatever: 123 + "#, + ); + let result = parse_task_step(&step).expect("unmapped task is not an error"); + assert!(result.is_none(), "unmapped task should pass through (None)"); + } + + #[test] + fn non_task_step_passes_through() { + // A `bash:`/`script:`/checkout step has no `task:` key → None. + let step = yaml("bash: echo hi\ndisplayName: greet"); + assert!(parse_task_step(&step).expect("not an error").is_none()); + + // A scalar (malformed step) also just passes through rather than erroring. + let scalar = yaml("\"- some weird string\""); + assert!(parse_task_step(&scalar).expect("not an error").is_none()); + } +}