diff --git a/AGENTS.md b/AGENTS.md index 81c57c92..46f0fcd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ ├── mod.rs # Pipeline / PipelineBody / PipelineShape root types │ │ ├── ids.rs # Typed StageId / JobId / StepId newtypes │ │ ├── step.rs # Step variants (Bash, Task, Checkout, Download, Publish, RawYaml) -│ │ ├── 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/Npm/UniversalPackages; typestate builders for PowerShell; docker.rs canonical template) +│ │ ├── 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/Npm/UniversalPackages; typestate builders for PowerShell; docker.rs canonical template; tasks/parse.rs reuses the builders as serde schemas to advisory-validate authored front-matter task steps — surfaced as task-input-invalid warnings via ado-aw lint / lint_workflow MCP, NOT via compile) │ │ ├── job.rs # Job, Pool, TemplateContext, JobVariable │ │ ├── stage.rs # Stage + external-params wrap │ │ ├── env.rs # Typed EnvValue (Literal, AdoMacro, PipelineVar, Secret, StepOutput, Coalesce, Concat) diff --git a/docs/front-matter.md b/docs/front-matter.md index 946b8ba4..09c19622 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -223,6 +223,36 @@ parameters: # optional ADO runtime parameters (surfaced in UI Build the project and run all tests... ``` +## Inline step validation (`setup` / `steps` / `post-steps` / `teardown`) + +Inline steps are authored as raw Azure DevOps YAML and are emitted into the +generated pipeline **verbatim** (a passthrough). For steps that invoke a +built-in ADO task the compiler also knows (e.g. `CopyFiles@2`, `Docker@2`, +`DotNetCoreCLI@2`, and most other first-party tasks), `ado-aw lint` performs an +**advisory** validation of the `inputs:` mapping against the task's typed +schema — checking for missing required inputs, unknown input keys, bad +constrained values, and (for command/mode tasks) inputs supplied for the wrong +command. + +This validation is surfaced through the **lint** channel, not compile: + +- Run `ado-aw lint ` (add `--json` for machine-readable findings), or + call the `lint_workflow` tool on the author-facing MCP server. Each invalid + step produces a `task-input-invalid` **warning** finding with the offending + task id and which step list it came from. +- It is **warning-only**: findings never fail `lint` (exit code stays 0) and + never affect `compile` or the emitted YAML — the step is always passed through + unchanged. +- A task the compiler does not model, or a non-task step (`bash:`/`script:`/ + `checkout:`), produces no finding. + +Surfacing this through lint (rather than as a compile-time stderr warning) keeps +the feedback in the structured channel that authoring agents already consume to +check the steps they synthesise, and keeps the in-pipeline integrity recompile +quiet. So adding validation coverage can only ever *surface* authoring mistakes +— it never rejects a workflow that compiled before. See [`ir.md`](ir.md) +(`tasks/parse.rs`) for the mechanism and how to extend coverage. + ## Debug-only `ado-aw-debug:` `ado-aw-debug:` is accepted in front matter for repository dogfooding and diff --git a/docs/ir.md b/docs/ir.md index e7686c2a..e406bb62 100644 --- a/docs/ir.md +++ b/docs/ir.md @@ -20,6 +20,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/` — 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-dispatch tasks (`Docker@2`, `DotNetCoreCLI@2`, `NuGetCommand@2`, `Npm@1`, `UniversalPackages@1`) use a command enum with per-variant data so invalid input/command combos are unrepresentable (`tasks/docker.rs` is the canonical template). `PowerShell@2` uses distinct typestate builders (`PowerShellFile` / `PowerShellInline`) instead — mode-specific inputs are only available on the matching typestate builder. Prefer these builders for compiler-generated tasks rather than open-coding `TaskStep::new(...)` with raw string keys at each call site. + - `tasks/parse.rs` — uses the same builder structs *in reverse* to **validate authored front-matter task steps** ("parse, don't validate"): each builder derives `serde::Deserialize` keyed on its ADO input names (`deny_unknown_fields`), so a successful deserialization *is* the validation. `validate_task_step(&Value) -> Option>` looks the `task:` up in the `VALIDATORS` registry (`None` = unmodeled task, passed through untouched; `Some(Ok)` = valid; `Some(Err)` = recognized task with bad inputs), and `validate_front_matter_task_steps(...)` runs it over the four authored step lists, returning structured `TaskStepFinding`s. It **never** returns an unrecoverable error. The findings are surfaced through the **lint** channel — `inspect::lint::lint_front_matter_tasks` maps them to `task-input-invalid` warning findings that `ado-aw lint` / the `lint_workflow` MCP tool report (not via `compile`, which never warns and never alters emitted YAML). Command/mode-dispatch tasks supply a bespoke `validate_inputs` that dispatches on their discriminator input before deserializing the matching variant. Adding a task = derive `Deserialize` on the builder + one `VALIDATORS` line. - `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`, macro-form `Concat`, and `RuntimeExpression` (a `$[ ... ]` ADO runtime expression that the lowering pass auto-hoists to a job-level `variables:` entry — ADO does not evaluate `$[ ... ]` inside step `env:`). `RuntimeExpression` is only valid at the top level of a step `env:` value: nesting it inside `Concat` or `Coalesce` is rejected at lower time (the hoist pass walks only the top level, so a nested occurrence would emit a dangling `$(AwRtExpr_…)` macro). A `Literal` or `RawYamlScalar(String)` smuggling a raw `$[ ... ]` into a step `env:` value (whether at the top level or nested inside a `Concat`) is likewise rejected at lower time — ADO passes such scalars verbatim, so the typed `RuntimeExpression` variant must be used instead. diff --git a/prompts/create-ado-agentic-workflow.md b/prompts/create-ado-agentic-workflow.md index ddd527c9..f9631d2e 100644 --- a/prompts/create-ado-agentic-workflow.md +++ b/prompts/create-ado-agentic-workflow.md @@ -705,6 +705,19 @@ ado-aw compile -o This generates a `.lock.yml` pipeline file. Both the source `.md` and generated `.lock.yml` must be committed together. The compiler also writes/updates a `.gitattributes` file at the repository root so compiled pipelines are marked `linguist-generated=true merge=ours`. +### Lint authored task steps + +If the workflow includes raw Azure DevOps `task:` steps in `setup:`, `steps:`, `post-steps:`, or `teardown:`, run the linter and fix any findings before committing: + +```bash +ado-aw lint --json +``` + +The compiler emits those steps **verbatim** and does **not** validate their inputs — invalid task inputs (a missing required input, an unknown/misspelled input key, a bad enum value, or an input for the wrong command of a command-dispatch task like `Docker@2`) surface **only** through `ado-aw lint` as `task-input-invalid` warning findings. (The same checks are available via the `lint_workflow` tool on the author-facing MCP server.) + +> **Read the findings, not the exit code.** These are advisory *warnings*, so `ado-aw lint` still exits `0` — parse the JSON `findings` array and address any entry whose `code` is `task-input-invalid` (its `location.step` names the offending task and `location.job` the step list). Unmodeled tasks and non-`task:` steps (`bash:`/`script:`) are never flagged, so a clean lint means every recognized task's inputs are well-formed. + + If the `ado-aw` CLI is not installed or not available on `PATH`, guide the user to download it from: https://github.com/githubnext/ado-aw/releases @@ -808,3 +821,4 @@ safe-outputs: - **No direct writes**: All mutations go through safe outputs — the agent cannot push code or call write APIs directly. - **Compile before committing**: Always compile with `ado-aw compile` and commit both the `.md` source and generated `.lock.yml` together. - **Check validation**: The compiler validates front-matter fields and emits errors for invalid configurations (e.g., conflicting filter rules, missing required fields like `comment-on-work-item.target`). Write-bearing safe outputs do **not** require `permissions.write` — the executor defaults to `$(System.AccessToken)`. +- **Lint raw task steps**: When you author `task:` steps, run `ado-aw lint --json` and fix any `task-input-invalid` findings — the compiler passes those steps through unchecked, so lint is the only place invalid task inputs surface. diff --git a/src/compile/ir/tasks/archive_files.rs b/src/compile/ir/tasks/archive_files.rs index 6c26fc05..18855fa6 100644 --- a/src/compile/ir/tasks/archive_files.rs +++ b/src/compile/ir/tasks/archive_files.rs @@ -1,14 +1,19 @@ //! Typed builder for `ArchiveFiles@2`. -use super::common::bool_input; +use super::common::{bool_input, de_opt_bool_flex}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Archive format for [`ArchiveFiles`] (`archiveType` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum ArchiveType { + #[serde(rename = "zip")] Zip, + #[serde(rename = "7z")] SevenZip, + #[serde(rename = "tar")] Tar, + #[serde(rename = "wim")] Wim, } @@ -26,11 +31,15 @@ impl ArchiveType { /// Tar compression for [`ArchiveFiles`] (`tarCompression` input, only when /// `archiveType = tar`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum TarCompression { + #[serde(rename = "gz")] Gz, + #[serde(rename = "bz2")] Bz2, + #[serde(rename = "xz")] Xz, + #[serde(rename = "none")] None, } @@ -48,13 +57,19 @@ impl TarCompression { /// 7-Zip compression level for [`ArchiveFiles`] (`sevenZipCompression` input, /// only when `archiveType = 7z`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum SevenZipCompression { + #[serde(rename = "ultra")] Ultra, + #[serde(rename = "maximum")] Maximum, + #[serde(rename = "normal")] Normal, + #[serde(rename = "fast")] Fast, + #[serde(rename = "fastest")] Fastest, + #[serde(rename = "none")] None, } @@ -78,26 +93,42 @@ impl SevenZipCompression { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ArchiveFiles { + #[serde(rename = "rootFolderOrFile")] root_folder_or_file: String, + #[serde(rename = "archiveFile")] archive_file: String, + #[serde(rename = "archiveType", default)] archive_type: Option, + #[serde( + rename = "includeRootFolder", + default, + deserialize_with = "de_opt_bool_flex" + )] include_root_folder: Option, + #[serde( + rename = "replaceExistingArchive", + default, + deserialize_with = "de_opt_bool_flex" + )] replace_existing_archive: Option, + #[serde(rename = "sevenZipCompression", default)] seven_zip_compression: Option, + #[serde(rename = "tarCompression", default)] tar_compression: Option, + #[serde(default, deserialize_with = "de_opt_bool_flex")] verbose: Option, + #[serde(default, deserialize_with = "de_opt_bool_flex")] quiet: Option, + #[serde(skip)] display_name: Option, } impl ArchiveFiles { /// Required inputs: `rootFolderOrFile` and `archiveFile`. - pub fn new( - root_folder_or_file: impl Into, - archive_file: impl Into, - ) -> Self { + 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(), @@ -223,8 +254,14 @@ mod tests { .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")); + 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] @@ -234,6 +271,9 @@ mod tests { .seven_zip_compression(SevenZipCompression::Ultra) .into_step(); assert_eq!(t.inputs.get("archiveType").map(String::as_str), Some("7z")); - assert_eq!(t.inputs.get("sevenZipCompression").map(String::as_str), Some("ultra")); + assert_eq!( + t.inputs.get("sevenZipCompression").map(String::as_str), + Some("ultra") + ); } } diff --git a/src/compile/ir/tasks/azure_cli.rs b/src/compile/ir/tasks/azure_cli.rs index 8046c36e..f32305da 100644 --- a/src/compile/ir/tasks/azure_cli.rs +++ b/src/compile/ir/tasks/azure_cli.rs @@ -1,22 +1,27 @@ //! Typed builder for `AzureCLI@2`. -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Script type for `AzureCLI@2`. /// /// Selects the shell that executes the script body. /// /// ADO input: `scriptType` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum ScriptType { /// Bash shell (`bash`). + #[serde(rename = "bash")] Bash, /// Windows PowerShell (`ps`). + #[serde(rename = "ps")] Ps, /// PowerShell Core (`pscore`). + #[serde(rename = "pscore")] PsCore, /// Windows batch script (`batch`). + #[serde(rename = "batch")] Batch, } @@ -39,6 +44,12 @@ impl ScriptType { /// invalid combination (e.g. `scriptLocation: scriptPath` with no path) is /// unrepresentable. /// +/// This is a **construction-only** type and deliberately does *not* derive +/// `Deserialize`: the authored ADO shape is flat (a bare +/// `scriptLocation: inlineScript` discriminator plus a *sibling* `inlineScript:` +/// key), which an externally-tagged enum cannot represent. Validation therefore +/// uses the flat [`AzureCliInputs`] schema in [`validate_inputs`] instead. +/// /// ADO input: `scriptLocation` #[derive(Debug, Clone)] pub enum ScriptLocation { @@ -51,13 +62,16 @@ pub enum ScriptLocation { /// `ErrorActionPreference` for PowerShell scripts (`scriptType: ps` or `pscore`). /// /// ADO input: `powerShellErrorActionPreference` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum PowerShellErrorActionPreference { /// Stop on first error (`stop`). This is the ADO default. + #[serde(rename = "stop")] Stop, /// Continue on errors (`continue`). + #[serde(rename = "continue")] Continue, /// Silently continue (`silentlyContinue`). + #[serde(rename = "silentlyContinue")] SilentlyContinue, } @@ -72,6 +86,83 @@ impl PowerShellErrorActionPreference { } } +/// Validate an authored `AzureCLI@2` `inputs:` mapping (advisory front-matter +/// validation, see [`super::parse`]). +/// +/// The [`AzureCli`] builder models `scriptLocation` + its script content as the +/// typed [`ScriptLocation`] enum for *construction*, but ADO authors the **flat** +/// shape — a bare `scriptLocation: inlineScript|scriptPath` discriminator plus a +/// *sibling* `inlineScript:`/`scriptPath:` key. That flat shape does not match an +/// externally-tagged enum field, so validation uses a dedicated flat schema here +/// rather than `Deserialize` on the builder. `AzureCLI@2` is therefore registered +/// with this `validate_inputs`, not `validate_by_deserialize::`. +pub(crate) fn validate_inputs(inputs: serde_yaml::Value) -> Result<(), String> { + /// Flat `scriptLocation` discriminator value (`inlineScript` / `scriptPath`). + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + enum ScriptLocationKind { + InlineScript, + ScriptPath, + } + + /// Flat validation schema mirroring the inputs `AzureCli::into_step` emits. + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + #[allow(dead_code)] // fields exist only to drive deserialization-as-validation + struct AzureCliInputs { + #[serde(rename = "azureSubscription")] + azure_subscription: String, + #[serde(rename = "scriptType")] + script_type: ScriptType, + #[serde(rename = "scriptLocation", default)] + script_location: Option, + #[serde(rename = "inlineScript", default)] + inline_script: Option, + #[serde(rename = "scriptPath", default)] + script_path: Option, + #[serde(rename = "arguments", default)] + arguments: Option, + #[serde(rename = "powerShellErrorActionPreference", default)] + ps_error_action_preference: Option, + #[serde( + rename = "addSpnToEnvironment", + default, + deserialize_with = "de_opt_bool_flex" + )] + add_spn_to_environment: Option, + #[serde( + rename = "useGlobalConfig", + default, + deserialize_with = "de_opt_bool_flex" + )] + use_global_config: Option, + #[serde(rename = "workingDirectory", default)] + working_directory: Option, + #[serde( + rename = "failOnStandardError", + default, + deserialize_with = "de_opt_bool_flex" + )] + fail_on_standard_error: Option, + #[serde( + rename = "powerShellIgnoreLASTEXITCODE", + default, + deserialize_with = "de_opt_bool_flex" + )] + ps_ignore_last_exit_code: Option, + #[serde( + rename = "visibleAzLogin", + default, + deserialize_with = "de_opt_bool_flex" + )] + visible_az_login: Option, + } + + serde_yaml::from_value::(inputs) + .map(drop) + .map_err(|e| e.to_string()) +} + /// Builder for a [`TaskStep`] invoking `AzureCLI@2`. /// /// Runs a bash, PowerShell, or batch script inside an authenticated Azure CLI @@ -96,6 +187,10 @@ impl PowerShellErrorActionPreference { /// /// ADO task reference: /// +/// +/// This is the **construction** builder; it does not derive `Deserialize`. +/// Validation of an authored `AzureCLI@2` step uses the flat [`AzureCliInputs`] +/// schema in [`validate_inputs`] (see [`ScriptLocation`] for why). #[derive(Debug, Clone)] pub struct AzureCli { azure_subscription: String, @@ -147,10 +242,7 @@ impl AzureCli { /// `powerShellErrorActionPreference` — how PowerShell handles non-terminating errors. /// Relevant only when `script_type` is [`ScriptType::Ps`] or [`ScriptType::PsCore`]. - pub fn ps_error_action_preference( - mut self, - value: PowerShellErrorActionPreference, - ) -> Self { + pub fn ps_error_action_preference(mut self, value: PowerShellErrorActionPreference) -> Self { self.ps_error_action_preference = Some(value); self } @@ -285,7 +377,10 @@ mod tests { ) .into_step(); - assert_eq!(t.inputs.get("scriptType").map(String::as_str), Some("pscore")); + assert_eq!( + t.inputs.get("scriptType").map(String::as_str), + Some("pscore") + ); assert_eq!( t.inputs.get("scriptLocation").map(String::as_str), Some("scriptPath") @@ -384,10 +479,7 @@ mod tests { #[test] fn ps_error_preference_ado_strings() { - assert_eq!( - PowerShellErrorActionPreference::Stop.as_ado_str(), - "stop" - ); + assert_eq!(PowerShellErrorActionPreference::Stop.as_ado_str(), "stop"); assert_eq!( PowerShellErrorActionPreference::Continue.as_ado_str(), "continue" diff --git a/src/compile/ir/tasks/azure_container_apps.rs b/src/compile/ir/tasks/azure_container_apps.rs index 25884490..e7e01392 100644 --- a/src/compile/ir/tasks/azure_container_apps.rs +++ b/src/compile/ir/tasks/azure_container_apps.rs @@ -3,16 +3,19 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Ingress setting for an Azure Container App (`AzureContainerApps@1`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum ContainerAppIngress { /// Accepts traffic from the internet (`external`). + #[serde(rename = "external")] External, /// Accepts traffic only from within the Azure Container Apps environment /// (`internal`). + #[serde(rename = "internal")] Internal, } @@ -42,52 +45,77 @@ impl ContainerAppIngress { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AzureContainerApps { /// `azureSubscription` / `connectedServiceNameARM` — Azure RM service connection (required). + #[serde(rename = "azureSubscription")] azure_subscription: String, /// `workingDirectory` / `cwd` — working directory for the task. + #[serde(rename = "workingDirectory", default)] working_directory: Option, /// `appSourcePath` — local path to the application source to build. + #[serde(rename = "appSourcePath", default)] app_source_path: Option, /// `acrName` — Azure Container Registry name (without `.azurecr.io`). + #[serde(rename = "acrName", default)] acr_name: Option, /// `acrUsername` — ACR username (use a secret variable in practice). + #[serde(rename = "acrUsername", default)] acr_username: Option, /// `acrPassword` — ACR password (use a secret variable in practice). + #[serde(rename = "acrPassword", default)] acr_password: Option, /// `dockerfilePath` — path to a Dockerfile relative to `app_source_path`. + #[serde(rename = "dockerfilePath", default)] dockerfile_path: Option, /// `imageToBuild` — fully-qualified image name to build and push. + #[serde(rename = "imageToBuild", default)] image_to_build: Option, /// `imageToDeploy` — fully-qualified image name of an existing image to /// deploy (mutually exclusive with build-from-source inputs). + #[serde(rename = "imageToDeploy", default)] image_to_deploy: Option, /// `containerAppName` — name of the Container App to create or update. + #[serde(rename = "containerAppName", default)] container_app_name: Option, /// `resourceGroup` — Azure resource group that contains the Container App. + #[serde(rename = "resourceGroup", default)] resource_group: Option, /// `containerAppEnvironment` — name or resource ID of the Container Apps /// environment. + #[serde(rename = "containerAppEnvironment", default)] container_app_environment: Option, /// `runtimeStack` — runtime stack to use when Oryx detects the runtime /// automatically (e.g. `"node:18"`, `"python:3.11"`). + #[serde(rename = "runtimeStack", default)] runtime_stack: Option, /// `targetPort` — port that the container listens on. + #[serde(rename = "targetPort", default)] target_port: Option, /// `location` — Azure region for the Container App (e.g. `"eastus"`). + #[serde(default)] location: Option, /// `environmentVariables` — space-separated list of `KEY=VALUE` pairs (or /// `KEY=secretref:SECRET_NAME` for secret references). + #[serde(rename = "environmentVariables", default)] environment_variables: Option, /// `ingress` — ingress setting for the Container App. + #[serde(default)] ingress: Option, /// `yamlConfigPath` — path to a YAML configuration file that defines the /// Container App. When set, most other inputs are ignored. + #[serde(rename = "yamlConfigPath", default)] yaml_config_path: Option, /// `disableTelemetry` — suppress telemetry sent to Microsoft. + #[serde( + rename = "disableTelemetry", + default, + deserialize_with = "de_opt_bool_flex" + )] disable_telemetry: Option, /// Override the default `displayName`. + #[serde(skip)] display_name: Option, } @@ -335,7 +363,10 @@ mod tests { t.inputs.get("appSourcePath").map(String::as_str), Some("$(System.DefaultWorkingDirectory)") ); - assert_eq!(t.inputs.get("acrName").map(String::as_str), Some("mytestacr")); + assert_eq!( + t.inputs.get("acrName").map(String::as_str), + Some("mytestacr") + ); assert!(t.inputs.get("imageToDeploy").is_none()); } @@ -344,12 +375,18 @@ mod tests { let ext = AzureContainerApps::new("sc") .ingress(ContainerAppIngress::External) .into_step(); - assert_eq!(ext.inputs.get("ingress").map(String::as_str), Some("external")); + assert_eq!( + ext.inputs.get("ingress").map(String::as_str), + Some("external") + ); let int = AzureContainerApps::new("sc") .ingress(ContainerAppIngress::Internal) .into_step(); - assert_eq!(int.inputs.get("ingress").map(String::as_str), Some("internal")); + assert_eq!( + int.inputs.get("ingress").map(String::as_str), + Some("internal") + ); } #[test] @@ -374,7 +411,10 @@ mod tests { t.inputs.get("containerAppEnvironment").map(String::as_str), Some("my-env") ); - assert_eq!(t.inputs.get("runtimeStack").map(String::as_str), Some("node:18")); + assert_eq!( + t.inputs.get("runtimeStack").map(String::as_str), + Some("node:18") + ); assert_eq!(t.inputs.get("targetPort").map(String::as_str), Some("3000")); assert_eq!(t.inputs.get("location").map(String::as_str), Some("eastus")); assert_eq!( diff --git a/src/compile/ir/tasks/azure_file_copy.rs b/src/compile/ir/tasks/azure_file_copy.rs index 8891ec66..f7a0fdb9 100644 --- a/src/compile/ir/tasks/azure_file_copy.rs +++ b/src/compile/ir/tasks/azure_file_copy.rs @@ -9,17 +9,80 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; +use serde_yaml::Value; + +/// Validate an authored `AzureFileCopy@6` `inputs:` mapping (advisory +/// front-matter validation, see [`super::parse`]). +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let destination = match map.remove("Destination") { + Some(value) => value + .as_str() + .map(str::to_string) + .ok_or_else(|| "`Destination` must be a string".to_string())?, + None => "AzureBlob".to_string(), + }; + + validate_common(&Value::Mapping(map.clone())).map_err(|e| format!("common inputs: {e}"))?; + remove_common_inputs(&mut map); + let rest = Value::Mapping(map); + + let result = match destination.as_str() { + "AzureBlob" => serde_yaml::from_value::(rest).map(drop), + "AzureVMs" => serde_yaml::from_value::(rest).map(drop), + other => return Err(format!("AzureFileCopy@6: unknown Destination `{other}`")), + }; + result.map_err(|e| format!("Destination `{destination}`: {e}")) +} + +fn validate_common(inputs: &Value) -> Result<(), serde_yaml::Error> { + serde_yaml::from_value::(inputs.clone()).map(drop) +} + +fn remove_common_inputs(map: &mut serde_yaml::Mapping) { + for key in [ + "SourcePath", + "azureSubscription", + "storage", + "CleanTargetBeforeCopy", + ] { + map.remove(key); + } +} + +#[derive(Debug, Deserialize)] +struct AzureFileCopyCommonInputs { + #[serde(rename = "SourcePath")] + _source_path: String, + #[serde(rename = "azureSubscription")] + _azure_subscription: String, + #[serde(rename = "storage")] + _storage: String, + #[serde( + rename = "CleanTargetBeforeCopy", + default, + deserialize_with = "de_opt_bool_flex" + )] + _clean_target_before_copy: Option, +} /// Resource filtering method for Azure VM targets. /// /// Controls how the `MachineNames` filter is interpreted. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum ResourceFilteringMethod { /// Filter by machine name (`machineNames`). ADO default. + #[serde(rename = "machineNames")] MachineNames, /// Filter by tag (`tags`). + #[serde(rename = "tags")] Tags, } @@ -52,10 +115,14 @@ pub enum AzureFileCopyDestination { /// Inputs for [`AzureFileCopyDestination::AzureBlob`]. /// /// `container_name` is required; all other inputs are optional. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AzureFileCopyToBlob { + #[serde(rename = "ContainerName")] container_name: String, + #[serde(rename = "BlobPrefix", default)] blob_prefix: Option, + #[serde(rename = "AdditionalArgumentsForBlobCopy", default)] additional_arguments: Option, } @@ -95,17 +162,36 @@ impl AzureFileCopyToBlob { /// The four positional parameters (`resource_group`, `admin_user`, /// `admin_password`, `target_path`) are required; all other inputs are /// optional. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AzureFileCopyToVMs { + #[serde(rename = "resourceGroup")] resource_group: String, + #[serde(rename = "vmsAdminUserName")] admin_user: String, + #[serde(rename = "vmsAdminPassword")] admin_password: String, + #[serde(rename = "TargetPath")] target_path: String, + #[serde(rename = "ResourceFilteringMethod", default)] resource_filtering_method: Option, + #[serde(rename = "MachineNames", default)] machine_names: Option, + #[serde(rename = "AdditionalArgumentsForVMCopy", default)] additional_arguments: Option, + #[serde( + rename = "enableCopyPrerequisites", + default, + deserialize_with = "de_opt_bool_flex" + )] enable_copy_prerequisites: Option, + #[serde( + rename = "CopyFilesInParallel", + default, + deserialize_with = "de_opt_bool_flex" + )] copy_files_in_parallel: Option, + #[serde(rename = "skipCACheck", default, deserialize_with = "de_opt_bool_flex")] skip_ca_check: Option, } @@ -275,14 +361,19 @@ impl AzureFileCopy { let mut t = TaskStep::new( "AzureFileCopy@6", - self.display_name.unwrap_or_else(|| "Azure File Copy".into()), + self.display_name + .unwrap_or_else(|| "Azure File Copy".into()), ) .with_input("SourcePath", self.source_path) .with_input("azureSubscription", self.azure_subscription) .with_input("Destination", destination_str) .with_input("storage", self.storage); - push_bool(&mut t, "CleanTargetBeforeCopy", self.clean_target_before_copy); + push_bool( + &mut t, + "CleanTargetBeforeCopy", + self.clean_target_before_copy, + ); match self.destination { AzureFileCopyDestination::AzureBlob(blob) => { @@ -308,7 +399,11 @@ impl AzureFileCopy { "AdditionalArgumentsForVMCopy", vms.additional_arguments, ); - push_bool(&mut t, "enableCopyPrerequisites", vms.enable_copy_prerequisites); + push_bool( + &mut t, + "enableCopyPrerequisites", + vms.enable_copy_prerequisites, + ); push_bool(&mut t, "CopyFilesInParallel", vms.copy_files_in_parallel); push_bool(&mut t, "skipCACheck", vms.skip_ca_check); } @@ -376,7 +471,9 @@ mod tests { Some("$(Build.BuildNumber)") ); assert_eq!( - step.inputs.get("AdditionalArgumentsForBlobCopy").map(String::as_str), + step.inputs + .get("AdditionalArgumentsForBlobCopy") + .map(String::as_str), Some("--blob-type=PageBlob") ); assert_eq!( @@ -446,7 +543,9 @@ mod tests { .into_step(); assert_eq!( - step.inputs.get("ResourceFilteringMethod").map(String::as_str), + step.inputs + .get("ResourceFilteringMethod") + .map(String::as_str), Some("tags") ); assert_eq!( @@ -454,7 +553,9 @@ mod tests { Some("Role:Web") ); assert_eq!( - step.inputs.get("enableCopyPrerequisites").map(String::as_str), + step.inputs + .get("enableCopyPrerequisites") + .map(String::as_str), Some("true") ); assert_eq!( diff --git a/src/compile/ir/tasks/azure_key_vault.rs b/src/compile/ir/tasks/azure_key_vault.rs index dcb197b2..5546baca 100644 --- a/src/compile/ir/tasks/azure_key_vault.rs +++ b/src/compile/ir/tasks/azure_key_vault.rs @@ -1,7 +1,8 @@ //! Typed builder for `AzureKeyVault@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 `AzureKeyVault@2`. /// @@ -12,12 +13,18 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AzureKeyVault { + #[serde(rename = "ConnectedServiceName")] connected_service_name: String, + #[serde(rename = "KeyVaultName")] key_vault_name: String, + #[serde(rename = "SecretsFilter", default)] secrets_filter: Option, + #[serde(rename = "RunAsPreJob", default, deserialize_with = "de_opt_bool_flex")] run_as_pre_job: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/azure_powershell.rs b/src/compile/ir/tasks/azure_powershell.rs index d0f4db79..ea13eeed 100644 --- a/src/compile/ir/tasks/azure_powershell.rs +++ b/src/compile/ir/tasks/azure_powershell.rs @@ -9,14 +9,46 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::{Deserialize, Deserializer}; +use serde_yaml::Value; + +/// Validate an authored `AzurePowerShell@5` `inputs:` mapping (advisory +/// front-matter validation, see [`super::parse`]). +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let script_type = match map.remove("ScriptType") { + Some(v) => Some( + v.as_str() + .ok_or_else(|| "AzurePowerShell@5: `ScriptType` must be a string".to_string())? + .to_string(), + ), + None => None, + }; + let mode = script_type.as_deref().unwrap_or("FilePath"); + let rest = Value::Mapping(map); + + let result = match mode { + "FilePath" => serde_yaml::from_value::(rest).map(drop), + "InlineScript" => serde_yaml::from_value::(rest).map(drop), + other => return Err(format!("AzurePowerShell@5: unknown ScriptType `{other}`")), + }; + result.map_err(|e| format!("ScriptType `{mode}`: {e}")) +} /// Non-terminating error behaviour (`errorActionPreference`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum ErrorActionPreference { + #[serde(rename = "stop")] Stop, + #[serde(rename = "continue")] Continue, + #[serde(rename = "silentlyContinue")] SilentlyContinue, } @@ -43,37 +75,28 @@ pub enum AzurePowerShellVersion { Preferred(String), } -/// Optionals shared by both `AzurePowerShell@5` modes. -#[derive(Debug, Clone, Default)] -struct Shared { - error_action_preference: Option, - fail_on_standard_error: Option, - pwsh: Option, - working_directory: Option, - azure_powershell_version: Option, -} - -impl Shared { - fn apply(self, t: &mut TaskStep) { - if let Some(v) = self.error_action_preference { - t.inputs - .insert("errorActionPreference".to_string(), v.as_ado_str().to_string()); - } - push_bool(t, "FailOnStandardError", self.fail_on_standard_error); - push_bool(t, "pwsh", self.pwsh); - push_opt(t, "workingDirectory", self.working_directory); - match self.azure_powershell_version { - None => {} - Some(AzurePowerShellVersion::Latest) => { - t.inputs - .insert("azurePowerShellVersion".to_string(), "LatestVersion".to_string()); - } - Some(AzurePowerShellVersion::Preferred(ver)) => { - t.inputs - .insert("azurePowerShellVersion".to_string(), "OtherVersion".to_string()); - t.inputs - .insert("preferredAzurePowerShellVersion".to_string(), ver); - } +impl<'de> Deserialize<'de> for AzurePowerShellVersion { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let token = String::deserialize(deserializer)?; + match token.as_str() { + "LatestVersion" => Ok(Self::Latest), + // Sentinel: the flat ADO shape carries the pinned version in a + // *separate* `preferredAzurePowerShellVersion` input, not inside + // this one, so on deserialization we only know it's `OtherVersion` + // here. The empty string is a placeholder; the real value is + // validated via the struct's own `preferred_azure_powershell_version` + // field. `into_step()` is never called on a deserialized value (the + // validation path only checks that it *parses*), and in the builder + // path this variant is constructed with the real version, so the + // sentinel never reaches emitted YAML. + "OtherVersion" => Ok(Self::Preferred(String::new())), + other => Err(serde::de::Error::unknown_variant( + other, + &["LatestVersion", "OtherVersion"], + )), } } } @@ -83,25 +106,25 @@ macro_rules! shared_setters { () => { /// `errorActionPreference` — non-terminating error behaviour (default `stop`). pub fn error_action_preference(mut self, value: ErrorActionPreference) -> Self { - self.shared.error_action_preference = Some(value); + self.error_action_preference = Some(value); self } /// `FailOnStandardError` — fail the step when anything is written to stderr. pub fn fail_on_standard_error(mut self, value: bool) -> Self { - self.shared.fail_on_standard_error = Some(value); + self.fail_on_standard_error = Some(value); self } /// `pwsh` — use PowerShell Core (`pwsh`) instead of Windows PowerShell. pub fn pwsh(mut self, value: bool) -> Self { - self.shared.pwsh = Some(value); + self.pwsh = Some(value); self } /// `workingDirectory` — working directory for the script. pub fn working_directory(mut self, value: impl Into) -> Self { - self.shared.working_directory = Some(value.into()); + self.working_directory = Some(value.into()); self } @@ -111,7 +134,7 @@ macro_rules! shared_setters { /// [`AzurePowerShellVersion::Preferred`] → `OtherVersion` + /// `preferredAzurePowerShellVersion`. pub fn azure_powershell_version(mut self, value: AzurePowerShellVersion) -> Self { - self.shared.azure_powershell_version = Some(value); + self.azure_powershell_version = Some(value); self } @@ -124,12 +147,32 @@ macro_rules! shared_setters { } /// Builder for `AzurePowerShell@5` in file-path mode (`ScriptType: FilePath`). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AzurePowerShellFile { + #[serde(rename = "azureSubscription")] azure_subscription: String, + #[serde(rename = "ScriptPath")] script_path: String, + #[serde(rename = "ScriptArguments", default)] script_arguments: Option, - shared: Shared, + #[serde(rename = "errorActionPreference", default)] + error_action_preference: Option, + #[serde( + rename = "FailOnStandardError", + default, + deserialize_with = "de_opt_bool_flex" + )] + fail_on_standard_error: Option, + #[serde(rename = "pwsh", default, deserialize_with = "de_opt_bool_flex")] + pwsh: Option, + #[serde(rename = "workingDirectory", default)] + working_directory: Option, + #[serde(rename = "azurePowerShellVersion", default)] + azure_powershell_version: Option, + #[serde(rename = "preferredAzurePowerShellVersion", default)] + preferred_azure_powershell_version: Option, + #[serde(skip)] display_name: Option, } @@ -153,17 +196,66 @@ impl AzurePowerShellFile { .with_input("ScriptType", "FilePath") .with_input("ScriptPath", self.script_path); push_opt(&mut t, "ScriptArguments", self.script_arguments); - self.shared.apply(&mut t); + if let Some(v) = self.error_action_preference { + t.inputs.insert( + "errorActionPreference".to_string(), + v.as_ado_str().to_string(), + ); + } + push_bool(&mut t, "FailOnStandardError", self.fail_on_standard_error); + push_bool(&mut t, "pwsh", self.pwsh); + push_opt(&mut t, "workingDirectory", self.working_directory); + match self.azure_powershell_version { + None => {} + Some(AzurePowerShellVersion::Latest) => { + t.inputs.insert( + "azurePowerShellVersion".to_string(), + "LatestVersion".to_string(), + ); + } + Some(AzurePowerShellVersion::Preferred(ver)) => { + t.inputs.insert( + "azurePowerShellVersion".to_string(), + "OtherVersion".to_string(), + ); + t.inputs + .insert("preferredAzurePowerShellVersion".to_string(), ver); + } + } + push_opt( + &mut t, + "preferredAzurePowerShellVersion", + self.preferred_azure_powershell_version, + ); t } } /// Builder for `AzurePowerShell@5` in inline mode (`ScriptType: InlineScript`). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AzurePowerShellInline { + #[serde(rename = "azureSubscription")] azure_subscription: String, + #[serde(rename = "Inline")] script: String, - shared: Shared, + #[serde(rename = "errorActionPreference", default)] + error_action_preference: Option, + #[serde( + rename = "FailOnStandardError", + default, + deserialize_with = "de_opt_bool_flex" + )] + fail_on_standard_error: Option, + #[serde(rename = "pwsh", default, deserialize_with = "de_opt_bool_flex")] + pwsh: Option, + #[serde(rename = "workingDirectory", default)] + working_directory: Option, + #[serde(rename = "azurePowerShellVersion", default)] + azure_powershell_version: Option, + #[serde(rename = "preferredAzurePowerShellVersion", default)] + preferred_azure_powershell_version: Option, + #[serde(skip)] display_name: Option, } @@ -180,7 +272,37 @@ impl AzurePowerShellInline { .with_input("azureSubscription", self.azure_subscription) .with_input("ScriptType", "InlineScript") .with_input("Inline", self.script); - self.shared.apply(&mut t); + if let Some(v) = self.error_action_preference { + t.inputs.insert( + "errorActionPreference".to_string(), + v.as_ado_str().to_string(), + ); + } + push_bool(&mut t, "FailOnStandardError", self.fail_on_standard_error); + push_bool(&mut t, "pwsh", self.pwsh); + push_opt(&mut t, "workingDirectory", self.working_directory); + match self.azure_powershell_version { + None => {} + Some(AzurePowerShellVersion::Latest) => { + t.inputs.insert( + "azurePowerShellVersion".to_string(), + "LatestVersion".to_string(), + ); + } + Some(AzurePowerShellVersion::Preferred(ver)) => { + t.inputs.insert( + "azurePowerShellVersion".to_string(), + "OtherVersion".to_string(), + ); + t.inputs + .insert("preferredAzurePowerShellVersion".to_string(), ver); + } + } + push_opt( + &mut t, + "preferredAzurePowerShellVersion", + self.preferred_azure_powershell_version, + ); t } } @@ -203,7 +325,12 @@ impl AzurePowerShell { azure_subscription: azure_subscription.into(), script_path: script_path.into(), script_arguments: None, - shared: Shared::default(), + error_action_preference: None, + fail_on_standard_error: None, + pwsh: None, + working_directory: None, + azure_powershell_version: None, + preferred_azure_powershell_version: None, display_name: None, } } @@ -217,7 +344,12 @@ impl AzurePowerShell { AzurePowerShellInline { azure_subscription: azure_subscription.into(), script: script.into(), - shared: Shared::default(), + error_action_preference: None, + fail_on_standard_error: None, + pwsh: None, + working_directory: None, + azure_powershell_version: None, + preferred_azure_powershell_version: None, display_name: None, } } @@ -235,7 +367,10 @@ mod tests { t.inputs.get("azureSubscription").map(String::as_str), Some("my-azure-sub") ); - assert_eq!(t.inputs.get("ScriptType").map(String::as_str), Some("FilePath")); + assert_eq!( + t.inputs.get("ScriptType").map(String::as_str), + Some("FilePath") + ); assert_eq!( t.inputs.get("ScriptPath").map(String::as_str), Some("scripts/deploy.ps1") @@ -272,7 +407,10 @@ mod tests { #[test] fn inline_mode_sets_script() { let t = AzurePowerShell::inline("my-sub", "Write-Host 'hello'").into_step(); - assert_eq!(t.inputs.get("ScriptType").map(String::as_str), Some("InlineScript")); + assert_eq!( + t.inputs.get("ScriptType").map(String::as_str), + Some("InlineScript") + ); assert_eq!( t.inputs.get("Inline").map(String::as_str), Some("Write-Host 'hello'") @@ -291,7 +429,9 @@ mod tests { Some("OtherVersion") ); assert_eq!( - t.inputs.get("preferredAzurePowerShellVersion").map(String::as_str), + t.inputs + .get("preferredAzurePowerShellVersion") + .map(String::as_str), Some("8.0.0") ); } diff --git a/src/compile/ir/tasks/azure_web_app.rs b/src/compile/ir/tasks/azure_web_app.rs index 6ae9b181..31be6335 100644 --- a/src/compile/ir/tasks/azure_web_app.rs +++ b/src/compile/ir/tasks/azure_web_app.rs @@ -3,19 +3,22 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// The type of Azure App Service to deploy to. /// /// Controls which optional inputs are applicable: /// - [`AppType::WebApp`] enables `deployment_method` and `custom_web_config`. /// - [`AppType::WebAppLinux`] enables `runtime_stack` and `startup_command`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum AppType { /// Web App on Windows (`webApp`). + #[serde(rename = "webApp")] WebApp, /// Web App on Linux (`webAppLinux`). + #[serde(rename = "webAppLinux")] WebAppLinux, } @@ -33,13 +36,16 @@ impl AppType { /// /// Only applicable when `app_type` is [`AppType::WebApp`] and the package is /// not a `.war` or `.jar` file. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum DeploymentMethod { /// ADO auto-selects the best method (`auto`). This is the ADO default. + #[serde(rename = "auto")] Auto, /// Deploy as a zip package (`zipDeploy`). + #[serde(rename = "zipDeploy")] ZipDeploy, /// Deploy as a run-from-package mount (`runFromPackage`). + #[serde(rename = "runFromPackage")] RunFromPackage, } @@ -92,20 +98,37 @@ impl DeploymentMethod { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AzureWebApp { + #[serde(rename = "azureSubscription")] azure_subscription: String, + #[serde(rename = "appType")] app_type: AppType, + #[serde(rename = "appName")] app_name: String, package: String, + #[serde( + rename = "deployToSlotOrASE", + default, + deserialize_with = "de_opt_bool_flex" + )] deploy_to_slot_or_ase: Option, + #[serde(rename = "resourceGroupName", default)] resource_group_name: Option, + #[serde(rename = "slotName", default)] slot_name: Option, + #[serde(rename = "customDeployFolder", default)] custom_deploy_folder: Option, + #[serde(rename = "runtimeStack", default)] runtime_stack: Option, + #[serde(rename = "startUpCommand", default)] startup_command: Option, + #[serde(rename = "customWebConfig", default)] custom_web_config: Option, + #[serde(rename = "deploymentMethod", default)] deployment_method: Option, + #[serde(skip)] display_name: Option, } @@ -367,6 +390,9 @@ mod tests { fn deployment_method_as_ado_str() { assert_eq!(DeploymentMethod::Auto.as_ado_str(), "auto"); assert_eq!(DeploymentMethod::ZipDeploy.as_ado_str(), "zipDeploy"); - assert_eq!(DeploymentMethod::RunFromPackage.as_ado_str(), "runFromPackage"); + assert_eq!( + DeploymentMethod::RunFromPackage.as_ado_str(), + "runFromPackage" + ); } } diff --git a/src/compile/ir/tasks/cargo_authenticate.rs b/src/compile/ir/tasks/cargo_authenticate.rs index 9bf5958e..dc1a5661 100644 --- a/src/compile/ir/tasks/cargo_authenticate.rs +++ b/src/compile/ir/tasks/cargo_authenticate.rs @@ -1,6 +1,7 @@ //! Typed builder for `CargoAuthenticate@0`. use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Builder for a [`TaskStep`] invoking `CargoAuthenticate@0`. /// @@ -14,12 +15,18 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CargoAuthenticate { + #[serde(rename = "configFile")] config_file: String, + #[serde(rename = "cargoServiceConnections", default)] cargo_service_connections: Option, + #[serde(rename = "registryNames", default)] registry_names: Option, + #[serde(rename = "azureDevOpsServiceConnection", default)] azure_devops_service_connection: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/cmd_line.rs b/src/compile/ir/tasks/cmd_line.rs index c83454d4..02bced52 100644 --- a/src/compile/ir/tasks/cmd_line.rs +++ b/src/compile/ir/tasks/cmd_line.rs @@ -1,7 +1,8 @@ //! Typed builder for `CmdLine@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 `CmdLine@2`. /// @@ -10,11 +11,20 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CmdLine { + #[serde(rename = "script")] script: String, + #[serde(rename = "workingDirectory", default)] working_directory: Option, + #[serde( + rename = "failOnStderr", + default, + deserialize_with = "de_opt_bool_flex" + )] fail_on_stderr: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/common.rs b/src/compile/ir/tasks/common.rs index e444280d..0296b4d7 100644 --- a/src/compile/ir/tasks/common.rs +++ b/src/compile/ir/tasks/common.rs @@ -31,3 +31,73 @@ 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)), + // Accept the ADO string form case-insensitively: serde_yaml (YAML 1.2) + // only treats lowercase `true`/`false` as native booleans, so an + // authored `CleanTargetFolder: True` arrives here as the string + // `"True"` — which ADO would accept, so we must not flag it. + Some(BoolOrStr::Str(s)) => match s.to_ascii_lowercase().as_str() { + "true" => Ok(Some(true)), + "false" => Ok(Some(false)), + _ => Err(serde::de::Error::custom(format!( + "expected a boolean or \"true\"/\"false\", got {s:?}" + ))), + }, + } +} + +/// Deserialize an optional ADO string input, accepting a native YAML number in +/// addition to a string. ADO task inputs are all strings, but authors naturally +/// write integer-valued ones bare (e.g. `retryCount: 3`, +/// `delayBetweenRetries: 1000`); serde would otherwise reject the integer scalar +/// against an `Option` and produce a false-positive validation finding. +/// Numbers are stringified to match how ADO coerces them. +/// +/// A bare `f64` (e.g. `retryCount: 3.14`) is also accepted and stringified, +/// which ADO would then reject at runtime for an integer input. That is a +/// deliberate *false-negative* (a gap, not noise): authoring a count as a +/// non-integer float is rare, and this validation intentionally prefers gaps +/// over false positives. +pub(crate) fn de_opt_str_or_int<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + + #[derive(Deserialize)] + #[serde(untagged)] + enum StrOrNum { + Str(String), + Int(i64), + UInt(u64), + Float(f64), + } + + Ok(match Option::::deserialize(deserializer)? { + None => None, + Some(StrOrNum::Str(s)) => Some(s), + Some(StrOrNum::Int(i)) => Some(i.to_string()), + Some(StrOrNum::UInt(u)) => Some(u.to_string()), + Some(StrOrNum::Float(f)) => Some(f.to_string()), + }) +} diff --git a/src/compile/ir/tasks/copy_files.rs b/src/compile/ir/tasks/copy_files.rs index 1d365995..9e0769ad 100644 --- a/src/compile/ir/tasks/copy_files.rs +++ b/src/compile/ir/tasks/copy_files.rs @@ -1,27 +1,48 @@ //! Typed builder for `CopyFiles@2`. -use super::common::bool_input; +use super::common::{bool_input, de_opt_bool_flex, de_opt_str_or_int}; 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, deserialize_with = "de_opt_str_or_int")] retry_count: Option, + #[serde( + rename = "delayBetweenRetries", + default, + deserialize_with = "de_opt_str_or_int" + )] 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/delete_files.rs b/src/compile/ir/tasks/delete_files.rs index faa3533d..d6d930d3 100644 --- a/src/compile/ir/tasks/delete_files.rs +++ b/src/compile/ir/tasks/delete_files.rs @@ -1,7 +1,8 @@ //! Typed builder for `DeleteFiles@1`. -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 `DeleteFiles@1`. /// @@ -9,12 +10,26 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DeleteFiles { + #[serde(rename = "Contents")] contents: String, + #[serde(rename = "SourceFolder", default)] source_folder: Option, + #[serde( + rename = "RemoveSourceFolder", + default, + deserialize_with = "de_opt_bool_flex" + )] remove_source_folder: Option, + #[serde( + rename = "RemoveDotFiles", + default, + deserialize_with = "de_opt_bool_flex" + )] remove_dot_files: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/docker.rs b/src/compile/ir/tasks/docker.rs index f90e14db..9ccebab4 100644 --- a/src/compile/ir/tasks/docker.rs +++ b/src/compile/ir/tasks/docker.rs @@ -17,6 +17,42 @@ use super::common::push_opt; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; +use serde_yaml::Value; + +/// Validate an authored `Docker@2` `inputs:` mapping (advisory front-matter +/// validation, see [`super::parse`]). +/// +/// `Docker@2` selects the command via its `command` input; we dispatch on it +/// and deserialize the remaining inputs into that command's variant struct. +/// Each variant struct derives `#[serde(deny_unknown_fields)]`, so an input +/// belonging to a different command (or an unknown input) is rejected. This is +/// done with an explicit dispatch rather than a serde internally-tagged enum +/// because serde silently ignores `deny_unknown_fields` on tagged enums. +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let command = map + .remove("command") + .and_then(|v| v.as_str().map(str::to_string)) + // ADO defaults `command` to `buildAndPush` when omitted, so a missing + // command is valid (not an error) — fall back to the default variant. + .unwrap_or_else(|| "buildAndPush".to_string()); + let rest = Value::Mapping(map); + + let result = match command.as_str() { + "buildAndPush" => serde_yaml::from_value::(rest).map(drop), + "build" => serde_yaml::from_value::(rest).map(drop), + "push" => serde_yaml::from_value::(rest).map(drop), + "login" => serde_yaml::from_value::(rest).map(drop), + "logout" => serde_yaml::from_value::(rest).map(drop), + other => return Err(format!("Docker@2: unknown command `{other}`")), + }; + result.map_err(|e| format!("command `{command}`: {e}")) +} /// `Docker@2` `command` selector, carrying the per-command optional inputs. #[derive(Debug, Clone)] @@ -29,12 +65,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, } @@ -70,13 +112,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, } @@ -117,11 +166,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, } @@ -152,8 +206,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, } @@ -169,8 +225,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/docker_installer.rs b/src/compile/ir/tasks/docker_installer.rs index 7cab5f49..4c10ae47 100644 --- a/src/compile/ir/tasks/docker_installer.rs +++ b/src/compile/ir/tasks/docker_installer.rs @@ -1,13 +1,18 @@ //! Typed builder for `DockerInstaller@0`. use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Docker release channel for [`DockerInstaller`] (`releaseType` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum ReleaseType { + #[serde(rename = "stable")] Stable, + #[serde(rename = "edge")] Edge, + #[serde(rename = "test")] Test, + #[serde(rename = "nightly")] Nightly, } @@ -29,10 +34,14 @@ impl ReleaseType { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DockerInstaller { + #[serde(rename = "dockerVersion")] docker_version: String, + #[serde(rename = "releaseType", default)] release_type: Option, + #[serde(skip)] display_name: Option, } @@ -81,13 +90,21 @@ mod tests { 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_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")); + 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 index 39c603f7..64952993 100644 --- a/src/compile/ir/tasks/dotnet_core_cli.rs +++ b/src/compile/ir/tasks/dotnet_core_cli.rs @@ -8,8 +8,40 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; +use serde_yaml::Value; + +/// Validate an authored `DotNetCoreCLI@2` `inputs:` mapping (advisory +/// front-matter validation, see [`super::parse`]). +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let command = map + .remove("command") + .and_then(|v| v.as_str().map(str::to_string)) + // ADO defaults `command` to `build` when omitted — treat a missing + // command as the default variant rather than an error. + .unwrap_or_else(|| "build".to_string()); + let rest = Value::Mapping(map); + + let result = match command.as_str() { + "build" => serde_yaml::from_value::(rest).map(drop), + "test" => serde_yaml::from_value::(rest).map(drop), + "publish" => serde_yaml::from_value::(rest).map(drop), + "restore" => serde_yaml::from_value::(rest).map(drop), + "pack" => serde_yaml::from_value::(rest).map(drop), + "run" => serde_yaml::from_value::(rest).map(drop), + "push" => serde_yaml::from_value::(rest).map(drop), + "custom" => serde_yaml::from_value::(rest).map(drop), + other => return Err(format!("DotNetCoreCLI@2: unknown command `{other}`")), + }; + result.map_err(|e| format!("command `{command}`: {e}")) +} /// `DotNetCoreCLI@2` `command` selector, carrying per-command optional inputs. #[derive(Debug, Clone)] @@ -25,10 +57,14 @@ pub enum DotNetCommand { } /// Optionals for `dotnet build`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DotNetBuild { + #[serde(rename = "projects", default)] projects: Option, + #[serde(rename = "arguments", default)] arguments: Option, + #[serde(rename = "workingDirectory", default)] working_directory: Option, } @@ -54,12 +90,22 @@ impl DotNetBuild { } /// Optionals for `dotnet test`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DotNetTest { + #[serde(rename = "projects", default)] projects: Option, + #[serde(rename = "arguments", default)] arguments: Option, + #[serde(rename = "workingDirectory", default)] working_directory: Option, + #[serde( + rename = "publishTestResults", + default, + deserialize_with = "de_opt_bool_flex" + )] publish_test_results: Option, + #[serde(rename = "testRunTitle", default)] test_run_title: Option, } @@ -95,13 +141,32 @@ impl DotNetTest { } /// Optionals for `dotnet publish`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DotNetPublish { + #[serde(rename = "projects", default)] projects: Option, + #[serde(rename = "arguments", default)] arguments: Option, + #[serde(rename = "workingDirectory", default)] working_directory: Option, + #[serde( + rename = "zipAfterPublish", + default, + deserialize_with = "de_opt_bool_flex" + )] zip_after_publish: Option, + #[serde( + rename = "modifyOutputPath", + default, + deserialize_with = "de_opt_bool_flex" + )] modify_output_path: Option, + #[serde( + rename = "publishWebProjects", + default, + deserialize_with = "de_opt_bool_flex" + )] publish_web_projects: Option, } @@ -142,8 +207,10 @@ impl DotNetPublish { } /// Optionals for `dotnet restore`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DotNetRestore { + #[serde(rename = "projects", default)] projects: Option, } @@ -159,8 +226,10 @@ impl DotNetRestore { } /// Optionals for `dotnet pack`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DotNetPack { + #[serde(rename = "packagesToPack", default)] packages_to_pack: Option, } @@ -176,10 +245,14 @@ impl DotNetPack { } /// Optionals for `dotnet run`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DotNetRun { + #[serde(rename = "projects", default)] projects: Option, + #[serde(rename = "arguments", default)] arguments: Option, + #[serde(rename = "workingDirectory", default)] working_directory: Option, } @@ -205,8 +278,10 @@ impl DotNetRun { } /// Optionals for `dotnet push` (NuGet publish). -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DotNetPush { + #[serde(rename = "packagesToPush", default)] packages_to_push: Option, } @@ -222,9 +297,12 @@ impl DotNetPush { } /// Inputs for `dotnet custom`. `custom` (the sub-command word) is required. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DotNetCustom { + #[serde(rename = "custom")] custom: String, + #[serde(rename = "arguments", default)] arguments: Option, } @@ -312,7 +390,8 @@ impl DotNetCoreCli { }; let mut t = TaskStep::new( "DotNetCoreCLI@2", - self.display_name.unwrap_or_else(|| format!("dotnet {command}")), + self.display_name + .unwrap_or_else(|| format!("dotnet {command}")), ) .with_input("command", command); match self.command { @@ -382,9 +461,18 @@ mod 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")); + 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] @@ -393,6 +481,9 @@ mod tests { .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")); + assert_eq!( + t.inputs.get("arguments").map(String::as_str), + Some("install -g foo") + ); } } diff --git a/src/compile/ir/tasks/download_build_artifacts.rs b/src/compile/ir/tasks/download_build_artifacts.rs index b77ce28d..e0c95657 100644 --- a/src/compile/ir/tasks/download_build_artifacts.rs +++ b/src/compile/ir/tasks/download_build_artifacts.rs @@ -12,15 +12,18 @@ //! ADO task reference: //! -use super::common::{bool_input, push_bool, push_opt}; +use super::common::{bool_input, de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Which build to download artifacts from (`buildType` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum BuildType { /// Download from the current pipeline run (default). + #[serde(rename = "current")] Current, /// Download from a specific pipeline definition / run. + #[serde(rename = "specific")] Specific, } @@ -36,13 +39,16 @@ impl BuildType { /// Which build version to retrieve when `buildType = specific` /// (`buildVersionToDownload` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum BuildVersionToDownload { /// Latest successful build (default). + #[serde(rename = "latest")] Latest, /// Latest successful build from a specific branch. + #[serde(rename = "latestFromBranch")] LatestFromBranch, /// A specific build identified by its build ID. + #[serde(rename = "specific")] Specific, } @@ -58,11 +64,13 @@ impl BuildVersionToDownload { } /// How many artifacts to download (`downloadType` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum DownloadType { /// Download a single artifact by name (default). + #[serde(rename = "single")] Single, /// Download all artifacts whose paths match `itemPattern`. + #[serde(rename = "specific")] Specific, } @@ -94,46 +102,82 @@ impl DownloadType { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DownloadBuildArtifacts { /// `downloadPath` — destination directory on the agent. + #[serde(rename = "downloadPath")] download_path: String, /// `buildType` — which build to download from. + #[serde(rename = "buildType", default)] build_type: Option, /// `downloadType` — single artifact or all matching a pattern. + #[serde(rename = "downloadType", default)] download_type: Option, /// `artifactName` — name of the artifact to download (`downloadType = single`). + #[serde(rename = "artifactName", default)] artifact_name: Option, /// `itemPattern` — minimatch glob applied to artifact contents (`downloadType = specific`). + #[serde(rename = "itemPattern", default)] item_pattern: Option, // --- buildType=specific inputs --- /// `project` — ADO project name or ID. + #[serde(default)] project: Option, /// `definition` — pipeline definition ID or name. + #[serde(default)] definition: Option, /// `buildVersionToDownload` — which build to select. + #[serde(rename = "buildVersionToDownload", default)] build_version_to_download: Option, /// `branchName` — branch filter (`buildVersionToDownload = latestFromBranch`). + #[serde(rename = "branchName", default)] branch_name: Option, /// `buildId` — build ID to download from (`buildVersionToDownload = specific`). + #[serde(rename = "buildId", default)] build_id: Option, /// `tags` — comma-separated build tags to filter candidate builds. + #[serde(default)] tags: Option, /// `specificBuildWithTriggering` — use the triggering build when it matches. + #[serde( + rename = "specificBuildWithTriggering", + default, + deserialize_with = "de_opt_bool_flex" + )] specific_build_with_triggering: Option, /// `allowPartiallySucceededBuilds` — include partially-succeeded builds. + #[serde( + rename = "allowPartiallySucceededBuilds", + default, + deserialize_with = "de_opt_bool_flex" + )] allow_partially_succeeded_builds: Option, // --- misc optionals --- /// `cleanDestinationFolder` — clean the destination folder before downloading. + #[serde( + rename = "cleanDestinationFolder", + default, + deserialize_with = "de_opt_bool_flex" + )] clean_destination_folder: Option, /// `parallelizationLimit` — number of parallel download threads (default `"8"`). + #[serde(rename = "parallelizationLimit", default)] parallelization_limit: Option, /// `checkDownloadedFiles` — verify downloaded files are not corrupt. + #[serde( + rename = "checkDownloadedFiles", + default, + deserialize_with = "de_opt_bool_flex" + )] check_downloaded_files: Option, /// `retryDownloadCount` — number of retries on download failure (default `"4"`). + #[serde(rename = "retryDownloadCount", default)] retry_download_count: Option, /// `extractTars` — automatically extract downloaded tar archives. + #[serde(rename = "extractTars", default, deserialize_with = "de_opt_bool_flex")] extract_tars: Option, + #[serde(skip)] display_name: Option, } @@ -298,13 +342,21 @@ impl DownloadBuildArtifacts { push_opt(&mut t, "branchName", self.branch_name); push_opt(&mut t, "buildId", self.build_id); push_opt(&mut t, "tags", self.tags); - push_bool(&mut t, "specificBuildWithTriggering", self.specific_build_with_triggering); + push_bool( + &mut t, + "specificBuildWithTriggering", + self.specific_build_with_triggering, + ); push_bool( &mut t, "allowPartiallySucceededBuilds", self.allow_partially_succeeded_builds, ); - push_bool(&mut t, "cleanDestinationFolder", self.clean_destination_folder); + push_bool( + &mut t, + "cleanDestinationFolder", + self.clean_destination_folder, + ); push_opt(&mut t, "parallelizationLimit", self.parallelization_limit); push_bool(&mut t, "checkDownloadedFiles", self.check_downloaded_files); push_opt(&mut t, "retryDownloadCount", self.retry_download_count); @@ -341,9 +393,18 @@ mod tests { .download_type(DownloadType::Single) .artifact_name("drop") .into_step(); - assert_eq!(t.inputs.get("buildType").map(String::as_str), Some("current")); - assert_eq!(t.inputs.get("downloadType").map(String::as_str), Some("single")); - assert_eq!(t.inputs.get("artifactName").map(String::as_str), Some("drop")); + assert_eq!( + t.inputs.get("buildType").map(String::as_str), + Some("current") + ); + assert_eq!( + t.inputs.get("downloadType").map(String::as_str), + Some("single") + ); + assert_eq!( + t.inputs.get("artifactName").map(String::as_str), + Some("drop") + ); } #[test] @@ -356,7 +417,10 @@ mod tests { .artifact_name("binaries") .allow_partially_succeeded_builds(true) .into_step(); - assert_eq!(t.inputs.get("buildType").map(String::as_str), Some("specific")); + assert_eq!( + t.inputs.get("buildType").map(String::as_str), + Some("specific") + ); assert_eq!( t.inputs.get("project").map(String::as_str), Some("$(System.TeamProject)") @@ -367,7 +431,9 @@ mod tests { Some("latest") ); assert_eq!( - t.inputs.get("allowPartiallySucceededBuilds").map(String::as_str), + t.inputs + .get("allowPartiallySucceededBuilds") + .map(String::as_str), Some("true") ); } @@ -419,8 +485,14 @@ mod tests { .retry_download_count("3") .extract_tars(false) .into_step(); - assert_eq!(t.inputs.get("downloadType").map(String::as_str), Some("specific")); - assert_eq!(t.inputs.get("itemPattern").map(String::as_str), Some("**/*.nupkg")); + assert_eq!( + t.inputs.get("downloadType").map(String::as_str), + Some("specific") + ); + assert_eq!( + t.inputs.get("itemPattern").map(String::as_str), + Some("**/*.nupkg") + ); assert_eq!( t.inputs.get("cleanDestinationFolder").map(String::as_str), Some("true") @@ -433,8 +505,14 @@ mod tests { t.inputs.get("checkDownloadedFiles").map(String::as_str), Some("true") ); - assert_eq!(t.inputs.get("retryDownloadCount").map(String::as_str), Some("3")); - assert_eq!(t.inputs.get("extractTars").map(String::as_str), Some("false")); + assert_eq!( + t.inputs.get("retryDownloadCount").map(String::as_str), + Some("3") + ); + assert_eq!( + t.inputs.get("extractTars").map(String::as_str), + Some("false") + ); } #[test] diff --git a/src/compile/ir/tasks/download_package.rs b/src/compile/ir/tasks/download_package.rs index 4b1297be..3d51fa89 100644 --- a/src/compile/ir/tasks/download_package.rs +++ b/src/compile/ir/tasks/download_package.rs @@ -1,16 +1,23 @@ //! Typed builder for `DownloadPackage@1`. -use super::common::{bool_input, push_opt}; +use super::common::{bool_input, de_opt_bool_flex, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Package ecosystem for [`DownloadPackage`] (`packageType` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum PackageType { + #[serde(rename = "nuget")] NuGet, + #[serde(rename = "npm")] Npm, + #[serde(rename = "pypi")] PyPi, + #[serde(rename = "maven")] Maven, + #[serde(rename = "upack")] UPack, + #[serde(rename = "cargo")] Cargo, } @@ -40,17 +47,24 @@ impl PackageType { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DownloadPackage { + #[serde(rename = "packageType")] package_type: PackageType, feed: String, /// The package name (`definition` input). definition: String, version: String, + #[serde(rename = "downloadPath")] download_path: String, + #[serde(default)] view: Option, + #[serde(default)] files: Option, + #[serde(default, deserialize_with = "de_opt_bool_flex")] extract: Option, + #[serde(skip)] display_name: Option, } @@ -118,7 +132,8 @@ impl DownloadPackage { pub fn into_step(self) -> TaskStep { let mut t = TaskStep::new( "DownloadPackage@1", - self.display_name.unwrap_or_else(|| "Download Package".into()), + self.display_name + .unwrap_or_else(|| "Download Package".into()), ) .with_input("packageType", self.package_type.as_ado_str()) .with_input("feed", self.feed) @@ -149,9 +164,15 @@ mod tests { .into_step(); assert_eq!(t.task, "DownloadPackage@1"); assert_eq!(t.display_name, "Download Package"); - assert_eq!(t.inputs.get("packageType").map(String::as_str), Some("nuget")); + assert_eq!( + t.inputs.get("packageType").map(String::as_str), + Some("nuget") + ); assert_eq!(t.inputs.get("feed").map(String::as_str), Some("my-feed")); - assert_eq!(t.inputs.get("definition").map(String::as_str), Some("my-package")); + assert_eq!( + t.inputs.get("definition").map(String::as_str), + Some("my-package") + ); assert_eq!(t.inputs.get("version").map(String::as_str), Some("1.2.3")); assert_eq!( t.inputs.get("downloadPath").map(String::as_str), @@ -199,7 +220,10 @@ mod tests { ]; for (pt, expected) in cases { let t = DownloadPackage::new(pt, "feed", "pkg", "1.0.0", "/out").into_step(); - assert_eq!(t.inputs.get("packageType").map(String::as_str), Some(expected)); + assert_eq!( + t.inputs.get("packageType").map(String::as_str), + Some(expected) + ); } } } diff --git a/src/compile/ir/tasks/download_pipeline_artifact.rs b/src/compile/ir/tasks/download_pipeline_artifact.rs index bded39b2..3a4b688c 100644 --- a/src/compile/ir/tasks/download_pipeline_artifact.rs +++ b/src/compile/ir/tasks/download_pipeline_artifact.rs @@ -1,12 +1,15 @@ //! Typed builder for `DownloadPipelineArtifact@2`. -use super::common::bool_input; +use super::common::{bool_input, de_opt_bool_flex}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Run source for [`DownloadPipelineArtifact`] (`source` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum ArtifactSource { + #[serde(rename = "current")] Current, + #[serde(rename = "specific")] Specific, } @@ -21,10 +24,13 @@ impl ArtifactSource { } /// Which run to download from for [`DownloadPipelineArtifact`] (`runVersion`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum RunVersion { + #[serde(rename = "latest")] Latest, + #[serde(rename = "latestFromBranch")] LatestFromBranch, + #[serde(rename = "specific")] Specific, } @@ -43,22 +49,50 @@ impl RunVersion { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DownloadPipelineArtifact { + #[serde(rename = "targetPath")] target_path: String, + #[serde(default)] artifact: Option, + #[serde(default)] patterns: Option, + #[serde(default)] source: Option, + #[serde(default)] project: Option, + #[serde(default)] pipeline: Option, + #[serde(rename = "runVersion", default)] run_version: Option, + #[serde(rename = "branchName", default)] branch_name: Option, + #[serde(rename = "runId", default)] run_id: Option, + #[serde(default)] tags: Option, + #[serde( + rename = "allowPartiallySucceededBuilds", + default, + deserialize_with = "de_opt_bool_flex" + )] allow_partially_succeeded_builds: Option, + #[serde( + rename = "allowFailedBuilds", + default, + deserialize_with = "de_opt_bool_flex" + )] allow_failed_builds: Option, + #[serde( + rename = "preferTriggeringPipeline", + default, + deserialize_with = "de_opt_bool_flex" + )] prefer_triggering_pipeline: Option, + #[serde(rename = "itemPattern", default)] item_pattern: Option, + #[serde(skip)] display_name: Option, } @@ -172,7 +206,8 @@ impl DownloadPipelineArtifact { pub fn into_step(self) -> TaskStep { let mut t = TaskStep::new( "DownloadPipelineArtifact@2", - self.display_name.unwrap_or_else(|| "Download Pipeline Artifact".into()), + self.display_name + .unwrap_or_else(|| "Download Pipeline Artifact".into()), ) .with_input("targetPath", self.target_path); if let Some(v) = self.artifact { @@ -244,10 +279,18 @@ mod tests { .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), + 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/download_secure_file.rs b/src/compile/ir/tasks/download_secure_file.rs index 31b7d2e5..a1595a6c 100644 --- a/src/compile/ir/tasks/download_secure_file.rs +++ b/src/compile/ir/tasks/download_secure_file.rs @@ -1,6 +1,9 @@ //! Typed builder for `DownloadSecureFile@1`. use crate::compile::ir::step::TaskStep; +use serde::Deserialize; + +use super::common::de_opt_str_or_int; /// Builder for a [`TaskStep`] invoking `DownloadSecureFile@1`. /// @@ -11,11 +14,20 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DownloadSecureFile { + #[serde(rename = "secureFile")] secure_file: String, + #[serde(rename = "retryCount", default, deserialize_with = "de_opt_str_or_int")] retry_count: Option, + #[serde( + rename = "socketTimeout", + default, + deserialize_with = "de_opt_str_or_int" + )] socket_timeout: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/extract_files.rs b/src/compile/ir/tasks/extract_files.rs index 95067750..02901cc0 100644 --- a/src/compile/ir/tasks/extract_files.rs +++ b/src/compile/ir/tasks/extract_files.rs @@ -1,7 +1,8 @@ //! Typed builder for `ExtractFiles@1`. -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 `ExtractFiles@1`. /// @@ -9,13 +10,28 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ExtractFiles { + #[serde(rename = "archiveFilePatterns")] archive_file_patterns: String, + #[serde(rename = "destinationFolder")] destination_folder: String, + #[serde( + rename = "cleanDestinationFolder", + default, + deserialize_with = "de_opt_bool_flex" + )] clean_destination_folder: Option, + #[serde( + rename = "overwriteExistingFiles", + default, + deserialize_with = "de_opt_bool_flex" + )] overwrite_existing_files: Option, + #[serde(rename = "pathToSevenZipTool", default)] path_to_seven_zip_tool: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/github_release.rs b/src/compile/ir/tasks/github_release.rs index 3d5bed64..4486ba95 100644 --- a/src/compile/ir/tasks/github_release.rs +++ b/src/compile/ir/tasks/github_release.rs @@ -8,18 +8,69 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; +use serde_yaml::Value; + +/// Validate an authored `GitHubRelease@1` `inputs:` mapping (advisory +/// front-matter validation, see [`super::parse`]). +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let action = match map.remove("action") { + Some(value) => value + .as_str() + .map(str::to_string) + .ok_or_else(|| "`action` must be a string".to_string())?, + None => "create".to_string(), + }; + + validate_common(&Value::Mapping(map.clone())).map_err(|e| format!("common inputs: {e}"))?; + remove_common_inputs(&mut map); + let rest = Value::Mapping(map); + + let result = match action.as_str() { + "create" => serde_yaml::from_value::(rest).map(drop), + "edit" => serde_yaml::from_value::(rest).map(drop), + "delete" => serde_yaml::from_value::(rest).map(drop), + other => return Err(format!("GitHubRelease@1: unknown action `{other}`")), + }; + result.map_err(|e| format!("action `{action}`: {e}")) +} + +fn validate_common(inputs: &Value) -> Result<(), serde_yaml::Error> { + serde_yaml::from_value::(inputs.clone()).map(drop) +} + +fn remove_common_inputs(map: &mut serde_yaml::Mapping) { + for key in ["gitHubConnection", "repositoryName"] { + map.remove(key); + } +} + +#[derive(Debug, Deserialize)] +struct GitHubReleaseCommonInputs { + #[serde(rename = "gitHubConnection")] + _git_hub_connection: String, + #[serde(rename = "repositoryName")] + _repository_name: String, +} // ─── Constrained input enums ────────────────────────────────────────────────── /// Where the release tag comes from (`tagSource` input). Only applies to /// `action: create`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum TagSource { /// Use the git tag that triggered the pipeline run (default). + #[serde(rename = "gitTag")] GitTag, /// Use a user-specified tag string. + #[serde(rename = "userSpecifiedTag")] UserSpecifiedTag, } @@ -34,11 +85,13 @@ impl TagSource { /// Where the release notes come from (`releaseNotesSource` input). Applies to /// `action: create` and `action: edit`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum ReleaseNotesSource { /// Read release notes from a file (`releaseNotesFilePath`). + #[serde(rename = "filePath")] FilePath, /// Provide release notes inline (`releaseNotesInline`). + #[serde(rename = "inline")] Inline, } @@ -53,11 +106,13 @@ impl ReleaseNotesSource { /// Upload mode for assets when editing a release (`assetUploadMode` input). /// Only applies to `action: edit`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum AssetUploadMode { /// Delete all existing assets before uploading (default). + #[serde(rename = "delete")] Delete, /// Replace individual matching assets. + #[serde(rename = "replace")] Replace, } @@ -73,11 +128,14 @@ impl AssetUploadMode { /// Whether to mark the release as the latest GitHub release (`makeLatest` /// input). This is a three-way enum, not a plain bool: `legacy` preserves the /// pre-2022 comparison logic based on `isDraft` / `isPreRelease`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum MakeLatest { + #[serde(rename = "true")] True, + #[serde(rename = "false")] False, /// Use the legacy `isPreRelease` / `isDraft` comparison. + #[serde(rename = "legacy")] Legacy, } @@ -93,13 +151,16 @@ impl MakeLatest { /// Which prior release to compare against when generating the changelog /// (`changeLogCompareToRelease` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum ChangeLogCompareToRelease { /// Compare against the last full (non-draft, non-pre-release) release (default). + #[serde(rename = "lastFullRelease")] FullRelease, /// Compare against the last non-draft release. + #[serde(rename = "lastNonDraftRelease")] NonDraftRelease, /// Compare against the last non-draft release matching a given tag. + #[serde(rename = "lastNonDraftReleaseByTag")] NonDraftReleaseByTag, } @@ -114,11 +175,13 @@ impl ChangeLogCompareToRelease { } /// Changelog entry format (`changeLogType` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum ChangeLogType { /// Entries based on commit messages (default). + #[serde(rename = "commitBased")] CommitBased, /// Entries based on closed issues. + #[serde(rename = "issueBased")] IssueBased, } @@ -138,24 +201,50 @@ impl ChangeLogType { /// All fields are optional because the ADO task provides sensible defaults. Set /// `tag_source` to [`TagSource::UserSpecifiedTag`] and call /// [`GitHubReleaseCreate::tag`] when you need to pin the tag string explicitly. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GitHubReleaseCreate { + #[serde(default)] target: Option, + #[serde(rename = "tagSource", default)] tag_source: Option, + #[serde(rename = "tagPattern", default)] tag_pattern: Option, + #[serde(default)] tag: Option, + #[serde(default)] title: Option, + #[serde(rename = "releaseNotesSource", default)] release_notes_source: Option, + #[serde(rename = "releaseNotesFilePath", default)] release_notes_file_path: Option, + #[serde(rename = "releaseNotesInline", default)] release_notes_inline: Option, + #[serde(default)] assets: Option, + #[serde(rename = "isDraft", default, deserialize_with = "de_opt_bool_flex")] is_draft: Option, + #[serde( + rename = "isPreRelease", + default, + deserialize_with = "de_opt_bool_flex" + )] is_pre_release: Option, + #[serde(rename = "makeLatest", default)] make_latest: Option, + #[serde( + rename = "addChangeLog", + default, + deserialize_with = "de_opt_bool_flex" + )] add_change_log: Option, + #[serde(rename = "changeLogCompareToRelease", default)] change_log_compare_to_release: Option, + #[serde(rename = "changeLogCompareToReleaseTag", default)] change_log_compare_to_release_tag: Option, + #[serde(rename = "changeLogType", default)] change_log_type: Option, + #[serde(rename = "changeLogLabels", default)] change_log_labels: Option, } @@ -277,24 +366,49 @@ impl GitHubReleaseCreate { } /// Required and optional inputs for `GitHubRelease@1` `action: edit`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GitHubReleaseEdit { /// `tag` — tag identifying the release to edit. Required. + #[serde(rename = "tag")] tag: String, + #[serde(default)] target: Option, + #[serde(default)] title: Option, + #[serde(rename = "releaseNotesSource", default)] release_notes_source: Option, + #[serde(rename = "releaseNotesFilePath", default)] release_notes_file_path: Option, + #[serde(rename = "releaseNotesInline", default)] release_notes_inline: Option, + #[serde(default)] assets: Option, + #[serde(rename = "assetUploadMode", default)] asset_upload_mode: Option, + #[serde(rename = "isDraft", default, deserialize_with = "de_opt_bool_flex")] is_draft: Option, + #[serde( + rename = "isPreRelease", + default, + deserialize_with = "de_opt_bool_flex" + )] is_pre_release: Option, + #[serde(rename = "makeLatest", default)] make_latest: Option, + #[serde( + rename = "addChangeLog", + default, + deserialize_with = "de_opt_bool_flex" + )] add_change_log: Option, + #[serde(rename = "changeLogCompareToRelease", default)] change_log_compare_to_release: Option, + #[serde(rename = "changeLogCompareToReleaseTag", default)] change_log_compare_to_release_tag: Option, + #[serde(rename = "changeLogType", default)] change_log_type: Option, + #[serde(rename = "changeLogLabels", default)] change_log_labels: Option, } @@ -420,9 +534,11 @@ impl GitHubReleaseEdit { } /// Required inputs for `GitHubRelease@1` `action: delete`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GitHubReleaseDelete { /// `tag` — tag identifying the release to delete. Required. + #[serde(rename = "tag")] tag: String, } @@ -498,7 +614,11 @@ impl GitHubRelease { repository_name: impl Into, spec: GitHubReleaseCreate, ) -> Self { - Self::new(git_hub_connection, repository_name, GitHubReleaseAction::Create(spec)) + Self::new( + git_hub_connection, + repository_name, + GitHubReleaseAction::Create(spec), + ) } /// `action: edit` — update an existing release. @@ -507,7 +627,11 @@ impl GitHubRelease { repository_name: impl Into, spec: GitHubReleaseEdit, ) -> Self { - Self::new(git_hub_connection, repository_name, GitHubReleaseAction::Edit(spec)) + Self::new( + git_hub_connection, + repository_name, + GitHubReleaseAction::Edit(spec), + ) } /// `action: delete` — delete an existing release. @@ -516,7 +640,11 @@ impl GitHubRelease { repository_name: impl Into, spec: GitHubReleaseDelete, ) -> Self { - Self::new(git_hub_connection, repository_name, GitHubReleaseAction::Delete(spec)) + Self::new( + git_hub_connection, + repository_name, + GitHubReleaseAction::Delete(spec), + ) } /// Override the default per-action `displayName`. @@ -543,7 +671,11 @@ impl GitHubRelease { match self.action { GitHubReleaseAction::Create(s) => { push_opt(&mut t, "target", s.target); - push_opt(&mut t, "tagSource", s.tag_source.map(|v| v.as_ado_str().to_string())); + push_opt( + &mut t, + "tagSource", + s.tag_source.map(|v| v.as_ado_str().to_string()), + ); push_opt(&mut t, "tagPattern", s.tag_pattern); push_opt(&mut t, "tag", s.tag); push_opt(&mut t, "title", s.title); @@ -557,14 +689,23 @@ impl GitHubRelease { push_opt(&mut t, "assets", s.assets); push_bool(&mut t, "isDraft", s.is_draft); push_bool(&mut t, "isPreRelease", s.is_pre_release); - push_opt(&mut t, "makeLatest", s.make_latest.map(|v| v.as_ado_str().to_string())); + push_opt( + &mut t, + "makeLatest", + s.make_latest.map(|v| v.as_ado_str().to_string()), + ); push_bool(&mut t, "addChangeLog", s.add_change_log); push_opt( &mut t, "changeLogCompareToRelease", - s.change_log_compare_to_release.map(|v| v.as_ado_str().to_string()), + s.change_log_compare_to_release + .map(|v| v.as_ado_str().to_string()), + ); + push_opt( + &mut t, + "changeLogCompareToReleaseTag", + s.change_log_compare_to_release_tag, ); - push_opt(&mut t, "changeLogCompareToReleaseTag", s.change_log_compare_to_release_tag); push_opt( &mut t, "changeLogType", @@ -591,14 +732,23 @@ impl GitHubRelease { ); push_bool(&mut t, "isDraft", s.is_draft); push_bool(&mut t, "isPreRelease", s.is_pre_release); - push_opt(&mut t, "makeLatest", s.make_latest.map(|v| v.as_ado_str().to_string())); + push_opt( + &mut t, + "makeLatest", + s.make_latest.map(|v| v.as_ado_str().to_string()), + ); push_bool(&mut t, "addChangeLog", s.add_change_log); push_opt( &mut t, "changeLogCompareToRelease", - s.change_log_compare_to_release.map(|v| v.as_ado_str().to_string()), + s.change_log_compare_to_release + .map(|v| v.as_ado_str().to_string()), + ); + push_opt( + &mut t, + "changeLogCompareToReleaseTag", + s.change_log_compare_to_release_tag, ); - push_opt(&mut t, "changeLogCompareToReleaseTag", s.change_log_compare_to_release_tag); push_opt( &mut t, "changeLogType", @@ -661,9 +811,15 @@ mod tests { .into_step(); assert_eq!(t.display_name, "Publish Release"); assert_eq!(t.inputs.get("action").map(String::as_str), Some("create")); - assert_eq!(t.inputs.get("tagSource").map(String::as_str), Some("userSpecifiedTag")); + assert_eq!( + t.inputs.get("tagSource").map(String::as_str), + Some("userSpecifiedTag") + ); assert_eq!(t.inputs.get("tag").map(String::as_str), Some("v1.2.3")); - assert_eq!(t.inputs.get("title").map(String::as_str), Some("Release 1.2.3")); + assert_eq!( + t.inputs.get("title").map(String::as_str), + Some("Release 1.2.3") + ); assert_eq!( t.inputs.get("releaseNotesSource").map(String::as_str), Some("inline") @@ -676,7 +832,10 @@ mod tests { t.inputs.get("assets").map(String::as_str), Some("$(Build.ArtifactStagingDirectory)/*.tar.gz") ); - assert_eq!(t.inputs.get("isPreRelease").map(String::as_str), Some("false")); + assert_eq!( + t.inputs.get("isPreRelease").map(String::as_str), + Some("false") + ); assert_eq!(t.inputs.get("makeLatest").map(String::as_str), Some("true")); } @@ -690,8 +849,14 @@ mod tests { .tag_pattern(r"^v\d+\.\d+\.\d+$"), ) .into_step(); - assert_eq!(t.inputs.get("tagSource").map(String::as_str), Some("gitTag")); - assert_eq!(t.inputs.get("tagPattern").map(String::as_str), Some(r"^v\d+\.\d+\.\d+$")); + assert_eq!( + t.inputs.get("tagSource").map(String::as_str), + Some("gitTag") + ); + assert_eq!( + t.inputs.get("tagPattern").map(String::as_str), + Some(r"^v\d+\.\d+\.\d+$") + ); } #[test] @@ -707,16 +872,26 @@ mod tests { .change_log_labels(r#"[{"label":"bug","displayName":"Bugs","state":"closed"}]"#), ) .into_step(); - assert_eq!(t.inputs.get("addChangeLog").map(String::as_str), Some("true")); assert_eq!( - t.inputs.get("changeLogCompareToRelease").map(String::as_str), + t.inputs.get("addChangeLog").map(String::as_str), + Some("true") + ); + assert_eq!( + t.inputs + .get("changeLogCompareToRelease") + .map(String::as_str), Some("lastNonDraftReleaseByTag") ); assert_eq!( - t.inputs.get("changeLogCompareToReleaseTag").map(String::as_str), + t.inputs + .get("changeLogCompareToReleaseTag") + .map(String::as_str), Some("v1.0.0") ); - assert_eq!(t.inputs.get("changeLogType").map(String::as_str), Some("issueBased")); + assert_eq!( + t.inputs.get("changeLogType").map(String::as_str), + Some("issueBased") + ); } #[test] @@ -744,19 +919,18 @@ mod tests { .asset_upload_mode(AssetUploadMode::Replace), ) .into_step(); - assert_eq!(t.inputs.get("assetUploadMode").map(String::as_str), Some("replace")); + assert_eq!( + t.inputs.get("assetUploadMode").map(String::as_str), + Some("replace") + ); // tag_source should NOT be emitted for edit assert!(t.inputs.get("tagSource").is_none()); } #[test] fn delete_emits_required_tag_only() { - let t = GitHubRelease::delete( - "conn", - "org/repo", - GitHubReleaseDelete::new("v0.1.0-rc.1"), - ) - .into_step(); + let t = GitHubRelease::delete("conn", "org/repo", GitHubReleaseDelete::new("v0.1.0-rc.1")) + .into_step(); assert_eq!(t.task, "GitHubRelease@1"); assert_eq!(t.display_name, "Delete GitHub Release"); assert_eq!(t.inputs.get("action").map(String::as_str), Some("delete")); @@ -774,6 +948,9 @@ mod tests { GitHubReleaseCreate::new().make_latest(MakeLatest::Legacy), ) .into_step(); - assert_eq!(t.inputs.get("makeLatest").map(String::as_str), Some("legacy")); + assert_eq!( + t.inputs.get("makeLatest").map(String::as_str), + Some("legacy") + ); } } diff --git a/src/compile/ir/tasks/go_tool.rs b/src/compile/ir/tasks/go_tool.rs index f7a5ce47..dc9b2cd9 100644 --- a/src/compile/ir/tasks/go_tool.rs +++ b/src/compile/ir/tasks/go_tool.rs @@ -4,6 +4,7 @@ //! use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Builder for a [`TaskStep`] invoking `GoTool@0`. /// @@ -12,12 +13,18 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GoTool { + #[serde(rename = "version")] version: String, + #[serde(rename = "goPath", default)] go_path: Option, + #[serde(rename = "goBin", default)] go_bin: Option, + #[serde(rename = "goDownloadUrl", default)] go_download_url: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/gradle.rs b/src/compile/ir/tasks/gradle.rs index 5525b76b..37f9bf02 100644 --- a/src/compile/ir/tasks/gradle.rs +++ b/src/compile/ir/tasks/gradle.rs @@ -7,15 +7,18 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// How JAVA_HOME is determined (`javaHomeOption` / `javaHomeSelection` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum JavaHomeOption { /// `"JDKVersion"` — resolve the JDK install for a specific version. + #[serde(rename = "JDKVersion")] JdkVersion, /// `"Path"` — set JAVA_HOME to a user-supplied directory path. + #[serde(rename = "Path")] Path, } @@ -31,25 +34,34 @@ impl JavaHomeOption { /// JDK version to install when `javaHomeOption = JDKVersion` /// (`jdkVersionOption` / `jdkVersion` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum JdkVersion { /// `"default"` — use whatever JDK is already on PATH. + #[serde(rename = "default")] Default, /// `"1.21"` — JDK 21. + #[serde(rename = "1.21")] V1_21, /// `"1.17"` — JDK 17. + #[serde(rename = "1.17")] V1_17, /// `"1.11"` — JDK 11. + #[serde(rename = "1.11")] V1_11, /// `"1.10"` — JDK 10. + #[serde(rename = "1.10")] V1_10, /// `"1.9"` — JDK 9. + #[serde(rename = "1.9")] V1_9, /// `"1.8"` — JDK 8. + #[serde(rename = "1.8")] V1_8, /// `"1.7"` — JDK 7. + #[serde(rename = "1.7")] V1_7, /// `"1.6"` — JDK 6. + #[serde(rename = "1.6")] V1_6, } @@ -71,13 +83,16 @@ impl JdkVersion { } /// JDK CPU architecture (`jdkArchitectureOption` / `jdkArchitecture` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum JdkArchitecture { /// `"x86"` — 32-bit x86. + #[serde(rename = "x86")] X86, /// `"x64"` — 64-bit x64 (default). + #[serde(rename = "x64")] X64, /// `"arm64"` — ARM 64-bit. + #[serde(rename = "arm64")] Arm64, } @@ -93,13 +108,16 @@ impl JdkArchitecture { } /// Code coverage tool (`codeCoverageToolOption` / `codeCoverageTool` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum CodeCoverageTool { /// `"None"` — no code coverage (default). + #[serde(rename = "None")] None, /// `"Cobertura"` — Cobertura XML coverage. + #[serde(rename = "Cobertura")] Cobertura, /// `"JaCoCo"` — JaCoCo XML coverage. + #[serde(rename = "JaCoCo")] JaCoCo, } @@ -131,36 +149,59 @@ impl CodeCoverageTool { /// .into_step(); /// assert_eq!(step.task, "Gradle@3"); /// ``` -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Gradle { /// `gradleWrapperFile` — path to the Gradle wrapper script (e.g. `gradlew`). + #[serde(rename = "gradleWrapperFile")] gradle_wrapper_file: String, /// `tasks` — space-separated list of Gradle tasks to execute (e.g. `build test`). + #[serde(rename = "tasks")] tasks: String, /// `options` — additional command-line options passed to Gradle. + #[serde(rename = "options", default)] options: Option, /// `publishJUnitResults` — publish JUnit XML results to Azure Pipelines. + #[serde( + rename = "publishJUnitResults", + default, + deserialize_with = "de_opt_bool_flex" + )] publish_junit_results: Option, /// `testResultsFiles` — glob for JUnit XML files (required when `publishJUnitResults = true`). + #[serde(rename = "testResultsFiles", default)] test_results_files: Option, /// `codeCoverageToolOption` — optional code coverage tool. + #[serde(rename = "codeCoverageToolOption", default)] code_coverage_tool: Option, /// `codeCoverageClassFilesDirectories` — class file paths for coverage (required when /// `codeCoverageToolOption != None`). + #[serde(rename = "codeCoverageClassFilesDirectories", default)] code_coverage_class_files_dirs: Option, /// `codeCoverageFailIfEmpty` — fail the build when coverage results are missing. + #[serde( + rename = "codeCoverageFailIfEmpty", + default, + deserialize_with = "de_opt_bool_flex" + )] code_coverage_fail_if_empty: Option, /// `javaHomeOption` — how to resolve `JAVA_HOME`. + #[serde(rename = "javaHomeOption", default)] java_home_option: Option, /// `jdkVersionOption` — JDK version (only when `javaHomeOption = JDKVersion`). + #[serde(rename = "jdkVersionOption", default)] jdk_version_option: Option, /// `jdkDirectory` — path to JDK home (only when `javaHomeOption = Path`). + #[serde(rename = "jdkDirectory", default)] jdk_directory: Option, /// `jdkArchitectureOption` — JDK architecture (only when `jdkVersionOption != default`). + #[serde(rename = "jdkArchitectureOption", default)] jdk_architecture: Option, /// `gradleOptions` — value for the `GRADLE_OPTS` environment variable. + #[serde(rename = "gradleOptions", default)] gradle_options: Option, /// Override for the step's `displayName`. + #[serde(skip)] display_name: Option, } @@ -171,10 +212,7 @@ impl Gradle { /// (typically `"gradlew"` on Linux/macOS, `"gradlew.bat"` on Windows). /// - `tasks` — space-separated list of Gradle tasks to run (e.g. `"build"`, /// `"build test"`, `"clean build"`). - pub fn new( - gradle_wrapper_file: impl Into, - tasks: impl Into, - ) -> Self { + pub fn new(gradle_wrapper_file: impl Into, tasks: impl Into) -> Self { Self { gradle_wrapper_file: gradle_wrapper_file.into(), tasks: tasks.into(), @@ -284,8 +322,10 @@ impl Gradle { push_bool(&mut t, "publishJUnitResults", self.publish_junit_results); push_opt(&mut t, "testResultsFiles", self.test_results_files); if let Some(v) = self.code_coverage_tool { - t.inputs - .insert("codeCoverageToolOption".to_string(), v.as_ado_str().to_string()); + t.inputs.insert( + "codeCoverageToolOption".to_string(), + v.as_ado_str().to_string(), + ); } push_opt( &mut t, @@ -307,8 +347,10 @@ impl Gradle { } push_opt(&mut t, "jdkDirectory", self.jdk_directory); if let Some(v) = self.jdk_architecture { - t.inputs - .insert("jdkArchitectureOption".to_string(), v.as_ado_str().to_string()); + t.inputs.insert( + "jdkArchitectureOption".to_string(), + v.as_ado_str().to_string(), + ); } push_opt(&mut t, "gradleOptions", self.gradle_options); @@ -425,9 +467,7 @@ mod tests { Some("build/classes/java/main") ); assert_eq!( - t.inputs - .get("codeCoverageFailIfEmpty") - .map(String::as_str), + t.inputs.get("codeCoverageFailIfEmpty").map(String::as_str), Some("true") ); } diff --git a/src/compile/ir/tasks/helm_installer.rs b/src/compile/ir/tasks/helm_installer.rs index 1f17d9d4..6f8554aa 100644 --- a/src/compile/ir/tasks/helm_installer.rs +++ b/src/compile/ir/tasks/helm_installer.rs @@ -4,6 +4,7 @@ //! use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Builder for a [`TaskStep`] invoking `HelmInstaller@1`. /// @@ -13,9 +14,12 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct HelmInstaller { + #[serde(rename = "helmVersionToInstall", default)] helm_version: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/java_tool_installer.rs b/src/compile/ir/tasks/java_tool_installer.rs index a7fe8946..fb07200d 100644 --- a/src/compile/ir/tasks/java_tool_installer.rs +++ b/src/compile/ir/tasks/java_tool_installer.rs @@ -7,14 +7,74 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; +use serde_yaml::Value; + +/// Validate an authored `JavaToolInstaller@0` `inputs:` mapping (advisory +/// front-matter validation, see [`super::parse`]). +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let source = match map.remove("jdkSourceOption") { + Some(value) => value + .as_str() + .map(str::to_string) + .ok_or_else(|| "`jdkSourceOption` must be a string".to_string())?, + None => "PreInstalled".to_string(), + }; + + validate_common(&Value::Mapping(map.clone())).map_err(|e| format!("common inputs: {e}"))?; + remove_common_inputs(&mut map); + let rest = Value::Mapping(map); + + let result = match source.as_str() { + "PreInstalled" => serde_yaml::from_value::(rest).map(drop), + "LocalDirectory" => serde_yaml::from_value::(rest).map(drop), + "AzureStorage" => serde_yaml::from_value::(rest).map(drop), + other => { + return Err(format!( + "JavaToolInstaller@0: unknown jdkSourceOption `{other}`" + )) + } + }; + result.map_err(|e| format!("jdkSourceOption `{source}`: {e}")) +} + +fn validate_common(inputs: &Value) -> Result<(), serde_yaml::Error> { + serde_yaml::from_value::(inputs.clone()).map(drop) +} + +fn remove_common_inputs(map: &mut serde_yaml::Mapping) { + for key in ["versionSpec", "jdkArchitectureOption"] { + map.remove(key); + } +} + +#[derive(Debug, Deserialize)] +struct JavaToolInstallerCommonInputs { + #[serde(rename = "versionSpec")] + _version_spec: String, + #[serde(rename = "jdkArchitectureOption")] + _architecture: JdkArchitecture, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct PreInstalledSpec {} /// JDK CPU architecture for [`JavaToolInstaller`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum JdkArchitecture { + #[serde(rename = "x64")] X64, + #[serde(rename = "x86")] X86, + #[serde(rename = "arm64")] Arm64, } @@ -44,22 +104,32 @@ pub enum JdkSource { } /// Per-source required and optional inputs for `jdkSourceOption: LocalDirectory`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct LocalDirectorySpec { /// `jdkFile` — path to the JDK archive on the build agent. + #[serde(rename = "jdkFile")] pub jdk_file: String, /// `jdkDestinationDirectory` — directory where the JDK is installed. + #[serde(rename = "jdkDestinationDirectory")] pub jdk_destination_directory: String, + #[serde( + rename = "cleanDestinationDirectory", + default, + deserialize_with = "de_opt_bool_flex" + )] clean_destination_directory: Option, + #[serde( + rename = "createExtractDirectory", + default, + deserialize_with = "de_opt_bool_flex" + )] create_extract_directory: Option, } impl LocalDirectorySpec { /// Required: `jdk_file` path and `jdk_destination_directory`. - pub fn new( - jdk_file: impl Into, - jdk_destination_directory: impl Into, - ) -> Self { + pub fn new(jdk_file: impl Into, jdk_destination_directory: impl Into) -> Self { Self { jdk_file: jdk_file.into(), jdk_destination_directory: jdk_destination_directory.into(), @@ -82,18 +152,34 @@ impl LocalDirectorySpec { } /// Per-source required and optional inputs for `jdkSourceOption: AzureStorage`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AzureStorageSpec { /// `azureStorageAccountName` — Azure Storage account containing the JDK archive. + #[serde(rename = "azureStorageAccountName")] pub azure_storage_account_name: String, /// `azureContainerName` — Blob container name. + #[serde(rename = "azureContainerName")] pub azure_container_name: String, /// `azureCommonVirtualFile` — common virtual path to the JDK archive. + #[serde(rename = "azureCommonVirtualFile")] pub azure_common_virtual_file: String, /// `jdkDestinationDirectory` — directory where the JDK is installed. + #[serde(rename = "jdkDestinationDirectory")] pub jdk_destination_directory: String, + #[serde(rename = "azureResourceGroupName", default)] azure_resource_group_name: Option, + #[serde( + rename = "cleanDestinationDirectory", + default, + deserialize_with = "de_opt_bool_flex" + )] clean_destination_directory: Option, + #[serde( + rename = "createExtractDirectory", + default, + deserialize_with = "de_opt_bool_flex" + )] create_extract_directory: Option, } @@ -223,7 +309,11 @@ impl JavaToolInstaller { t = t .with_input("jdkFile", s.jdk_file) .with_input("jdkDestinationDirectory", s.jdk_destination_directory); - push_bool(&mut t, "cleanDestinationDirectory", s.clean_destination_directory); + push_bool( + &mut t, + "cleanDestinationDirectory", + s.clean_destination_directory, + ); push_bool(&mut t, "createExtractDirectory", s.create_extract_directory); } JdkSource::AzureStorage(s) => { @@ -232,8 +322,16 @@ impl JavaToolInstaller { .with_input("azureContainerName", s.azure_container_name) .with_input("azureCommonVirtualFile", s.azure_common_virtual_file) .with_input("jdkDestinationDirectory", s.jdk_destination_directory); - push_opt(&mut t, "azureResourceGroupName", s.azure_resource_group_name); - push_bool(&mut t, "cleanDestinationDirectory", s.clean_destination_directory); + push_opt( + &mut t, + "azureResourceGroupName", + s.azure_resource_group_name, + ); + push_bool( + &mut t, + "cleanDestinationDirectory", + s.clean_destination_directory, + ); push_bool(&mut t, "createExtractDirectory", s.create_extract_directory); } } @@ -292,7 +390,9 @@ mod tests { Some("/tools/jdk17") ); assert_eq!( - t.inputs.get("cleanDestinationDirectory").map(String::as_str), + t.inputs + .get("cleanDestinationDirectory") + .map(String::as_str), Some("true") ); assert_eq!( @@ -321,8 +421,7 @@ mod tests { ) .azure_resource_group_name("my-rg") .clean_destination_directory(false); - let t = - JavaToolInstaller::azure_storage("17", JdkArchitecture::X64, spec).into_step(); + let t = JavaToolInstaller::azure_storage("17", JdkArchitecture::X64, spec).into_step(); assert_eq!( t.inputs.get("jdkSourceOption").map(String::as_str), Some("AzureStorage") @@ -348,7 +447,9 @@ mod tests { Some("my-rg") ); assert_eq!( - t.inputs.get("cleanDestinationDirectory").map(String::as_str), + t.inputs + .get("cleanDestinationDirectory") + .map(String::as_str), Some("false") ); // LocalDirectory keys must not be emitted. @@ -358,8 +459,7 @@ mod tests { #[test] fn azure_storage_optional_absent_when_unset() { let spec = AzureStorageSpec::new("acct", "container", "path/jdk.tar.gz", "/jdk"); - let t = - JavaToolInstaller::azure_storage("11", JdkArchitecture::X64, spec).into_step(); + let t = JavaToolInstaller::azure_storage("11", JdkArchitecture::X64, spec).into_step(); assert!(t.inputs.get("azureResourceGroupName").is_none()); assert!(t.inputs.get("cleanDestinationDirectory").is_none()); assert!(t.inputs.get("createExtractDirectory").is_none()); diff --git a/src/compile/ir/tasks/manual_validation.rs b/src/compile/ir/tasks/manual_validation.rs index 9e09df01..5edadf9d 100644 --- a/src/compile/ir/tasks/manual_validation.rs +++ b/src/compile/ir/tasks/manual_validation.rs @@ -3,17 +3,20 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// What the task should do when the timeout elapses with no human response. /// /// Maps to the `onTimeout` input of `ManualValidation@1`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum OnTimeout { /// Automatically reject the run on timeout (ADO default). + #[serde(rename = "reject")] Reject, /// Automatically resume (approve) the run on timeout. + #[serde(rename = "resume")] Resume, } @@ -35,14 +38,26 @@ impl OnTimeout { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ManualValidation { + #[serde(rename = "notifyUsers")] notify_users: String, + #[serde(rename = "approvers", default)] approvers: Option, + #[serde( + rename = "allowApproversToApproveTheirOwnRuns", + default, + deserialize_with = "de_opt_bool_flex" + )] allow_approvers_to_approve_their_own_runs: Option, + #[serde(rename = "instructions", default)] instructions: Option, + #[serde(rename = "onTimeout", default)] on_timeout: Option, + #[serde(skip)] timeout_minutes: Option, + #[serde(skip)] display_name: Option, } @@ -156,17 +171,17 @@ mod tests { #[test] fn empty_notify_users_is_valid() { let t = ManualValidation::new("").into_step(); - assert_eq!( - t.inputs.get("notifyUsers").map(String::as_str), - Some("") - ); + assert_eq!(t.inputs.get("notifyUsers").map(String::as_str), Some("")); } #[test] fn optional_inputs_not_emitted_by_default() { let t = ManualValidation::new("team@example.com").into_step(); assert!(t.inputs.get("approvers").is_none()); - assert!(t.inputs.get("allowApproversToApproveTheirOwnRuns").is_none()); + assert!(t + .inputs + .get("allowApproversToApproveTheirOwnRuns") + .is_none()); assert!(t.inputs.get("instructions").is_none()); assert!(t.inputs.get("onTimeout").is_none()); } @@ -232,9 +247,7 @@ mod tests { #[test] fn timeout_minutes_sets_step_timeout_not_an_input() { - let t = ManualValidation::new("") - .timeout_minutes(120) - .into_step(); + let t = ManualValidation::new("").timeout_minutes(120).into_step(); // It is a step-level control option (lowers to timeoutInMinutes), not // a task input — ManualValidation@1 has no `timeout` input. assert!(t.inputs.get("timeout").is_none()); diff --git a/src/compile/ir/tasks/maven.rs b/src/compile/ir/tasks/maven.rs index 0b5bbc5c..3bebf504 100644 --- a/src/compile/ir/tasks/maven.rs +++ b/src/compile/ir/tasks/maven.rs @@ -1,18 +1,22 @@ //! Typed builder for `Maven@3`. -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Code-coverage tool selection for `Maven@3`. /// /// Controls the `codeCoverageToolOption` input. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum CodeCoverageTool { /// No code-coverage instrumentation (ADO default). + #[serde(rename = "None")] None, /// Cobertura coverage format. + #[serde(rename = "Cobertura")] Cobertura, /// JaCoCo coverage format. + #[serde(rename = "JaCoCo")] JaCoCo, } @@ -31,25 +35,34 @@ impl CodeCoverageTool { /// /// Controls the `jdkVersionOption` input (used when `javaHomeOption` is /// `JDKVersion`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum JdkVersion { /// Use the JDK already on PATH (ADO default). + #[serde(rename = "default")] Default, /// JDK 21. + #[serde(rename = "1.21")] V21, /// JDK 17. + #[serde(rename = "1.17")] V17, /// JDK 11. + #[serde(rename = "1.11")] V11, /// JDK 10. + #[serde(rename = "1.10")] V10, /// JDK 9. + #[serde(rename = "1.9")] V9, /// JDK 8. + #[serde(rename = "1.8")] V8, /// JDK 7. + #[serde(rename = "1.7")] V7, /// JDK 6. + #[serde(rename = "1.6")] V6, } @@ -73,13 +86,16 @@ impl JdkVersion { /// JDK architecture for `Maven@3`. /// /// Controls the `jdkArchitectureOption` input. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum JdkArchitecture { /// 32-bit JDK. + #[serde(rename = "x86")] X86, /// 64-bit JDK (ADO default). + #[serde(rename = "x64")] X64, /// ARM 64-bit JDK. + #[serde(rename = "arm64")] Arm64, } @@ -103,34 +119,88 @@ impl JdkArchitecture { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Maven { + #[serde(rename = "mavenPOMFile")] maven_pom_file: String, // Build + #[serde(rename = "goals", default)] goals: Option, + #[serde(rename = "options", default)] options: Option, // JUnit test results + #[serde( + rename = "publishJUnitResults", + default, + deserialize_with = "de_opt_bool_flex" + )] publish_junit_results: Option, + #[serde(rename = "testResultsFiles", default)] test_results_files: Option, + #[serde(rename = "testRunTitle", default)] test_run_title: Option, + #[serde( + rename = "allowBrokenSymlinks", + default, + deserialize_with = "de_opt_bool_flex" + )] allow_broken_symlinks: Option, // Code coverage + #[serde(rename = "codeCoverageToolOption", default)] code_coverage_tool: Option, + #[serde(rename = "codeCoverageClassFilter", default)] code_coverage_class_filter: Option, + #[serde(rename = "codeCoverageClassFilesDirectories", default)] code_coverage_class_files_directories: Option, + #[serde(rename = "codeCoverageSourceDirectories", default)] code_coverage_source_directories: Option, + #[serde( + rename = "codeCoverageFailIfEmpty", + default, + deserialize_with = "de_opt_bool_flex" + )] code_coverage_fail_if_empty: Option, + #[serde( + rename = "codeCoverageRestoreOriginalPomXml", + default, + deserialize_with = "de_opt_bool_flex" + )] code_coverage_restore_original_pom_xml: Option, // JDK / advanced + #[serde(rename = "jdkVersionOption", default)] jdk_version: Option, + #[serde(rename = "jdkArchitectureOption", default)] jdk_architecture: Option, + #[serde(rename = "mavenOptions", default)] maven_options: Option, + #[serde( + rename = "mavenAuthenticateFeed", + default, + deserialize_with = "de_opt_bool_flex" + )] maven_authenticate_feed: Option, // Code analysis + #[serde( + rename = "checkStyleRunAnalysis", + default, + deserialize_with = "de_opt_bool_flex" + )] checkstyle_run_analysis: Option, + #[serde( + rename = "pmdRunAnalysis", + default, + deserialize_with = "de_opt_bool_flex" + )] pmd_run_analysis: Option, + #[serde( + rename = "spotBugsRunAnalysis", + default, + deserialize_with = "de_opt_bool_flex" + )] spot_bugs_run_analysis: Option, + #[serde(skip)] display_name: Option, } @@ -316,7 +386,11 @@ impl Maven { if let Some(v) = self.code_coverage_tool { t = t.with_input("codeCoverageToolOption", v.as_ado_str()); } - push_opt(&mut t, "codeCoverageClassFilter", self.code_coverage_class_filter); + push_opt( + &mut t, + "codeCoverageClassFilter", + self.code_coverage_class_filter, + ); push_opt( &mut t, "codeCoverageClassFilesDirectories", @@ -327,7 +401,11 @@ impl Maven { "codeCoverageSourceDirectories", self.code_coverage_source_directories, ); - push_bool(&mut t, "codeCoverageFailIfEmpty", self.code_coverage_fail_if_empty); + push_bool( + &mut t, + "codeCoverageFailIfEmpty", + self.code_coverage_fail_if_empty, + ); push_bool( &mut t, "codeCoverageRestoreOriginalPomXml", @@ -341,8 +419,16 @@ impl Maven { t = t.with_input("jdkArchitectureOption", v.as_ado_str()); } push_opt(&mut t, "mavenOptions", self.maven_options); - push_bool(&mut t, "mavenAuthenticateFeed", self.maven_authenticate_feed); - push_bool(&mut t, "checkStyleRunAnalysis", self.checkstyle_run_analysis); + push_bool( + &mut t, + "mavenAuthenticateFeed", + self.maven_authenticate_feed, + ); + push_bool( + &mut t, + "checkStyleRunAnalysis", + self.checkstyle_run_analysis, + ); push_bool(&mut t, "pmdRunAnalysis", self.pmd_run_analysis); push_bool(&mut t, "spotBugsRunAnalysis", self.spot_bugs_run_analysis); @@ -359,7 +445,10 @@ mod tests { let t = Maven::new("pom.xml").into_step(); assert_eq!(t.task, "Maven@3"); assert_eq!(t.display_name, "Maven"); - assert_eq!(t.inputs.get("mavenPOMFile").map(String::as_str), Some("pom.xml")); + assert_eq!( + t.inputs.get("mavenPOMFile").map(String::as_str), + Some("pom.xml") + ); // Optional inputs absent by default. assert!(t.inputs.get("goals").is_none()); assert!(t.inputs.get("options").is_none()); @@ -387,13 +476,22 @@ mod tests { .test_run_title("Unit Tests") .allow_broken_symlinks(false) .into_step(); - assert_eq!(t.inputs.get("publishJUnitResults").map(String::as_str), Some("true")); + assert_eq!( + t.inputs.get("publishJUnitResults").map(String::as_str), + Some("true") + ); assert_eq!( t.inputs.get("testResultsFiles").map(String::as_str), Some("**/surefire-reports/TEST-*.xml") ); - assert_eq!(t.inputs.get("testRunTitle").map(String::as_str), Some("Unit Tests")); - assert_eq!(t.inputs.get("allowBrokenSymlinks").map(String::as_str), Some("false")); + assert_eq!( + t.inputs.get("testRunTitle").map(String::as_str), + Some("Unit Tests") + ); + assert_eq!( + t.inputs.get("allowBrokenSymlinks").map(String::as_str), + Some("false") + ); } #[test] @@ -411,7 +509,10 @@ mod tests { t.inputs.get("codeCoverageClassFilter").map(String::as_str), Some("+:com.example.*,-:com.example.generated.*") ); - assert_eq!(t.inputs.get("codeCoverageFailIfEmpty").map(String::as_str), Some("true")); + assert_eq!( + t.inputs.get("codeCoverageFailIfEmpty").map(String::as_str), + Some("true") + ); } #[test] @@ -420,8 +521,14 @@ mod tests { .jdk_version(JdkVersion::V17) .jdk_architecture(JdkArchitecture::X64) .into_step(); - assert_eq!(t.inputs.get("jdkVersionOption").map(String::as_str), Some("1.17")); - assert_eq!(t.inputs.get("jdkArchitectureOption").map(String::as_str), Some("x64")); + assert_eq!( + t.inputs.get("jdkVersionOption").map(String::as_str), + Some("1.17") + ); + assert_eq!( + t.inputs.get("jdkArchitectureOption").map(String::as_str), + Some("x64") + ); } #[test] @@ -430,8 +537,14 @@ mod tests { .maven_options("-Xmx2048m") .maven_authenticate_feed(true) .into_step(); - assert_eq!(t.inputs.get("mavenOptions").map(String::as_str), Some("-Xmx2048m")); - assert_eq!(t.inputs.get("mavenAuthenticateFeed").map(String::as_str), Some("true")); + assert_eq!( + t.inputs.get("mavenOptions").map(String::as_str), + Some("-Xmx2048m") + ); + assert_eq!( + t.inputs.get("mavenAuthenticateFeed").map(String::as_str), + Some("true") + ); } #[test] @@ -441,9 +554,18 @@ mod tests { .pmd_run_analysis(true) .spot_bugs_run_analysis(false) .into_step(); - assert_eq!(t.inputs.get("checkStyleRunAnalysis").map(String::as_str), Some("true")); - assert_eq!(t.inputs.get("pmdRunAnalysis").map(String::as_str), Some("true")); - assert_eq!(t.inputs.get("spotBugsRunAnalysis").map(String::as_str), Some("false")); + assert_eq!( + t.inputs.get("checkStyleRunAnalysis").map(String::as_str), + Some("true") + ); + assert_eq!( + t.inputs.get("pmdRunAnalysis").map(String::as_str), + Some("true") + ); + assert_eq!( + t.inputs.get("spotBugsRunAnalysis").map(String::as_str), + Some("false") + ); } #[test] diff --git a/src/compile/ir/tasks/maven_authenticate.rs b/src/compile/ir/tasks/maven_authenticate.rs index 2df8efd0..93040433 100644 --- a/src/compile/ir/tasks/maven_authenticate.rs +++ b/src/compile/ir/tasks/maven_authenticate.rs @@ -1,6 +1,7 @@ //! Typed builder for `MavenAuthenticate@0`. use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Builder for a [`TaskStep`] invoking `MavenAuthenticate@0`. /// @@ -22,11 +23,16 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct MavenAuthenticate { + #[serde(rename = "artifactsFeeds", default)] artifacts_feeds: Option, + #[serde(rename = "mavenServiceConnections", default)] maven_service_connections: Option, + #[serde(rename = "workloadIdentityServiceConnection", default)] workload_identity_service_connection: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/mod.rs b/src/compile/ir/tasks/mod.rs index 60be370c..9896a75b 100644 --- a/src/compile/ir/tasks/mod.rs +++ b/src/compile/ir/tasks/mod.rs @@ -50,6 +50,7 @@ pub mod npm; pub mod npm_authenticate; pub mod nuget_authenticate; pub mod nuget_command; +pub mod parse; pub mod pip_authenticate; pub mod powershell; pub mod python_script; diff --git a/src/compile/ir/tasks/npm.rs b/src/compile/ir/tasks/npm.rs index 9cbaa7fc..946d36d2 100644 --- a/src/compile/ir/tasks/npm.rs +++ b/src/compile/ir/tasks/npm.rs @@ -7,13 +7,43 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; +use serde_yaml::Value; + +/// Validate an authored `Npm@1` `inputs:` mapping (advisory front-matter +/// validation, see [`super::parse`]). +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let command = map + .remove("command") + .and_then(|v| v.as_str().map(str::to_string)) + // ADO defaults `command` to `install` when omitted — treat a missing + // command as the default variant rather than an error. + .unwrap_or_else(|| "install".to_string()); + let rest = Value::Mapping(map); + + let result = match command.as_str() { + "install" => serde_yaml::from_value::(rest).map(drop), + "ci" => serde_yaml::from_value::(rest).map(drop), + "publish" => serde_yaml::from_value::(rest).map(drop), + "custom" => serde_yaml::from_value::(rest).map(drop), + other => return Err(format!("Npm@1: unknown command `{other}`")), + }; + result.map_err(|e| format!("command `{command}`: {e}")) +} /// `customRegistry` selector for `npm install` / `ci` / `custom`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum NpmCustomRegistry { + #[serde(rename = "useNpmrc")] UseNpmrc, + #[serde(rename = "useFeed")] UseFeed, } @@ -28,9 +58,11 @@ impl NpmCustomRegistry { } /// `publishRegistry` selector for `npm publish`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum NpmPublishRegistry { + #[serde(rename = "useExternalRegistry")] UseExternalRegistry, + #[serde(rename = "useFeed")] UseFeed, } @@ -54,12 +86,18 @@ pub enum NpmCommand { } /// Optionals for `npm install`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct NpmInstall { + #[serde(rename = "workingDir", default)] working_dir: Option, + #[serde(rename = "verbose", default, deserialize_with = "de_opt_bool_flex")] verbose: Option, + #[serde(rename = "customRegistry", default)] custom_registry: Option, + #[serde(rename = "customFeed", default)] custom_feed: Option, + #[serde(rename = "customEndpoint", default)] custom_endpoint: Option, } @@ -101,13 +139,24 @@ impl NpmInstall { pub type NpmCi = NpmInstall; /// Optionals for `npm publish`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct NpmPublish { + #[serde(rename = "workingDir", default)] working_dir: Option, + #[serde(rename = "verbose", default, deserialize_with = "de_opt_bool_flex")] verbose: Option, + #[serde(rename = "publishRegistry", default)] publish_registry: Option, + #[serde(rename = "publishFeed", default)] publish_feed: Option, + #[serde(rename = "publishEndpoint", default)] publish_endpoint: Option, + #[serde( + rename = "publishPackageMetadata", + default, + deserialize_with = "de_opt_bool_flex" + )] publish_package_metadata: Option, } @@ -149,12 +198,18 @@ impl NpmPublish { } /// Inputs for `npm custom`. `customCommand` is required. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct NpmCustom { + #[serde(rename = "customCommand")] custom_command: String, + #[serde(rename = "workingDir", default)] working_dir: Option, + #[serde(rename = "customRegistry", default)] custom_registry: Option, + #[serde(rename = "customFeed", default)] custom_feed: Option, + #[serde(rename = "customEndpoint", default)] custom_endpoint: Option, } @@ -240,7 +295,8 @@ impl Npm { }; let mut t = TaskStep::new( "Npm@1", - self.display_name.unwrap_or_else(|| format!("npm {command}")), + self.display_name + .unwrap_or_else(|| format!("npm {command}")), ) .with_input("command", command); match self.command { @@ -315,9 +371,18 @@ mod tests { ) .into_step(); assert_eq!(t.inputs.get("command").map(String::as_str), Some("publish")); - assert_eq!(t.inputs.get("publishRegistry").map(String::as_str), Some("useFeed")); - assert_eq!(t.inputs.get("publishFeed").map(String::as_str), Some("myorg/myfeed")); - assert_eq!(t.inputs.get("publishPackageMetadata").map(String::as_str), Some("true")); + assert_eq!( + t.inputs.get("publishRegistry").map(String::as_str), + Some("useFeed") + ); + assert_eq!( + t.inputs.get("publishFeed").map(String::as_str), + Some("myorg/myfeed") + ); + assert_eq!( + t.inputs.get("publishPackageMetadata").map(String::as_str), + Some("true") + ); } #[test] @@ -347,8 +412,14 @@ mod tests { .verbose(true), ) .into_step(); - assert_eq!(t.inputs.get("customRegistry").map(String::as_str), Some("useFeed")); - assert_eq!(t.inputs.get("customFeed").map(String::as_str), Some("myorg/myproject/myfeed")); + assert_eq!( + t.inputs.get("customRegistry").map(String::as_str), + Some("useFeed") + ); + assert_eq!( + t.inputs.get("customFeed").map(String::as_str), + Some("myorg/myproject/myfeed") + ); assert_eq!(t.inputs.get("verbose").map(String::as_str), Some("true")); } } diff --git a/src/compile/ir/tasks/npm_authenticate.rs b/src/compile/ir/tasks/npm_authenticate.rs index 5118cfbc..be403536 100644 --- a/src/compile/ir/tasks/npm_authenticate.rs +++ b/src/compile/ir/tasks/npm_authenticate.rs @@ -1,6 +1,7 @@ //! Typed builder for `npmAuthenticate@0`. use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Builder for a [`TaskStep`] invoking `npmAuthenticate@0`. /// @@ -17,12 +18,18 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct NpmAuthenticate { + #[serde(rename = "workingFile")] working_file: String, + #[serde(rename = "customEndpoint", default)] custom_endpoint: Option, + #[serde(rename = "azureDevOpsServiceConnection", default)] azure_devops_service_connection: Option, + #[serde(rename = "feedUrl", default)] feed_url: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/nuget_authenticate.rs b/src/compile/ir/tasks/nuget_authenticate.rs index 94b1782c..4e8ae7c1 100644 --- a/src/compile/ir/tasks/nuget_authenticate.rs +++ b/src/compile/ir/tasks/nuget_authenticate.rs @@ -1,7 +1,8 @@ //! Typed builder for `NuGetAuthenticate@1`. -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 `NuGetAuthenticate@1`. /// @@ -13,12 +14,22 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct NuGetAuthenticate { + #[serde(rename = "nuGetServiceConnections", default)] nuget_service_connections: Option, + #[serde( + rename = "forceReinstallCredentialProvider", + default, + deserialize_with = "de_opt_bool_flex" + )] force_reinstall_credential_provider: Option, + #[serde(rename = "workloadIdentityServiceConnection", default)] workload_identity_service_connection: Option, + #[serde(rename = "feedUrl", default)] feed_url: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/nuget_command.rs b/src/compile/ir/tasks/nuget_command.rs index 3a82461f..8fbc8c8d 100644 --- a/src/compile/ir/tasks/nuget_command.rs +++ b/src/compile/ir/tasks/nuget_command.rs @@ -7,14 +7,45 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; +use serde_yaml::Value; + +/// Validate an authored `NuGetCommand@2` `inputs:` mapping (advisory +/// front-matter validation, see [`super::parse`]). +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let command = map + .remove("command") + .and_then(|v| v.as_str().map(str::to_string)) + // ADO defaults `command` to `restore` when omitted — treat a missing + // command as the default variant rather than an error. + .unwrap_or_else(|| "restore".to_string()); + let rest = Value::Mapping(map); + + let result = match command.as_str() { + "restore" => serde_yaml::from_value::(rest).map(drop), + "push" => serde_yaml::from_value::(rest).map(drop), + "pack" => serde_yaml::from_value::(rest).map(drop), + "custom" => serde_yaml::from_value::(rest).map(drop), + other => return Err(format!("NuGetCommand@2: unknown command `{other}`")), + }; + result.map_err(|e| format!("command `{command}`: {e}")) +} /// NuGet task verbosity (`verbosityRestore` / `verbosityPush` / `verbosityPack`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum Verbosity { + #[serde(rename = "Quiet")] Quiet, + #[serde(rename = "Normal")] Normal, + #[serde(rename = "Detailed")] Detailed, } @@ -30,9 +61,11 @@ impl Verbosity { } /// `feedsToUse` selector for `nuget restore`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum FeedsToUse { + #[serde(rename = "select")] Select, + #[serde(rename = "config")] Config, } @@ -47,9 +80,11 @@ impl FeedsToUse { } /// `nuGetFeedType` selector for `nuget push`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum NuGetFeedType { + #[serde(rename = "internal")] Internal, + #[serde(rename = "external")] External, } @@ -64,11 +99,15 @@ impl NuGetFeedType { } /// `versioningScheme` selector for `nuget pack`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum VersioningScheme { + #[serde(rename = "off")] Off, + #[serde(rename = "byPrereleaseNumber")] ByPrereleaseNumber, + #[serde(rename = "byEnvVar")] ByEnvVar, + #[serde(rename = "byBuildNumber")] ByBuildNumber, } @@ -94,17 +133,36 @@ pub enum NuGetOp { } /// Optionals for `nuget restore`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct NuGetRestore { + #[serde(rename = "solution", default)] solution: Option, + #[serde(rename = "feedsToUse", default)] feeds_to_use: Option, + #[serde(rename = "vstsFeed", default)] vsts_feed: Option, + #[serde( + rename = "includeNuGetOrg", + default, + deserialize_with = "de_opt_bool_flex" + )] include_nuget_org: Option, + #[serde(rename = "nugetConfigPath", default)] nuget_config_path: Option, + #[serde(rename = "externalFeedCredentials", default)] external_feed_credentials: Option, + #[serde(rename = "noCache", default, deserialize_with = "de_opt_bool_flex")] no_cache: Option, + #[serde( + rename = "disableParallelProcessing", + default, + deserialize_with = "de_opt_bool_flex" + )] disable_parallel_processing: Option, + #[serde(rename = "restoreDirectory", default)] restore_directory: Option, + #[serde(rename = "verbosityRestore", default)] verbosity_restore: Option, } @@ -165,14 +223,30 @@ impl NuGetRestore { } /// Optionals for `nuget push`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct NuGetPush { + #[serde(rename = "packagesToPush", default)] packages_to_push: Option, + #[serde(rename = "nuGetFeedType", default)] nuget_feed_type: Option, + #[serde(rename = "publishVstsFeed", default)] publish_vsts_feed: Option, + #[serde( + rename = "allowPackageConflicts", + default, + deserialize_with = "de_opt_bool_flex" + )] allow_package_conflicts: Option, + #[serde(rename = "publishFeedCredentials", default)] publish_feed_credentials: Option, + #[serde( + rename = "publishPackageMetadata", + default, + deserialize_with = "de_opt_bool_flex" + )] publish_package_metadata: Option, + #[serde(rename = "verbosityPush", default)] verbosity_push: Option, } @@ -218,11 +292,16 @@ impl NuGetPush { } /// Optionals for `nuget pack`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct NuGetPack { + #[serde(rename = "packagesToPack", default)] packages_to_pack: Option, + #[serde(rename = "configuration", default)] configuration: Option, + #[serde(rename = "versioningScheme", default)] versioning_scheme: Option, + #[serde(rename = "verbosityPack", default)] verbosity_pack: Option, } @@ -253,8 +332,10 @@ impl NuGetPack { } /// Inputs for `nuget custom`. `arguments` is required. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct NuGetCustom { + #[serde(rename = "arguments")] arguments: String, } @@ -316,19 +397,32 @@ impl NuGetCommand { }; let mut t = TaskStep::new( "NuGetCommand@2", - self.display_name.unwrap_or_else(|| format!("NuGet {command}")), + 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, + "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_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_bool( + &mut t, + "disableParallelProcessing", + s.disable_parallel_processing, + ); push_opt(&mut t, "restoreDirectory", s.restore_directory); push_opt( &mut t, @@ -398,10 +492,22 @@ mod tests { .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")); + 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] @@ -414,8 +520,14 @@ mod tests { ) .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")); + 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] diff --git a/src/compile/ir/tasks/parse.rs b/src/compile/ir/tasks/parse.rs new file mode 100644 index 00000000..0da3a039 --- /dev/null +++ b/src/compile/ir/tasks/parse.rs @@ -0,0 +1,676 @@ +//! Front-matter task-step **validation** (advisory). +//! +//! 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. This follows the idiomatic Rust *parse, don't +//! validate* pattern: the typed builder **is** the schema, a successful +//! deserialization **is** the validation, and a serde error **is** the +//! diagnostic. +//! +//! [`validate_task_step`] sits in front of the front-matter `steps:` passthrough +//! with **partial coverage** and three outcomes: +//! +//! - `None` — the step is **not** something we validate (a task with no typed +//! builder yet, or a non-task step like `bash:`/`script:`). The caller keeps +//! the original YAML untouched. Coverage is additive: mapping a new task only +//! ever *adds* validation and never rejects a workflow that compiled before. +//! - `Some(Ok(()))` — the `task:` is one we model and its inputs are valid. +//! - `Some(Err(msg))` — 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). +//! +//! The function **never** returns `anyhow::Err` / panics, so the compiler can +//! treat every finding as an advisory **warning** rather than a hard failure. +//! +//! New tasks are wired up by deriving `Deserialize` on the builder (keyed on ADO +//! input names) and adding one line to [`VALIDATORS`]. + +use serde::de::DeserializeOwned; +use serde_yaml::Value; + +use super::{ + archive_files, azure_cli, azure_container_apps, azure_file_copy, azure_key_vault, + azure_powershell, azure_web_app, cargo_authenticate, cmd_line, copy_files, delete_files, docker, + docker_installer, dotnet_core_cli, download_build_artifacts, download_package, + download_pipeline_artifact, download_secure_file, extract_files, github_release, go_tool, gradle, + helm_installer, java_tool_installer, manual_validation, maven, maven_authenticate, npm, + npm_authenticate, nuget_authenticate, nuget_command, pip_authenticate, powershell, + publish_build_artifacts, publish_code_coverage_results, publish_pipeline_artifact, + publish_test_results, python_script, twine_authenticate, universal_packages, use_dotnet, + use_node, use_python_version, use_ruby_version, vs_build, vstest, +}; + +/// Registry mapping an ADO task id (`"CopyFiles@2"`) to a validator that checks +/// an authored `inputs:` mapping. Flat builders use +/// [`validate_by_deserialize`]; command/mode-dispatch tasks supply a bespoke +/// `validate_inputs` that dispatches on their discriminator input (e.g. +/// `command`, `targetType`, `Destination`). One line per task — adding a new +/// task is a `#[derive(Deserialize)]` on the builder plus one entry here. +/// +/// Lookup is a linear scan ([`validate_task_step`]), run once per authored task +/// step. At ~46 entries and a handful of steps per workflow this is negligible; +/// switch to a sorted array + `partition_point` binary search (or a +/// `OnceLock>`) if coverage grows past a few hundred entries. +#[allow(clippy::type_complexity)] +const VALIDATORS: &[(&str, fn(Value) -> Result<(), String>)] = &[ + // ── Flat single-struct builders (validity == clean deserialization) ── + ("ArchiveFiles@2", validate_by_deserialize::), + ("AzureContainerApps@1", validate_by_deserialize::), + ("AzureKeyVault@2", validate_by_deserialize::), + ("AzureWebApp@1", validate_by_deserialize::), + ("CargoAuthenticate@0", validate_by_deserialize::), + ("CmdLine@2", validate_by_deserialize::), + ("CopyFiles@2", validate_by_deserialize::), + ("DeleteFiles@1", validate_by_deserialize::), + ("DockerInstaller@0", validate_by_deserialize::), + ( + "DownloadBuildArtifacts@1", + validate_by_deserialize::, + ), + ("DownloadPackage@1", validate_by_deserialize::), + ( + "DownloadPipelineArtifact@2", + validate_by_deserialize::, + ), + ("DownloadSecureFile@1", validate_by_deserialize::), + ("ExtractFiles@1", validate_by_deserialize::), + ("GoTool@0", validate_by_deserialize::), + ("Gradle@3", validate_by_deserialize::), + ("HelmInstaller@1", validate_by_deserialize::), + ("ManualValidation@1", validate_by_deserialize::), + ("Maven@3", validate_by_deserialize::), + ("MavenAuthenticate@0", validate_by_deserialize::), + ("NuGetAuthenticate@1", validate_by_deserialize::), + ("PipAuthenticate@1", validate_by_deserialize::), + ( + "PublishCodeCoverageResults@2", + validate_by_deserialize::, + ), + ( + "PublishPipelineArtifact@1", + validate_by_deserialize::, + ), + ("PublishTestResults@2", validate_by_deserialize::), + ("TwineAuthenticate@1", validate_by_deserialize::), + ("UseDotNet@2", validate_by_deserialize::), + ("UseNode@1", validate_by_deserialize::), + ("UsePythonVersion@0", validate_by_deserialize::), + ("UseRubyVersion@0", validate_by_deserialize::), + ("VSBuild@1", validate_by_deserialize::), + ("npmAuthenticate@0", validate_by_deserialize::), + // ── Command / mode-dispatch builders (custom discriminator dispatch) ── + ("AzureCLI@2", azure_cli::validate_inputs), + ("AzureFileCopy@6", azure_file_copy::validate_inputs), + ("AzurePowerShell@5", azure_powershell::validate_inputs), + ("Docker@2", docker::validate_inputs), + ("DotNetCoreCLI@2", dotnet_core_cli::validate_inputs), + ("GitHubRelease@1", github_release::validate_inputs), + ("JavaToolInstaller@0", java_tool_installer::validate_inputs), + ("Npm@1", npm::validate_inputs), + ("NuGetCommand@2", nuget_command::validate_inputs), + ("PowerShell@2", powershell::validate_inputs), + ("PublishBuildArtifacts@1", publish_build_artifacts::validate_inputs), + ("PythonScript@0", python_script::validate_inputs), + ("UniversalPackages@1", universal_packages::validate_inputs), + ("VSTest@2", vstest::validate_inputs), +]; + +/// Validate one authored front-matter step. +/// +/// Returns `None` for anything we don't model (pass it through unchanged), +/// `Some(Ok(()))` for a recognized + valid task, and `Some(Err(msg))` for a +/// recognized task with invalid inputs. Never returns an unrecoverable error. +pub fn validate_task_step(step: &Value) -> Option> { + // Not a mapping, or not a `task:` step (e.g. `bash:` / `script:` / a + // checkout) → nothing for us to validate. + let map = step.as_mapping()?; + let task = map.get("task").and_then(Value::as_str)?; + + // No typed builder for this task (yet) → not validated. + let (_, validate) = VALIDATORS.iter().find(|(id, _)| *id == task)?; + + let inputs = map + .get("inputs") + .cloned() + .unwrap_or_else(|| Value::Mapping(Default::default())); + + Some(validate(inputs).map_err(|e| format!("task `{task}`: {e}"))) +} + +/// A structured finding for one invalid authored task step, suitable for +/// surfacing through the agent-facing lint channel (`ado-aw lint` / +/// `lint_workflow` MCP tool). Carries enough location to let an agent that +/// synthesised the step attribute and fix the finding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskStepFinding { + /// Which front-matter step list the step came from: `"setup"`, `"steps"`, + /// `"post-steps"`, or `"teardown"`. + pub list: &'static str, + /// Zero-based index of the step within that list. + pub index: usize, + /// The ADO task id (e.g. `"CopyFiles@2"`). + pub task: String, + /// Human-readable description of why the inputs are invalid. + pub message: String, +} + +/// Validate every authored task step across the four front-matter step lists, +/// returning a structured finding per recognized-but-invalid step. Valid steps, +/// unmodeled tasks, and non-task steps produce no finding. Never errors. +/// +/// The lists are passed individually (rather than the `FrontMatter` type) to +/// keep this module free of the front-matter grammar; callers pass +/// `front_matter.setup`, `.steps`, `.post_steps`, `.teardown`. +pub fn validate_front_matter_task_steps( + setup: &[Value], + steps: &[Value], + post_steps: &[Value], + teardown: &[Value], +) -> Vec { + let mut findings = Vec::new(); + for (list, values) in [ + ("setup", setup), + ("steps", steps), + ("post-steps", post_steps), + ("teardown", teardown), + ] { + for (index, step) in values.iter().enumerate() { + if let Some(Err(message)) = validate_task_step(step) { + // A `Some(_)` result guarantees `validate_task_step` already + // matched a string `task:` key (it early-returns `None` + // otherwise), so this re-extraction cannot actually fail; the + // fallback is only to avoid another `Option` in the finding. + let task = step + .as_mapping() + .and_then(|m| m.get("task")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + findings.push(TaskStepFinding { + list, + index, + task, + message, + }); + } + } + } + findings +} + +/// Validate an `inputs:` mapping by attempting to deserialize it into the typed +/// builder `T`. `Ok(())` iff it deserializes cleanly (required inputs present, +/// no unknown keys, constrained values valid). +pub(crate) fn validate_by_deserialize(inputs: Value) -> Result<(), String> { + serde_yaml::from_value::(inputs) + .map(drop) + .map_err(|e| e.to_string()) +} + +#[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_is_ok() { + 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 r = validate_task_step(&step).expect("recognized task"); + assert!(r.is_ok(), "expected valid, got: {r:?}"); + } + + #[test] + fn copy_files_missing_required_input_warns() { + let step = yaml( + r#" + task: CopyFiles@2 + inputs: + Contents: "**" + "#, + ); + let err = validate_task_step(&step).expect("recognized").unwrap_err(); + assert!(err.contains("CopyFiles@2"), "got: {err}"); + } + + #[test] + fn copy_files_unknown_input_warns() { + let step = yaml( + r#" + task: CopyFiles@2 + inputs: + Contents: "**" + TargetFolder: out + Bogus: nope + "#, + ); + let err = validate_task_step(&step).expect("recognized").unwrap_err(); + assert!(err.contains("CopyFiles@2"), "got: {err}"); + } + + #[test] + fn copy_files_bad_bool_value_warns() { + let step = yaml( + r#" + task: CopyFiles@2 + inputs: + Contents: "**" + TargetFolder: out + CleanTargetFolder: yesplease + "#, + ); + assert!(validate_task_step(&step).expect("recognized").is_err()); + } + + #[test] + fn copy_files_capitalized_bool_is_ok() { + // serde_yaml (YAML 1.2) parses `True`/`False` as strings, not bools; + // ADO accepts them, so the flexible bool deserializer must too. + let step = yaml( + r#" + task: CopyFiles@2 + inputs: + Contents: "**" + TargetFolder: out + CleanTargetFolder: "True" + OverWrite: "FALSE" + "#, + ); + assert!( + validate_task_step(&step).expect("recognized").is_ok(), + "capitalized string booleans must not be rejected" + ); + } + + #[test] + fn copy_files_integer_authored_retry_count_is_ok() { + // `retryCount`/`delayBetweenRetries` are ADO string inputs, but authors + // naturally write them as bare integers. These must validate (no + // false-positive), matching how ADO coerces them. + let step = yaml( + r#" + task: CopyFiles@2 + inputs: + Contents: "**" + TargetFolder: out + retryCount: 3 + delayBetweenRetries: 1000 + "#, + ); + assert!( + validate_task_step(&step).expect("recognized").is_ok(), + "integer-authored string inputs must not be rejected" + ); + } + + // ── Docker@2 (command-dispatch) ────────────────────────────────────── + + #[test] + fn docker_build_and_push_valid_is_ok() { + let step = yaml( + r#" + task: Docker@2 + inputs: + command: buildAndPush + repository: myapp + tags: latest + "#, + ); + assert!(validate_task_step(&step).expect("recognized").is_ok()); + } + + #[test] + fn docker_login_valid_is_ok() { + let step = yaml( + r#" + task: Docker@2 + inputs: + command: login + containerRegistry: myRegistry + "#, + ); + assert!(validate_task_step(&step).expect("recognized").is_ok()); + } + + #[test] + fn docker_input_for_wrong_command_warns() { + // `repository` is not valid for `command: login`. + let step = yaml( + r#" + task: Docker@2 + inputs: + command: login + repository: myapp + "#, + ); + let err = validate_task_step(&step).expect("recognized").unwrap_err(); + assert!(err.contains("login"), "got: {err}"); + } + + #[test] + fn docker_missing_command_defaults_to_build_and_push() { + // ADO defaults `command` to `buildAndPush`; `repository` is valid there, + // so an omitted command is NOT flagged (matches what ADO would run). + let step = yaml( + r#" + task: Docker@2 + inputs: + repository: myapp + "#, + ); + assert!(validate_task_step(&step).expect("recognized").is_ok()); + } + + #[test] + fn docker_null_inputs_defaults_and_is_ok() { + // `inputs: ~` deserialises to a null inputs value; the dispatcher must + // treat it as an empty mapping (default command), not panic or error. + let step = yaml("task: Docker@2\ninputs: ~"); + assert!(validate_task_step(&step).expect("recognized").is_ok()); + } + + #[test] + fn docker_unknown_command_warns() { + let step = yaml( + r#" + task: Docker@2 + inputs: + command: teleport + "#, + ); + let err = validate_task_step(&step).expect("recognized").unwrap_err(); + assert!(err.contains("teleport"), "got: {err}"); + } + + // ── Dispatch / partial coverage ────────────────────────────────────── + + #[test] + fn unmapped_task_is_not_validated() { + // A task we don't model returns None so the caller keeps the original + // YAML. Note the bogus input: we deliberately don't validate it. + let step = yaml( + r#" + task: SomeRandomTask@9 + inputs: + whatever: 123 + "#, + ); + assert!(validate_task_step(&step).is_none()); + } + + #[test] + fn non_task_step_is_not_validated() { + // A `bash:`/`script:`/checkout step has no `task:` key → None. + let step = yaml("bash: echo hi\ndisplayName: greet"); + assert!(validate_task_step(&step).is_none()); + + // A scalar (malformed step) also just passes through rather than erroring. + let scalar = yaml("\"- some weird string\""); + assert!(validate_task_step(&scalar).is_none()); + } + + // ── Round-trip coverage ────────────────────────────────────────────── + // `into_step()` output must re-validate. This proves the Deserialize side + // (field renames + enum tokens + discriminator dispatch) accepts exactly + // what the construction side emits, across a representative spread of + // flat-enum and command/mode-dispatch builders. + + use crate::compile::ir::step::TaskStep; + + /// Turn an `into_step()` result into a `{ task, inputs }` step value and + /// assert it re-validates cleanly through the registry. + fn assert_roundtrips(t: TaskStep) { + let mut inputs = serde_yaml::Mapping::new(); + for (k, v) in &t.inputs { + inputs.insert(Value::String(k.clone()), Value::String(v.clone())); + } + let mut step = serde_yaml::Mapping::new(); + step.insert(Value::String("task".into()), Value::String(t.task.clone())); + step.insert(Value::String("inputs".into()), Value::Mapping(inputs)); + + let result = validate_task_step(&Value::Mapping(step)) + .unwrap_or_else(|| panic!("task `{}` is not registered", t.task)); + assert!( + result.is_ok(), + "into_step() output for `{}` failed re-validation: {:?}", + t.task, + result + ); + } + + #[test] + fn roundtrip_archive_files_flat_enum() { + assert_roundtrips(archive_files::ArchiveFiles::new("src", "out.zip").into_step()); + } + + #[test] + fn roundtrip_gradle_flat_enum() { + assert_roundtrips(gradle::Gradle::new("gradlew", "build").into_step()); + } + + #[test] + fn roundtrip_powershell_file_dispatch() { + assert_roundtrips(powershell::PowerShell::file("scripts/build.ps1").into_step()); + } + + #[test] + fn roundtrip_powershell_inline_with_enum() { + assert_roundtrips( + powershell::PowerShell::inline("Write-Host hi") + .error_action_preference(powershell::ErrorActionPreference::Continue) + .into_step(), + ); + } + + #[test] + fn roundtrip_dotnet_command_dispatch() { + assert_roundtrips( + dotnet_core_cli::DotNetCoreCli::build(dotnet_core_cli::DotNetBuild::new()).into_step(), + ); + } + + #[test] + fn roundtrip_vstest_selector_dispatch() { + assert_roundtrips( + vstest::VsTest::assemblies(vstest::VsTestAssemblies::new("**/*tests.dll")).into_step(), + ); + } + + #[test] + fn roundtrip_universal_packages_command_dispatch() { + assert_roundtrips( + universal_packages::UniversalPackages::download( + "my-feed", + "my-package", + universal_packages::UniversalPackagesDownload::new(), + ) + .into_step(), + ); + } + + #[test] + fn roundtrip_azure_cli_flat_script_location() { + // Guards the flat `scriptLocation: inlineScript` + sibling `inlineScript:` + // authoring shape (the builder models it as a typed enum for construction, + // but validation must accept the flat form ADO authors / into_step emits). + assert_roundtrips( + azure_cli::AzureCli::new( + "conn", + azure_cli::ScriptType::Bash, + azure_cli::ScriptLocation::Inline("echo hi\n".into()), + ) + .into_step(), + ); + assert_roundtrips( + azure_cli::AzureCli::new( + "conn", + azure_cli::ScriptType::PsCore, + azure_cli::ScriptLocation::ScriptPath("scripts/deploy.ps1".into()), + ) + .into_step(), + ); + } + + #[test] + fn azure_cli_unknown_input_warns() { + let step = yaml( + r#" + task: AzureCLI@2 + inputs: + azureSubscription: conn + scriptType: bash + scriptLocation: inlineScript + inlineScript: echo hi + Bogus: nope + "#, + ); + assert!(validate_task_step(&step).expect("recognized").is_err()); + } + + #[test] + fn roundtrip_nuget_command_dispatch() { + assert_roundtrips( + nuget_command::NuGetCommand::restore(nuget_command::NuGetRestore::new()).into_step(), + ); + } + + #[test] + fn roundtrip_npm_command_dispatch() { + assert_roundtrips(npm::Npm::install(npm::NpmInstall::new()).into_step()); + } + + #[test] + fn roundtrip_github_release_action_dispatch() { + assert_roundtrips( + github_release::GitHubRelease::create( + "gh-conn", + "owner/repo", + github_release::GitHubReleaseCreate::new(), + ) + .into_step(), + ); + } + + #[test] + fn roundtrip_azure_file_copy_destination_dispatch() { + assert_roundtrips( + azure_file_copy::AzureFileCopy::new( + "$(Build.ArtifactStagingDirectory)", + "arm-conn", + "mystorage", + azure_file_copy::AzureFileCopyDestination::AzureBlob( + azure_file_copy::AzureFileCopyToBlob::new("my-container"), + ), + ) + .into_step(), + ); + } + + #[test] + fn roundtrip_publish_build_artifacts_location_dispatch() { + assert_roundtrips( + publish_build_artifacts::PublishBuildArtifacts::container( + "$(Build.ArtifactStagingDirectory)", + "drop", + ) + .into_step(), + ); + } + + #[test] + fn roundtrip_java_tool_installer_source_dispatch() { + assert_roundtrips( + java_tool_installer::JavaToolInstaller::pre_installed( + "11", + java_tool_installer::JdkArchitecture::X64, + ) + .into_step(), + ); + } + + #[test] + fn roundtrip_azure_powershell_inline_dispatch() { + assert_roundtrips( + azure_powershell::AzurePowerShell::inline("arm-conn", "Write-Host hi").into_step(), + ); + } + + #[test] + fn roundtrip_azure_powershell_file_dispatch() { + assert_roundtrips( + azure_powershell::AzurePowerShell::file("arm-conn", "scripts/deploy.ps1").into_step(), + ); + } + + #[test] + fn roundtrip_python_script_file_dispatch() { + assert_roundtrips(python_script::PythonScript::file("scripts/run.py").into_step()); + } + + // Flat builders whose enum input is a *required* constructor arg — these + // exercise the enum's `as_ado_str()` token through the validation path, + // guarding against a wrong `#[serde(rename = "...")]` on a variant. + + #[test] + fn roundtrip_azure_web_app_required_enum() { + assert_roundtrips( + azure_web_app::AzureWebApp::new( + "arm-conn", + azure_web_app::AppType::WebApp, + "my-app", + "$(Build.ArtifactStagingDirectory)/app.zip", + ) + .into_step(), + ); + } + + #[test] + fn roundtrip_download_package_required_enum() { + assert_roundtrips( + download_package::DownloadPackage::new( + download_package::PackageType::NuGet, + "my-feed", + "my-def", + "1.0.0", + "$(System.ArtifactsDirectory)", + ) + .into_step(), + ); + } + + #[test] + fn roundtrip_publish_test_results_required_enum() { + assert_roundtrips( + publish_test_results::PublishTestResults::new( + publish_test_results::TestResultsFormat::JUnit, + "**/TEST-*.xml", + ) + .into_step(), + ); + } + + #[test] + fn registry_has_no_duplicate_task_ids() { + let mut ids: Vec<&str> = VALIDATORS.iter().map(|(id, _)| *id).collect(); + ids.sort_unstable(); + let mut deduped = ids.clone(); + deduped.dedup(); + assert_eq!(ids, deduped, "VALIDATORS must not contain duplicate task ids"); + } +} diff --git a/src/compile/ir/tasks/pip_authenticate.rs b/src/compile/ir/tasks/pip_authenticate.rs index 9f3612ff..a6ba6de2 100644 --- a/src/compile/ir/tasks/pip_authenticate.rs +++ b/src/compile/ir/tasks/pip_authenticate.rs @@ -1,7 +1,8 @@ //! Typed builder for `PipAuthenticate@1`. -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 `PipAuthenticate@1`. /// @@ -20,13 +21,24 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct PipAuthenticate { + #[serde(rename = "azureDevOpsServiceConnection", default)] azure_devops_service_connection: Option, + #[serde(rename = "feedUrl", default)] feed_url: Option, + #[serde(rename = "artifactFeeds", default)] artifact_feeds: Option, + #[serde(rename = "pythonDownloadServiceConnections", default)] python_download_service_connections: Option, + #[serde( + rename = "onlyAddExtraIndex", + default, + deserialize_with = "de_opt_bool_flex" + )] only_add_extra_index: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/powershell.rs b/src/compile/ir/tasks/powershell.rs index 00ecf48d..cd81f6d9 100644 --- a/src/compile/ir/tasks/powershell.rs +++ b/src/compile/ir/tasks/powershell.rs @@ -11,15 +11,47 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; +use serde_yaml::Value; + +/// Validate an authored `PowerShell@2` `inputs:` mapping (advisory front-matter +/// validation, see [`super::parse`]). +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let target_type = match map.remove("targetType") { + Some(v) => Some( + v.as_str() + .ok_or_else(|| "PowerShell@2: `targetType` must be a string".to_string())? + .to_string(), + ), + None => None, + }; + let mode = target_type.as_deref().unwrap_or("filePath"); + let rest = Value::Mapping(map); + + let result = match mode { + "filePath" => serde_yaml::from_value::(rest).map(drop), + "inline" => serde_yaml::from_value::(rest).map(drop), + other => return Err(format!("PowerShell@2: unknown targetType `{other}`")), + }; + result.map_err(|e| format!("targetType `{mode}`: {e}")) +} /// Non-terminating error behaviour for the PowerShell builders /// (`errorActionPreference`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum ErrorActionPreference { + #[serde(rename = "stop")] Stop, + #[serde(rename = "continue")] Continue, + #[serde(rename = "silentlyContinue")] SilentlyContinue, } @@ -34,60 +66,37 @@ impl ErrorActionPreference { } } -/// Optionals shared by both `PowerShell@2` targets. -#[derive(Debug, Clone, Default)] -struct Shared { - error_action_preference: Option, - fail_on_stderr: Option, - ignore_last_exit_code: Option, - pwsh: Option, - working_directory: Option, -} - -impl Shared { - fn apply(self, t: &mut TaskStep) { - if let Some(v) = self.error_action_preference { - t.inputs - .insert("errorActionPreference".to_string(), v.as_ado_str().to_string()); - } - push_bool(t, "failOnStderr", self.fail_on_stderr); - push_bool(t, "ignoreLASTEXITCODE", self.ignore_last_exit_code); - push_bool(t, "pwsh", self.pwsh); - push_opt(t, "workingDirectory", self.working_directory); - } -} - /// Generate the optional setters shared by both PowerShell builders. Each -/// builder has a `shared: Shared` field and a `display_name: Option`. +/// builder has the shared optional fields and a `display_name: Option`. macro_rules! shared_powershell_setters { () => { /// `errorActionPreference` — non-terminating error behaviour (default `stop`). pub fn error_action_preference(mut self, value: ErrorActionPreference) -> Self { - self.shared.error_action_preference = Some(value); + 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.shared.fail_on_stderr = Some(value); + 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.shared.ignore_last_exit_code = Some(value); + 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.shared.pwsh = Some(value); + self.pwsh = Some(value); self } /// `workingDirectory` — working directory for the script. pub fn working_directory(mut self, value: impl Into) -> Self { - self.shared.working_directory = Some(value.into()); + self.working_directory = Some(value.into()); self } @@ -100,11 +109,32 @@ macro_rules! shared_powershell_setters { } /// Builder for `PowerShell@2` in file-path mode (`targetType: filePath`). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct PowerShellFile { + #[serde(rename = "filePath")] file_path: String, + #[serde(rename = "arguments", default)] arguments: Option, - shared: Shared, + #[serde(rename = "errorActionPreference", default)] + error_action_preference: Option, + #[serde( + rename = "failOnStderr", + default, + deserialize_with = "de_opt_bool_flex" + )] + fail_on_stderr: Option, + #[serde( + rename = "ignoreLASTEXITCODE", + default, + deserialize_with = "de_opt_bool_flex" + )] + ignore_last_exit_code: Option, + #[serde(rename = "pwsh", default, deserialize_with = "de_opt_bool_flex")] + pwsh: Option, + #[serde(rename = "workingDirectory", default)] + working_directory: Option, + #[serde(skip)] display_name: Option, } @@ -122,21 +152,51 @@ impl PowerShellFile { pub fn into_step(self) -> TaskStep { let mut t = TaskStep::new( "PowerShell@2", - self.display_name.unwrap_or_else(|| "PowerShell Script".into()), + self.display_name + .unwrap_or_else(|| "PowerShell Script".into()), ) .with_input("targetType", "filePath") .with_input("filePath", self.file_path); push_opt(&mut t, "arguments", self.arguments); - self.shared.apply(&mut t); + if let Some(v) = self.error_action_preference { + t.inputs.insert( + "errorActionPreference".to_string(), + v.as_ado_str().to_string(), + ); + } + push_bool(&mut t, "failOnStderr", self.fail_on_stderr); + push_bool(&mut t, "ignoreLASTEXITCODE", self.ignore_last_exit_code); + push_bool(&mut t, "pwsh", self.pwsh); + push_opt(&mut t, "workingDirectory", self.working_directory); t } } /// Builder for `PowerShell@2` in inline mode (`targetType: inline`). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct PowerShellInline { + #[serde(rename = "script")] script: String, - shared: Shared, + #[serde(rename = "errorActionPreference", default)] + error_action_preference: Option, + #[serde( + rename = "failOnStderr", + default, + deserialize_with = "de_opt_bool_flex" + )] + fail_on_stderr: Option, + #[serde( + rename = "ignoreLASTEXITCODE", + default, + deserialize_with = "de_opt_bool_flex" + )] + ignore_last_exit_code: Option, + #[serde(rename = "pwsh", default, deserialize_with = "de_opt_bool_flex")] + pwsh: Option, + #[serde(rename = "workingDirectory", default)] + working_directory: Option, + #[serde(skip)] display_name: Option, } @@ -147,11 +207,21 @@ impl PowerShellInline { pub fn into_step(self) -> TaskStep { let mut t = TaskStep::new( "PowerShell@2", - self.display_name.unwrap_or_else(|| "PowerShell Script".into()), + self.display_name + .unwrap_or_else(|| "PowerShell Script".into()), ) .with_input("targetType", "inline") .with_input("script", self.script); - self.shared.apply(&mut t); + if let Some(v) = self.error_action_preference { + t.inputs.insert( + "errorActionPreference".to_string(), + v.as_ado_str().to_string(), + ); + } + push_bool(&mut t, "failOnStderr", self.fail_on_stderr); + push_bool(&mut t, "ignoreLASTEXITCODE", self.ignore_last_exit_code); + push_bool(&mut t, "pwsh", self.pwsh); + push_opt(&mut t, "workingDirectory", self.working_directory); t } } @@ -166,7 +236,11 @@ impl PowerShell { PowerShellFile { file_path: file_path.into(), arguments: None, - shared: Shared::default(), + error_action_preference: None, + fail_on_stderr: None, + ignore_last_exit_code: None, + pwsh: None, + working_directory: None, display_name: None, } } @@ -175,7 +249,11 @@ impl PowerShell { pub fn inline(script: impl Into) -> PowerShellInline { PowerShellInline { script: script.into(), - shared: Shared::default(), + error_action_preference: None, + fail_on_stderr: None, + ignore_last_exit_code: None, + pwsh: None, + working_directory: None, display_name: None, } } @@ -189,8 +267,14 @@ mod tests { 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")); + 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] @@ -201,9 +285,15 @@ mod tests { .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("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")); + assert_eq!( + t.inputs.get("errorActionPreference").map(String::as_str), + Some("continue") + ); } #[test] @@ -211,9 +301,18 @@ mod tests { 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")); + 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") + ); } // Note: there is intentionally no `arguments` setter on `PowerShellInline`, diff --git a/src/compile/ir/tasks/publish_build_artifacts.rs b/src/compile/ir/tasks/publish_build_artifacts.rs index becd3a43..035f8000 100644 --- a/src/compile/ir/tasks/publish_build_artifacts.rs +++ b/src/compile/ir/tasks/publish_build_artifacts.rs @@ -9,8 +9,91 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, de_opt_str_or_int, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; +use serde_yaml::Value; + +/// Validate an authored `PublishBuildArtifacts@1` `inputs:` mapping (advisory +/// front-matter validation, see [`super::parse`]). +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let publish_location = match (map.remove("publishLocation"), map.remove("ArtifactType")) { + (Some(primary), Some(alias)) => { + let primary = primary + .as_str() + .ok_or_else(|| "`publishLocation` must be a string".to_string())?; + let alias = alias + .as_str() + .ok_or_else(|| "`ArtifactType` must be a string".to_string())?; + if primary != alias { + return Err(format!( + "PublishBuildArtifacts@1: conflicting publishLocation `{primary}` and ArtifactType `{alias}`" + )); + } + primary.to_string() + } + (Some(value), None) => value + .as_str() + .map(str::to_string) + .ok_or_else(|| "`publishLocation` must be a string".to_string())?, + (None, Some(value)) => value + .as_str() + .map(str::to_string) + .ok_or_else(|| "`ArtifactType` must be a string".to_string())?, + (None, None) => "Container".to_string(), + }; + + validate_common(&Value::Mapping(map.clone())).map_err(|e| format!("common inputs: {e}"))?; + remove_common_inputs(&mut map); + let rest = Value::Mapping(map); + + let result = match publish_location.as_str() { + "Container" => serde_yaml::from_value::(rest).map(drop), + "FilePath" => serde_yaml::from_value::(rest).map(drop), + other => { + return Err(format!( + "PublishBuildArtifacts@1: unknown publishLocation `{other}`" + )) + } + }; + result.map_err(|e| format!("publishLocation `{publish_location}`: {e}")) +} + +fn validate_common(inputs: &Value) -> Result<(), serde_yaml::Error> { + serde_yaml::from_value::(inputs.clone()).map(drop) +} + +fn remove_common_inputs(map: &mut serde_yaml::Mapping) { + for key in [ + "PathtoPublish", + "ArtifactName", + "MaxArtifactSize", + "StoreAsTar", + ] { + map.remove(key); + } +} + +#[derive(Debug, Deserialize)] +struct PublishBuildArtifactsCommonInputs { + #[serde(rename = "PathtoPublish")] + _path_to_publish: String, + #[serde(rename = "ArtifactName")] + _artifact_name: String, + #[serde(rename = "MaxArtifactSize", default)] + _max_artifact_size: Option, + #[serde(rename = "StoreAsTar", default, deserialize_with = "de_opt_bool_flex")] + _store_as_tar: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct PublishBuildArtifactsContainer {} /// `PublishBuildArtifacts@1` `publishLocation` selector, carrying /// per-location optional inputs. @@ -23,15 +106,24 @@ pub enum PublishLocation { } /// Per-location optionals for `publishLocation: FilePath`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct FilePathLocation { /// `TargetPath` — UNC file share path. Required for the `FilePath` location. + #[serde(rename = "TargetPath")] target_path: String, /// `Parallel` — copy files in parallel using multiple threads. + #[serde(rename = "Parallel", default, deserialize_with = "de_opt_bool_flex")] parallel: Option, /// `ParallelCount` — number of threads for parallel copy (1–128, default 8). + #[serde( + rename = "ParallelCount", + default, + deserialize_with = "de_opt_str_or_int" + )] parallel_count: Option, /// `FileCopyOptions` — additional robocopy arguments. + #[serde(rename = "FileCopyOptions", default)] file_copy_options: Option, } @@ -104,10 +196,7 @@ impl PublishBuildArtifacts { } /// `publishLocation: Container` — publish to Azure Pipelines artifact storage. - pub fn container( - path_to_publish: impl Into, - artifact_name: impl Into, - ) -> Self { + pub fn container(path_to_publish: impl Into, artifact_name: impl Into) -> Self { Self::new(path_to_publish, artifact_name, PublishLocation::Container) } @@ -117,7 +206,11 @@ impl PublishBuildArtifacts { artifact_name: impl Into, location: FilePathLocation, ) -> Self { - Self::new(path_to_publish, artifact_name, PublishLocation::FilePath(location)) + Self::new( + path_to_publish, + artifact_name, + PublishLocation::FilePath(location), + ) } /// `MaxArtifactSize` — maximum artifact size in bytes; `"0"` means no limit. @@ -142,7 +235,8 @@ impl PublishBuildArtifacts { pub fn into_step(self) -> TaskStep { let mut t = TaskStep::new( "PublishBuildArtifacts@1", - self.display_name.unwrap_or_else(|| "Publish Artifact".into()), + self.display_name + .unwrap_or_else(|| "Publish Artifact".into()), ) .with_input("PathtoPublish", self.path_to_publish) .with_input("ArtifactName", self.artifact_name); @@ -171,21 +265,55 @@ impl PublishBuildArtifacts { mod tests { use super::*; + /// Drift guard for the two-pass validator: `remove_common_inputs` must stay + /// in sync with `PublishBuildArtifactsCommonInputs`. A step that sets every + /// common input must validate for both locations — a common field added to + /// the struct but forgotten in the removal list would survive into the + /// variant dispatch and trip `deny_unknown_fields`, failing this test. + #[test] + fn all_common_inputs_validate_across_both_locations() { + let common = concat!( + "\nPathtoPublish: $(Build.ArtifactStagingDirectory)", + "\nArtifactName: drop", + "\nMaxArtifactSize: '0'", + "\nStoreAsTar: true", + ); + + let container = + serde_yaml::from_str(&format!("publishLocation: Container{common}")).unwrap(); + assert!( + validate_inputs(container).is_ok(), + "common inputs must validate for Container" + ); + + let file_path = serde_yaml::from_str(&format!( + "publishLocation: FilePath\nTargetPath: \\\\share\\drops{common}" + )) + .unwrap(); + assert!( + validate_inputs(file_path).is_ok(), + "common inputs must validate for FilePath" + ); + } + #[test] fn container_defaults() { - let t = PublishBuildArtifacts::container( - "$(Build.ArtifactStagingDirectory)", - "drop", - ) - .into_step(); + let t = PublishBuildArtifacts::container("$(Build.ArtifactStagingDirectory)", "drop") + .into_step(); assert_eq!(t.task, "PublishBuildArtifacts@1"); assert_eq!(t.display_name, "Publish Artifact"); assert_eq!( t.inputs.get("PathtoPublish").map(String::as_str), Some("$(Build.ArtifactStagingDirectory)") ); - assert_eq!(t.inputs.get("ArtifactName").map(String::as_str), Some("drop")); - assert_eq!(t.inputs.get("publishLocation").map(String::as_str), Some("Container")); + assert_eq!( + t.inputs.get("ArtifactName").map(String::as_str), + Some("drop") + ); + assert_eq!( + t.inputs.get("publishLocation").map(String::as_str), + Some("Container") + ); assert!(t.inputs.get("TargetPath").is_none()); } @@ -215,14 +343,23 @@ mod tests { .file_copy_options("/MIR"), ) .into_step(); - assert_eq!(t.inputs.get("publishLocation").map(String::as_str), Some("FilePath")); + assert_eq!( + t.inputs.get("publishLocation").map(String::as_str), + Some("FilePath") + ); assert_eq!( t.inputs.get("TargetPath").map(String::as_str), Some(r"\\myshare\builds") ); assert_eq!(t.inputs.get("Parallel").map(String::as_str), Some("true")); - assert_eq!(t.inputs.get("ParallelCount").map(String::as_str), Some("16")); - assert_eq!(t.inputs.get("FileCopyOptions").map(String::as_str), Some("/MIR")); + assert_eq!( + t.inputs.get("ParallelCount").map(String::as_str), + Some("16") + ); + assert_eq!( + t.inputs.get("FileCopyOptions").map(String::as_str), + Some("/MIR") + ); } #[test] @@ -233,7 +370,10 @@ mod tests { FilePathLocation::new(r"\\myshare\builds"), ) .into_step(); - assert_eq!(t.inputs.get("publishLocation").map(String::as_str), Some("FilePath")); + assert_eq!( + t.inputs.get("publishLocation").map(String::as_str), + Some("FilePath") + ); assert!(t.inputs.get("Parallel").is_none()); assert!(t.inputs.get("ParallelCount").is_none()); assert!(t.inputs.get("FileCopyOptions").is_none()); diff --git a/src/compile/ir/tasks/publish_code_coverage_results.rs b/src/compile/ir/tasks/publish_code_coverage_results.rs index 07e272a5..6cb6487e 100644 --- a/src/compile/ir/tasks/publish_code_coverage_results.rs +++ b/src/compile/ir/tasks/publish_code_coverage_results.rs @@ -1,7 +1,8 @@ //! Typed builder for `PublishCodeCoverageResults@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 `PublishCodeCoverageResults@2`. /// @@ -10,15 +11,24 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct PublishCodeCoverageResults { /// `summaryFileLocation` — path (minimatch patterns accepted) to the coverage summary file(s). + #[serde(rename = "summaryFileLocation")] summary_file_location: String, /// `pathToSources` — path to source files, required when coverage XML lacks absolute paths /// (e.g. JaCoCo for Java). + #[serde(rename = "pathToSources", default)] path_to_sources: Option, /// `failIfCoverageEmpty` — fail the step when no coverage data was produced. + #[serde( + rename = "failIfCoverageEmpty", + default, + deserialize_with = "de_opt_bool_flex" + )] fail_if_coverage_empty: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/publish_pipeline_artifact.rs b/src/compile/ir/tasks/publish_pipeline_artifact.rs index c2066977..6d27e406 100644 --- a/src/compile/ir/tasks/publish_pipeline_artifact.rs +++ b/src/compile/ir/tasks/publish_pipeline_artifact.rs @@ -1,12 +1,15 @@ //! Typed builder for `PublishPipelineArtifact@1`. -use super::common::bool_input; +use super::common::{bool_input, de_opt_bool_flex}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Storage location for [`PublishPipelineArtifact`] (`publishLocation` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum PublishLocation { + #[serde(rename = "pipeline")] Pipeline, + #[serde(rename = "filepath")] Filepath, } @@ -24,15 +27,24 @@ impl PublishLocation { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct PublishPipelineArtifact { + #[serde(rename = "targetPath")] target_path: String, + #[serde(rename = "artifact", default)] artifact: Option, + #[serde(rename = "publishLocation", default)] publish_location: Option, + #[serde(rename = "fileSharePath", default)] file_share_path: Option, + #[serde(rename = "parallel", default, deserialize_with = "de_opt_bool_flex")] parallel: Option, + #[serde(rename = "parallelCount", default)] parallel_count: Option, + #[serde(rename = "properties", default)] properties: Option, + #[serde(skip)] display_name: Option, } @@ -97,7 +109,8 @@ impl PublishPipelineArtifact { pub fn into_step(self) -> TaskStep { let mut t = TaskStep::new( "PublishPipelineArtifact@1", - self.display_name.unwrap_or_else(|| "Publish Pipeline Artifact".into()), + self.display_name + .unwrap_or_else(|| "Publish Pipeline Artifact".into()), ) .with_input("targetPath", self.target_path); if let Some(v) = self.artifact { @@ -143,8 +156,14 @@ mod tests { .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("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 index 3e5c3d09..6f6edd80 100644 --- a/src/compile/ir/tasks/publish_test_results.rs +++ b/src/compile/ir/tasks/publish_test_results.rs @@ -1,15 +1,21 @@ //! Typed builder for `PublishTestResults@2`. -use super::common::bool_input; +use super::common::{bool_input, de_opt_bool_flex}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Test result format for [`PublishTestResults`] (`testResultsFormat` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum TestResultsFormat { + #[serde(rename = "JUnit")] JUnit, + #[serde(rename = "NUnit")] NUnit, + #[serde(rename = "VSTest")] VSTest, + #[serde(rename = "XUnit")] XUnit, + #[serde(rename = "CTest")] CTest, } @@ -30,15 +36,36 @@ impl TestResultsFormat { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct PublishTestResults { + #[serde(rename = "testResultsFormat")] test_results_format: TestResultsFormat, + #[serde(rename = "testResultsFiles")] test_results_files: String, + #[serde(rename = "testRunTitle", default)] test_run_title: Option, + #[serde(rename = "searchFolder", default)] search_folder: Option, + #[serde( + rename = "mergeTestResults", + default, + deserialize_with = "de_opt_bool_flex" + )] merge_test_results: Option, + #[serde( + rename = "failTaskOnFailedTests", + default, + deserialize_with = "de_opt_bool_flex" + )] fail_task_on_failed_tests: Option, + #[serde( + rename = "publishRunAttachments", + default, + deserialize_with = "de_opt_bool_flex" + )] publish_run_attachments: Option, + #[serde(skip)] display_name: Option, } @@ -100,7 +127,8 @@ impl PublishTestResults { pub fn into_step(self) -> TaskStep { let mut t = TaskStep::new( "PublishTestResults@2", - self.display_name.unwrap_or_else(|| "Publish Test Results".into()), + 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); @@ -131,8 +159,14 @@ mod tests { 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")); + 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] @@ -142,8 +176,14 @@ mod 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("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)") diff --git a/src/compile/ir/tasks/python_script.rs b/src/compile/ir/tasks/python_script.rs index ec317001..b8577341 100644 --- a/src/compile/ir/tasks/python_script.rs +++ b/src/compile/ir/tasks/python_script.rs @@ -10,23 +10,36 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; +use serde_yaml::Value; -/// Optionals shared by both `PythonScript@0` builders. -#[derive(Debug, Clone, Default)] -struct Shared { - python_interpreter: Option, - working_directory: Option, - fail_on_stderr: Option, -} +/// Validate an authored `PythonScript@0` `inputs:` mapping (advisory +/// front-matter validation, see [`super::parse`]). +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let script_source = match map.remove("scriptSource") { + Some(v) => Some( + v.as_str() + .ok_or_else(|| "PythonScript@0: `scriptSource` must be a string".to_string())? + .to_string(), + ), + None => None, + }; + let mode = script_source.as_deref().unwrap_or("filePath"); + let rest = Value::Mapping(map); -impl Shared { - fn apply(self, t: &mut TaskStep) { - push_opt(t, "pythonInterpreter", self.python_interpreter); - push_opt(t, "workingDirectory", self.working_directory); - push_bool(t, "failOnStderr", self.fail_on_stderr); - } + let result = match mode { + "filePath" => serde_yaml::from_value::(rest).map(drop), + "inline" => serde_yaml::from_value::(rest).map(drop), + other => return Err(format!("PythonScript@0: unknown scriptSource `{other}`")), + }; + result.map_err(|e| format!("scriptSource `{mode}`: {e}")) } /// Generate the optional setters shared by both PythonScript builders. @@ -36,20 +49,20 @@ macro_rules! shared_python_script_setters { /// uses the interpreter from PATH (or the one configured by /// `UsePythonVersion@0` / `UsePythonVersion@0`). pub fn python_interpreter(mut self, value: impl Into) -> Self { - self.shared.python_interpreter = Some(value.into()); + self.python_interpreter = Some(value.into()); self } /// `workingDirectory` — working directory for the script. Defaults to /// the repository root (`$(System.DefaultWorkingDirectory)`). pub fn working_directory(mut self, value: impl Into) -> Self { - self.shared.working_directory = Some(value.into()); + 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.shared.fail_on_stderr = Some(value); + self.fail_on_stderr = Some(value); self } @@ -62,11 +75,24 @@ macro_rules! shared_python_script_setters { } /// Builder for `PythonScript@0` in file-path mode (`scriptSource: filePath`). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct PythonScriptFile { + #[serde(rename = "scriptPath")] script_path: String, + #[serde(rename = "arguments", default)] arguments: Option, - shared: Shared, + #[serde(rename = "pythonInterpreter", default)] + python_interpreter: Option, + #[serde(rename = "workingDirectory", default)] + working_directory: Option, + #[serde( + rename = "failOnStderr", + default, + deserialize_with = "de_opt_bool_flex" + )] + fail_on_stderr: Option, + #[serde(skip)] display_name: Option, } @@ -90,16 +116,30 @@ impl PythonScriptFile { .with_input("scriptSource", "filePath") .with_input("scriptPath", self.script_path); push_opt(&mut t, "arguments", self.arguments); - self.shared.apply(&mut t); + push_opt(&mut t, "pythonInterpreter", self.python_interpreter); + push_opt(&mut t, "workingDirectory", self.working_directory); + push_bool(&mut t, "failOnStderr", self.fail_on_stderr); t } } /// Builder for `PythonScript@0` in inline mode (`scriptSource: inline`). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct PythonScriptInline { + #[serde(rename = "script")] script: String, - shared: Shared, + #[serde(rename = "pythonInterpreter", default)] + python_interpreter: Option, + #[serde(rename = "workingDirectory", default)] + working_directory: Option, + #[serde( + rename = "failOnStderr", + default, + deserialize_with = "de_opt_bool_flex" + )] + fail_on_stderr: Option, + #[serde(skip)] display_name: Option, } @@ -115,7 +155,9 @@ impl PythonScriptInline { ) .with_input("scriptSource", "inline") .with_input("script", self.script); - self.shared.apply(&mut t); + push_opt(&mut t, "pythonInterpreter", self.python_interpreter); + push_opt(&mut t, "workingDirectory", self.working_directory); + push_bool(&mut t, "failOnStderr", self.fail_on_stderr); t } } @@ -131,7 +173,9 @@ impl PythonScript { PythonScriptFile { script_path: script_path.into(), arguments: None, - shared: Shared::default(), + python_interpreter: None, + working_directory: None, + fail_on_stderr: None, display_name: None, } } @@ -140,7 +184,9 @@ impl PythonScript { pub fn inline(script: impl Into) -> PythonScriptInline { PythonScriptInline { script: script.into(), - shared: Shared::default(), + python_interpreter: None, + working_directory: None, + fail_on_stderr: None, display_name: None, } } diff --git a/src/compile/ir/tasks/twine_authenticate.rs b/src/compile/ir/tasks/twine_authenticate.rs index 3bfa91b1..336c8c64 100644 --- a/src/compile/ir/tasks/twine_authenticate.rs +++ b/src/compile/ir/tasks/twine_authenticate.rs @@ -1,6 +1,7 @@ //! Typed builder for `TwineAuthenticate@1`. use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Builder for a [`TaskStep`] invoking `TwineAuthenticate@1`. /// @@ -19,10 +20,14 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TwineAuthenticate { + #[serde(rename = "artifactFeed", default)] artifact_feed: Option, + #[serde(rename = "pythonUploadServiceConnection", default)] python_upload_service_connection: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/universal_packages.rs b/src/compile/ir/tasks/universal_packages.rs index 679d7d39..980ce352 100644 --- a/src/compile/ir/tasks/universal_packages.rs +++ b/src/compile/ir/tasks/universal_packages.rs @@ -12,17 +12,77 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; +use serde_yaml::{Mapping, Value}; + +/// Validate an authored `UniversalPackages@1` `inputs:` mapping (advisory +/// front-matter validation, see [`super::parse`]). +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let command = map + .remove("command") + .and_then(|v| v.as_str().map(str::to_string)) + // ADO defaults `command` to `download` when omitted — treat a missing + // command as the default variant rather than an error. + .unwrap_or_else(|| "download".to_string()); + match command.as_str() { + "download" | "publish" => {} + other => return Err(format!("UniversalPackages@1: unknown command `{other}`")), + } + + let mut common = Mapping::new(); + for key in [ + "feed", + "packageName", + "workloadIdentityServiceConnection", + "organization", + ] { + if let Some(value) = map.remove(key) { + common.insert(Value::String(key.to_owned()), value); + } + } + serde_yaml::from_value::(Value::Mapping(common)) + .map_err(|e| format!("command `{command}`: {e}"))?; + + let rest = Value::Mapping(map); + let result = match command.as_str() { + "download" => serde_yaml::from_value::(rest).map(drop), + "publish" => serde_yaml::from_value::(rest).map(drop), + _ => unreachable!("validated command discriminator above"), + }; + result.map_err(|e| format!("command `{command}`: {e}")) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct UniversalPackagesCommonInputs { + #[serde(rename = "feed")] + _feed: String, + #[serde(rename = "packageName")] + _package_name: String, + #[serde(rename = "workloadIdentityServiceConnection", default)] + _workload_identity_service_connection: Option, + #[serde(rename = "organization", default)] + _organization: Option, +} /// `versionIncrement` value for the `publish` command. /// /// Automatically increments the specified component of the package version. /// Cannot be used together with an explicit `packageVersion`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum VersionIncrement { + #[serde(rename = "major")] Major, + #[serde(rename = "minor")] Minor, + #[serde(rename = "patch")] Patch, } @@ -38,10 +98,14 @@ impl VersionIncrement { } /// Per-command optionals for `UniversalPackages@1` `command: download`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct UniversalPackagesDownload { + #[serde(rename = "packageVersion", default)] package_version: Option, + #[serde(rename = "directory", default)] directory: Option, + #[serde(rename = "extract", default, deserialize_with = "de_opt_bool_flex")] extract: Option, } @@ -71,11 +135,16 @@ impl UniversalPackagesDownload { } /// Per-command optionals for `UniversalPackages@1` `command: publish`. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct UniversalPackagesPublish { + #[serde(rename = "packageVersion", default)] package_version: Option, + #[serde(rename = "versionIncrement", default)] version_increment: Option, + #[serde(rename = "directory", default)] directory: Option, + #[serde(rename = "packageDescription", default)] package_description: Option, } @@ -212,8 +281,7 @@ impl UniversalPackages { }; let mut t = TaskStep::new( "UniversalPackages@1", - self.display_name - .unwrap_or_else(|| default_display.into()), + self.display_name.unwrap_or_else(|| default_display.into()), ) .with_input("command", command_str) .with_input("feed", self.feed) @@ -251,15 +319,15 @@ mod tests { #[test] fn download_minimal() { - let t = UniversalPackages::download( - "my-feed", - "my-package", - UniversalPackagesDownload::new(), - ) - .into_step(); + let t = + UniversalPackages::download("my-feed", "my-package", UniversalPackagesDownload::new()) + .into_step(); assert_eq!(t.task, "UniversalPackages@1"); assert_eq!(t.display_name, "Download Universal Package"); - assert_eq!(t.inputs.get("command").map(String::as_str), Some("download")); + assert_eq!( + t.inputs.get("command").map(String::as_str), + Some("download") + ); assert_eq!(t.inputs.get("feed").map(String::as_str), Some("my-feed")); assert_eq!( t.inputs.get("packageName").map(String::as_str), @@ -372,9 +440,6 @@ mod tests { UniversalPackagesDownload::new().extract(true), ) .into_step(); - assert_eq!( - t.inputs.get("extract").map(String::as_str), - Some("true") - ); + assert_eq!(t.inputs.get("extract").map(String::as_str), Some("true")); } } diff --git a/src/compile/ir/tasks/use_dotnet.rs b/src/compile/ir/tasks/use_dotnet.rs index 39a90073..7aa0e2ec 100644 --- a/src/compile/ir/tasks/use_dotnet.rs +++ b/src/compile/ir/tasks/use_dotnet.rs @@ -3,18 +3,21 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// `packageType` input for [`UseDotNet`]: whether to install the SDK or only /// the runtime. /// /// ADO default: [`PackageType::Sdk`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum PackageType { /// Install the full .NET SDK (`"sdk"`). This is the ADO task default. + #[serde(rename = "sdk")] Sdk, /// Install only the .NET runtime (`"runtime"`). + #[serde(rename = "runtime")] Runtime, } @@ -44,15 +47,36 @@ impl PackageType { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct UseDotNet { + #[serde(rename = "version", default)] version: Option, + #[serde(rename = "packageType", default)] package_type: Option, + #[serde( + rename = "useGlobalJson", + default, + deserialize_with = "de_opt_bool_flex" + )] use_global_json: Option, + #[serde(rename = "workingDirectory", default)] working_directory: Option, + #[serde(rename = "installationPath", default)] installation_path: Option, + #[serde( + rename = "performMultiLevelLookup", + default, + deserialize_with = "de_opt_bool_flex" + )] perform_multi_level_lookup: Option, + #[serde( + rename = "failOnStandardError", + default, + deserialize_with = "de_opt_bool_flex" + )] fail_on_standard_error: Option, + #[serde(skip)] display_name: Option, } @@ -168,10 +192,7 @@ impl UseDotNet { format!("Install .NET {package_label}") }; - let mut t = TaskStep::new( - "UseDotNet@2", - self.display_name.unwrap_or(default_name), - ); + let mut t = TaskStep::new("UseDotNet@2", self.display_name.unwrap_or(default_name)); push_opt( &mut t, "packageType", @@ -241,10 +262,7 @@ mod tests { let t = UseDotNet::with_version("6.0.x") .package_type(PackageType::Sdk) .into_step(); - assert_eq!( - t.inputs.get("packageType").map(String::as_str), - Some("sdk") - ); + assert_eq!(t.inputs.get("packageType").map(String::as_str), Some("sdk")); } #[test] @@ -287,10 +305,7 @@ mod tests { let t = UseDotNet::with_version("8.0.x") .with_display_name("Install .NET SDK (from global.json)") .into_step(); - assert_eq!( - t.display_name, - "Install .NET SDK (from global.json)" - ); + assert_eq!(t.display_name, "Install .NET SDK (from global.json)"); assert_eq!(t.inputs.get("version").map(String::as_str), Some("8.0.x")); } @@ -310,9 +325,7 @@ mod tests { #[test] fn bool_input_false_emits_false_string() { - let t = UseDotNet::new() - .use_global_json(false) - .into_step(); + let t = UseDotNet::new().use_global_json(false).into_step(); assert_eq!( t.inputs.get("useGlobalJson").map(String::as_str), Some("false") diff --git a/src/compile/ir/tasks/use_node.rs b/src/compile/ir/tasks/use_node.rs index 4e29d325..eca8c55e 100644 --- a/src/compile/ir/tasks/use_node.rs +++ b/src/compile/ir/tasks/use_node.rs @@ -3,8 +3,9 @@ //! ADO task reference: //! -use super::common::bool_input; +use super::common::{bool_input, de_opt_bool_flex, de_opt_str_or_int}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Builder for a [`TaskStep`] invoking `UseNode@1`. /// @@ -13,13 +14,28 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct UseNode { + #[serde(rename = "version")] version: String, + #[serde(rename = "checkLatest", default, deserialize_with = "de_opt_bool_flex")] check_latest: Option, + #[serde(rename = "force32bit", default, deserialize_with = "de_opt_bool_flex")] force32bit: Option, + #[serde( + rename = "retryCountOnDownloadFails", + default, + deserialize_with = "de_opt_str_or_int" + )] retry_count_on_download_fails: Option, + #[serde( + rename = "delayBetweenRetries", + default, + deserialize_with = "de_opt_str_or_int" + )] delay_between_retries: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/use_python_version.rs b/src/compile/ir/tasks/use_python_version.rs index 305a11d6..99f92ba0 100644 --- a/src/compile/ir/tasks/use_python_version.rs +++ b/src/compile/ir/tasks/use_python_version.rs @@ -3,17 +3,21 @@ //! ADO task reference: //! -use super::common::bool_input; +use super::common::{bool_input, de_opt_bool_flex}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Target architecture for the Python interpreter. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum Architecture { /// 32-bit (x86) Python interpreter. + #[serde(rename = "x86")] X86, /// 64-bit (x64) Python interpreter (default). + #[serde(rename = "x64")] X64, /// ARM 64-bit Python interpreter. + #[serde(rename = "arm64")] Arm64, } @@ -36,14 +40,30 @@ impl Architecture { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct UsePythonVersion { + #[serde(rename = "versionSpec")] version_spec: String, + #[serde(rename = "architecture", default)] architecture: Option, + #[serde(rename = "addToPath", default, deserialize_with = "de_opt_bool_flex")] add_to_path: Option, + #[serde( + rename = "disableDownloadFromRegistry", + default, + deserialize_with = "de_opt_bool_flex" + )] disable_download_from_registry: Option, + #[serde( + rename = "allowUnstable", + default, + deserialize_with = "de_opt_bool_flex" + )] allow_unstable: Option, + #[serde(rename = "githubToken", default)] github_token: Option, + #[serde(skip)] display_name: Option, } @@ -142,10 +162,7 @@ mod tests { let t = UsePythonVersion::new("3.x").into_step(); assert_eq!(t.task, "UsePythonVersion@0"); assert_eq!(t.display_name, "Install Python 3.x"); - assert_eq!( - t.inputs.get("versionSpec").map(String::as_str), - Some("3.x") - ); + assert_eq!(t.inputs.get("versionSpec").map(String::as_str), Some("3.x")); } #[test] @@ -176,10 +193,7 @@ mod tests { #[test] fn add_to_path_false() { let t = UsePythonVersion::new("3.x").add_to_path(false).into_step(); - assert_eq!( - t.inputs.get("addToPath").map(String::as_str), - Some("false") - ); + assert_eq!(t.inputs.get("addToPath").map(String::as_str), Some("false")); } #[test] @@ -241,10 +255,7 @@ mod tests { t.inputs.get("architecture").map(String::as_str), Some("x64") ); - assert_eq!( - t.inputs.get("addToPath").map(String::as_str), - Some("true") - ); + assert_eq!(t.inputs.get("addToPath").map(String::as_str), Some("true")); assert_eq!( t.inputs .get("disableDownloadFromRegistry") diff --git a/src/compile/ir/tasks/use_ruby_version.rs b/src/compile/ir/tasks/use_ruby_version.rs index 49dee096..679d73b5 100644 --- a/src/compile/ir/tasks/use_ruby_version.rs +++ b/src/compile/ir/tasks/use_ruby_version.rs @@ -3,8 +3,9 @@ //! ADO task reference: //! -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 `UseRubyVersion@0`. /// @@ -14,10 +15,14 @@ use crate::compile::ir::step::TaskStep; /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct UseRubyVersion { + #[serde(rename = "versionSpec")] version_spec: String, + #[serde(rename = "addToPath", default, deserialize_with = "de_opt_bool_flex")] add_to_path: Option, + #[serde(skip)] display_name: Option, } diff --git a/src/compile/ir/tasks/vs_build.rs b/src/compile/ir/tasks/vs_build.rs index 08c4fe5d..f3a17d67 100644 --- a/src/compile/ir/tasks/vs_build.rs +++ b/src/compile/ir/tasks/vs_build.rs @@ -3,25 +3,32 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; /// Visual Studio version constraint for [`VsBuild`]. /// /// Maps to the `vsVersion` input of `VSBuild@1`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum VsVersion { /// Use the latest installed Visual Studio version. + #[serde(rename = "latest")] Latest, /// Visual Studio 2022 (18.0). + #[serde(rename = "18.0")] Vs2022, /// Visual Studio 2022 (17.0). + #[serde(rename = "17.0")] Vs2019, /// Visual Studio 2019 (16.0). + #[serde(rename = "16.0")] Vs2017, /// Visual Studio 2017 (15.0). + #[serde(rename = "15.0")] Vs2015, /// Visual Studio 2015 (14.0). + #[serde(rename = "14.0")] Vs2013, } @@ -42,13 +49,16 @@ impl VsVersion { /// MSBuild architecture for [`VsBuild`]. /// /// Maps to the `msbuildArchitecture` input of `VSBuild@1`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum MsBuildArchitecture { /// 32-bit MSBuild. + #[serde(rename = "x86")] X86, /// 64-bit MSBuild. + #[serde(rename = "x64")] X64, /// ARM64 MSBuild. + #[serde(rename = "arm64")] Arm64, } @@ -66,17 +76,22 @@ impl MsBuildArchitecture { /// MSBuild log verbosity for [`VsBuild`]. /// /// Maps to the `logFileVerbosity` input of `VSBuild@1`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum LogFileVerbosity { /// Minimal output. + #[serde(rename = "quiet")] Quiet, /// Minimal output plus errors and warnings. + #[serde(rename = "minimal")] Minimal, /// Standard output (default). + #[serde(rename = "normal")] Normal, /// Detailed output. + #[serde(rename = "detailed")] Detailed, /// All available output. + #[serde(rename = "diagnostic")] Diagnostic, } @@ -101,22 +116,58 @@ impl LogFileVerbosity { /// /// ADO task reference: /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct VsBuild { + #[serde(rename = "solution")] solution: String, + #[serde(rename = "vsVersion", default)] vs_version: Option, + #[serde(rename = "msbuildArgs", default)] msbuild_args: Option, + #[serde(rename = "platform", default)] platform: Option, + #[serde(rename = "configuration", default)] configuration: Option, + #[serde(rename = "clean", default, deserialize_with = "de_opt_bool_flex")] clean: Option, + #[serde( + rename = "maximumCpuCount", + default, + deserialize_with = "de_opt_bool_flex" + )] maximum_cpu_count: Option, + #[serde( + rename = "restoreNugetPackages", + default, + deserialize_with = "de_opt_bool_flex" + )] restore_nuget_packages: Option, + #[serde(rename = "msbuildArchitecture", default)] msbuild_architecture: Option, + #[serde( + rename = "logProjectEvents", + default, + deserialize_with = "de_opt_bool_flex" + )] log_project_events: Option, + #[serde( + rename = "createLogFile", + default, + deserialize_with = "de_opt_bool_flex" + )] create_log_file: Option, + #[serde(rename = "logFileVerbosity", default)] log_file_verbosity: Option, + #[serde( + rename = "enableDefaultLogger", + default, + deserialize_with = "de_opt_bool_flex" + )] enable_default_logger: Option, + #[serde(rename = "customVersion", default)] custom_version: Option, + #[serde(skip)] display_name: Option, } @@ -268,7 +319,10 @@ mod tests { let t = VsBuild::new("**\\*.sln").into_step(); assert_eq!(t.task, "VSBuild@1"); assert_eq!(t.display_name, "Build solution"); - assert_eq!(t.inputs.get("solution").map(String::as_str), Some("**\\*.sln")); + assert_eq!( + t.inputs.get("solution").map(String::as_str), + Some("**\\*.sln") + ); // No optional inputs should be emitted. assert!(t.inputs.get("vsVersion").is_none()); assert!(t.inputs.get("msbuildArgs").is_none()); @@ -285,23 +339,36 @@ mod tests { .clean(true) .maximum_cpu_count(true) .into_step(); - assert_eq!(t.inputs.get("configuration").map(String::as_str), Some("Release")); + assert_eq!( + t.inputs.get("configuration").map(String::as_str), + Some("Release") + ); assert_eq!(t.inputs.get("platform").map(String::as_str), Some("x64")); assert_eq!( t.inputs.get("msbuildArgs").map(String::as_str), Some("/p:DeployOnBuild=true") ); assert_eq!(t.inputs.get("clean").map(String::as_str), Some("true")); - assert_eq!(t.inputs.get("maximumCpuCount").map(String::as_str), Some("true")); + assert_eq!( + t.inputs.get("maximumCpuCount").map(String::as_str), + Some("true") + ); } #[test] fn vs_version_enum_emits_correct_token() { - let t = VsBuild::new("**\\*.sln").vs_version(VsVersion::Vs2019).into_step(); + let t = VsBuild::new("**\\*.sln") + .vs_version(VsVersion::Vs2019) + .into_step(); assert_eq!(t.inputs.get("vsVersion").map(String::as_str), Some("17.0")); - let t = VsBuild::new("**\\*.sln").vs_version(VsVersion::Latest).into_step(); - assert_eq!(t.inputs.get("vsVersion").map(String::as_str), Some("latest")); + let t = VsBuild::new("**\\*.sln") + .vs_version(VsVersion::Latest) + .into_step(); + assert_eq!( + t.inputs.get("vsVersion").map(String::as_str), + Some("latest") + ); } #[test] @@ -309,7 +376,10 @@ mod tests { let t = VsBuild::new("**\\*.sln") .msbuild_architecture(MsBuildArchitecture::X64) .into_step(); - assert_eq!(t.inputs.get("msbuildArchitecture").map(String::as_str), Some("x64")); + assert_eq!( + t.inputs.get("msbuildArchitecture").map(String::as_str), + Some("x64") + ); } #[test] @@ -318,8 +388,14 @@ mod tests { .create_log_file(true) .log_file_verbosity(LogFileVerbosity::Detailed) .into_step(); - assert_eq!(t.inputs.get("createLogFile").map(String::as_str), Some("true")); - assert_eq!(t.inputs.get("logFileVerbosity").map(String::as_str), Some("detailed")); + assert_eq!( + t.inputs.get("createLogFile").map(String::as_str), + Some("true") + ); + assert_eq!( + t.inputs.get("logFileVerbosity").map(String::as_str), + Some("detailed") + ); } #[test] diff --git a/src/compile/ir/tasks/vstest.rs b/src/compile/ir/tasks/vstest.rs index 83bb3b3a..5b263440 100644 --- a/src/compile/ir/tasks/vstest.rs +++ b/src/compile/ir/tasks/vstest.rs @@ -16,23 +16,142 @@ //! ADO task reference: //! -use super::common::{push_bool, push_opt}; +use super::common::{de_opt_bool_flex, push_bool, push_opt}; use crate::compile::ir::step::TaskStep; +use serde::Deserialize; +use serde_yaml::Value; + +/// Validate an authored `VSTest@2` `inputs:` mapping (advisory front-matter +/// validation, see [`super::parse`]). +pub(crate) fn validate_inputs(inputs: Value) -> Result<(), String> { + let mut map = match inputs { + Value::Mapping(m) => m, + Value::Null => Default::default(), + other => return Err(format!("`inputs` must be a mapping, got {other:?}")), + }; + let selector = match map.remove("testSelector") { + Some(value) => value + .as_str() + .map(str::to_string) + .ok_or_else(|| "`testSelector` must be a string".to_string())?, + None => "testAssemblies".to_string(), + }; + + validate_common(&Value::Mapping(map.clone())).map_err(|e| format!("common inputs: {e}"))?; + remove_common_inputs(&mut map); + let rest = Value::Mapping(map); + + let result = match selector.as_str() { + "testAssemblies" => serde_yaml::from_value::(rest).map(drop), + "testPlan" => serde_yaml::from_value::(rest).map(drop), + "testRun" => serde_yaml::from_value::(rest).map(drop), + other => return Err(format!("VSTest@2: unknown testSelector `{other}`")), + }; + result.map_err(|e| format!("testSelector `{selector}`: {e}")) +} + +/// Type-checks the inputs shared by every `testSelector` variant. +/// +/// Note the backing [`VsTestCommonInputs`] deliberately does **not** use +/// `deny_unknown_fields`: this runs on the full input map, which still contains +/// the *selector-specific* inputs (e.g. `testAssemblyVer2`, `testPlan`), so +/// denying unknowns here would reject those valid fields. Typos are still +/// caught — an unknown/misspelled input survives `remove_common_inputs` and is +/// reported by the selector variant's own `deny_unknown_fields` (which names the +/// offending field, attributed to the selector rather than to "common inputs"). +fn validate_common(inputs: &Value) -> Result<(), serde_yaml::Error> { + serde_yaml::from_value::(inputs.clone()).map(drop) +} + +fn remove_common_inputs(map: &mut serde_yaml::Mapping) { + for key in [ + "searchFolder", + "resultsFolder", + "runSettingsFile", + "overrideTestrunParameters", + "pathtoCustomTestAdapters", + "runInParallel", + "runTestsInIsolation", + "codeCoverageEnabled", + "testRunTitle", + "platform", + "configuration", + "publishRunAttachments", + "otherConsoleOptions", + "vsTestVersion", + ] { + map.remove(key); + } +} + +#[derive(Debug, Deserialize)] +struct VsTestCommonInputs { + #[serde(rename = "searchFolder", default)] + _search_folder: Option, + #[serde(rename = "resultsFolder", default)] + _results_folder: Option, + #[serde(rename = "runSettingsFile", default)] + _run_settings_file: Option, + #[serde(rename = "overrideTestrunParameters", default)] + _override_testrun_parameters: Option, + #[serde(rename = "pathtoCustomTestAdapters", default)] + _path_to_custom_test_adapters: Option, + #[serde( + rename = "runInParallel", + default, + deserialize_with = "de_opt_bool_flex" + )] + _run_in_parallel: Option, + #[serde( + rename = "runTestsInIsolation", + default, + deserialize_with = "de_opt_bool_flex" + )] + _run_tests_in_isolation: Option, + #[serde( + rename = "codeCoverageEnabled", + default, + deserialize_with = "de_opt_bool_flex" + )] + _code_coverage_enabled: Option, + #[serde(rename = "testRunTitle", default)] + _test_run_title: Option, + #[serde(rename = "platform", default)] + _platform: Option, + #[serde(rename = "configuration", default)] + _configuration: Option, + #[serde( + rename = "publishRunAttachments", + default, + deserialize_with = "de_opt_bool_flex" + )] + _publish_run_attachments: Option, + #[serde(rename = "otherConsoleOptions", default)] + _other_console_options: Option, + #[serde(rename = "vsTestVersion", default)] + _vs_test_version: Option, +} /// Visual Studio Test runner version (`vsTestVersion` input). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum VsTestVersion { /// `"latest"` — always use the newest installed version. + #[serde(rename = "latest")] Latest, /// `"17.0"` — Visual Studio 2022. + #[serde(rename = "17.0")] V17, /// `"16.0"` — Visual Studio 2019. + #[serde(rename = "16.0")] V16, /// `"15.0"` — Visual Studio 2017. + #[serde(rename = "15.0")] V15, /// `"14.0"` — Visual Studio 2015. + #[serde(rename = "14.0")] V14, /// `"toolsInstaller"` — use the version installed by `VisualStudioTestPlatformInstaller@1`. + #[serde(rename = "toolsInstaller")] ToolsInstaller, } @@ -54,12 +173,15 @@ impl VsTestVersion { /// /// Tests are discovered via file-glob patterns (`testAssemblyVer2`). An optional /// `testFiltercriteria` can narrow which tests within matched assemblies to run. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct VsTestAssemblies { /// `testAssemblyVer2` — newline-separated glob patterns for test DLLs, /// e.g. `"**\\bin\\**\\*tests.dll"`. + #[serde(rename = "testAssemblyVer2")] test_assembly: String, /// `testFiltercriteria` — VSTest filter expression, e.g. `"TestCategory=Unit"`. + #[serde(rename = "testFiltercriteria", default)] test_filter_criteria: Option, } @@ -80,13 +202,17 @@ impl VsTestAssemblies { } /// Per-selector data for `testSelector: testPlan` — run tests from an Azure Test Plan. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct VsTestPlan { /// `testPlan` — ID of the Azure Test Plan. + #[serde(rename = "testPlan")] test_plan: String, /// `testSuite` — ID(s) of the test suite(s) within the plan. + #[serde(rename = "testSuite")] test_suite: String, /// `testConfiguration` — ID of the test configuration. + #[serde(rename = "testConfiguration")] test_configuration: String, } @@ -106,9 +232,11 @@ impl VsTestPlan { } /// Per-selector data for `testSelector: testRun` — run tests from a triggered test run. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct VsTestRun { /// `tcmTestRun` — test run ID; defaults to `$(test.RunId)` when omitted. + #[serde(rename = "tcmTestRun", default)] tcm_test_run: Option, } @@ -351,15 +479,27 @@ impl VsTest { push_opt(&mut t, "searchFolder", self.search_folder); push_opt(&mut t, "resultsFolder", self.results_folder); push_opt(&mut t, "runSettingsFile", self.run_settings_file); - push_opt(&mut t, "overrideTestrunParameters", self.override_testrun_parameters); - push_opt(&mut t, "pathtoCustomTestAdapters", self.path_to_custom_test_adapters); + push_opt( + &mut t, + "overrideTestrunParameters", + self.override_testrun_parameters, + ); + push_opt( + &mut t, + "pathtoCustomTestAdapters", + self.path_to_custom_test_adapters, + ); push_bool(&mut t, "runInParallel", self.run_in_parallel); push_bool(&mut t, "runTestsInIsolation", self.run_tests_in_isolation); push_bool(&mut t, "codeCoverageEnabled", self.code_coverage_enabled); push_opt(&mut t, "testRunTitle", self.test_run_title); push_opt(&mut t, "platform", self.platform); push_opt(&mut t, "configuration", self.configuration); - push_bool(&mut t, "publishRunAttachments", self.publish_run_attachments); + push_bool( + &mut t, + "publishRunAttachments", + self.publish_run_attachments, + ); push_opt(&mut t, "otherConsoleOptions", self.other_console_options); if let Some(v) = self.vs_test_version { t = t.with_input("vsTestVersion", v.as_ado_str()); @@ -372,12 +512,58 @@ impl VsTest { mod tests { use super::*; + /// Drift guard for the two-pass validator: `remove_common_inputs` must stay + /// in sync with `VsTestCommonInputs`. A step that sets **every** common input + /// must validate for all three selectors — if a common field were added to + /// the struct but forgotten in the removal list, the leftover key would trip + /// the variant's `deny_unknown_fields` and this test would fail. #[test] - fn assemblies_sets_selector_and_pattern() { - let t = VsTest::assemblies(VsTestAssemblies::new( - "**\\bin\\**\\*tests.dll", + fn all_common_inputs_validate_across_every_selector() { + let common = concat!( + "\nsearchFolder: $(System.DefaultWorkingDirectory)", + "\nresultsFolder: $(Agent.TempDirectory)/results", + "\nrunSettingsFile: tests.runsettings", + "\noverrideTestrunParameters: -key value", + "\npathtoCustomTestAdapters: adapters/", + "\nrunInParallel: true", + "\nrunTestsInIsolation: true", + "\ncodeCoverageEnabled: true", + "\ntestRunTitle: My Tests", + "\nplatform: x64", + "\nconfiguration: Release", + "\npublishRunAttachments: true", + "\notherConsoleOptions: /Blame", + "\nvsTestVersion: latest", + ); + + let assemblies = serde_yaml::from_str(&format!( + "testSelector: testAssemblies\ntestAssemblyVer2: '**/*tests.dll'{common}" )) - .into_step(); + .unwrap(); + assert!( + validate_inputs(assemblies).is_ok(), + "common inputs must validate for testAssemblies" + ); + + let plan = serde_yaml::from_str(&format!( + "testSelector: testPlan\ntestPlan: '1'\ntestSuite: '2'\ntestConfiguration: '3'{common}" + )) + .unwrap(); + assert!( + validate_inputs(plan).is_ok(), + "common inputs must validate for testPlan" + ); + + let run = serde_yaml::from_str(&format!("testSelector: testRun{common}")).unwrap(); + assert!( + validate_inputs(run).is_ok(), + "common inputs must validate for testRun" + ); + } + + #[test] + fn assemblies_sets_selector_and_pattern() { + let t = VsTest::assemblies(VsTestAssemblies::new("**\\bin\\**\\*tests.dll")).into_step(); assert_eq!(t.task, "VSTest@2"); assert_eq!(t.display_name, "Run Visual Studio Tests"); assert_eq!( diff --git a/src/inspect/cli.rs b/src/inspect/cli.rs index e330eeda..e0ab936f 100644 --- a/src/inspect/cli.rs +++ b/src/inspect/cli.rs @@ -281,9 +281,20 @@ pub async fn dispatch_lint(opts: LintOptions<'_>) -> Result { } /// Build the structural lint report for an agent source file. +/// +/// Merges the structural rules over the lowered [`PipelineSummary`] with the +/// front-matter task-step validation (`lint::lint_front_matter_tasks`), so that +/// `ado-aw lint` and the `lint_workflow` MCP tool surface invalid authored task +/// inputs alongside the graph-level findings. pub async fn build_lint(source: &Path) -> Result { - let summary = build_inspect(source).await?; - Ok(lint::report(&summary)) + let (front_matter, pipeline) = build_pipeline_ir(source) + .await + .with_context(|| format!("Failed to build IR for {}", source.display()))?; + let summary = PipelineSummary::from_pipeline(&pipeline)?; + + let mut findings = lint::lint(&summary); + findings.extend(lint::lint_front_matter_tasks(&front_matter)); + Ok(lint::report_from_findings(findings)) } /// Options for `ado-aw catalog`. diff --git a/src/inspect/lint.rs b/src/inspect/lint.rs index b4c832a9..6fb3a927 100644 --- a/src/inspect/lint.rs +++ b/src/inspect/lint.rs @@ -10,6 +10,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; use crate::compile::ir::summary::{JobSummary, PipelineSummary, StepSummary}; +use crate::compile::types::FrontMatter; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -71,11 +72,10 @@ pub fn lint(summary: &PipelineSummary) -> Vec { findings } -pub fn report(summary: &PipelineSummary) -> LintReport { - let findings = lint(summary); - // Rename the local to avoid shadowing the `PipelineSummary` - // parameter with a `LintSummary` of the same name in the same - // scope; the struct field is still called `summary` below. +/// Build a [`LintReport`] from an already-collected set of findings (e.g. the +/// structural rules over a [`PipelineSummary`] merged with the front-matter +/// task-step findings from [`lint_front_matter_tasks`]). +pub fn report_from_findings(findings: Vec) -> LintReport { let tally = summarize_findings(&findings); LintReport { findings, @@ -323,6 +323,48 @@ fn location_for(job: &JobSummary, step: Option<&str>) -> LintLocation { } } +/// Lint rule: authored task steps (`setup` / `steps` / `post-steps` / +/// `teardown`) whose inputs are invalid for the ADO task they invoke. +/// +/// This is the agent-facing surface of the typed-builder task validation (see +/// `crate::compile::ir::tasks::parse`). It runs over the **front matter** rather +/// than the lowered [`PipelineSummary`] (authored task steps are opaque +/// `Step::RawYaml` passthroughs once lowered), so it is merged into the report +/// separately by `build_lint`. Findings are advisory `Warning`s — a recognized +/// task with bad inputs is flagged with enough location (which list + the task +/// id) for an agent to fix the step it synthesised; unmodeled tasks and +/// non-task steps produce nothing. +/// +/// **`location` convention for `task-input-invalid`.** These findings originate +/// in front matter, before jobs/stages exist, so `location` does **not** carry +/// real graph identifiers like the structural rules do. Instead it encodes the +/// authored source position: `stage` is always `None`, `job` holds the +/// front-matter step *list* name (`"setup"` / `"steps"` / `"post-steps"` / +/// `"teardown"`), and `step` holds the offending ADO task id (e.g. +/// `"CopyFiles@2"`). Consumers correlating findings with the pipeline graph +/// should branch on `code == "task-input-invalid"` and treat `job` as a list +/// name rather than a job id for this class. +pub fn lint_front_matter_tasks(front_matter: &FrontMatter) -> Vec { + crate::compile::ir::tasks::parse::validate_front_matter_task_steps( + &front_matter.setup, + &front_matter.steps, + &front_matter.post_steps, + &front_matter.teardown, + ) + .into_iter() + .map(|f| LintFinding { + severity: LintSeverity::Warning, + code: "task-input-invalid".to_string(), + message: format!("{} (in `{}[{}]`)", f.message, f.list, f.index), + location: Some(LintLocation { + stage: None, + job: Some(f.list.to_string()), + step: Some(f.task), + }), + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -344,7 +386,7 @@ mod tests { #[test] fn no_findings_inspect_lint_emits_empty_list_and_zero_errors() { let summary = summary_with_steps(vec![plain_step("only")], vec![]); - let report = report(&summary); + let report = report_from_findings(lint(&summary)); assert!(report.findings.is_empty()); assert_eq!(report.summary.errors, 0); } @@ -415,6 +457,31 @@ mod tests { assert!(!findings.iter().any(|f| f.code == "unused-output")); } + #[test] + fn invalid_front_matter_task_step_emits_task_input_invalid_finding() { + let (front_matter, _) = crate::compile::parse_markdown( + "---\nname: t\ndescription: d\nsteps:\n- task: CopyFiles@2\n inputs:\n Contents: \"**\"\n Bogus: nope\n---\n", + ) + .unwrap(); + let findings = lint_front_matter_tasks(&front_matter); + assert_eq!(findings.len(), 1, "got: {findings:?}"); + let f = &findings[0]; + assert_eq!(f.code, "task-input-invalid"); + assert_eq!(f.severity, LintSeverity::Warning); + let loc = f.location.as_ref().expect("location present"); + assert_eq!(loc.job.as_deref(), Some("steps")); + assert_eq!(loc.step.as_deref(), Some("CopyFiles@2")); + } + + #[test] + fn valid_front_matter_task_steps_emit_no_findings() { + let (front_matter, _) = crate::compile::parse_markdown( + "---\nname: t\ndescription: d\nsteps:\n- task: CopyFiles@2\n inputs:\n Contents: \"**\"\n TargetFolder: out\n- bash: echo hi\n---\n", + ) + .unwrap(); + assert!(lint_front_matter_tasks(&front_matter).is_empty()); + } + #[test] fn lint_finding_json_serialization_round_trips_for_inspect() { let finding = LintFinding { diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 6308529c..71f6cecb 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -6738,3 +6738,71 @@ safe-outputs: ); } +// ── Front-matter task-step validation (advisory; surfaced via lint) ────────── + +/// An authored task step with invalid inputs — here `CopyFiles@2` missing the +/// required `TargetFolder` and carrying an unknown `Bogus` input — must: +/// 1. NOT fail the compile (validation is advisory and lives in `lint`), and +/// 2. still be passed through to the generated YAML verbatim, and +/// 3. NOT print a warning during compile — the validation feedback is +/// surfaced through `ado-aw lint` / the `lint_workflow` MCP tool instead, +/// so compile (which also re-runs in-pipeline for integrity) stays quiet. +#[test] +fn invalid_task_input_compiles_silently_and_preserves_passthrough() { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique_id = COUNTER.fetch_add(1, Ordering::Relaxed); + + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-task-validate-{}-{}", + std::process::id(), + unique_id, + )); + fs::create_dir_all(&temp_dir).expect("create temp dir"); + + let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("invalid-task-input-agent.md"); + let fixture_path = temp_dir.join("invalid-task-input-agent.md"); + fs::copy(&fixture_src, &fixture_path).expect("copy fixture"); + let output_path = temp_dir.join("invalid-task-input-agent.yml"); + + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args([ + "compile", + fixture_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) + .output() + .expect("run compiler"); + + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let compiled = fs::read_to_string(&output_path).unwrap_or_default(); + let _ = fs::remove_dir_all(&temp_dir); + + // (1) Never fail: the process still exits 0. + assert!( + output.status.success(), + "compile must succeed despite invalid task inputs; stderr:\n{stderr}" + ); + // (2) Passthrough preserved verbatim — validation never alters output. + assert!( + compiled.contains("CopyFiles@2"), + "the authored task step must be emitted unchanged:\n{compiled}" + ); + assert!( + compiled.contains("Bogus"), + "the (invalid) input must be passed through unchanged:\n{compiled}" + ); + // (3) Compile does NOT emit the task-validation warning — that feedback is + // surfaced through lint, not compile (see `ado-aw lint` integration test). + assert!( + !stderr.contains("CopyFiles@2"), + "compile must not warn about task inputs (that belongs to lint); got:\n{stderr}" + ); +} + + diff --git a/tests/fixtures/invalid-task-input-agent.md b/tests/fixtures/invalid-task-input-agent.md new file mode 100644 index 00000000..1ea9d068 --- /dev/null +++ b/tests/fixtures/invalid-task-input-agent.md @@ -0,0 +1,18 @@ +--- +name: invalid-task-input-agent +description: task input validation surfaces via lint but never fails compile +steps: +- task: CopyFiles@2 + displayName: Copy with bad input + inputs: + Contents: "**" + Bogus: nope +--- +## Body + +This fixture authors a `CopyFiles@2` step that is missing the required +`TargetFolder` input and supplies an unknown `Bogus` input. `compile` succeeds +**silently** and passes the step through to the generated YAML unchanged; the +advisory validation finding is surfaced only through `ado-aw lint` / +the `lint_workflow` MCP tool (as a `task-input-invalid` warning), not on the +compile path. diff --git a/tests/inspect_integration.rs b/tests/inspect_integration.rs index 9089aef4..b96c48e0 100644 --- a/tests/inspect_integration.rs +++ b/tests/inspect_integration.rs @@ -137,3 +137,45 @@ fn graph_rejects_unknown_format() { "expected clap value-enum rejection for --format, got:\n{stderr}" ); } + +/// `ado-aw lint` surfaces invalid authored task inputs as a `task-input-invalid` +/// **warning** finding (not an error), so the agent self-optimization loop can +/// read structured feedback on the steps it synthesised. Warnings do not fail +/// the command (exit 0). +#[test] +fn lint_reports_invalid_task_input_as_warning_finding() { + let workspace = tempfile::tempdir().expect("create temp dir"); + let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("invalid-task-input-agent.md"); + let dst = workspace.path().join("invalid-task-input-agent.md"); + std::fs::copy(&src, &dst).expect("copy fixture into temp dir"); + + let out = Command::new(binary_path()) + .arg("lint") + .arg(&dst) + .arg("--json") + .output() + .expect("run ado-aw lint --json"); + + // Warning-level findings must not fail the command. + assert!( + out.status.success(), + "lint must exit 0 for warning-only findings. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("\"code\": \"task-input-invalid\""), + "expected a task-input-invalid finding, got:\n{stdout}" + ); + assert!( + stdout.contains("CopyFiles@2"), + "the finding should name the offending task, got:\n{stdout}" + ); + assert!( + stdout.contains("\"severity\": \"warning\""), + "task-input-invalid must be a warning, got:\n{stdout}" + ); +}