Skip to content

Commit c30698d

Browse files
jamesadevineCopilot
andcommitted
feat(ir): validate front-matter task steps against typed builders
Add a parse direction to the typed task builders: parse_task_step() deserializes an authored ADO task-step mapping and validates it by reusing the builder structs as the schema. Derive Deserialize (keyed on ADO input names, deny_unknown_fields) on CopyFiles and the Docker@2 command variants, plus a flexible bool deserializer accepting both rue and "true". Partial coverage is safe and additive: parse_task_step returns Ok(Some(TaskStep)) for a recognized+valid step, Ok(None) for anything not modeled (unmapped task or non-task step) so the caller keeps the original YAML as today's opaque passthrough, and Err only for a recognized task with invalid inputs (missing required, unknown key, bad value, or an input for the wrong command). Wired up for CopyFiles@2 and Docker@2 as a proof of concept. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 602c99b commit c30698d

5 files changed

Lines changed: 380 additions & 7 deletions

File tree

src/compile/ir/tasks/common.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,33 @@ pub(crate) fn push_bool(t: &mut TaskStep, key: &str, value: Option<bool>) {
3131
t.inputs.insert(key.to_string(), bool_input(v).to_string());
3232
}
3333
}
34+
35+
/// Deserialize an optional ADO bool-string input, accepting either a native
36+
/// YAML boolean (`true`) or the ADO-canonical string form (`"true"` /
37+
/// `"false"`). Used by the front-matter validation path in [`super::parse`] so
38+
/// authored task inputs match ADO's accepted shapes.
39+
pub(crate) fn de_opt_bool_flex<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
40+
where
41+
D: serde::Deserializer<'de>,
42+
{
43+
use serde::Deserialize;
44+
45+
#[derive(Deserialize)]
46+
#[serde(untagged)]
47+
enum BoolOrStr {
48+
Bool(bool),
49+
Str(String),
50+
}
51+
52+
match Option::<BoolOrStr>::deserialize(deserializer)? {
53+
None => Ok(None),
54+
Some(BoolOrStr::Bool(b)) => Ok(Some(b)),
55+
Some(BoolOrStr::Str(s)) => match s.as_str() {
56+
"true" => Ok(Some(true)),
57+
"false" => Ok(Some(false)),
58+
other => Err(serde::de::Error::custom(format!(
59+
"expected a boolean or \"true\"/\"false\", got {other:?}"
60+
))),
61+
},
62+
}
63+
}

src/compile/ir/tasks/copy_files.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,44 @@
11
//! Typed builder for `CopyFiles@2`.
22
3-
use super::common::bool_input;
3+
use super::common::{bool_input, de_opt_bool_flex};
44
use crate::compile::ir::step::TaskStep;
5+
use serde::Deserialize;
56

67
/// Builder for a [`TaskStep`] invoking `CopyFiles@2`.
78
///
89
/// Copies files matching `contents` into `target_folder`. Optional inputs are
910
/// applied through the typed setters; only those that are set are emitted.
1011
///
12+
/// Also implements [`serde::Deserialize`] keyed on ADO input names so a
13+
/// front-matter task `inputs:` mapping can be parsed and validated via
14+
/// [`super::parse`] (required inputs enforced, unknown inputs rejected).
15+
///
1116
/// ADO task reference:
1217
/// <https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/copy-files-v2>
13-
#[derive(Debug, Clone)]
18+
#[derive(Debug, Clone, Deserialize)]
19+
#[serde(deny_unknown_fields)]
1420
pub struct CopyFiles {
21+
#[serde(rename = "Contents")]
1522
contents: String,
23+
#[serde(rename = "TargetFolder")]
1624
target_folder: String,
25+
#[serde(rename = "SourceFolder", default)]
1726
source_folder: Option<String>,
27+
#[serde(rename = "CleanTargetFolder", default, deserialize_with = "de_opt_bool_flex")]
1828
clean_target_folder: Option<bool>,
29+
#[serde(rename = "OverWrite", default, deserialize_with = "de_opt_bool_flex")]
1930
over_write: Option<bool>,
31+
#[serde(rename = "flattenFolders", default, deserialize_with = "de_opt_bool_flex")]
2032
flatten_folders: Option<bool>,
33+
#[serde(rename = "preserveTimestamp", default, deserialize_with = "de_opt_bool_flex")]
2134
preserve_timestamp: Option<bool>,
35+
#[serde(rename = "retryCount", default)]
2236
retry_count: Option<String>,
37+
#[serde(rename = "delayBetweenRetries", default)]
2338
delay_between_retries: Option<String>,
39+
#[serde(rename = "ignoreMakeDirErrors", default, deserialize_with = "de_opt_bool_flex")]
2440
ignore_make_dir_errors: Option<bool>,
41+
#[serde(skip)]
2542
display_name: Option<String>,
2643
}
2744

