diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs new file mode 100644 index 0000000..1935f35 --- /dev/null +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/Checks.cs @@ -0,0 +1,40 @@ +namespace ZB.MOM.WW.Configuration; + +/// +/// Internal rule primitives shared by (validates a bound options +/// object) and (validates raw IConfiguration). Each method +/// returns null when valid, or a formatted "<field> <reason>" message +/// otherwise. Centralizing them keeps wording identical across both front-ends. +/// +internal static class Checks +{ + internal static string? Required(string? value, string field) => + string.IsNullOrWhiteSpace(value) ? $"{field} is required" : null; + + internal static string? Port(int value, string field) => + value is < 1 or > 65535 ? $"{field} must be between 1 and 65535 (was {value})" : null; + + internal static string? HostPort(string? value, string field) + { + if (string.IsNullOrWhiteSpace(value)) return $"{field} is required"; + var idx = value.LastIndexOf(':'); + if (idx <= 0 || idx == value.Length - 1 + || !int.TryParse(value[(idx + 1)..], out var port) + || port is < 1 or > 65535) + return $"{field} must be 'host:port' with port 1-65535 (was '{value}')"; + return null; + } + + internal static string? PositiveTimeSpan(TimeSpan value, string field) => + value <= TimeSpan.Zero ? $"{field} must be a positive duration (was {value})" : null; + + internal static string? OneOf(string? value, IReadOnlyCollection allowed, string field) => + value is not null && allowed.Contains(value, StringComparer.OrdinalIgnoreCase) + ? null + : $"{field} must be one of [{string.Join(", ", allowed)}] (was '{value ?? "null"}')"; + + internal static string? MinCount(IReadOnlyCollection? value, int min, string field) => + value is null || value.Count < min + ? $"{field} must contain at least {min} item(s) (had {value?.Count ?? 0})" + : null; +} diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs new file mode 100644 index 0000000..e221734 --- /dev/null +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ValidationBuilder.cs @@ -0,0 +1,56 @@ +namespace ZB.MOM.WW.Configuration; + +/// +/// Accumulates validation failures for an options object. Passed by +/// into your Validate override; each primitive +/// both checks a value and appends a consistently-formatted message on failure. Use +/// / for custom or cross-field rules. +/// +public sealed class ValidationBuilder +{ + private readonly List _failures = []; + + /// The accumulated failure messages (empty when validation passed). + public IReadOnlyList Failures => _failures; + + /// True when no failures have been accumulated. + public bool IsValid => _failures.Count == 0; + + /// Records as a failure when is false. + public ValidationBuilder RequireThat(bool ok, string message) + { + if (!ok) _failures.Add(message); + return this; + } + + /// Unconditionally records as a failure. + public ValidationBuilder Add(string message) + { + _failures.Add(message); + return this; + } + + /// Requires a non-null, non-whitespace string. + public ValidationBuilder Required(string? value, string field) => AddIf(Checks.Required(value, field)); + + /// Requires a TCP port in 1-65535. + public ValidationBuilder Port(int value, string field) => AddIf(Checks.Port(value, field)); + + /// Requires a 'host:port' endpoint with a valid port. + public ValidationBuilder HostPort(string? value, string field) => AddIf(Checks.HostPort(value, field)); + + /// Requires a strictly positive duration. + public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field) => AddIf(Checks.PositiveTimeSpan(value, field)); + + /// Requires the value to be one of (case-insensitive). + public ValidationBuilder OneOf(string? value, IReadOnlyCollection allowed, string field) => AddIf(Checks.OneOf(value, allowed, field)); + + /// Requires a collection with at least items. + public ValidationBuilder MinCount(IReadOnlyCollection? value, int min, string field) => AddIf(Checks.MinCount(value, min, field)); + + private ValidationBuilder AddIf(string? message) + { + if (message is not null) _failures.Add(message); + return this; + } +} diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs new file mode 100644 index 0000000..e28a495 --- /dev/null +++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs @@ -0,0 +1,74 @@ +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.Configuration.Tests; + +public sealed class ValidationBuilderTests +{ + [Theory] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(65535, true)] + [InlineData(65536, false)] + public void Port_validates_range(int port, bool valid) + { + var b = new ValidationBuilder(); + b.Port(port, "X:Port"); + Assert.Equal(valid, b.IsValid); + } + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("ok", true)] + public void Required_rejects_null_empty_whitespace(string? value, bool valid) + { + var b = new ValidationBuilder(); + b.Required(value, "X:Name"); + Assert.Equal(valid, b.IsValid); + } + + [Theory] + [InlineData("host:5000", true)] + [InlineData("host", false)] + [InlineData("host:0", false)] + [InlineData("host:notaport", false)] + public void HostPort_validates_endpoint(string value, bool valid) + { + var b = new ValidationBuilder(); + b.HostPort(value, "X:Endpoint"); + Assert.Equal(valid, b.IsValid); + } + + [Fact] + public void PositiveTimeSpan_rejects_zero_and_negative() + { + var b = new ValidationBuilder(); + b.PositiveTimeSpan(TimeSpan.Zero, "X:T1").PositiveTimeSpan(TimeSpan.FromSeconds(-1), "X:T2"); + Assert.Equal(2, b.Failures.Count); + } + + [Fact] + public void OneOf_is_case_insensitive() + { + var b = new ValidationBuilder(); + b.OneOf("CENTRAL", new[] { "Central", "Site" }, "X:Role"); + Assert.True(b.IsValid); + } + + [Fact] + public void MinCount_requires_minimum() + { + var b = new ValidationBuilder(); + b.MinCount(new[] { "a" }, 2, "X:Seeds"); + Assert.False(b.IsValid); + } + + [Fact] + public void Accumulates_all_failures_and_RequireThat_Add_work() + { + var b = new ValidationBuilder(); + b.Required(null, "A").RequireThat(false, "B failed").Add("C failed"); + Assert.Equal(3, b.Failures.Count); + } +}