From 9a186341ee28104b651c9d3626b3878a02f38515 Mon Sep 17 00:00:00 2001 From: 9swampy Date: Fri, 27 Jun 2025 09:06:43 +0100 Subject: [PATCH] Support C# format strings in config --- .../Extensions/ShouldlyExtensions.cs | 24 +++++ .../StringFormatWithExtensionTests.cs | 25 ++++++ .../Helpers/DateFormatterTests.cs | 39 ++++++++ .../Helpers/EdgeCaseTests.cs | 60 +++++++++++++ .../Helpers/FormattableFormatterTests.cs | 41 +++++++++ .../Helpers/InputSanitizerTests.cs | 82 +++++++++++++++++ .../Helpers/NumericFormatterTests.cs | 38 ++++++++ .../Helpers/SanitizeEnvVarNameTests.cs | 67 ++++++++++++++ .../Helpers/SanitizeMemberNameTests.cs | 78 ++++++++++++++++ .../Helpers/StringFormatterTests.cs | 76 ++++++++++++++++ .../VariableProviderTests.cs | 30 +++++++ src/GitVersion.Core/Core/RegexPatterns.cs | 43 ++++++++- .../Extensions/StringExtensions.cs | 20 +++++ src/GitVersion.Core/Helpers/DateFormatter.cs | 31 +++++++ .../Helpers/ExpressionCompiler.cs | 20 +++++ .../Helpers/FormattableFormatter.cs | 43 +++++++++ .../Helpers/IExpressionCompiler.cs | 7 ++ .../Helpers/IInputSanitizer.cs | 11 +++ .../Helpers/IMemberResolver.cs | 6 ++ .../Helpers/IValueFormatter.cs | 11 +++ src/GitVersion.Core/Helpers/InputSanitizer.cs | 66 ++++++++++++++ src/GitVersion.Core/Helpers/MemberResolver.cs | 74 ++++++++++++++++ .../Helpers/NumericFormatter.cs | 48 ++++++++++ .../Helpers/StringFormatWith.cs | 88 +++++++++++++------ .../Helpers/StringFormatter.cs | 54 ++++++++++++ src/GitVersion.Core/Helpers/ValueFormatter.cs | 36 ++++++++ 26 files changed, 1089 insertions(+), 29 deletions(-) create mode 100644 src/GitVersion.Core.Tests/Extensions/ShouldlyExtensions.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/DateFormatterTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/EdgeCaseTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/FormattableFormatterTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/InputSanitizerTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/NumericFormatterTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/SanitizeEnvVarNameTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/SanitizeMemberNameTests.cs create mode 100644 src/GitVersion.Core.Tests/Helpers/StringFormatterTests.cs create mode 100644 src/GitVersion.Core/Helpers/DateFormatter.cs create mode 100644 src/GitVersion.Core/Helpers/ExpressionCompiler.cs create mode 100644 src/GitVersion.Core/Helpers/FormattableFormatter.cs create mode 100644 src/GitVersion.Core/Helpers/IExpressionCompiler.cs create mode 100644 src/GitVersion.Core/Helpers/IInputSanitizer.cs create mode 100644 src/GitVersion.Core/Helpers/IMemberResolver.cs create mode 100644 src/GitVersion.Core/Helpers/IValueFormatter.cs create mode 100644 src/GitVersion.Core/Helpers/InputSanitizer.cs create mode 100644 src/GitVersion.Core/Helpers/MemberResolver.cs create mode 100644 src/GitVersion.Core/Helpers/NumericFormatter.cs create mode 100644 src/GitVersion.Core/Helpers/StringFormatter.cs create mode 100644 src/GitVersion.Core/Helpers/ValueFormatter.cs diff --git a/src/GitVersion.Core.Tests/Extensions/ShouldlyExtensions.cs b/src/GitVersion.Core.Tests/Extensions/ShouldlyExtensions.cs new file mode 100644 index 0000000000..bac7a4346e --- /dev/null +++ b/src/GitVersion.Core.Tests/Extensions/ShouldlyExtensions.cs @@ -0,0 +1,24 @@ +namespace GitVersion.Core.Tests.Extensions; + +public static class ShouldlyExtensions +{ + /// + /// Asserts that the action throws an exception of type TException + /// with the expected message. + /// + public static void ShouldThrowWithMessage(this Action action, string expectedMessage) where TException : Exception + { + var ex = Should.Throw(action); + ex.Message.ShouldBe(expectedMessage); + } + + /// + /// Asserts that the action throws an exception of type TException, + /// and allows further assertion on the exception instance. + /// + public static void ShouldThrow(this Action action, Action additionalAssertions) where TException : Exception + { + var ex = Should.Throw(action); + additionalAssertions(ex); + } +} diff --git a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs index 791625aeaa..144b69baf0 100644 --- a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs +++ b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs @@ -244,4 +244,29 @@ public void FormatProperty_NullObject_WithFallback_QuotedAndEmpty() var actual = target.FormatWith(propertyObject, this.environment); Assert.That(actual, Is.EqualTo("")); } + + [Test] + public void FormatAssemblyInformationalVersionWithSemanticVersionCustomFormattedCommitsSinceVersionSource() + { + var semanticVersion = new SemanticVersion + { + Major = 1, + Minor = 2, + Patch = 3, + PreReleaseTag = new SemanticVersionPreReleaseTag(string.Empty, 9, true), + BuildMetaData = new SemanticVersionBuildMetaData("Branch.main") + { + Branch = "main", + VersionSourceSha = "versionSourceSha", + Sha = "commitSha", + ShortSha = "commitShortSha", + CommitsSinceVersionSource = 42, + CommitDate = DateTimeOffset.Parse("2014-03-06 23:59:59Z") + } + }; + const string target = "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}"; + const string expected = "1.2.3-0042"; + var actual = target.FormatWith(semanticVersion, this.environment); + Assert.That(actual, Is.EqualTo(expected)); + } } diff --git a/src/GitVersion.Core.Tests/Helpers/DateFormatterTests.cs b/src/GitVersion.Core.Tests/Helpers/DateFormatterTests.cs new file mode 100644 index 0000000000..2fa58b3fb6 --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/DateFormatterTests.cs @@ -0,0 +1,39 @@ +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +[TestFixture] +public class DateFormatterTests +{ + [Test] + public void Priority_ShouldBe2() => new DateFormatter().Priority.ShouldBe(2); + + [Test] + public void TryFormat_NullValue_ReturnsFalse() + { + var sut = new DateFormatter(); + var result = sut.TryFormat(null, "yyyy-MM-dd", out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } + + [TestCase("2021-01-01", "date:yyyy-MM-dd", "2021-01-01")] + [TestCase("2021-01-01T12:00:00Z", "date:yyyy-MM-ddTHH:mm:ssZ", "2021-01-01T12:00:00Z")] + public void TryFormat_ValidDateFormats_ReturnsExpectedResult(string input, string format, string expected) + { + var date = DateTime.Parse(input); + var sut = new DateFormatter(); + var result = sut.TryFormat(date, format, out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe(expected); + } + + [Test] + public void TryFormat_UnsupportedFormat_ReturnsFalse() + { + var sut = new DateFormatter(); + var result = sut.TryFormat(DateTime.Now, "unsupported", out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/EdgeCaseTests.cs b/src/GitVersion.Core.Tests/Helpers/EdgeCaseTests.cs new file mode 100644 index 0000000000..9aacdb7e9d --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/EdgeCaseTests.cs @@ -0,0 +1,60 @@ +using GitVersion.Core.Tests.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +public partial class InputSanitizerTests +{ + [TestFixture] + public class EdgeCaseTests : InputSanitizerTests + { + [TestCase(49)] + [TestCase(50)] + public void SanitizeFormat_WithBoundaryLengths_ReturnsInput(int length) + { + var input = new string('x', length); + new InputSanitizer().SanitizeFormat(input).ShouldBe(input); + } + + [TestCase(199)] + [TestCase(200)] + public void SanitizeEnvVarName_WithBoundaryLengths_ReturnsInput(int length) + { + var input = new string('A', length); + new InputSanitizer().SanitizeEnvVarName(input).ShouldBe(input); + } + + [TestCase(99)] + [TestCase(100)] + public void SanitizeMemberName_WithBoundaryLengths_ReturnsInput(int length) + { + var input = new string('A', length); + new InputSanitizer().SanitizeMemberName(input).ShouldBe(input); + } + + [Test] + public void SanitizeFormat_WithUnicode_ReturnsInput() + { + const string unicodeFormat = "测试format"; + new InputSanitizer().SanitizeFormat(unicodeFormat).ShouldBe(unicodeFormat); + } + + [Test] + public void SanitizeEnvVarName_WithUnicode_ThrowsArgumentException() + { + const string unicodeEnvVar = "测试_VAR"; + Action act = () => new InputSanitizer().SanitizeEnvVarName(unicodeEnvVar); + act.ShouldThrowWithMessage( + $"Environment variable name contains disallowed characters: '{unicodeEnvVar}'"); + } + + [Test] + public void SanitizeMemberName_WithUnicode_ThrowsArgumentException() + { + const string unicodeMember = "测试Member"; + Action act = () => new InputSanitizer().SanitizeMemberName(unicodeMember); + act.ShouldThrowWithMessage( + $"Member name contains disallowed characters: '{unicodeMember}'"); + } + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/FormattableFormatterTests.cs b/src/GitVersion.Core.Tests/Helpers/FormattableFormatterTests.cs new file mode 100644 index 0000000000..6145397ec5 --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/FormattableFormatterTests.cs @@ -0,0 +1,41 @@ +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +[TestFixture] +public class FormattableFormatterTests +{ + [Test] + public void Priority_ShouldBe2() => new FormattableFormatter().Priority.ShouldBe(2); + + [Test] + public void TryFormat_NullValue_ReturnsFalse() + { + var sut = new FormattableFormatter(); + var result = sut.TryFormat(null, "G", out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } + + [TestCase(123.456, "F2", "123.46")] + [TestCase(1234.456, "F2", "1234.46")] + public void TryFormat_ValidFormats_ReturnsExpectedResult(object input, string format, string expected) + { + var sut = new FormattableFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe(expected); + } + + [TestCase(123.456, "C", "Format 'C' is not supported in FormattableFormatter")] + [TestCase(123.456, "P", "Format 'P' is not supported in FormattableFormatter")] + [TestCase(1234567890, "N0", "Format 'N0' is not supported in FormattableFormatter")] + [TestCase(1234567890, "Z", "Format 'Z' is not supported in FormattableFormatter")] + public void TryFormat_UnsupportedFormat_ReturnsFalse(object input, string format, string expected) + { + var sut = new FormattableFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBe(expected); + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/InputSanitizerTests.cs b/src/GitVersion.Core.Tests/Helpers/InputSanitizerTests.cs new file mode 100644 index 0000000000..c0899c13ad --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/InputSanitizerTests.cs @@ -0,0 +1,82 @@ +using GitVersion.Core.Tests.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +[TestFixture] +public partial class InputSanitizerTests +{ + [TestFixture] + public class SanitizeFormatTests : InputSanitizerTests + { + [Test] + public void SanitizeFormat_WithValidFormat_ReturnsInput() + { + var sut = new InputSanitizer(); + const string validFormat = "yyyy-MM-dd"; + sut.SanitizeFormat(validFormat).ShouldBe(validFormat); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + public void SanitizeFormat_WithEmptyOrWhitespace_ThrowsFormatException(string invalidFormat) + { + var sut = new InputSanitizer(); + Action act = () => sut.SanitizeFormat(invalidFormat); + act.ShouldThrowWithMessage("Format string cannot be empty."); + } + + [Test] + public void SanitizeFormat_WithTooLongFormat_ThrowsFormatException() + { + var sut = new InputSanitizer(); + var longFormat = new string('x', 51); + Action act = () => sut.SanitizeFormat(longFormat); + act.ShouldThrowWithMessage("Format string too long: 'xxxxxxxxxxxxxxxxxxxx...'"); + } + + [Test] + public void SanitizeFormat_WithMaxValidLength_ReturnsInput() + { + var sut = new InputSanitizer(); + var maxLengthFormat = new string('x', 50); + sut.SanitizeFormat(maxLengthFormat).ShouldBe(maxLengthFormat); + } + + [TestCase("\r", TestName = "SanitizeFormat_ControlChar_CR")] + [TestCase("\n", TestName = "SanitizeFormat_ControlChar_LF")] + [TestCase("\0", TestName = "SanitizeFormat_ControlChar_Null")] + [TestCase("\x01", TestName = "SanitizeFormat_ControlChar_0x01")] + [TestCase("\x1F", TestName = "SanitizeFormat_ControlChar_0x1F")] + public void SanitizeFormat_WithControlCharacters_ThrowsFormatException(string controlChar) + { + var sut = new InputSanitizer(); + var formatWithControl = $"valid{controlChar}format"; + Action act = () => sut.SanitizeFormat(formatWithControl); + act.ShouldThrowWithMessage("Format string contains invalid control characters"); + } + + [Test] + public void SanitizeFormat_WithTabCharacter_ReturnsInput() + { + var sut = new InputSanitizer(); + const string formatWithTab = "format\twith\ttab"; + sut.SanitizeFormat(formatWithTab).ShouldBe(formatWithTab); + } + + [TestCase("yyyy-MM-dd")] + [TestCase("HH:mm:ss")] + [TestCase("0.00")] + [TestCase("C2")] + [TestCase("X8")] + [TestCase("format with spaces")] + [TestCase("format-with-dashes")] + [TestCase("format_with_underscores")] + public void SanitizeFormat_WithValidFormats_ReturnsInput(string validFormat) + { + var sut = new InputSanitizer(); + sut.SanitizeFormat(validFormat).ShouldBe(validFormat); + } + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/NumericFormatterTests.cs b/src/GitVersion.Core.Tests/Helpers/NumericFormatterTests.cs new file mode 100644 index 0000000000..0d34e2fa57 --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/NumericFormatterTests.cs @@ -0,0 +1,38 @@ +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +[TestFixture] +public class NumericFormatterTests +{ + [Test] + public void Priority_ShouldBe1() => new NumericFormatter().Priority.ShouldBe(1); + [Test] + public void TryFormat_NullValue_ReturnsFalse() + { + var sut = new NumericFormatter(); + var result = sut.TryFormat(null, "n", out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } + + [TestCase("1234.5678", "n", "1,234.57")] + [TestCase("1234.5678", "f2", "1234.57")] + [TestCase("1234.5678", "f0", "1235")] + [TestCase("1234.5678", "g", "1234.5678")] + public void TryFormat_ValidFormats_ReturnsExpectedResult(string input, string format, string expected) + { + var sut = new NumericFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe(expected); + } + [Test] + public void TryFormat_UnsupportedFormat_ReturnsFalse() + { + var sut = new NumericFormatter(); + var result = sut.TryFormat(1234.5678, "z", out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/SanitizeEnvVarNameTests.cs b/src/GitVersion.Core.Tests/Helpers/SanitizeEnvVarNameTests.cs new file mode 100644 index 0000000000..d66ce416a1 --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/SanitizeEnvVarNameTests.cs @@ -0,0 +1,67 @@ +using GitVersion.Core.Tests.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +public partial class InputSanitizerTests +{ + [TestFixture] + public class SanitizeEnvVarNameTests : InputSanitizerTests + { + [Test] + public void SanitizeEnvVarName_WithValidName_ReturnsInput() + { + var sut = new InputSanitizer(); + const string validName = "VALID_ENV_VAR"; + sut.SanitizeEnvVarName(validName).ShouldBe(validName); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + public void SanitizeEnvVarName_WithEmptyOrWhitespace_ThrowsArgumentException(string invalidName) + { + var sut = new InputSanitizer(); + Action act = () => sut.SanitizeEnvVarName(invalidName); + act.ShouldThrowWithMessage("Environment variable name cannot be null or empty."); + } + + [Test] + public void SanitizeEnvVarName_WithTooLongName_ThrowsArgumentException() + { + var sut = new InputSanitizer(); + var longName = new string('A', 201); + Action act = () => sut.SanitizeEnvVarName(longName); + act.ShouldThrowWithMessage("Environment variable name too long: 'AAAAAAAAAAAAAAAAAAAA...'"); + } + + [Test] + public void SanitizeEnvVarName_WithMaxValidLength_ReturnsInput() + { + var sut = new InputSanitizer(); + var maxLengthName = new string('A', 200); + sut.SanitizeEnvVarName(maxLengthName).ShouldBe(maxLengthName); + } + + [Test] + public void SanitizeEnvVarName_WithInvalidCharacters_ThrowsArgumentException() + { + var sut = new InputSanitizer(); + const string invalidName = "INVALID@NAME"; + Action act = () => sut.SanitizeEnvVarName(invalidName); + act.ShouldThrowWithMessage("Environment variable name contains disallowed characters: 'INVALID@NAME'"); + } + + [TestCase("PATH")] + [TestCase("HOME")] + [TestCase("USER_NAME")] + [TestCase("MY_VAR_123")] + [TestCase("_PRIVATE_VAR")] + [TestCase("VAR123")] + public void SanitizeEnvVarName_WithValidNames_ReturnsInput(string validName) + { + var sut = new InputSanitizer(); + sut.SanitizeEnvVarName(validName).ShouldBe(validName); + } + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/SanitizeMemberNameTests.cs b/src/GitVersion.Core.Tests/Helpers/SanitizeMemberNameTests.cs new file mode 100644 index 0000000000..2fc4d07b48 --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/SanitizeMemberNameTests.cs @@ -0,0 +1,78 @@ +using GitVersion.Core.Tests.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +public partial class InputSanitizerTests +{ + [TestFixture] + public class SanitizeMemberNameTests : InputSanitizerTests + { + [Test] + public void SanitizeMemberName_WithValidName_ReturnsInput() + { + var sut = new InputSanitizer(); + const string validName = "ValidMemberName"; + sut.SanitizeMemberName(validName).ShouldBe(validName); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + public void SanitizeMemberName_WithEmptyOrWhitespace_ThrowsArgumentException(string invalidName) + { + var sut = new InputSanitizer(); + Action act = () => sut.SanitizeMemberName(invalidName); + act.ShouldThrowWithMessage("Member name cannot be empty."); + } + + [Test] + public void SanitizeMemberName_WithTooLongName_ThrowsArgumentException() + { + var sut = new InputSanitizer(); + var longName = new string('A', 101); + Action act = () => sut.SanitizeMemberName(longName); + act.ShouldThrowWithMessage("Member name too long: 'AAAAAAAAAAAAAAAAAAAA...'"); + } + + [Test] + public void SanitizeMemberName_WithMaxValidLength_ReturnsInput() + { + var sut = new InputSanitizer(); + var maxLengthName = new string('A', 100); + sut.SanitizeMemberName(maxLengthName).ShouldBe(maxLengthName); + } + + [Test] + public void SanitizeMemberName_WithInvalidCharacters_ThrowsArgumentException() + { + var sut = new InputSanitizer(); + const string invalidName = "Invalid@Member"; + Action act = () => sut.SanitizeMemberName(invalidName); + act.ShouldThrowWithMessage("Member name contains disallowed characters: 'Invalid@Member'"); + } + + [TestCase("PropertyName")] + [TestCase("FieldName")] + [TestCase("Member123")] + [TestCase("_privateMember")] + [TestCase("CamelCaseName")] + [TestCase("PascalCaseName")] + [TestCase("member_with_underscores")] + public void SanitizeMemberName_WithValidNames_ReturnsInput(string validName) + { + var sut = new InputSanitizer(); + sut.SanitizeMemberName(validName).ShouldBe(validName); + } + + [TestCase("member.nested")] + [TestCase("Parent.Child.GrandChild")] + public void SanitizeMemberName_WithDottedNames_HandledByRegex(string dottedName) + { + var sut = new InputSanitizer(); + Action act = () => sut.SanitizeMemberName(dottedName); + + act.ShouldNotThrow(); + } + } +} diff --git a/src/GitVersion.Core.Tests/Helpers/StringFormatterTests.cs b/src/GitVersion.Core.Tests/Helpers/StringFormatterTests.cs new file mode 100644 index 0000000000..e1eb140277 --- /dev/null +++ b/src/GitVersion.Core.Tests/Helpers/StringFormatterTests.cs @@ -0,0 +1,76 @@ +using GitVersion.Helpers; + +namespace GitVersion.Tests.Helpers; + +[TestFixture] +public class StringFormatterTests +{ + [Test] + public void Priority_ShouldBe2() => new StringFormatter().Priority.ShouldBe(2); + + [TestCase("u")] + [TestCase("")] + [TestCase(" ")] + [TestCase("invalid")] + public void TryFormat_NullValue_ReturnsFalse(string format) + { + var sut = new StringFormatter(); + var result = sut.TryFormat(null, format, out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } + + [TestCase("hello", "u", "HELLO")] + [TestCase("HELLO", "l", "hello")] + [TestCase("hello world", "t", "Hello World")] + [TestCase("hELLO", "s", "Hello")] + [TestCase("hello world", "c", "HelloWorld")] + public void TryFormat_ValidFormats_ReturnsExpectedResult(string input, string format, string expected) + { + var sut = new StringFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBe(expected); + } + + [TestCase("", "s")] + [TestCase("", "u")] + [TestCase("", "l")] + [TestCase("", "t")] + [TestCase("", "c")] + public void TryFormat_EmptyStringWithValidFormat_ReturnsEmpty(string input, string format) + { + var sut = new StringFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBeEmpty(); + } + + [TestCase("test", "")] + [TestCase("test", " ")] + [TestCase("test", "invalid")] + [TestCase("invalid", "")] + [TestCase("invalid", " ")] + [TestCase("invalid", "invalid")] + public void TryFormat_ValidStringWithInvalidFormat_ReturnsFalse(string input, string format) + { + var sut = new StringFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeFalse(); + formatted.ShouldBeEmpty(); + } + + [TestCase("", "")] + [TestCase("", " ")] + [TestCase("", "invalid")] + [TestCase(" ", "")] + [TestCase(" ", " ")] + [TestCase(" ", "invalid")] + public void TryFormat_EmptyOrWhitespaceStringWithInvalidFormat_ReturnsTrue(string input, string format) + { + var sut = new StringFormatter(); + var result = sut.TryFormat(input, format, out var formatted); + result.ShouldBeTrue(); + formatted.ShouldBeEmpty(); + } +} diff --git a/src/GitVersion.Core.Tests/VersionCalculation/VariableProviderTests.cs b/src/GitVersion.Core.Tests/VersionCalculation/VariableProviderTests.cs index dc96e7c4f7..5795da40ed 100644 --- a/src/GitVersion.Core.Tests/VersionCalculation/VariableProviderTests.cs +++ b/src/GitVersion.Core.Tests/VersionCalculation/VariableProviderTests.cs @@ -284,4 +284,34 @@ public void ProvidesVariablesInContinuousDeploymentModeForMainBranchWithEmptyLab variables.ToJson().ShouldMatchApproved(x => x.SubFolder("Approved")); } + + [Test] + public void Format_Allows_CSharp_FormatStrings() + { + var semanticVersion = new SemanticVersion + { + Major = 1, + Minor = 2, + Patch = 3, + PreReleaseTag = new(string.Empty, 9, true), + BuildMetaData = new("Branch.main") + { + Branch = "main", + VersionSourceSha = "versionSourceSha", + Sha = "commitSha", + ShortSha = "commitShortSha", + CommitsSinceVersionSource = 42, + CommitDate = DateTimeOffset.Parse("2014-03-06 23:59:59Z") + } + }; + + var configuration = GitFlowConfigurationBuilder.New + .WithTagPreReleaseWeight(0) + .WithAssemblyInformationalFormat("{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}") + .Build(); + var preReleaseWeight = configuration.GetEffectiveConfiguration(ReferenceName.FromBranchName("develop")).PreReleaseWeight; + var variables = this.variableProvider.GetVariablesFor(semanticVersion, configuration, preReleaseWeight); + + variables.InformationalVersion.ShouldBe("1.2.3-0042"); + } } diff --git a/src/GitVersion.Core/Core/RegexPatterns.cs b/src/GitVersion.Core/Core/RegexPatterns.cs index 9bdb704808..a84161731d 100644 --- a/src/GitVersion.Core/Core/RegexPatterns.cs +++ b/src/GitVersion.Core/Core/RegexPatterns.cs @@ -29,6 +29,8 @@ public static Regex GetOrAdd([StringSyntax(StringSyntaxAttribute.Regex)] string [Common.SwitchArgumentRegexPattern] = Common.SwitchArgumentRegex, [Common.ObscurePasswordRegexPattern] = Common.ObscurePasswordRegex, [Common.ExpandTokensRegexPattern] = Common.ExpandTokensRegex, + [Common.SanitizeEnvVarNameRegexPattern] = Common.SanitizeEnvVarNameRegex, + [Common.SanitizeMemberNameRegexPattern] = Common.SanitizeMemberNameRegex, [Common.SanitizeNameRegexPattern] = Common.SanitizeNameRegex, [Configuration.DefaultTagPrefixRegexPattern] = Configuration.DefaultTagPrefixRegex, [Configuration.DefaultVersionInBranchRegexPattern] = Configuration.DefaultVersionInBranchRegex, @@ -84,7 +86,38 @@ internal static partial class Common internal const string ObscurePasswordRegexPattern = "(https?://)(.+)(:.+@)"; [StringSyntax(StringSyntaxAttribute.Regex)] - internal const string ExpandTokensRegexPattern = """{((env:(?\w+))|(?\w+))(\s+(\?\?)??\s+((?\w+)|"(?.*)"))??}"""; + internal const string ExpandTokensRegexPattern = """ + \{ # Opening brace + (?: # Start of either env or member expression + env:(?!env:)(?[A-Za-z_][A-Za-z0-9_]*) # Only a single env: prefix, not followed by another env: + | # OR + (?[A-Za-z_][A-Za-z0-9_]*) # member/property name + (?: # Optional format specifier + :(?[A-Za-z0-9\.\-,]+) # Colon followed by format string (no spaces, ?, or }), format cannot contain colon + )? # Format is optional + ) # End group for env or member + (?: # Optional fallback group + \s*\?\?\s+ # '??' operator with optional whitespace: exactly two question marks for fallback + (?: # Fallback value alternatives: + (?\w+) # A single word fallback + | # OR + "(?[^"]*)" # A quoted string fallback + ) + )? # Fallback is optional + \} + """; + + /// + /// Allow alphanumeric, underscore, colon (for custom format specification), hyphen, and dot + /// + [StringSyntax(StringSyntaxAttribute.Regex, Options)] + internal const string SanitizeEnvVarNameRegexPattern = @"^[A-Za-z0-9_:\-\.]+$"; + + /// + /// Allow alphanumeric, underscore, and dot for property/field access + /// + [StringSyntax(StringSyntaxAttribute.Regex, Options)] + internal const string SanitizeMemberNameRegexPattern = @"^[A-Za-z0-9_\.]+$"; [StringSyntax(StringSyntaxAttribute.Regex, Options)] internal const string SanitizeNameRegexPattern = "[^a-zA-Z0-9-]"; @@ -95,9 +128,15 @@ internal static partial class Common [GeneratedRegex(ObscurePasswordRegexPattern, Options)] public static partial Regex ObscurePasswordRegex(); - [GeneratedRegex(ExpandTokensRegexPattern, Options)] + [GeneratedRegex(ExpandTokensRegexPattern, RegexOptions.IgnorePatternWhitespace | Options)] public static partial Regex ExpandTokensRegex(); + [GeneratedRegex(SanitizeEnvVarNameRegexPattern, Options)] + public static partial Regex SanitizeEnvVarNameRegex(); + + [GeneratedRegex(SanitizeMemberNameRegexPattern, Options)] + public static partial Regex SanitizeMemberNameRegex(); + [GeneratedRegex(SanitizeNameRegexPattern, Options)] public static partial Regex SanitizeNameRegex(); } diff --git a/src/GitVersion.Core/Extensions/StringExtensions.cs b/src/GitVersion.Core/Extensions/StringExtensions.cs index 317fe51704..f7b73127ea 100644 --- a/src/GitVersion.Core/Extensions/StringExtensions.cs +++ b/src/GitVersion.Core/Extensions/StringExtensions.cs @@ -30,4 +30,24 @@ public static bool IsEquivalentTo(this string self, string? other) => public static string WithPrefixIfNotNullOrEmpty(this string value, string prefix) => string.IsNullOrEmpty(value) ? value : prefix + value; + + internal static string PascalCase(this string input) + { + var sb = new StringBuilder(input.Length); + var capitalizeNext = true; + + foreach (var c in input) + { + if (!char.IsLetterOrDigit(c)) + { + capitalizeNext = true; + continue; + } + + sb.Append(capitalizeNext ? char.ToUpperInvariant(c) : char.ToLowerInvariant(c)); + capitalizeNext = false; + } + + return sb.ToString(); + } } diff --git a/src/GitVersion.Core/Helpers/DateFormatter.cs b/src/GitVersion.Core/Helpers/DateFormatter.cs new file mode 100644 index 0000000000..17fc03e0c8 --- /dev/null +++ b/src/GitVersion.Core/Helpers/DateFormatter.cs @@ -0,0 +1,31 @@ +using System.Globalization; + +namespace GitVersion.Helpers; + +internal class DateFormatter : IValueFormatter +{ + public int Priority => 2; + + public bool TryFormat(object? value, string format, out string result) + { + result = string.Empty; + + if (value is DateTime dt && format.StartsWith("date:")) + { + var dateFormat = RemoveDatePrefix(format); + result = dt.ToString(dateFormat, CultureInfo.InvariantCulture); + return true; + } + + if (value is string dateStr && DateTime.TryParse(dateStr, out var parsedDate) && format.StartsWith("date:")) + { + var dateFormat = format.Substring(5); + result = parsedDate.ToString(dateFormat, CultureInfo.InvariantCulture); + return true; + } + + return false; + } + + private static string RemoveDatePrefix(string format) => format.Substring(5); +} diff --git a/src/GitVersion.Core/Helpers/ExpressionCompiler.cs b/src/GitVersion.Core/Helpers/ExpressionCompiler.cs new file mode 100644 index 0000000000..270b3e8308 --- /dev/null +++ b/src/GitVersion.Core/Helpers/ExpressionCompiler.cs @@ -0,0 +1,20 @@ +using System.Linq.Expressions; + +namespace GitVersion.Helpers; + +internal class ExpressionCompiler : IExpressionCompiler +{ + public Func CompileGetter(Type type, MemberInfo[] memberPath) + { + var param = Expression.Parameter(typeof(object)); + Expression body = Expression.Convert(param, type); + + foreach (var member in memberPath) + { + body = Expression.PropertyOrField(body, member.Name); + } + + body = Expression.Convert(body, typeof(object)); + return Expression.Lambda>(body, param).Compile(); + } +} diff --git a/src/GitVersion.Core/Helpers/FormattableFormatter.cs b/src/GitVersion.Core/Helpers/FormattableFormatter.cs new file mode 100644 index 0000000000..8835827f10 --- /dev/null +++ b/src/GitVersion.Core/Helpers/FormattableFormatter.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace GitVersion.Helpers; + +internal class FormattableFormatter : IValueFormatter +{ + public int Priority => 2; + + public bool TryFormat(object? value, string format, out string result) + { + result = string.Empty; + + if (string.IsNullOrWhiteSpace(format)) + return false; + + if (IsBlockedFormat(format)) + { + result = $"Format '{format}' is not supported in {nameof(FormattableFormatter)}"; + return false; + } + + if (value is IFormattable formattable) + { + try + { + result = formattable.ToString(format, CultureInfo.InvariantCulture); + return true; + } + catch (FormatException) + { + result = $"Format '{format}' is not supported in {nameof(FormattableFormatter)}"; + return false; + } + } + + return false; + } + + private static bool IsBlockedFormat(string format) => + format.Equals("C", StringComparison.OrdinalIgnoreCase) || + format.Equals("P", StringComparison.OrdinalIgnoreCase) || + format.StartsWith("N", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/GitVersion.Core/Helpers/IExpressionCompiler.cs b/src/GitVersion.Core/Helpers/IExpressionCompiler.cs new file mode 100644 index 0000000000..e270f3a4de --- /dev/null +++ b/src/GitVersion.Core/Helpers/IExpressionCompiler.cs @@ -0,0 +1,7 @@ +namespace GitVersion.Helpers +{ + internal interface IExpressionCompiler + { + Func CompileGetter(Type type, MemberInfo[] memberPath); + } +} diff --git a/src/GitVersion.Core/Helpers/IInputSanitizer.cs b/src/GitVersion.Core/Helpers/IInputSanitizer.cs new file mode 100644 index 0000000000..f130457a94 --- /dev/null +++ b/src/GitVersion.Core/Helpers/IInputSanitizer.cs @@ -0,0 +1,11 @@ +namespace GitVersion.Helpers +{ + internal interface IInputSanitizer + { + string SanitizeEnvVarName(string name); + + string SanitizeFormat(string format); + + string SanitizeMemberName(string memberName); + } +} diff --git a/src/GitVersion.Core/Helpers/IMemberResolver.cs b/src/GitVersion.Core/Helpers/IMemberResolver.cs new file mode 100644 index 0000000000..9805e7f978 --- /dev/null +++ b/src/GitVersion.Core/Helpers/IMemberResolver.cs @@ -0,0 +1,6 @@ +namespace GitVersion.Helpers; + +internal interface IMemberResolver +{ + MemberInfo[] ResolveMemberPath(Type type, string memberExpression); +} diff --git a/src/GitVersion.Core/Helpers/IValueFormatter.cs b/src/GitVersion.Core/Helpers/IValueFormatter.cs new file mode 100644 index 0000000000..eec804e032 --- /dev/null +++ b/src/GitVersion.Core/Helpers/IValueFormatter.cs @@ -0,0 +1,11 @@ +namespace GitVersion.Helpers; + +internal interface IValueFormatter +{ + bool TryFormat(object? value, string format, out string result); + + /// + /// Lower number = higher priority + /// + int Priority { get; } +} diff --git a/src/GitVersion.Core/Helpers/InputSanitizer.cs b/src/GitVersion.Core/Helpers/InputSanitizer.cs new file mode 100644 index 0000000000..7a33484a9f --- /dev/null +++ b/src/GitVersion.Core/Helpers/InputSanitizer.cs @@ -0,0 +1,66 @@ +using GitVersion.Core; + +namespace GitVersion.Helpers; + +internal class InputSanitizer : IInputSanitizer +{ + public string SanitizeFormat(string format) + { + if (string.IsNullOrWhiteSpace(format)) + { + throw new FormatException("Format string cannot be empty."); + } + + if (format.Length > 50) + { + throw new FormatException($"Format string too long: '{format[..20]}...'"); + } + + if (format.Any(c => char.IsControl(c) && c != '\t')) + { + throw new FormatException("Format string contains invalid control characters"); + } + + return format; + } + + public string SanitizeEnvVarName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Environment variable name cannot be null or empty."); + } + + if (name.Length > 200) + { + throw new ArgumentException($"Environment variable name too long: '{name[..20]}...'"); + } + + if (!RegexPatterns.Cache.GetOrAdd(RegexPatterns.Common.SanitizeEnvVarNameRegexPattern).IsMatch(name)) + { + throw new ArgumentException($"Environment variable name contains disallowed characters: '{name}'"); + } + + return name; + } + + public string SanitizeMemberName(string memberName) + { + if (string.IsNullOrWhiteSpace(memberName)) + { + throw new ArgumentException("Member name cannot be empty."); + } + + if (memberName.Length > 100) + { + throw new ArgumentException($"Member name too long: '{memberName[..20]}...'"); + } + + if (!RegexPatterns.Cache.GetOrAdd(RegexPatterns.Common.SanitizeMemberNameRegexPattern).IsMatch(memberName)) + { + throw new ArgumentException($"Member name contains disallowed characters: '{memberName}'"); + } + + return memberName; + } +} diff --git a/src/GitVersion.Core/Helpers/MemberResolver.cs b/src/GitVersion.Core/Helpers/MemberResolver.cs new file mode 100644 index 0000000000..4df54bd04f --- /dev/null +++ b/src/GitVersion.Core/Helpers/MemberResolver.cs @@ -0,0 +1,74 @@ +namespace GitVersion.Helpers; + +internal class MemberResolver : IMemberResolver +{ + public MemberInfo[] ResolveMemberPath(Type type, string memberExpression) + { + var memberNames = memberExpression.Split('.'); + var path = new List(); + var currentType = type; + + foreach (var memberName in memberNames) + { + var member = FindDirectMember(currentType, memberName); + if (member == null) + { + var recursivePath = FindMemberRecursive(type, memberName, []); + return recursivePath == null + ? throw new ArgumentException($"'{memberName}' is not a property or field on type '{type.Name}'") + : [.. recursivePath]; + } + + path.Add(member); + currentType = GetMemberType(member); + } + + return [.. path]; + } + + public static List? FindMemberRecursive(Type type, string memberName, HashSet visited) + { + if (!visited.Add(type)) + { + return null; + } + + var member = FindDirectMember(type, memberName); + if (member != null) + { + return [member]; + } + + foreach (var prop in type.GetProperties()) + { + var nestedPath = FindMemberRecursive(prop.PropertyType, memberName, visited); + if (nestedPath != null) + { + nestedPath.Insert(0, prop); + return nestedPath; + } + } + + foreach (var field in type.GetFields()) + { + var nestedPath = FindMemberRecursive(field.FieldType, memberName, visited); + if (nestedPath != null) + { + nestedPath.Insert(0, field); + return nestedPath; + } + } + + return null; + } + + private static MemberInfo? FindDirectMember(Type type, string memberName) + => type.GetProperty(memberName) ?? (MemberInfo?)type.GetField(memberName); + + private static Type GetMemberType(MemberInfo member) => member switch + { + PropertyInfo p => p.PropertyType, + FieldInfo f => f.FieldType, + _ => throw new ArgumentException($"Unsupported member type: {member.GetType()}") + }; +} diff --git a/src/GitVersion.Core/Helpers/NumericFormatter.cs b/src/GitVersion.Core/Helpers/NumericFormatter.cs new file mode 100644 index 0000000000..8c1f4d4acf --- /dev/null +++ b/src/GitVersion.Core/Helpers/NumericFormatter.cs @@ -0,0 +1,48 @@ +using System.Globalization; + +namespace GitVersion.Helpers; + +internal class NumericFormatter : IValueFormatter +{ + public int Priority => 1; + + public bool TryFormat(object? value, string format, out string result) + { + result = string.Empty; + + if (value is not string s) + { + return false; + } + + // Integer formatting + if (format.All(char.IsDigit) && int.TryParse(s, out var i)) + { + result = i.ToString(format, CultureInfo.InvariantCulture); + return true; + } + + // Hexadecimal formatting + if ((format.StartsWith('X') || format.StartsWith('x')) && int.TryParse(s, out var hex)) + { + result = hex.ToString(format, CultureInfo.InvariantCulture); + return true; + } + + // Floating point formatting + if ("FEGNCP".Contains(char.ToUpperInvariant(format[0])) && double.TryParse(s, out var d)) + { + result = d.ToString(format, CultureInfo.InvariantCulture); + return true; + } + + // Decimal formatting + if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var dec)) + { + result = dec.ToString(format, CultureInfo.InvariantCulture); + return true; + } + + return false; + } +} diff --git a/src/GitVersion.Core/Helpers/StringFormatWith.cs b/src/GitVersion.Core/Helpers/StringFormatWith.cs index d36d5df01d..4786d244a3 100644 --- a/src/GitVersion.Core/Helpers/StringFormatWith.cs +++ b/src/GitVersion.Core/Helpers/StringFormatWith.cs @@ -1,4 +1,3 @@ -using System.Linq.Expressions; using System.Text.RegularExpressions; using GitVersion.Core; @@ -6,6 +5,12 @@ namespace GitVersion.Helpers; internal static class StringFormatWithExtension { + internal static IExpressionCompiler ExpressionCompiler { get; set; } = new ExpressionCompiler(); + + internal static IInputSanitizer InputSanitizer { get; set; } = new InputSanitizer(); + + internal static IMemberResolver MemberResolver { get; set; } = new MemberResolver(); + /// /// Formats the , replacing each expression wrapped in curly braces /// with the corresponding property from the or . @@ -33,38 +38,67 @@ public static string FormatWith(this string template, T? source, IEnvironment ArgumentNullException.ThrowIfNull(template); ArgumentNullException.ThrowIfNull(source); + var result = new StringBuilder(); + var lastIndex = 0; + foreach (var match in RegexPatterns.Common.ExpandTokensRegex().Matches(template).Cast()) { - string propertyValue; - var fallback = match.Groups["fallback"].Success ? match.Groups["fallback"].Value : null; - - if (match.Groups["envvar"].Success) - { - var envVar = match.Groups["envvar"].Value; - propertyValue = environment.GetEnvironmentVariable(envVar) ?? fallback - ?? throw new ArgumentException($"Environment variable {envVar} not found and no fallback string provided"); - } - else - { - var objType = source.GetType(); - var memberAccessExpression = match.Groups["member"].Value; - var expression = CompileDataBinder(objType, memberAccessExpression); - // It would be better to throw if the expression and fallback produce null, but provide an empty string for back compat. - propertyValue = expression(source)?.ToString() ?? fallback ?? ""; - } - - template = template.Replace(match.Value, propertyValue); + var replacement = EvaluateMatch(match, source, environment); + result.Append(template, lastIndex, match.Index - lastIndex); + result.Append(replacement); + lastIndex = match.Index + match.Length; + } + + result.Append(template, lastIndex, template.Length - lastIndex); + return result.ToString(); + } + + private static string EvaluateMatch(Match match, T source, IEnvironment environment) + { + var fallback = match.Groups["fallback"].Success ? match.Groups["fallback"].Value : null; + + if (match.Groups["envvar"].Success) + { + return EvaluateEnvVar(match.Groups["envvar"].Value, fallback, environment); + } + + if (match.Groups["member"].Success) + { + var format = match.Groups["format"].Success ? match.Groups["format"].Value : null; + return EvaluateMember(source, match.Groups["member"].Value, format, fallback); } - return template; + throw new ArgumentException($"Invalid token format: '{match.Value}'"); + } + + private static string EvaluateEnvVar(string name, string? fallback, IEnvironment env) + { + var safeName = InputSanitizer.SanitizeEnvVarName(name); + return env.GetEnvironmentVariable(safeName) + ?? fallback + ?? throw new ArgumentException($"Environment variable {safeName} not found and no fallback provided"); } - private static Func CompileDataBinder(Type type, string expr) + private static string EvaluateMember(T source, string member, string? format, string? fallback) { - var param = Expression.Parameter(typeof(object)); - Expression body = Expression.Convert(param, type); - body = expr.Split('.').Aggregate(body, Expression.PropertyOrField); - body = Expression.Convert(body, typeof(object)); // Convert result in case the body produces a Nullable value type. - return Expression.Lambda>(body, param).Compile(); + var safeMember = InputSanitizer.SanitizeMemberName(member); + var memberPath = MemberResolver.ResolveMemberPath(source!.GetType(), safeMember); + var getter = ExpressionCompiler.CompileGetter(source.GetType(), memberPath); + var value = getter(source); + + if (value is null) + { + return fallback ?? string.Empty; + } + + if (format is not null && ValueFormatter.TryFormat( + value, + InputSanitizer.SanitizeFormat(format), + out var formatted)) + { + return formatted; + } + + return value.ToString() ?? fallback ?? string.Empty; } } diff --git a/src/GitVersion.Core/Helpers/StringFormatter.cs b/src/GitVersion.Core/Helpers/StringFormatter.cs new file mode 100644 index 0000000000..12b6d55395 --- /dev/null +++ b/src/GitVersion.Core/Helpers/StringFormatter.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using GitVersion.Extensions; + +namespace GitVersion.Helpers; + +internal class StringFormatter : IValueFormatter +{ + public int Priority => 2; + + public bool TryFormat(object? value, string format, out string result) + { + if (value is not string stringValue) + { + result = string.Empty; + return false; + } + + if (string.IsNullOrWhiteSpace(stringValue)) + { + result = string.Empty; + return true; + } + + switch (format) + { + case "u": + result = stringValue.ToUpperInvariant(); + return true; + case "l": + result = stringValue.ToLowerInvariant(); + return true; + case "t": + result = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(stringValue.ToLowerInvariant()); + return true; + case "s": + if (stringValue.Length == 1) + { + result = stringValue.ToUpperInvariant(); + } + else + { + result = char.ToUpperInvariant(stringValue[0]) + stringValue[1..].ToLowerInvariant(); + } + + return true; + case "c": + result = stringValue.PascalCase(); + return true; + default: + result = string.Empty; + return false; + } + } +} diff --git a/src/GitVersion.Core/Helpers/ValueFormatter.cs b/src/GitVersion.Core/Helpers/ValueFormatter.cs new file mode 100644 index 0000000000..a947f44424 --- /dev/null +++ b/src/GitVersion.Core/Helpers/ValueFormatter.cs @@ -0,0 +1,36 @@ +namespace GitVersion.Helpers; + +internal static class ValueFormatter +{ + private static readonly List _formatters = + [ + new StringFormatter(), + new FormattableFormatter(), + new NumericFormatter(), + new DateFormatter() + ]; + + public static bool TryFormat(object? value, string format, out string result) + { + result = string.Empty; + + if (value is null) + { + return false; + } + + foreach (var formatter in _formatters.OrderBy(f => f.Priority)) + { + if (formatter.TryFormat(value, format, out result)) + { + return true; + } + } + + return false; + } + + public static void RegisterFormatter(IValueFormatter formatter) => _formatters.Add(formatter); + + public static void RemoveFormatter() where T : IValueFormatter => _formatters.RemoveAll(f => f is T); +}