src/compile/ir/tasks/docker.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//! <https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/docker-v2>
1212
1313
use crate::compile::ir::step::TaskStep;
14+
use serde::Deserialize;
1415

1516
/// `Docker@2` `command` selector, carrying the per-command optional inputs.
1617
#[derive(Debug, Clone)]
@@ -23,12 +24,18 @@ pub enum DockerCommand {
2324
}
2425

2526
/// Optionals for `Docker@2` `command: buildAndPush`.
26-
#[derive(Debug, Clone, Default)]
27+
#[derive(Debug, Clone, Default, Deserialize)]
28+
#[serde(deny_unknown_fields)]
2729
pub struct DockerBuildAndPush {
30+
#[serde(rename = "containerRegistry", default)]
2831
container_registry: Option<String>,
32+
#[serde(default)]
2933
repository: Option<String>,
34+
#[serde(rename = "Dockerfile", default)]
3035
dockerfile: Option<String>,
36+
#[serde(rename = "buildContext", default)]
3137
build_context: Option<String>,
38+
#[serde(default)]
3239
tags: Option<String>,
3340
}
3441

@@ -64,13 +71,20 @@ impl DockerBuildAndPush {
6471
}
6572

6673
/// Optionals for `Docker@2` `command: build`.
67-
#[derive(Debug, Clone, Default)]
74+
#[derive(Debug, Clone, Default, Deserialize)]
75+
#[serde(deny_unknown_fields)]
6876
pub struct DockerBuild {
77+
#[serde(rename = "containerRegistry", default)]
6978
container_registry: Option<String>,
79+
#[serde(default)]
7080
repository: Option<String>,
81+
#[serde(rename = "Dockerfile", default)]
7182
dockerfile: Option<String>,
83+
#[serde(rename = "buildContext", default)]
7284
build_context: Option<String>,
85+
#[serde(default)]
7386
tags: Option<String>,
87+
#[serde(default)]
7488
arguments: Option<String>,
7589
}
7690

@@ -111,11 +125,16 @@ impl DockerBuild {
111125
}
112126

113127
/// Optionals for `Docker@2` `command: push`.
114-
#[derive(Debug, Clone, Default)]
128+
#[derive(Debug, Clone, Default, Deserialize)]
129+
#[serde(deny_unknown_fields)]
115130
pub struct DockerPush {
131+
#[serde(rename = "containerRegistry", default)]
116132
container_registry: Option<String>,
133+
#[serde(default)]
117134
repository: Option<String>,
135+
#[serde(default)]
118136
tags: Option<String>,
137+
#[serde(default)]
119138
arguments: Option<String>,
120139
}
121140

@@ -146,8 +165,10 @@ impl DockerPush {
146165
}
147166

148167
/// Optionals for `Docker@2` `command: login`.
149-
#[derive(Debug, Clone, Default)]
168+
#[derive(Debug, Clone, Default, Deserialize)]
169+
#[serde(deny_unknown_fields)]
150170
pub struct DockerLogin {
171+
#[serde(rename = "containerRegistry", default)]
151172
container_registry: Option<String>,
152173
}
153174

@@ -163,8 +184,10 @@ impl DockerLogin {
163184
}
164185

165186
/// Optionals for `Docker@2` `command: logout`.
166-
#[derive(Debug, Clone, Default)]
187+
#[derive(Debug, Clone, Default, Deserialize)]
188+
#[serde(deny_unknown_fields)]
167189
pub struct DockerLogout {
190+
#[serde(rename = "containerRegistry", default)]
168191
container_registry: Option<String>,
169192
}
170193

src/compile/ir/tasks/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub mod dotnet_core_cli;
2929
pub mod download_pipeline_artifact;
3030
pub mod extract_files;
3131
pub mod nuget_command;
32+
pub mod parse;
3233
pub mod powershell;
3334
pub mod publish_pipeline_artifact;
3435
pub mod publish_test_results;

0 commit comments

Comments
 (0)