From d2920bc20fe2e804ac6188d6b30a203595b32eb6 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Wed, 10 Sep 2025 10:12:36 -0700 Subject: [PATCH 1/3] Add `Copy` support for resources --- dsc/tests/dsc_copy.tests.ps1 | 185 ++++++++++++++++++++++++++++ dsc_lib/locales/en-us.toml | 13 ++ dsc_lib/src/configure/config_doc.rs | 4 +- dsc_lib/src/configure/context.rs | 44 ++++--- dsc_lib/src/configure/mod.rs | 34 ++++- dsc_lib/src/functions/copy_index.rs | 86 +++++++++++++ dsc_lib/src/functions/mod.rs | 2 + dsc_lib/src/functions/reference.rs | 7 +- 8 files changed, 356 insertions(+), 19 deletions(-) create mode 100644 dsc/tests/dsc_copy.tests.ps1 create mode 100644 dsc_lib/src/functions/copy_index.rs diff --git a/dsc/tests/dsc_copy.tests.ps1 b/dsc/tests/dsc_copy.tests.ps1 new file mode 100644 index 000000000..f1850ab50 --- /dev/null +++ b/dsc/tests/dsc_copy.tests.ps1 @@ -0,0 +1,185 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Tests for copy loops' { + It 'Works for resources' { + $configYaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[format('Test-{0}', copyIndex())]" + copy: + name: testLoop + count: 3 + type: Microsoft.DSC.Debug/Echo + properties: + output: Hello +'@ + $out = dsc -l trace config get -i $configYaml 2>$testdrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because ((Get-Content $testdrive/error.log) | Out-String) + $out.results.Count | Should -Be 3 + $out.results[0].name | Should -Be 'Test-0' + $out.results[0].result.actualState.output | Should -Be 'Hello' + $out.results[1].name | Should -Be 'Test-1' + $out.results[1].result.actualState.output | Should -Be 'Hello' + $out.results[2].name | Should -Be 'Test-2' + $out.results[2].result.actualState.output | Should -Be 'Hello' + } + + It 'copyIndex() works with offset' { + $configYaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[format('Test-{0}', copyIndex(10))]" + copy: + name: testLoop + count: 3 + type: Microsoft.DSC.Debug/Echo + properties: + output: Hello +'@ + + $out = dsc -l trace config get -i $configYaml 2>$testdrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because ((Get-Content $testdrive/error.log) | Out-String) + $out.results.Count | Should -Be 3 + $out.results[0].name | Should -Be 'Test-10' + $out.results[0].result.actualState.output | Should -Be 'Hello' + $out.results[1].name | Should -Be 'Test-11' + $out.results[1].result.actualState.output | Should -Be 'Hello' + $out.results[2].name | Should -Be 'Test-12' + $out.results[2].result.actualState.output | Should -Be 'Hello' + } + + It 'copyIndex() with negative index returns error' { + $configYaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[format('Test-{0}', copyIndex(-1))]" + copy: + name: testLoop + count: 3 + type: Microsoft.DSC.Debug/Echo + properties: + output: Hello +'@ + + $null = dsc -l trace config get -i $configYaml 2>$testdrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 2 -Because ((Get-Content $testdrive/error.log) | Out-String) + (Get-Content $testdrive/error.log -Raw) | Should -Match 'The offset cannot be negative' + } + + It 'Copy works with count 0' { + $configYaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[format('Test-{0}', copyIndex())]" + copy: + name: testLoop + count: 0 + type: Microsoft.DSC.Debug/Echo + properties: + output: Hello +'@ + + $out = dsc -l trace config get -i $configYaml 2>$testdrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because ((Get-Content $testdrive/error.log) | Out-String) + $out.results.Count | Should -Be 0 + } + + It 'copyIndex() with loop name works' { + $configYaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[format('Test-{0}', copyIndex('testLoop'))]" + copy: + name: testLoop + count: 3 + type: Microsoft.DSC.Debug/Echo + properties: + output: Hello +'@ + $out = dsc -l trace config get -i $configYaml 2>$testdrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because ((Get-Content $testdrive/error.log) | Out-String) + $out.results.Count | Should -Be 3 + $out.results[0].name | Should -Be 'Test-0' + $out.results[0].result.actualState.output | Should -Be 'Hello' + $out.results[1].name | Should -Be 'Test-1' + $out.results[1].result.actualState.output | Should -Be 'Hello' + $out.results[2].name | Should -Be 'Test-2' + $out.results[2].result.actualState.output | Should -Be 'Hello' + } + + It 'copyIndex() with invalid loop name "" returns error' -TestCases @( + @{ name = "'noSuchLoop'" } + @{ name = "'noSuchLoop', 1" } + ){ + param($name) + $configYaml = @" +`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[format('Test-{0}', copyIndex($name))]" + copy: + name: testLoop + count: 3 + type: Microsoft.DSC.Debug/Echo + properties: + output: Hello +"@ + + $null = dsc -l trace config get -i $configYaml 2>$testdrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 2 -Because ((Get-Content $testdrive/error.log) | Out-String) + (Get-Content $testdrive/error.log -Raw) | Should -Match "The specified loop name 'noSuchLoop' was not found" + } + + It 'Copy mode is not supported' { + $configYaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[format('Test-{0}', copyIndex())]" + copy: + name: testLoop + count: 3 + mode: serial + type: Microsoft.DSC.Debug/Echo + properties: + output: Hello +'@ + $null = dsc -l trace config get -i $configYaml 2>$testdrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 2 -Because ((Get-Content $testdrive/error.log) | Out-String) + (Get-Content $testdrive/error.log -Raw) | Should -Match "Copy mode is not supported" + } + + It 'Copy batch size is not supported' { + $configYaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[format('Test-{0}', copyIndex())]" + copy: + name: testLoop + count: 3 + batchSize: 2 + type: Microsoft.DSC.Debug/Echo + properties: + output: Hello +'@ + $null = dsc -l trace config get -i $configYaml 2>$testdrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 2 -Because ((Get-Content $testdrive/error.log) | Out-String) + (Get-Content $testdrive/error.log -Raw) | Should -Match "Copy batch size is not supported" + } + + It 'Name expression during copy must be a string' { + $configYaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[copyIndex()]" + copy: + name: testLoop + count: 3 + type: Microsoft.DSC.Debug/Echo + properties: + output: Hello +'@ + $null = dsc -l trace config get -i $configYaml 2>$testdrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 2 -Because ((Get-Content $testdrive/error.log) | Out-String) + (Get-Content $testdrive/error.log -Raw) | Should -Match "Copy name result is not a string" + } +} diff --git a/dsc_lib/locales/en-us.toml b/dsc_lib/locales/en-us.toml index 3f57a4449..1b7074b3d 100644 --- a/dsc_lib/locales/en-us.toml +++ b/dsc_lib/locales/en-us.toml @@ -72,6 +72,10 @@ metadataMicrosoftDscIgnored = "Resource returned '_metadata' property 'Microsoft metadataNotObject = "Resource returned '_metadata' property which is not an object" metadataRestartRequiredInvalid = "Resource returned '_metadata' property '_restartRequired' which contains invalid value: %{value}" schemaExcludesMetadata = "Will not add '_metadata' to properties because resource schema does not support it" +unrollingCopy = "Unrolling copy for resource '%{name}' with count %{count}" +copyModeNotSupported = "Copy mode is not supported" +copyBatchSizeNotSupported = "Copy batch size is not supported" +copyNameResultNotString = "Copy name result is not a string" [discovery.commandDiscovery] couldNotReadSetting = "Could not read 'resourcePath' setting" @@ -259,6 +263,14 @@ invoked = "contains function" invalidItemToFind = "Invalid item to find, must be a string or number" invalidArgType = "Invalid argument type, first argument must be an array, object, or string" +[functions.copyIndex] +description = "Returns the current copy index" +invoked = "copyIndex function" +cannotUseOutsideCopy = "The 'copyIndex()' function can only be used when processing a 'Copy' loop" +loopNameNotFound = "The specified loop name '%{name}' was not found" +noCurrentLoop = "There is no current loop to get the index from" +offsetNegative = "The offset cannot be negative" + [functions.createArray] description = "Creates an array from the given elements" invoked = "createArray function" @@ -412,6 +424,7 @@ argsMustBeStrings = "Arguments must all be strings" description = "Retrieves the output of a previously executed resource" invoked = "reference function" keyNotFound = "Invalid resourceId or resource has not executed yet: %{key}" +cannotUseInCopyMode = "The 'reference()' function cannot be used when processing a 'Copy' loop" [functions.resourceId] description = "Constructs a resource ID from the given type and name" diff --git a/dsc_lib/src/configure/config_doc.rs b/dsc_lib/src/configure/config_doc.rs index 88b2a4df8..a888260e3 100644 --- a/dsc_lib/src/configure/config_doc.rs +++ b/dsc_lib/src/configure/config_doc.rs @@ -174,11 +174,11 @@ pub enum CopyMode { #[serde(deny_unknown_fields)] pub struct Copy { pub name: String, - pub count: i32, + pub count: i64, #[serde(skip_serializing_if = "Option::is_none")] pub mode: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "batchSize")] - pub batch_size: Option, + pub batch_size: Option, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] diff --git a/dsc_lib/src/configure/context.rs b/dsc_lib/src/configure/context.rs index ab7b6cb78..ad07c4265 100644 --- a/dsc_lib/src/configure/context.rs +++ b/dsc_lib/src/configure/context.rs @@ -9,40 +9,56 @@ use std::{collections::HashMap, path::PathBuf}; use super::config_doc::{DataType, RestartRequired, SecurityContextKind}; +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum ProcessMode { + Copy, + Normal, + NoExpressionEvaluation, + ParametersDefault, + UserFunction, +} + +#[derive(Clone)] pub struct Context { + pub copy: HashMap, + pub copy_current_loop_name: String, + pub dsc_version: Option, pub execution_type: ExecutionKind, pub extensions: Vec, - pub references: Map, - pub system_root: PathBuf, pub parameters: HashMap, - pub security_context: SecurityContextKind, - pub variables: Map, - pub start_datetime: DateTime, - pub restart_required: Option>, pub process_expressions: bool, + pub process_mode: ProcessMode, pub processing_parameter_defaults: bool, - pub dsc_version: Option, + pub references: Map, + pub restart_required: Option>, + pub security_context: SecurityContextKind, + pub start_datetime: DateTime, + pub system_root: PathBuf, + pub variables: Map, } impl Context { #[must_use] pub fn new() -> Self { Self { + copy: HashMap::new(), + copy_current_loop_name: String::new(), + dsc_version: None, execution_type: ExecutionKind::Actual, extensions: Vec::new(), - references: Map::new(), - system_root: get_default_os_system_root(), parameters: HashMap::new(), + process_expressions: true, + process_mode: ProcessMode::Normal, + processing_parameter_defaults: false, + references: Map::new(), + restart_required: None, security_context: match get_security_context() { SecurityContext::Admin => SecurityContextKind::Elevated, SecurityContext::User => SecurityContextKind::Restricted, }, - variables: Map::new(), start_datetime: chrono::Local::now(), - restart_required: None, - process_expressions: true, - processing_parameter_defaults: false, - dsc_version: None, + system_root: get_default_os_system_root(), + variables: Map::new(), } } } diff --git a/dsc_lib/src/configure/mod.rs b/dsc_lib/src/configure/mod.rs index c6a3f344f..31d8388e5 100644 --- a/dsc_lib/src/configure/mod.rs +++ b/dsc_lib/src/configure/mod.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use crate::configure::config_doc::{ExecutionKind, Metadata, Resource}; +use crate::configure::context::ProcessMode; use crate::configure::{config_doc::RestartRequired, parameters::Input}; use crate::discovery::discovery_trait::DiscoveryFilter; use crate::dscerror::DscError; @@ -875,17 +876,46 @@ impl Configurator { } fn validate_config(&mut self) -> Result<(), DscError> { - let config: Configuration = serde_json::from_str(self.json.as_str())?; + let mut config: Configuration = serde_json::from_str(self.json.as_str())?; check_security_context(config.metadata.as_ref())?; // Perform discovery of resources used in config // create an array of DiscoveryFilter using the resource types and api_versions from the config let mut discovery_filter: Vec = Vec::new(); - for resource in &config.resources { + let config_copy = config.clone(); + for resource in config_copy.resources { let filter = DiscoveryFilter::new(&resource.resource_type, resource.api_version.clone()); if !discovery_filter.contains(&filter) { discovery_filter.push(filter); } + // if the resource contains `Copy`, we need to unroll + if let Some(copy) = &resource.copy { + debug!("{}", t!("configure.mod.unrollingCopy", name = ©.name, count = copy.count)); + if copy.mode.is_some() { + return Err(DscError::Validation(t!("configure.mod.copyModeNotSupported").to_string())); + } + if copy.batch_size.is_some() { + return Err(DscError::Validation(t!("configure.mod.copyBatchSizeNotSupported").to_string())); + } + self.context.process_mode = ProcessMode::Copy; + self.context.copy_current_loop_name = copy.name.clone(); + let mut copy_resources = Vec::::new(); + for i in 0..copy.count { + self.context.copy.insert(copy.name.clone(), i); + let mut new_resource = resource.clone(); + let new_name = match self.statement_parser.parse_and_execute(&resource.name, &self.context)? { + Value::String(s) => s, + _ => return Err(DscError::Parser(t!("configure.mod.copyNameResultNotString", name = ©.name).to_string())), + }; + new_resource.name = new_name.to_string(); + new_resource.copy = None; + copy_resources.push(new_resource); + } + self.context.process_mode = ProcessMode::Normal; + // replace current resource with the unrolled copy resources + config.resources.retain(|r| *r != resource); + config.resources.extend(copy_resources); + } } self.discovery.find_resources(&discovery_filter, self.progress_format); diff --git a/dsc_lib/src/functions/copy_index.rs b/dsc_lib/src/functions/copy_index.rs new file mode 100644 index 000000000..cefd5cb3a --- /dev/null +++ b/dsc_lib/src/functions/copy_index.rs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use core::option::Option::Some; + +use crate::DscError; +use crate::configure::context::{Context, ProcessMode}; +use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata}; +use rust_i18n::t; +use serde_json::Value; +use tracing::debug; + +#[derive(Debug, Default)] +pub struct CopyIndex {} + +impl Function for CopyIndex { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "copyIndex".to_string(), + description: t!("functions.copyIndex.description").to_string(), + category: FunctionCategory::Numeric, + min_args: 0, + max_args: 2, + accepted_arg_ordered_types: vec![ + vec![FunctionArgKind::String, FunctionArgKind::Number], + vec![FunctionArgKind::Number], + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::Number], + } + } + + fn invoke(&self, args: &[Value], context: &Context) -> Result { + debug!("{}", t!("functions.copyIndex.invoked")); + if context.process_mode != ProcessMode::Copy { + return Err(DscError::Parser(t!("functions.copyIndex.cannotUseOutsideCopy").to_string())); + } + match args.len() { + // no args, we return the current index of the current loop + 0 => Ok(Value::Number(get_current_loop_index(context)?.into())), + 1 => { + // if arg is a number, we return current index + offset + // if arg is a string, we return the index of that loop + if let Some(offset) = args[0].as_i64() { + if offset < 0 { + return Err(DscError::Parser(t!("functions.copyIndex.offsetNegative").to_string())); + } + Ok(Value::Number((get_current_loop_index(context)? + offset).into())) + } else if let Some(loop_name) = args[0].as_str() { + if let Some(index) = context.copy.get(loop_name) { + Ok(Value::Number((*index).into())) + } else { + Err(DscError::Parser(t!("functions.copyIndex.loopNameNotFound", name = loop_name).to_string())) + } + } else { + Err(DscError::Parser(t!("functions.invalidArguments").to_string())) + } + } + // two args, first is loop name, second is offset + 2 => { + if let Some(loop_name) = args[0].as_str() { + if let Some(index) = context.copy.get(loop_name) { + if let Some(offset) = args[1].as_i64() { + Ok(Value::Number(((*index as i64) + offset).into())) + } else { + Err(DscError::Parser(t!("functions.invalidArguments").to_string())) + } + } else { + Err(DscError::Parser(t!("functions.copyIndex.loopNameNotFound", name = loop_name).to_string())) + } + } else { + Err(DscError::Parser(t!("functions.invalidArguments").to_string())) + } + } + _ => Err(DscError::Parser(t!("functions.invalidArguments").to_string())), + } + } +} + +fn get_current_loop_index(context: &Context) -> Result { + if let Some(index) = context.copy.get(&(context.copy_current_loop_name)) { + Ok(*index) + } else { + Err(DscError::Parser(t!("functions.copyIndex.noCurrentLoop").to_string())) + } +} diff --git a/dsc_lib/src/functions/mod.rs b/dsc_lib/src/functions/mod.rs index 922cd04c7..6b544606b 100644 --- a/dsc_lib/src/functions/mod.rs +++ b/dsc_lib/src/functions/mod.rs @@ -19,6 +19,7 @@ pub mod bool; pub mod coalesce; pub mod concat; pub mod contains; +pub mod copy_index; pub mod create_array; pub mod create_object; pub mod div; @@ -131,6 +132,7 @@ impl FunctionDispatcher { Box::new(coalesce::Coalesce{}), Box::new(concat::Concat{}), Box::new(contains::Contains{}), + Box::new(copy_index::CopyIndex{}), Box::new(create_array::CreateArray{}), Box::new(create_object::CreateObject{}), Box::new(div::Div{}), diff --git a/dsc_lib/src/functions/reference.rs b/dsc_lib/src/functions/reference.rs index f3fb3d137..3da7d486f 100644 --- a/dsc_lib/src/functions/reference.rs +++ b/dsc_lib/src/functions/reference.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. use crate::DscError; -use crate::configure::context::Context; +use crate::configure::context::{Context, ProcessMode}; use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata}; use rust_i18n::t; use serde_json::Value; @@ -33,6 +33,11 @@ impl Function for Reference { fn invoke(&self, args: &[Value], context: &Context) -> Result { debug!("{}", t!("functions.reference.invoked")); + + if context.process_mode == ProcessMode::Copy { + return Err(DscError::Parser(t!("functions.reference.cannotUseInCopyMode").to_string())); + } + if let Some(key) = args[0].as_str() { if context.references.contains_key(key) { Ok(context.references[key].clone()) From daccab19bdc025b8cc00caa9bfe786109d60e719 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Wed, 10 Sep 2025 10:19:44 -0700 Subject: [PATCH 2/3] fix clippy --- dsc_lib/src/configure/mod.rs | 7 +++---- dsc_lib/src/functions/copy_index.rs | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/dsc_lib/src/configure/mod.rs b/dsc_lib/src/configure/mod.rs index 31d8388e5..07317c70e 100644 --- a/dsc_lib/src/configure/mod.rs +++ b/dsc_lib/src/configure/mod.rs @@ -898,14 +898,13 @@ impl Configurator { return Err(DscError::Validation(t!("configure.mod.copyBatchSizeNotSupported").to_string())); } self.context.process_mode = ProcessMode::Copy; - self.context.copy_current_loop_name = copy.name.clone(); + self.context.copy_current_loop_name.clone_from(©.name); let mut copy_resources = Vec::::new(); for i in 0..copy.count { self.context.copy.insert(copy.name.clone(), i); let mut new_resource = resource.clone(); - let new_name = match self.statement_parser.parse_and_execute(&resource.name, &self.context)? { - Value::String(s) => s, - _ => return Err(DscError::Parser(t!("configure.mod.copyNameResultNotString", name = ©.name).to_string())), + let Value::String(new_name) = self.statement_parser.parse_and_execute(&resource.name, &self.context)? else { + return Err(DscError::Parser(t!("configure.mod.copyNameResultNotString", name = ©.name).to_string())) }; new_resource.name = new_name.to_string(); new_resource.copy = None; diff --git a/dsc_lib/src/functions/copy_index.rs b/dsc_lib/src/functions/copy_index.rs index cefd5cb3a..50f653a25 100644 --- a/dsc_lib/src/functions/copy_index.rs +++ b/dsc_lib/src/functions/copy_index.rs @@ -61,7 +61,7 @@ impl Function for CopyIndex { if let Some(loop_name) = args[0].as_str() { if let Some(index) = context.copy.get(loop_name) { if let Some(offset) = args[1].as_i64() { - Ok(Value::Number(((*index as i64) + offset).into())) + Ok(Value::Number(((*index) + offset).into())) } else { Err(DscError::Parser(t!("functions.invalidArguments").to_string())) } From b581e63595372eef371f6e3232a23c1d1f738efc Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Wed, 10 Sep 2025 10:27:23 -0700 Subject: [PATCH 3/3] address copilot feedback --- dsc_lib/src/configure/mod.rs | 2 +- dsc_lib/src/functions/copy_index.rs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/dsc_lib/src/configure/mod.rs b/dsc_lib/src/configure/mod.rs index 07317c70e..74ef2e773 100644 --- a/dsc_lib/src/configure/mod.rs +++ b/dsc_lib/src/configure/mod.rs @@ -904,7 +904,7 @@ impl Configurator { self.context.copy.insert(copy.name.clone(), i); let mut new_resource = resource.clone(); let Value::String(new_name) = self.statement_parser.parse_and_execute(&resource.name, &self.context)? else { - return Err(DscError::Parser(t!("configure.mod.copyNameResultNotString", name = ©.name).to_string())) + return Err(DscError::Parser(t!("configure.mod.copyNameResultNotString").to_string())) }; new_resource.name = new_name.to_string(); new_resource.copy = None; diff --git a/dsc_lib/src/functions/copy_index.rs b/dsc_lib/src/functions/copy_index.rs index 50f653a25..3cf7469ad 100644 --- a/dsc_lib/src/functions/copy_index.rs +++ b/dsc_lib/src/functions/copy_index.rs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use core::option::Option::Some; - use crate::DscError; use crate::configure::context::{Context, ProcessMode}; use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata};