From e49fdab48da405caba0abf7d69091bcc0c3e17a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:24:13 +0000 Subject: [PATCH] feat(ir): add typed builder for UseDotNet@2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `use_dotnet::UseDotNet` builder struct with a `PackageType` enum and migrate the stringly-typed `dotnet_install_task_step` in `runtimes/dotnet/extension.rs` from raw `TaskStep::new("UseDotNet@2", …)` calls to the new typed builder. The builder supports both .NET installation modes: - Version spec mode: `UseDotNet::with_version("8.0.x")` - Global-json mode: `UseDotNet::with_global_json()` All optional inputs (`packageType`, `workingDirectory`, `installationPath`, `performMultiLevelLookup`, `failOnStandardError`) are only emitted when explicitly set; the ADO defaults apply otherwise, keeping generated YAML minimal. Since the ADO default for `packageType` is `"sdk"`, the migration stops emitting the now-redundant explicit `packageType: sdk` — two existing tests that asserted on this explicit input are updated accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/extensions/tests.rs | 2 +- src/compile/ir/tasks/mod.rs | 1 + src/compile/ir/tasks/use_dotnet.rs | 321 +++++++++++++++++++++++++++++ src/runtimes/dotnet/extension.rs | 16 +- 4 files changed, 332 insertions(+), 8 deletions(-) create mode 100644 src/compile/ir/tasks/use_dotnet.rs diff --git a/src/compile/extensions/tests.rs b/src/compile/extensions/tests.rs index af66824c..097facb0 100644 --- a/src/compile/extensions/tests.rs +++ b/src/compile/extensions/tests.rs @@ -781,7 +781,7 @@ fn test_dotnet_declarations_prepare_steps() { let steps = ext.declarations(&ctx).unwrap().agent_prepare_steps; assert_eq!(steps.len(), 1, "no auth steps without feed-url/config"); assert!( - matches!(&steps[0], Step::Task(t) if t.task == "UseDotNet@2" && t.inputs.get("packageType").map(String::as_str) == Some("sdk")) + matches!(&steps[0], Step::Task(t) if t.task == "UseDotNet@2" && t.inputs.get("packageType").is_none()) ); } diff --git a/src/compile/ir/tasks/mod.rs b/src/compile/ir/tasks/mod.rs index 0b5dbc32..1587006e 100644 --- a/src/compile/ir/tasks/mod.rs +++ b/src/compile/ir/tasks/mod.rs @@ -44,5 +44,6 @@ pub mod publish_build_artifacts; pub mod publish_code_coverage_results; pub mod publish_pipeline_artifact; pub mod publish_test_results; +pub mod use_dotnet; pub mod use_node; pub mod vstest; diff --git a/src/compile/ir/tasks/use_dotnet.rs b/src/compile/ir/tasks/use_dotnet.rs new file mode 100644 index 00000000..39a90073 --- /dev/null +++ b/src/compile/ir/tasks/use_dotnet.rs @@ -0,0 +1,321 @@ +//! Typed builder for `UseDotNet@2`. +//! +//! ADO task reference: +//! + +use super::common::{push_bool, push_opt}; +use crate::compile::ir::step::TaskStep; + +/// `packageType` input for [`UseDotNet`]: whether to install the SDK or only +/// the runtime. +/// +/// ADO default: [`PackageType::Sdk`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackageType { + /// Install the full .NET SDK (`"sdk"`). This is the ADO task default. + Sdk, + /// Install only the .NET runtime (`"runtime"`). + Runtime, +} + +impl PackageType { + /// Returns the exact string token ADO expects for the `packageType` input. + pub fn as_ado_str(self) -> &'static str { + match self { + PackageType::Sdk => "sdk", + PackageType::Runtime => "runtime", + } + } +} + +/// Builder for a [`TaskStep`] invoking `UseDotNet@2`. +/// +/// Acquires and caches a specific version of the .NET SDK or runtime and adds +/// it to the PATH. Supports two version-resolution modes: +/// +/// * **Version spec** — call [`UseDotNet::with_version`] (or set via +/// [`UseDotNet::version`]) to pin a specific version spec (e.g. `"8.0.x"`). +/// * **global.json** — call [`UseDotNet::with_global_json`] (or set +/// [`UseDotNet::use_global_json`] to `true`) to read the version from a +/// `global.json` file in the workspace. +/// +/// Both modes share the optional [`PackageType`], `installationPath`, +/// `performMultiLevelLookup`, and `failOnStandardError` inputs. +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct UseDotNet { + version: Option, + package_type: Option, + use_global_json: Option, + working_directory: Option, + installation_path: Option, + perform_multi_level_lookup: Option, + fail_on_standard_error: Option, + display_name: Option, +} + +impl UseDotNet { + /// Create a builder with no inputs pre-set. Callers typically use the + /// convenience constructors [`UseDotNet::with_version`] or + /// [`UseDotNet::with_global_json`] instead. + pub fn new() -> Self { + Self { + version: None, + package_type: None, + use_global_json: None, + working_directory: None, + installation_path: None, + perform_multi_level_lookup: None, + fail_on_standard_error: None, + display_name: None, + } + } + + /// Convenience constructor: install a specific .NET version spec + /// (e.g. `"8.0.x"`, `"6.0.x"`, `">=6.0.0"`). + /// + /// Equivalent to `UseDotNet::new().version(spec)`. + pub fn with_version(spec: impl Into) -> Self { + Self::new().version(spec) + } + + /// Convenience constructor: resolve the .NET version from a `global.json` + /// file in the repository. + /// + /// Equivalent to `UseDotNet::new().use_global_json(true)`. + pub fn with_global_json() -> Self { + Self::new().use_global_json(true) + } + + /// `version` — .NET version spec to install (e.g. `"8.0.x"`, `"6.0.x"`). + /// Mutually exclusive with `useGlobalJson: true` in ADO (global.json + /// takes precedence when both are set). + pub fn version(mut self, value: impl Into) -> Self { + self.version = Some(value.into()); + self + } + + /// `packageType` — whether to install the SDK (`"sdk"`, the default) or + /// only the runtime (`"runtime"`). + pub fn package_type(mut self, value: PackageType) -> Self { + self.package_type = Some(value); + self + } + + /// `useGlobalJson` — read the .NET version from a `global.json` file. + /// When `true`, the `version` input is ignored by ADO. + pub fn use_global_json(mut self, value: bool) -> Self { + self.use_global_json = Some(value); + self + } + + /// `workingDirectory` — directory to search for `global.json`. Relevant + /// only when [`use_global_json`](Self::use_global_json) is `true`. + pub fn working_directory(mut self, value: impl Into) -> Self { + self.working_directory = Some(value.into()); + self + } + + /// `installationPath` — directory where the .NET SDK is installed. + /// Default: `$(Agent.ToolsDirectory)/dotnet`. + pub fn installation_path(mut self, value: impl Into) -> Self { + self.installation_path = Some(value.into()); + self + } + + /// `performMultiLevelLookup` — search parent directories for + /// `global.json`. Default: `false`. + pub fn perform_multi_level_lookup(mut self, value: bool) -> Self { + self.perform_multi_level_lookup = Some(value); + self + } + + /// `failOnStandardError` — fail the task if any output is written to + /// stderr. Default: `false`. + pub fn fail_on_standard_error(mut self, value: bool) -> Self { + self.fail_on_standard_error = Some(value); + self + } + + /// Override the default `displayName`. + /// + /// Default display name: + /// * Version spec set: `"Install .NET SDK "` (or + /// `"Install .NET Runtime "` when `packageType` is `Runtime`). + /// * `useGlobalJson: true`: `"Install .NET SDK (from global.json)"` (or + /// `"Install .NET Runtime (from global.json)"` when `packageType` is + /// `Runtime`). + /// * Neither set: `"Install .NET SDK"`. + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let package_label = match self.package_type { + Some(PackageType::Runtime) => "Runtime", + _ => "SDK", + }; + + let default_name = if self.use_global_json == Some(true) { + format!("Install .NET {package_label} (from global.json)") + } else if let Some(ref v) = self.version { + format!("Install .NET {package_label} {v}") + } else { + format!("Install .NET {package_label}") + }; + + let mut t = TaskStep::new( + "UseDotNet@2", + self.display_name.unwrap_or(default_name), + ); + push_opt( + &mut t, + "packageType", + self.package_type.map(|p| p.as_ado_str().to_string()), + ); + push_opt(&mut t, "version", self.version); + push_bool(&mut t, "useGlobalJson", self.use_global_json); + push_opt(&mut t, "workingDirectory", self.working_directory); + push_opt(&mut t, "installationPath", self.installation_path); + push_bool( + &mut t, + "performMultiLevelLookup", + self.perform_multi_level_lookup, + ); + push_bool(&mut t, "failOnStandardError", self.fail_on_standard_error); + t + } +} + +impl Default for UseDotNet { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn with_version_sets_task_and_version() { + let t = UseDotNet::with_version("8.0.x").into_step(); + assert_eq!(t.task, "UseDotNet@2"); + assert_eq!(t.display_name, "Install .NET SDK 8.0.x"); + assert_eq!(t.inputs.get("version").map(String::as_str), Some("8.0.x")); + assert!(t.inputs.get("useGlobalJson").is_none()); + assert!(t.inputs.get("packageType").is_none()); + } + + #[test] + fn with_global_json_sets_flag() { + let t = UseDotNet::with_global_json().into_step(); + assert_eq!(t.task, "UseDotNet@2"); + assert_eq!(t.display_name, "Install .NET SDK (from global.json)"); + assert_eq!( + t.inputs.get("useGlobalJson").map(String::as_str), + Some("true") + ); + assert!(t.inputs.get("version").is_none()); + assert!(t.inputs.get("packageType").is_none()); + } + + #[test] + fn package_type_runtime_emits_input_and_adjusts_display_name() { + let t = UseDotNet::with_version("8.0.x") + .package_type(PackageType::Runtime) + .into_step(); + assert_eq!(t.display_name, "Install .NET Runtime 8.0.x"); + assert_eq!( + t.inputs.get("packageType").map(String::as_str), + Some("runtime") + ); + } + + #[test] + fn package_type_sdk_emits_input_when_set_explicitly() { + 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") + ); + } + + #[test] + fn optional_inputs_emitted_when_set() { + let t = UseDotNet::with_global_json() + .working_directory("$(Build.SourcesDirectory)") + .installation_path("/opt/dotnet") + .perform_multi_level_lookup(true) + .fail_on_standard_error(false) + .into_step(); + assert_eq!( + t.inputs.get("workingDirectory").map(String::as_str), + Some("$(Build.SourcesDirectory)") + ); + assert_eq!( + t.inputs.get("installationPath").map(String::as_str), + Some("/opt/dotnet") + ); + assert_eq!( + t.inputs.get("performMultiLevelLookup").map(String::as_str), + Some("true") + ); + assert_eq!( + t.inputs.get("failOnStandardError").map(String::as_str), + Some("false") + ); + } + + #[test] + fn optional_inputs_absent_when_not_set() { + let t = UseDotNet::with_version("8.0.x").into_step(); + assert!(t.inputs.get("workingDirectory").is_none()); + assert!(t.inputs.get("installationPath").is_none()); + assert!(t.inputs.get("performMultiLevelLookup").is_none()); + assert!(t.inputs.get("failOnStandardError").is_none()); + } + + #[test] + fn display_name_override() { + 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.inputs.get("version").map(String::as_str), Some("8.0.x")); + } + + #[test] + fn default_display_name_no_version_no_global_json() { + let t = UseDotNet::new().into_step(); + assert_eq!(t.display_name, "Install .NET SDK"); + } + + #[test] + fn global_json_runtime_adjusts_display_name() { + let t = UseDotNet::with_global_json() + .package_type(PackageType::Runtime) + .into_step(); + assert_eq!(t.display_name, "Install .NET Runtime (from global.json)"); + } + + #[test] + fn bool_input_false_emits_false_string() { + 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/runtimes/dotnet/extension.rs b/src/runtimes/dotnet/extension.rs index c0b29fb1..7c8a31ca 100644 --- a/src/runtimes/dotnet/extension.rs +++ b/src/runtimes/dotnet/extension.rs @@ -4,6 +4,7 @@ use super::{DOTNET_BASH_COMMANDS, DotnetRuntimeConfig, GLOBAL_JSON_SENTINEL}; use crate::compile::extensions::{CompileContext, CompilerExtension, Declarations, ExtensionPhase}; use crate::compile::ir::step::{BashStep, Step, TaskStep}; use crate::compile::ir::tasks::nuget_authenticate::NuGetAuthenticate; +use crate::compile::ir::tasks::use_dotnet::UseDotNet; use crate::validate; use anyhow::Result; @@ -153,14 +154,14 @@ in the repository.\n" /// * no version → `version: '8.0.x'` (compiler default). fn dotnet_install_task_step(config: &DotnetRuntimeConfig) -> TaskStep { if config.use_global_json() { - return TaskStep::new("UseDotNet@2", "Install .NET SDK (from global.json)") - .with_input("packageType", "sdk") - .with_input("useGlobalJson", "true"); + return UseDotNet::with_global_json() + .with_display_name("Install .NET SDK (from global.json)") + .into_step(); } let version = config.version().unwrap_or("8.0.x"); - TaskStep::new("UseDotNet@2", format!("Install .NET SDK {version}")) - .with_input("packageType", "sdk") - .with_input("version", version) + UseDotNet::with_version(version) + .with_display_name(format!("Install .NET SDK {version}")) + .into_step() } /// Build the typed [`TaskStep`] for NuGet authentication. @@ -313,7 +314,8 @@ mod tests { Step::Task(t) => { assert_eq!(t.task, "UseDotNet@2"); assert_eq!(t.display_name, "Install .NET SDK 8.0.x"); - assert_eq!(t.inputs.get("packageType").map(String::as_str), Some("sdk")); + // packageType is the ADO default ("sdk") so the builder omits it + assert!(t.inputs.get("packageType").is_none()); assert_eq!(t.inputs.get("version").map(String::as_str), Some("8.0.x")); assert!(!t.inputs.contains_key("useGlobalJson")); }