diff --git a/all.sln b/all.sln index ac3f669c1..326be2274 100644 --- a/all.sln +++ b/all.sln @@ -241,6 +241,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Workflow.Versioning", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Workflow.Versioning", "test\Dapr.IntegrationTest.Workflow.Versioning\Dapr.IntegrationTest.Workflow.Versioning.csproj", "{1AD32297-630E-4DFB-B3E4-CAFCE993F27F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Workflow.Versioning.Runtime.Test", "test\Dapr.Workflow.Versioning.Runtime.Test\Dapr.Workflow.Versioning.Runtime.Test.csproj", "{4FF7F075-2818-41E4-A88F-743417EA0A99}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -639,6 +641,10 @@ Global {1AD32297-630E-4DFB-B3E4-CAFCE993F27F}.Debug|Any CPU.Build.0 = Debug|Any CPU {1AD32297-630E-4DFB-B3E4-CAFCE993F27F}.Release|Any CPU.ActiveCfg = Release|Any CPU {1AD32297-630E-4DFB-B3E4-CAFCE993F27F}.Release|Any CPU.Build.0 = Release|Any CPU + {4FF7F075-2818-41E4-A88F-743417EA0A99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4FF7F075-2818-41E4-A88F-743417EA0A99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4FF7F075-2818-41E4-A88F-743417EA0A99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4FF7F075-2818-41E4-A88F-743417EA0A99}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -756,6 +762,7 @@ Global {BD1FA767-AC6D-429D-8BC0-3C0B52AA11FF} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {CB619F1E-B90C-4BCB-9DDA-A5A4F5967661} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {1AD32297-630E-4DFB-B3E4-CAFCE993F27F} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} + {4FF7F075-2818-41E4-A88F-743417EA0A99} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} diff --git a/src/Dapr.Workflow.Versioning.Abstractions/WorkflowVersionAttribute.cs b/src/Dapr.Workflow.Versioning.Abstractions/WorkflowVersionAttribute.cs index 1861eaf18..832f0f4fa 100644 --- a/src/Dapr.Workflow.Versioning.Abstractions/WorkflowVersionAttribute.cs +++ b/src/Dapr.Workflow.Versioning.Abstractions/WorkflowVersionAttribute.cs @@ -40,7 +40,12 @@ public sealed class WorkflowVersionAttribute : Attribute /// /// Gets or sets an optional strategy type to use for this workflow, overriding the globally configured strategy. - /// The type must implement and expose a public parameterless constructor. + /// The type must implement and be constructible by the active /// public Type? StrategyType { get; init; } + + /// + /// Gets or sets an optional named options scope to use when configuring the strategy for this workflow. + /// + public string? OptionsName { get; init; } } diff --git a/src/Dapr.Workflow.Versioning.Runtime/DefaultWorkflowVersionStrategyFactory.cs b/src/Dapr.Workflow.Versioning.Runtime/DefaultWorkflowVersionStrategyFactory.cs index 722eb2eda..94bbb83a7 100644 --- a/src/Dapr.Workflow.Versioning.Runtime/DefaultWorkflowVersionStrategyFactory.cs +++ b/src/Dapr.Workflow.Versioning.Runtime/DefaultWorkflowVersionStrategyFactory.cs @@ -42,6 +42,11 @@ public IWorkflowVersionStrategy Create( throw new InvalidOperationException( $"Could not construct strategy of type '{strategyType.FullName}'."); + if (instance is IWorkflowVersionStrategyContextConsumer contextConsumer) + { + contextConsumer.Configure(new WorkflowVersionStrategyContext(canonicalName, optionsName)); + } + return instance; } } diff --git a/src/Dapr.Workflow.Versioning.Runtime/IWorkflowVersionStrategyContextConsumer.cs b/src/Dapr.Workflow.Versioning.Runtime/IWorkflowVersionStrategyContextConsumer.cs new file mode 100644 index 000000000..269791822 --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/IWorkflowVersionStrategyContextConsumer.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning; + +/// +/// Optional interface for strategies that want per-family context (canonical name and options scope). +/// +public interface IWorkflowVersionStrategyContextConsumer +{ + /// + /// Configures the strategy with the canonical name and optional options scope. + /// + /// The strategy context. + void Configure(WorkflowVersionStrategyContext context); +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/MaxVersionSelector.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionSelectors/MaxVersionSelector.cs similarity index 100% rename from src/Dapr.Workflow.Versioning.Runtime/MaxVersionSelector.cs rename to src/Dapr.Workflow.Versioning.Runtime/VersionSelectors/MaxVersionSelector.cs diff --git a/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/DateSuffixVersionStrategy.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/DateSuffixVersionStrategy.cs new file mode 100644 index 000000000..5dc6d9eb3 --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/DateSuffixVersionStrategy.cs @@ -0,0 +1,112 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Globalization; +using Microsoft.Extensions.Options; + +namespace Dapr.Workflow.Versioning; + +/// +/// Strategy that derives a date-based version from a trailing suffix +/// (for example, MyWorkflow20260212 with format yyyyMMdd). +/// +public sealed class DateSuffixVersionStrategy(IOptionsMonitor? optionsMonitor = null) + : IWorkflowVersionStrategy, IWorkflowVersionStrategyContextConsumer +{ + private DateSuffixVersionStrategyOptions _options = new(); + + /// + public void Configure(WorkflowVersionStrategyContext context) + { + var optionsName = string.IsNullOrWhiteSpace(context.OptionsName) + ? Options.DefaultName + : context.OptionsName; + + if (optionsMonitor is not null) + { + _options = optionsMonitor.Get(optionsName); + } + } + + /// + public bool TryParse(string typeName, out string canonicalName, out string version) + { + canonicalName = string.Empty; + version = string.Empty; + + if (string.IsNullOrWhiteSpace(typeName)) + return false; + + var format = string.IsNullOrWhiteSpace(_options.DateFormat) ? "yyyyMMdd" : _options.DateFormat; + if (typeName.Length <= format.Length) + return ApplyNoSuffix(typeName, out canonicalName, out version); + + var suffix = typeName.Substring(typeName.Length - format.Length); + if (!TryParseDate(suffix, format, out _)) + return ApplyNoSuffix(typeName, out canonicalName, out version); + + canonicalName = typeName.Substring(0, typeName.Length - format.Length); + if (string.IsNullOrEmpty(canonicalName)) + return false; + + version = suffix; + return true; + } + + /// + public int Compare(string? v1, string? v2) + { + if (ReferenceEquals(v1, v2)) return 0; + if (v1 is null) return -1; + if (v2 is null) return 1; + + var format = string.IsNullOrWhiteSpace(_options.DateFormat) ? "yyyyMMdd" : _options.DateFormat; + var ok1 = TryParseDate(v1.Trim(), format, out var d1); + var ok2 = TryParseDate(v2.Trim(), format, out var d2); + + switch (ok1) + { + case true when ok2: + return d1.CompareTo(d2); + case true: + return 1; + } + + if (ok2) return -1; + + return StringComparer.Ordinal.Compare(v1, v2); + } + + private bool ApplyNoSuffix(string typeName, out string canonicalName, out string version) + { + canonicalName = string.Empty; + version = string.Empty; + + if (!_options.AllowNoSuffix) + return false; + + canonicalName = typeName; + version = string.IsNullOrWhiteSpace(_options.DefaultVersion) ? "0" : _options.DefaultVersion; + return true; + } + + private static bool TryParseDate(string value, string format, out DateTime date) + { + return DateTime.TryParseExact( + value, + format, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out date); + } +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/DateSuffixVersionStrategyOptions.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/DateSuffixVersionStrategyOptions.cs new file mode 100644 index 000000000..454206779 --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/DateSuffixVersionStrategyOptions.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning; + +/// +/// Options for . +/// +public sealed class DateSuffixVersionStrategyOptions +{ + /// + /// Gets or sets the date format expected at the end of the workflow type name. + /// Defaults to yyyyMMdd. + /// + public string DateFormat { get; set; } = "yyyyMMdd"; + + /// + /// Gets or sets a value indicating whether names without a date suffix are allowed. + /// When enabled, the default version is applied. + /// + public bool AllowNoSuffix { get; set; } + + /// + /// Gets or sets the default version used when no suffix is present. + /// + public string DefaultVersion { get; set; } = "0"; +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/DelimitedSuffixVersionStrategy.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/DelimitedSuffixVersionStrategy.cs new file mode 100644 index 000000000..02dbd17a2 --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/DelimitedSuffixVersionStrategy.cs @@ -0,0 +1,104 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Globalization; +using Microsoft.Extensions.Options; + +namespace Dapr.Workflow.Versioning; + +/// +/// Strategy that derives the version from a delimiter-separated suffix (for example, MyWorkflow-1). +/// +public sealed class DelimitedSuffixVersionStrategy( + IOptionsMonitor? optionsMonitor = null) + : IWorkflowVersionStrategy, IWorkflowVersionStrategyContextConsumer +{ + private DelimitedSuffixVersionStrategyOptions _options = new(); + + /// + public void Configure(WorkflowVersionStrategyContext context) + { + var optionsName = string.IsNullOrWhiteSpace(context.OptionsName) + ? Options.DefaultName + : context.OptionsName; + + if (optionsMonitor is not null) + { + _options = optionsMonitor.Get(optionsName); + } + } + + /// + public bool TryParse(string typeName, out string canonicalName, out string version) + { + canonicalName = string.Empty; + version = string.Empty; + + if (string.IsNullOrWhiteSpace(typeName)) + return false; + + var delimiter = _options.Delimiter ?? string.Empty; + if (delimiter.Length == 0) + return false; + + var comparison = _options.IgnoreDelimiterCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var delimiterIndex = typeName.LastIndexOf(delimiter, comparison); + + if (delimiterIndex < 0) + { + if (_options.AllowNoSuffix) + { + canonicalName = typeName; + version = string.IsNullOrWhiteSpace(_options.DefaultVersion) ? "0" : _options.DefaultVersion; + return true; + } + + return false; + } + + var versionStart = delimiterIndex + delimiter.Length; + if (delimiterIndex == 0 || versionStart >= typeName.Length) + return false; + + canonicalName = typeName[..delimiterIndex]; + version = typeName[versionStart..]; + + return !string.IsNullOrEmpty(canonicalName) && !string.IsNullOrEmpty(version); + } + + /// + public int Compare(string? v1, string? v2) + { + if (ReferenceEquals(v1, v2)) return 0; + if (v1 is null) return -1; + if (v2 is null) return 1; + + var s1 = v1.Trim(); + var s2 = v2.Trim(); + + var ok1 = long.TryParse(s1, NumberStyles.None, CultureInfo.InvariantCulture, out var n1); + var ok2 = long.TryParse(s2, NumberStyles.None, CultureInfo.InvariantCulture, out var n2); + + switch (ok1) + { + case true when ok2: + return n1.CompareTo(n2); + case true: + return 1; + } + + if (ok2) return -1; + + return StringComparer.Ordinal.Compare(s1, s2); + } +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/DelimitedSuffixVersionStrategyOptions.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/DelimitedSuffixVersionStrategyOptions.cs new file mode 100644 index 000000000..713ac8778 --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/DelimitedSuffixVersionStrategyOptions.cs @@ -0,0 +1,41 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning; + +/// +/// Options for . +/// +public sealed class DelimitedSuffixVersionStrategyOptions +{ + /// + /// Gets or sets the delimiter separating the canonical name from the version. + /// + public string Delimiter { get; set; } = "-"; + + /// + /// Gets or sets a value indicating whether delimiter matching ignores case. + /// + public bool IgnoreDelimiterCase { get; set; } + + /// + /// Gets or sets a value indicating whether names without a delimiter are allowed. + /// When enabled, the default version is applied. + /// + public bool AllowNoSuffix { get; set; } + + /// + /// Gets or sets the default version used when no suffix is present. + /// + public string DefaultVersion { get; set; } = "0"; +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/ExplicitVersionStrategy.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/ExplicitVersionStrategy.cs new file mode 100644 index 000000000..d3a899db9 --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/ExplicitVersionStrategy.cs @@ -0,0 +1,63 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Options; + +namespace Dapr.Workflow.Versioning; + +/// +/// Strategy that requires versions to be supplied explicitly via . +/// +public sealed class ExplicitVersionStrategy(IOptionsMonitor? optionsMonitor = null) + : IWorkflowVersionStrategy, IWorkflowVersionStrategyContextConsumer +{ + private ExplicitVersionStrategyOptions _options = new(); + + /// + public void Configure(WorkflowVersionStrategyContext context) + { + var optionsName = string.IsNullOrWhiteSpace(context.OptionsName) + ? Options.DefaultName + : context.OptionsName; + + if (optionsMonitor is not null) + { + _options = optionsMonitor.Get(optionsName); + } + } + + /// + public bool TryParse(string typeName, out string canonicalName, out string version) + { + canonicalName = string.Empty; + version = string.Empty; + + if (!_options.AllowMissingVersion || string.IsNullOrWhiteSpace(typeName)) + return false; + + canonicalName = typeName; + version = string.IsNullOrWhiteSpace(_options.DefaultVersion) ? "0" : _options.DefaultVersion; + return true; + } + + /// + public int Compare(string? v1, string? v2) + { + if (ReferenceEquals(v1, v2)) return 0; + if (v1 is null) return -1; + if (v2 is null) return 1; + + var comparer = _options.IgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + return comparer.Compare(v1, v2); + } +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/ExplicitVersionStrategyOptions.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/ExplicitVersionStrategyOptions.cs new file mode 100644 index 000000000..57dd04f82 --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/ExplicitVersionStrategyOptions.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning; + +/// +/// Options for . +/// +public sealed class ExplicitVersionStrategyOptions +{ + /// + /// Gets or sets a value indicating whether parsing should be allowed when no explicit version is supplied. + /// + public bool AllowMissingVersion { get; set; } + + /// + /// Gets or sets the default version used when no explicit version is supplied and parsing is allowed. + /// + public string DefaultVersion { get; set; } = "0"; + + /// + /// Gets or sets a value indicating whether version comparisons ignore case. + /// + public bool IgnoreCase { get; set; } +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/NumericVersionStrategy.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/NumericVersionStrategy.cs new file mode 100644 index 000000000..d7e71e864 --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/NumericVersionStrategy.cs @@ -0,0 +1,137 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Globalization; +using Microsoft.Extensions.Options; + +namespace Dapr.Workflow.Versioning; + +/// +/// Strategy that derives a numeric version from a trailing suffix with an optional prefix +/// (for example, MyWorkflowV1 with prefix V). +/// +public sealed class NumericVersionStrategy : IWorkflowVersionStrategy, IWorkflowVersionStrategyContextConsumer +{ + private readonly IOptionsMonitor? _optionsMonitor; + private NumericVersionStrategyOptions _options = new(); + + /// + /// Initializes a new instance of the class. + /// + /// Optional options monitor for named configuration. + public NumericVersionStrategy(IOptionsMonitor? optionsMonitor = null) + { + _optionsMonitor = optionsMonitor; + } + + /// + public void Configure(WorkflowVersionStrategyContext context) + { + var optionsName = string.IsNullOrWhiteSpace(context.OptionsName) + ? Options.DefaultName + : context.OptionsName; + + if (_optionsMonitor is not null) + { + _options = _optionsMonitor.Get(optionsName); + } + } + + /// + public bool TryParse(string typeName, out string canonicalName, out string version) + { + canonicalName = string.Empty; + version = string.Empty; + + if (string.IsNullOrWhiteSpace(typeName)) + return false; + + var prefix = _options.SuffixPrefix ?? string.Empty; + var comparison = _options.IgnorePrefixCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + var digitsStart = FindTrailingDigits(typeName); + if (digitsStart < 0) + { + if (_options.AllowNoSuffix && !EndsWithPrefix(typeName, prefix, comparison)) + { + canonicalName = typeName; + version = string.IsNullOrWhiteSpace(_options.DefaultVersion) ? "0" : _options.DefaultVersion; + return true; + } + + return false; + } + + if (!string.IsNullOrEmpty(prefix)) + { + var prefixStart = digitsStart - prefix.Length; + if (prefixStart < 1) + return false; + + var candidatePrefix = typeName.Substring(prefixStart, prefix.Length); + if (!string.Equals(candidatePrefix, prefix, comparison)) + return false; + + canonicalName = typeName.Substring(0, prefixStart); + } + else + { + if (digitsStart < 1) + return false; + + canonicalName = typeName.Substring(0, digitsStart); + } + + version = typeName.Substring(digitsStart); + return !string.IsNullOrEmpty(canonicalName) && !string.IsNullOrEmpty(version); + } + + /// + public int Compare(string? v1, string? v2) + { + if (ReferenceEquals(v1, v2)) return 0; + if (v1 is null) return -1; + if (v2 is null) return 1; + + var s1 = v1.Trim(); + var s2 = v2.Trim(); + + var ok1 = long.TryParse(s1, NumberStyles.None, CultureInfo.InvariantCulture, out var n1); + var ok2 = long.TryParse(s2, NumberStyles.None, CultureInfo.InvariantCulture, out var n2); + + if (ok1 && ok2) return n1.CompareTo(n2); + if (ok1) return 1; + if (ok2) return -1; + + return StringComparer.Ordinal.Compare(s1, s2); + } + + private static int FindTrailingDigits(string value) + { + var i = value.Length - 1; + while (i >= 0 && value[i] >= '0' && value[i] <= '9') + { + i--; + } + + return i == value.Length - 1 ? -1 : i + 1; + } + + private static bool EndsWithPrefix(string value, string prefix, StringComparison comparison) + { + if (string.IsNullOrEmpty(prefix)) + return false; + + return value.EndsWith(prefix, comparison); + } +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/NumericVersionStrategyOptions.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/NumericVersionStrategyOptions.cs new file mode 100644 index 000000000..ca5ed60c5 --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/NumericVersionStrategyOptions.cs @@ -0,0 +1,42 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning; + +/// +/// Options for . +/// +public sealed class NumericVersionStrategyOptions +{ + /// + /// Gets or sets the prefix used before the numeric suffix (for example, "V" in MyWorkflowV1). + /// Set to an empty string to allow a raw numeric suffix (for example, MyWorkflow1). + /// + public string SuffixPrefix { get; set; } = "V"; + + /// + /// Gets or sets a value indicating whether prefix matching ignores case. + /// + public bool IgnorePrefixCase { get; set; } + + /// + /// Gets or sets a value indicating whether names without a numeric suffix are allowed. + /// When enabled, the default version is applied. + /// + public bool AllowNoSuffix { get; set; } = true; + + /// + /// Gets or sets the default version used when no suffix is present. + /// + public string DefaultVersion { get; set; } = "0"; +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/SemVerVersionStrategy.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/SemVerVersionStrategy.cs new file mode 100644 index 000000000..89b41e661 --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/SemVerVersionStrategy.cs @@ -0,0 +1,249 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Options; + +namespace Dapr.Workflow.Versioning; + +/// +/// Strategy that derives a SemVer version from a trailing suffix (for example, MyWorkflowv1.2.3). +/// +public sealed class SemVerVersionStrategy(IOptionsMonitor? optionsMonitor = null) + : IWorkflowVersionStrategy, IWorkflowVersionStrategyContextConsumer +{ + private SemVerVersionStrategyOptions _options = new(); + + /// + public void Configure(WorkflowVersionStrategyContext context) + { + var optionsName = string.IsNullOrWhiteSpace(context.OptionsName) + ? Options.DefaultName + : context.OptionsName; + + if (optionsMonitor is not null) + { + _options = optionsMonitor.Get(optionsName); + } + } + + /// + public bool TryParse(string typeName, out string canonicalName, out string version) + { + canonicalName = string.Empty; + version = string.Empty; + + if (string.IsNullOrWhiteSpace(typeName)) + return false; + + var prefix = _options.Prefix ?? string.Empty; + if (prefix.Length > 0) + { + var comparison = _options.IgnorePrefixCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var prefixIndex = typeName.LastIndexOf(prefix, comparison); + if (prefixIndex < 0) + { + return ApplyNoSuffix(typeName, out canonicalName, out version); + } + + var versionStart = prefixIndex + prefix.Length; + if (prefixIndex == 0 || versionStart >= typeName.Length) + return false; + + var candidate = typeName.Substring(versionStart); + if (!TryParseSemVer(candidate, _options, out _)) + return false; + + canonicalName = typeName.Substring(0, prefixIndex); + version = candidate; + return !string.IsNullOrEmpty(canonicalName); + } + + var candidateStart = FindSemVerSuffixStart(typeName); + if (candidateStart < 0) + return ApplyNoSuffix(typeName, out canonicalName, out version); + + var suffix = typeName.Substring(candidateStart); + if (!TryParseSemVer(suffix, _options, out _)) + return ApplyNoSuffix(typeName, out canonicalName, out version); + + canonicalName = typeName.Substring(0, candidateStart); + if (string.IsNullOrEmpty(canonicalName)) + return false; + + version = suffix; + return true; + } + + /// + public int Compare(string? v1, string? v2) + { + if (ReferenceEquals(v1, v2)) return 0; + if (v1 is null) return -1; + if (v2 is null) return 1; + + var ok1 = TryParseSemVer(v1.Trim(), _options, out var s1); + var ok2 = TryParseSemVer(v2.Trim(), _options, out var s2); + + switch (ok1) + { + case true when ok2: + return s1.CompareTo(s2); + case true: + return 1; + } + + if (ok2) return -1; + + return StringComparer.Ordinal.Compare(v1, v2); + } + + private bool ApplyNoSuffix(string typeName, out string canonicalName, out string version) + { + canonicalName = string.Empty; + version = string.Empty; + + if (!_options.AllowNoSuffix) + return false; + + canonicalName = typeName; + version = string.IsNullOrWhiteSpace(_options.DefaultVersion) ? "0.0.0" : _options.DefaultVersion; + return true; + } + + private static int FindSemVerSuffixStart(string value) + { + var i = value.Length - 1; + while (i >= 0 && IsSemVerChar(value[i])) + { + i--; + } + + return i == value.Length - 1 ? -1 : i + 1; + } + + private static bool IsSemVerChar(char c) => + c is >= '0' and <= '9' or >= 'A' and <= 'Z' or >= 'a' and <= 'z' or '.' or '-' or '+'; + + private static bool TryParseSemVer(string value, SemVerVersionStrategyOptions options, out SemVer semVer) + { + semVer = default; + + if (string.IsNullOrWhiteSpace(value)) + return false; + + var buildSplit = value.Split('+', 2); + if (buildSplit.Length == 2 && !options.AllowBuildMetadata) + return false; + + var withoutBuild = buildSplit[0]; + var preSplit = withoutBuild.Split('-', 2); + var core = preSplit[0]; + + if (preSplit.Length == 2 && !options.AllowPrerelease) + return false; + + var coreParts = core.Split('.'); + if (coreParts.Length != 3) + return false; + + if (!int.TryParse(coreParts[0], out var major) || + !int.TryParse(coreParts[1], out var minor) || + !int.TryParse(coreParts[2], out var patch)) + { + return false; + } + + var prerelease = preSplit.Length == 2 ? preSplit[1] : null; + var build = buildSplit.Length == 2 ? buildSplit[1] : null; + + semVer = new SemVer(major, minor, patch, prerelease, build); + return true; + } + + private readonly struct SemVer : IComparable + { + public SemVer(int major, int minor, int patch, string? prerelease, string? build) + { + Major = major; + Minor = minor; + Patch = patch; + Prerelease = prerelease; + Build = build; + } + + public int Major { get; } + public int Minor { get; } + public int Patch { get; } + public string? Prerelease { get; } + public string? Build { get; } + + public int CompareTo(SemVer other) + { + var major = Major.CompareTo(other.Major); + if (major != 0) return major; + + var minor = Minor.CompareTo(other.Minor); + if (minor != 0) return minor; + + var patch = Patch.CompareTo(other.Patch); + if (patch != 0) return patch; + + var thisPre = Prerelease; + var otherPre = other.Prerelease; + + if (string.IsNullOrEmpty(thisPre) && string.IsNullOrEmpty(otherPre)) return 0; + if (string.IsNullOrEmpty(thisPre)) return 1; + if (string.IsNullOrEmpty(otherPre)) return -1; + + return ComparePrerelease(thisPre, otherPre); + } + + private static int ComparePrerelease(string left, string right) + { + var leftParts = left.Split('.'); + var rightParts = right.Split('.'); + var length = Math.Max(leftParts.Length, rightParts.Length); + + for (var i = 0; i < length; i++) + { + if (i >= leftParts.Length) return -1; + if (i >= rightParts.Length) return 1; + + var l = leftParts[i]; + var r = rightParts[i]; + + var lIsNum = int.TryParse(l, out var lNum); + var rIsNum = int.TryParse(r, out var rNum); + + switch (lIsNum) + { + case true when rIsNum: + { + var cmp = lNum.CompareTo(rNum); + if (cmp != 0) return cmp; + continue; + } + case true: + return -1; + } + + if (rIsNum) return 1; + + var cmpStr = StringComparer.Ordinal.Compare(l, r); + if (cmpStr != 0) return cmpStr; + } + + return 0; + } + } +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/SemVerVersionStrategyOptions.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/SemVerVersionStrategyOptions.cs new file mode 100644 index 000000000..3f893169e --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/SemVerVersionStrategyOptions.cs @@ -0,0 +1,52 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning; + +/// +/// Options for . +/// +public sealed class SemVerVersionStrategyOptions +{ + /// + /// Gets or sets the prefix expected before the SemVer suffix (for example, "v" in MyWorkflowv1.2.3). + /// Set to an empty string to require no prefix. + /// + public string Prefix { get; set; } = "v"; + + /// + /// Gets or sets a value indicating whether prefix matching ignores case. + /// + public bool IgnorePrefixCase { get; set; } + + /// + /// Gets or sets a value indicating whether pre-release labels (for example, -alpha.1) are allowed. + /// + public bool AllowPrerelease { get; set; } = true; + + /// + /// Gets or sets a value indicating whether build metadata (for example, +build.5) is allowed. + /// + public bool AllowBuildMetadata { get; set; } = true; + + /// + /// Gets or sets a value indicating whether names without a SemVer suffix are allowed. + /// When enabled, the default version is applied. + /// + public bool AllowNoSuffix { get; set; } + + /// + /// Gets or sets the default version used when no suffix is present. + /// + public string DefaultVersion { get; set; } = "0.0.0"; +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/ZeroPaddedNumericVersionStrategy.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/ZeroPaddedNumericVersionStrategy.cs new file mode 100644 index 000000000..363b84fd9 --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/ZeroPaddedNumericVersionStrategy.cs @@ -0,0 +1,133 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Globalization; +using Microsoft.Extensions.Options; + +namespace Dapr.Workflow.Versioning; + +/// +/// Strategy that derives a numeric version from a zero-padded trailing suffix (for example, MyWorkflow0001). +/// +public sealed class ZeroPaddedNumericVersionStrategy( + IOptionsMonitor? optionsMonitor = null) + : IWorkflowVersionStrategy, IWorkflowVersionStrategyContextConsumer +{ + private ZeroPaddedNumericVersionStrategyOptions _options = new(); + + /// + public void Configure(WorkflowVersionStrategyContext context) + { + var optionsName = string.IsNullOrWhiteSpace(context.OptionsName) + ? Options.DefaultName + : context.OptionsName; + + if (optionsMonitor is not null) + { + _options = optionsMonitor.Get(optionsName); + } + } + + /// + public bool TryParse(string typeName, out string canonicalName, out string version) + { + canonicalName = string.Empty; + version = string.Empty; + + if (string.IsNullOrWhiteSpace(typeName)) + return false; + + var prefix = _options.SuffixPrefix ?? string.Empty; + var comparison = _options.IgnorePrefixCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + var digitsStart = FindTrailingDigits(typeName); + if (digitsStart < 0) + { + if (_options.AllowNoSuffix && !EndsWithPrefix(typeName, prefix, comparison)) + { + canonicalName = typeName; + version = string.IsNullOrWhiteSpace(_options.DefaultVersion) ? "0" : _options.DefaultVersion; + return true; + } + + return false; + } + + var digitsLength = typeName.Length - digitsStart; + if (_options.Width > 0 && digitsLength != _options.Width) + return false; + + if (!string.IsNullOrEmpty(prefix)) + { + var prefixStart = digitsStart - prefix.Length; + if (prefixStart < 1) + return false; + + var candidatePrefix = typeName.Substring(prefixStart, prefix.Length); + if (!string.Equals(candidatePrefix, prefix, comparison)) + return false; + + canonicalName = typeName.Substring(0, prefixStart); + } + else + { + if (digitsStart < 1) + return false; + + canonicalName = typeName.Substring(0, digitsStart); + } + + version = typeName.Substring(digitsStart); + return !string.IsNullOrEmpty(canonicalName) && !string.IsNullOrEmpty(version); + } + + /// + public int Compare(string? v1, string? v2) + { + if (ReferenceEquals(v1, v2)) return 0; + if (v1 is null) return -1; + if (v2 is null) return 1; + + var s1 = v1.Trim(); + var s2 = v2.Trim(); + + var ok1 = long.TryParse(s1, NumberStyles.None, CultureInfo.InvariantCulture, out var n1); + var ok2 = long.TryParse(s2, NumberStyles.None, CultureInfo.InvariantCulture, out var n2); + + switch (ok1) + { + case true when ok2: + return n1.CompareTo(n2); + case true: + return 1; + } + + if (ok2) return -1; + + return StringComparer.Ordinal.Compare(s1, s2); + } + + private static int FindTrailingDigits(string value) + { + var i = value.Length - 1; + while (i >= 0 && value[i] >= '0' && value[i] <= '9') + { + i--; + } + + return i == value.Length - 1 ? -1 : i + 1; + } + + private static bool EndsWithPrefix(string value, string prefix, StringComparison comparison) => + !string.IsNullOrEmpty(prefix) && value.EndsWith(prefix, comparison); +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/ZeroPaddedNumericVersionStrategyOptions.cs b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/ZeroPaddedNumericVersionStrategyOptions.cs new file mode 100644 index 000000000..c64ca9bdb --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/VersionStrategies/ZeroPaddedNumericVersionStrategyOptions.cs @@ -0,0 +1,47 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning; + +/// +/// Options for . +/// +public sealed class ZeroPaddedNumericVersionStrategyOptions +{ + /// + /// Gets or sets the prefix used before the numeric suffix (for example, "V" in MyWorkflowV0001). + /// Set to an empty string to allow a raw numeric suffix. + /// + public string SuffixPrefix { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether prefix matching ignores case. + /// + public bool IgnorePrefixCase { get; set; } + + /// + /// Gets or sets the required width for the numeric suffix. Set to 0 to allow any width. + /// + public int Width { get; set; } = 4; + + /// + /// Gets or sets a value indicating whether names without a numeric suffix are allowed. + /// When enabled, the default version is applied. + /// + public bool AllowNoSuffix { get; set; } + + /// + /// Gets or sets the default version used when no suffix is present. + /// + public string DefaultVersion { get; set; } = "0"; +} diff --git a/src/Dapr.Workflow.Versioning.Runtime/WorkflowVersionStrategyContext.cs b/src/Dapr.Workflow.Versioning.Runtime/WorkflowVersionStrategyContext.cs new file mode 100644 index 000000000..524c72f7e --- /dev/null +++ b/src/Dapr.Workflow.Versioning.Runtime/WorkflowVersionStrategyContext.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning; + +/// +/// Context information provided to version strategies when they are constructed by the factory. +/// +public readonly record struct WorkflowVersionStrategyContext(string CanonicalName, string? OptionsName); diff --git a/src/Dapr.Workflow.Versioning.Runtime/WorkflowVersioningServiceCollectionExtensions.cs b/src/Dapr.Workflow.Versioning.Runtime/WorkflowVersioningServiceCollectionExtensions.cs index 822f7c4a7..ad7403919 100644 --- a/src/Dapr.Workflow.Versioning.Runtime/WorkflowVersioningServiceCollectionExtensions.cs +++ b/src/Dapr.Workflow.Versioning.Runtime/WorkflowVersioningServiceCollectionExtensions.cs @@ -36,6 +36,24 @@ public static IServiceCollection AddDaprWorkflowVersioning( // Options container for defaults var opts = new WorkflowVersioningOptions(); configure?.Invoke(opts); + + if (opts.DefaultStrategy is null) + { + opts.DefaultStrategy = sp => + { + var factory = sp.GetRequiredService(); + return factory.Create(typeof(NumericVersionStrategy), canonicalName: "DEFAULT", optionsName: null, services: sp); + }; + } + + if (opts.DefaultSelector is null) + { + opts.DefaultSelector = sp => + { + var factory = sp.GetRequiredService(); + return factory.Create(typeof(MaxVersionSelector), canonicalName: "DEFAULT", optionsName: null, services: sp); + }; + } // Register singletons for options, diagnostics, factories and resolver services.AddSingleton(opts); diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/Dapr.Workflow.Versioning.Runtime.Test.csproj b/test/Dapr.Workflow.Versioning.Runtime.Test/Dapr.Workflow.Versioning.Runtime.Test.csproj new file mode 100644 index 000000000..1e29f1a8e --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/Dapr.Workflow.Versioning.Runtime.Test.csproj @@ -0,0 +1,25 @@ + + + + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/DateSuffixVersionStrategyOptionsTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/DateSuffixVersionStrategyOptionsTests.cs new file mode 100644 index 000000000..e86bdb9de --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/DateSuffixVersionStrategyOptionsTests.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class DateSuffixVersionStrategyOptionsTests +{ + [Fact] + public void Defaults_ShouldMatchExpectedValues() + { + var options = new DateSuffixVersionStrategyOptions(); + + Assert.Equal("yyyyMMdd", options.DateFormat); + Assert.False(options.AllowNoSuffix); + Assert.Equal("0", options.DefaultVersion); + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/DateSuffixVersionStrategyTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/DateSuffixVersionStrategyTests.cs new file mode 100644 index 000000000..2b2e517ea --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/DateSuffixVersionStrategyTests.cs @@ -0,0 +1,98 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class DateSuffixVersionStrategyTests +{ + [Fact] + public void TryParse_ShouldParseDefaultFormat() + { + var strategy = new DateSuffixVersionStrategy(); + + var parsed = strategy.TryParse("MyWorkflow20260212", out var canonical, out var version); + + Assert.True(parsed); + Assert.Equal("MyWorkflow", canonical); + Assert.Equal("20260212", version); + } + + [Fact] + public void TryParse_ShouldRejectNoSuffix_ByDefault() + { + var strategy = new DateSuffixVersionStrategy(); + + Assert.False(strategy.TryParse("MyWorkflow", out _, out _)); + } + + [Fact] + public void TryParse_ShouldAllowNoSuffix_WhenEnabled() + { + var services = new ServiceCollection(); + services.AddOptions(Options.DefaultName) + .Configure(o => o.AllowNoSuffix = true); + + using var provider = services.BuildServiceProvider(); + var factory = new DefaultWorkflowVersionStrategyFactory(); + var strategy = (DateSuffixVersionStrategy)factory.Create( + typeof(DateSuffixVersionStrategy), + canonicalName: "Orders", + optionsName: null, + services: provider); + + Assert.True(strategy.TryParse("Orders", out var canonical, out var version)); + Assert.Equal("Orders", canonical); + Assert.Equal("0", version); + } + + [Fact] + public void TryParse_ShouldUseNamedFormatFromFactory() + { + var services = new ServiceCollection(); + services.AddOptions("custom") + .Configure(o => o.DateFormat = "yyyy-MM-dd"); + + using var provider = services.BuildServiceProvider(); + var factory = new DefaultWorkflowVersionStrategyFactory(); + var strategy = (DateSuffixVersionStrategy)factory.Create( + typeof(DateSuffixVersionStrategy), + canonicalName: "Orders", + optionsName: "custom", + services: provider); + + Assert.True(strategy.TryParse("Orders2026-02-12", out var canonical, out var version)); + Assert.Equal("Orders", canonical); + Assert.Equal("2026-02-12", version); + } + + [Fact] + public void Compare_ShouldOrderByDate() + { + var strategy = new DateSuffixVersionStrategy(); + + Assert.True(strategy.Compare("20260101", "20261231") < 0); + Assert.True(strategy.Compare("20261231", "20260101") > 0); + } + + [Fact] + public void Compare_ShouldPreferValidDateOverInvalid() + { + var strategy = new DateSuffixVersionStrategy(); + + Assert.True(strategy.Compare("20261231", "not-a-date") > 0); + Assert.True(strategy.Compare("bad", "20260101") < 0); + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/DefaultWorkflowVersionStrategyFactoryTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/DefaultWorkflowVersionStrategyFactoryTests.cs new file mode 100644 index 000000000..e0e07b4c3 --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/DefaultWorkflowVersionStrategyFactoryTests.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class DefaultWorkflowVersionStrategyFactoryTests +{ + [Fact] + public void Create_ShouldConfigureContextConsumer() + { + using var services = new ServiceCollection().BuildServiceProvider(); + var factory = new DefaultWorkflowVersionStrategyFactory(); + + var strategy = factory.Create(typeof(TestContextStrategy), "Orders", "orders-options", services); + var typed = Assert.IsType(strategy); + + Assert.Equal("Orders", typed.Context.CanonicalName); + Assert.Equal("orders-options", typed.Context.OptionsName); + } + + private sealed class TestContextStrategy : IWorkflowVersionStrategy, IWorkflowVersionStrategyContextConsumer + { + public WorkflowVersionStrategyContext Context { get; private set; } + + public void Configure(WorkflowVersionStrategyContext context) + { + Context = context; + } + + public bool TryParse(string typeName, out string canonicalName, out string version) + { + canonicalName = typeName; + version = "0"; + return true; + } + + public int Compare(string? v1, string? v2) => 0; + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/DelimitedSuffixVersionStrategyOptionsTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/DelimitedSuffixVersionStrategyOptionsTests.cs new file mode 100644 index 000000000..b922779b7 --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/DelimitedSuffixVersionStrategyOptionsTests.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class DelimitedSuffixVersionStrategyOptionsTests +{ + [Fact] + public void Defaults_ShouldMatchExpectedValues() + { + var options = new DelimitedSuffixVersionStrategyOptions(); + + Assert.Equal("-", options.Delimiter); + Assert.False(options.IgnoreDelimiterCase); + Assert.False(options.AllowNoSuffix); + Assert.Equal("0", options.DefaultVersion); + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/DelimitedSuffixVersionStrategyTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/DelimitedSuffixVersionStrategyTests.cs new file mode 100644 index 000000000..c008a2b5f --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/DelimitedSuffixVersionStrategyTests.cs @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class DelimitedSuffixVersionStrategyTests +{ + [Fact] + public void TryParse_ShouldParseDelimitedSuffix() + { + var strategy = new DelimitedSuffixVersionStrategy(); + + var parsed = strategy.TryParse("Orders-2", out var canonical, out var version); + + Assert.True(parsed); + Assert.Equal("Orders", canonical); + Assert.Equal("2", version); + } + + [Fact] + public void TryParse_ShouldRejectNoSuffix_ByDefault() + { + var strategy = new DelimitedSuffixVersionStrategy(); + + Assert.False(strategy.TryParse("Orders", out _, out _)); + } + + [Fact] + public void TryParse_ShouldAllowNoSuffix_WhenConfigured() + { + var services = new ServiceCollection(); + services.AddOptions(Options.DefaultName) + .Configure(o => o.AllowNoSuffix = true); + + using var provider = services.BuildServiceProvider(); + var factory = new DefaultWorkflowVersionStrategyFactory(); + var strategy = (DelimitedSuffixVersionStrategy)factory.Create( + typeof(DelimitedSuffixVersionStrategy), + canonicalName: "Orders", + optionsName: null, + services: provider); + + Assert.True(strategy.TryParse("Orders", out var canonical, out var version)); + Assert.Equal("Orders", canonical); + Assert.Equal("0", version); + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/ExplicitVersionStrategyOptionsTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/ExplicitVersionStrategyOptionsTests.cs new file mode 100644 index 000000000..e6677762e --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/ExplicitVersionStrategyOptionsTests.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class ExplicitVersionStrategyOptionsTests +{ + [Fact] + public void Defaults_ShouldMatchExpectedValues() + { + var options = new ExplicitVersionStrategyOptions(); + + Assert.False(options.AllowMissingVersion); + Assert.Equal("0", options.DefaultVersion); + Assert.False(options.IgnoreCase); + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/ExplicitVersionStrategyTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/ExplicitVersionStrategyTests.cs new file mode 100644 index 000000000..6a9662e97 --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/ExplicitVersionStrategyTests.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class ExplicitVersionStrategyTests +{ + [Fact] + public void TryParse_ShouldRejectMissingVersion_ByDefault() + { + var strategy = new ExplicitVersionStrategy(); + + Assert.False(strategy.TryParse("Orders", out _, out _)); + } + + [Fact] + public void TryParse_ShouldAllowMissingVersion_WhenConfigured() + { + var services = new ServiceCollection(); + services.AddOptions(Options.DefaultName) + .Configure(o => o.AllowMissingVersion = true); + + using var provider = services.BuildServiceProvider(); + var factory = new DefaultWorkflowVersionStrategyFactory(); + var strategy = (ExplicitVersionStrategy)factory.Create( + typeof(ExplicitVersionStrategy), + canonicalName: "Orders", + optionsName: null, + services: provider); + + Assert.True(strategy.TryParse("Orders", out var canonical, out var version)); + Assert.Equal("Orders", canonical); + Assert.Equal("0", version); + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/NumericVersionStrategyOptionsTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/NumericVersionStrategyOptionsTests.cs new file mode 100644 index 000000000..cb7cedd73 --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/NumericVersionStrategyOptionsTests.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class NumericVersionStrategyOptionsTests +{ + [Fact] + public void Defaults_ShouldMatchExpectedValues() + { + var options = new NumericVersionStrategyOptions(); + + Assert.Equal("V", options.SuffixPrefix); + Assert.False(options.IgnorePrefixCase); + Assert.True(options.AllowNoSuffix); + Assert.Equal("0", options.DefaultVersion); + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/NumericVersionStrategyTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/NumericVersionStrategyTests.cs new file mode 100644 index 000000000..67725c2e5 --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/NumericVersionStrategyTests.cs @@ -0,0 +1,99 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class NumericVersionStrategyTests +{ + [Fact] + public void TryParse_ShouldParseWithDefaultPrefix() + { + var strategy = new NumericVersionStrategy(); + + var parsed = strategy.TryParse("MyWorkflowV1", out var canonical, out var version); + + Assert.True(parsed); + Assert.Equal("MyWorkflow", canonical); + Assert.Equal("1", version); + } + + [Fact] + public void TryParse_ShouldParseDefaultVersion_WhenNoSuffix() + { + var strategy = new NumericVersionStrategy(); + + var parsed = strategy.TryParse("MyWorkflow", out var canonical, out var version); + + Assert.True(parsed); + Assert.Equal("MyWorkflow", canonical); + Assert.Equal("0", version); + } + + [Fact] + public void TryParse_ShouldRejectMissingPrefix_WhenDigitsPresent() + { + var strategy = new NumericVersionStrategy(); + + var parsed = strategy.TryParse("MyWorkflow1", out _, out _); + + Assert.False(parsed); + } + + [Theory] + [InlineData("1", "2")] + [InlineData("9", "10")] + public void Compare_ShouldOrderNumerically(string older, string newer) + { + var strategy = new NumericVersionStrategy(); + + Assert.True(strategy.Compare(older, newer) < 0); + Assert.True(strategy.Compare(newer, older) > 0); + } + + [Fact] + public void Compare_ShouldPreferNumericOverNonNumeric() + { + var strategy = new NumericVersionStrategy(); + + Assert.True(strategy.Compare("2", "beta") > 0); + Assert.True(strategy.Compare("alpha", "3") < 0); + } + + [Fact] + public void TryParse_ShouldUseNamedOptionsFromFactory() + { + var services = new ServiceCollection(); + services.AddOptions("custom") + .Configure(o => + { + o.SuffixPrefix = "v"; + o.IgnorePrefixCase = true; + o.AllowNoSuffix = false; + }); + + using var provider = services.BuildServiceProvider(); + var factory = new DefaultWorkflowVersionStrategyFactory(); + var strategy = (NumericVersionStrategy)factory.Create( + typeof(NumericVersionStrategy), + canonicalName: "Orders", + optionsName: "custom", + services: provider); + + Assert.True(strategy.TryParse("Ordersv2", out var canonical, out var version)); + Assert.Equal("Orders", canonical); + Assert.Equal("2", version); + Assert.False(strategy.TryParse("Orders2", out _, out _)); + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/SemVerVersionStrategyOptionsTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/SemVerVersionStrategyOptionsTests.cs new file mode 100644 index 000000000..7569a3a6c --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/SemVerVersionStrategyOptionsTests.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class SemVerVersionStrategyOptionsTests +{ + [Fact] + public void Defaults_ShouldMatchExpectedValues() + { + var options = new SemVerVersionStrategyOptions(); + + Assert.Equal("v", options.Prefix); + Assert.False(options.IgnorePrefixCase); + Assert.True(options.AllowPrerelease); + Assert.True(options.AllowBuildMetadata); + Assert.False(options.AllowNoSuffix); + Assert.Equal("0.0.0", options.DefaultVersion); + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/SemVerVersionStrategyTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/SemVerVersionStrategyTests.cs new file mode 100644 index 000000000..0170cbb10 --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/SemVerVersionStrategyTests.cs @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class SemVerVersionStrategyTests +{ + [Fact] + public void TryParse_ShouldParseWithDefaultPrefix() + { + var strategy = new SemVerVersionStrategy(); + + var parsed = strategy.TryParse("Ordersv1.2.3", out var canonical, out var version); + + Assert.True(parsed); + Assert.Equal("Orders", canonical); + Assert.Equal("1.2.3", version); + } + + [Fact] + public void Compare_ShouldRespectSemVerRules() + { + var strategy = new SemVerVersionStrategy(); + + Assert.True(strategy.Compare("1.2.3", "1.3.0") < 0); + Assert.True(strategy.Compare("1.2.3-alpha", "1.2.3") < 0); + Assert.Equal(0, strategy.Compare("1.0.0+build1", "1.0.0+build2")); + } + + [Fact] + public void TryParse_ShouldAllowNoSuffix_WhenConfigured() + { + var services = new ServiceCollection(); + services.AddOptions(Options.DefaultName) + .Configure(o => o.AllowNoSuffix = true); + + using var provider = services.BuildServiceProvider(); + var factory = new DefaultWorkflowVersionStrategyFactory(); + var strategy = (SemVerVersionStrategy)factory.Create( + typeof(SemVerVersionStrategy), + canonicalName: "Orders", + optionsName: null, + services: provider); + + Assert.True(strategy.TryParse("Orders", out var canonical, out var version)); + Assert.Equal("Orders", canonical); + Assert.Equal("0.0.0", version); + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/WorkflowVersionStrategyContextTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/WorkflowVersionStrategyContextTests.cs new file mode 100644 index 000000000..7e36d16c2 --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/WorkflowVersionStrategyContextTests.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class WorkflowVersionStrategyContextTests +{ + [Fact] + public void Constructor_ShouldStoreValues() + { + var context = new WorkflowVersionStrategyContext("Orders", "options"); + + Assert.Equal("Orders", context.CanonicalName); + Assert.Equal("options", context.OptionsName); + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/ZeroPaddedNumericVersionStrategyOptionsTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/ZeroPaddedNumericVersionStrategyOptionsTests.cs new file mode 100644 index 000000000..233514c07 --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/ZeroPaddedNumericVersionStrategyOptionsTests.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class ZeroPaddedNumericVersionStrategyOptionsTests +{ + [Fact] + public void Defaults_ShouldMatchExpectedValues() + { + var options = new ZeroPaddedNumericVersionStrategyOptions(); + + Assert.Equal(string.Empty, options.SuffixPrefix); + Assert.False(options.IgnorePrefixCase); + Assert.Equal(4, options.Width); + Assert.False(options.AllowNoSuffix); + Assert.Equal("0", options.DefaultVersion); + } +} diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/ZeroPaddedNumericVersionStrategyTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/ZeroPaddedNumericVersionStrategyTests.cs new file mode 100644 index 000000000..9f3af941e --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/ZeroPaddedNumericVersionStrategyTests.cs @@ -0,0 +1,58 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class ZeroPaddedNumericVersionStrategyTests +{ + [Fact] + public void TryParse_ShouldParseWithWidth() + { + var strategy = new ZeroPaddedNumericVersionStrategy(); + + Assert.True(strategy.TryParse("Orders0007", out var canonical, out var version)); + Assert.Equal("Orders", canonical); + Assert.Equal("0007", version); + } + + [Fact] + public void TryParse_ShouldRejectWrongWidth() + { + var strategy = new ZeroPaddedNumericVersionStrategy(); + + Assert.False(strategy.TryParse("Orders007", out _, out _)); + } + + [Fact] + public void TryParse_ShouldAllowNoSuffix_WhenConfigured() + { + var services = new ServiceCollection(); + services.AddOptions(Options.DefaultName) + .Configure(o => o.AllowNoSuffix = true); + + using var provider = services.BuildServiceProvider(); + var factory = new DefaultWorkflowVersionStrategyFactory(); + var strategy = (ZeroPaddedNumericVersionStrategy)factory.Create( + typeof(ZeroPaddedNumericVersionStrategy), + canonicalName: "Orders", + optionsName: null, + services: provider); + + Assert.True(strategy.TryParse("Orders", out var canonical, out var version)); + Assert.Equal("Orders", canonical); + Assert.Equal("0", version); + } +}