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