diff --git a/docs/input/docs/reference/configuration.md b/docs/input/docs/reference/configuration.md
index 78f90204b8..9c4dd1a8f6 100644
--- a/docs/input/docs/reference/configuration.md
+++ b/docs/input/docs/reference/configuration.md
@@ -477,6 +477,9 @@ while still updating the `AssemblyFileVersion` and `AssemblyInformationVersion`
attributes. Valid values: `MajorMinorPatchTag`, `MajorMinorPatch`, `MajorMinor`,
`Major`, `None`.
+For information on using format strings in these properties, see
+[Format Strings](/docs/reference/custom-formatting).
+
### assembly-file-versioning-scheme
When updating assembly info, `assembly-file-versioning-scheme` tells GitVersion
@@ -632,7 +635,7 @@ ignore:
- ^docs\/
```
##### *Monorepo*
-This ignore config can be used to filter only those commits that belong to a specific project in a monorepo.
+This ignore config can be used to filter only those commits that belong to a specific project in a monorepo.
As an example, consider a monorepo consisting of subdirectories for `ProjectA`, `ProjectB` and a shared `LibraryC`. For GitVersion to consider only commits that are part of `projectA` and shared library `LibraryC`, a regex that matches all paths except those starting with `ProjectA` or `LibraryC` can be used. Either one of the following configs would filter out `ProjectB`.
* Specific match on `/ProjectB/*`:
```yaml
@@ -655,7 +658,7 @@ A commit having changes only in `/ProjectB/*` path would be ignored. A commit ha
* `/ProductA/*` and `/ProductB/*` and `/LibraryC/*`
:::
-Note: The `ignore.paths` configuration is case-sensitive. This can lead to unexpected behavior on case-insensitive file systems, such as Windows. To ensure consistent matching regardless of case, you can prefix your regular expressions with the case-insensitive flag `(?i)`. For example, `(?i)^docs\/` will match both `docs/` and `Docs/`.
+Note: The `ignore.paths` configuration is case-sensitive. This can lead to unexpected behavior on case-insensitive file systems, such as Windows. To ensure consistent matching regardless of case, you can prefix your regular expressions with the case-insensitive flag `(?i)`. For example, `(?i)^docs\/` will match both `docs/` and `Docs/`.
:::
::: {.alert .alert-warning}
diff --git a/docs/input/docs/reference/custom-formatting.md b/docs/input/docs/reference/custom-formatting.md
new file mode 100644
index 0000000000..621bd844de
--- /dev/null
+++ b/docs/input/docs/reference/custom-formatting.md
@@ -0,0 +1,252 @@
+---
+title: Format Strings
+description: Using C# format strings in GitVersion configuration
+---
+
+GitVersion supports C# format strings in configuration, allowing you to apply standard .NET formatting and custom transformations to version properties. This enhancement provides more flexibility and control over how version information is displayed and used throughout your build process.
+
+## Overview
+
+The custom formatter functionality introduces several new formatters that can be used in GitVersion configuration files and templates:
+
+- **FormattableFormatter**: Supports standard .NET format strings for numeric values, dates, and implements `IFormattable`
+- **NumericFormatter**: Handles numeric formatting with culture-aware output
+- **DateTimeFormatter**: Provides date and time formatting with standard and custom format specifiers
+- **String Case Formatters**: Provides text case transformations with custom format specifiers
+
+## Standard .NET Format Strings
+
+### Numeric Formatting
+
+You can now use standard .NET numeric format strings with version components:
+
+```yaml
+# GitVersion.yml
+template: "{Major}.{Minor}.{Patch:F2}-{PreReleaseLabel}"
+```
+
+**Supported Numeric Formats:**
+
+- `F` or `f` (Fixed-point): `{Patch:F2}` → `"1.23"`
+- `N` or `n` (Number): `{BuildMetadata:N0}` → `"1,234"`
+- `C` or `c` (Currency): `{Major:C}` → `"¤1.00"`
+- `P` or `p` (Percent): `{CommitsSinceVersionSource:P}` → `"12,345.60 %"`
+- `D` or `d` (Decimal): `{Major:D4}` → `"0001"`
+- `X` or `x` (Hexadecimal): `{Patch:X}` → `"FF"`
+
+### Date and Time Formatting
+
+When working with date-related properties like `CommitDate`:
+
+```yaml
+template: "Build-{SemVer}-{CommitDate:yyyy-MM-dd}"
+```
+
+**Common Date Format Specifiers:**
+
+- `yyyy-MM-dd` → `"2024-03-15"`
+- `HH:mm:ss` → `"14:30:22"`
+- `MMM dd, yyyy` → `"Mar 15, 2024"`
+- `yyyy-MM-dd'T'HH:mm:ss'Z'` → `"2024-03-15T14:30:22Z"`
+
+## Custom String Case Formatters
+
+GitVersion introduces custom format specifiers for string case transformations that can be used in templates:
+
+### Available Case Formats
+
+| Format | Description | Example Input | Example Output |
+|--------|---------------------------------------------------------------|------------------|------------------|
+| `u` | **Uppercase** - Converts entire string to uppercase | `feature-branch` | `FEATURE-BRANCH` |
+| `l` | **Lowercase** - Converts entire string to lowercase | `Feature-Branch` | `feature-branch` |
+| `t` | **Title Case** - Capitalizes first letter of each word | `feature-branch` | `Feature-Branch` |
+| `s` | **Sentence Case** - Capitalizes only the first letter | `feature-branch` | `Feature-branch` |
+| `c` | **PascalCase** - Removes separators and capitalizes each word | `feature-branch` | `FeatureBranch` |
+
+### Usage Examples
+
+```yaml
+# GitVersion.yml configuration
+branches:
+ feature:
+ label: "{BranchName:c}" # Converts to PascalCase
+
+template: "{Major}.{Minor}.{Patch}-{PreReleaseLabel:l}.{CommitsSinceVersionSource:0000}"
+```
+
+**Template Usage:**
+
+```yaml
+# Using format strings in templates
+assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}"
+template: "{SemVer}-{BranchName:l}"
+```
+
+## Examples
+
+Based on actual test cases from the implementation:
+
+### Zero-Padded Numeric Formatting
+
+```yaml
+# Zero-padded commit count
+assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}"
+# Result: "1.2.3-0042"
+```
+
+### String Case Transformations
+
+```yaml
+branches:
+ feature:
+ label: "{BranchName:c}" # PascalCase: "feature-branch" → "FeatureBranch"
+ hotfix:
+ label: "hotfix-{BranchName:l}" # Lowercase: "HOTFIX-BRANCH" → "hotfix-branch"
+```
+
+### Date and Time Formatting
+
+```yaml
+template: "{SemVer}-build-{CommitDate:yyyy-MM-dd}"
+# Result: "1.2.3-build-2021-01-01"
+```
+
+### Numeric Formatting
+
+```yaml
+# Currency format (uses InvariantCulture)
+template: "Cost-{Major:C}" # Result: "Cost-¤1.00"
+
+# Percentage format
+template: "Progress-{Minor:P}" # Result: "Progress-200.00 %"
+
+# Thousands separator
+template: "Build-{CommitsSinceVersionSource:N0}" # Result: "Build-1,234"
+```
+
+## Configuration Integration
+
+The format strings are used in GitVersion configuration files through various formatting properties:
+
+### Assembly Version Formatting
+
+```yaml
+# GitVersion.yml
+assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}"
+assembly-versioning-format: "{Major}.{Minor}.{Patch}.{env:BUILD_NUMBER}"
+assembly-file-versioning-format: "{MajorMinorPatch}.{CommitsSinceVersionSource}"
+```
+
+### Template-Based Configuration
+
+```yaml
+# Global template for consistent formatting across all variables
+template: "{SemVer}-{BranchName:l}-{ShortSha}"
+
+branches:
+ main:
+ label: ""
+ feature:
+ label: "{BranchName:c}.{CommitsSinceVersionSource}"
+ increment: Minor
+ release:
+ label: "rc-{CommitsSinceVersionSource:000}"
+ increment: None
+```
+
+### Environment Variable Integration
+
+```yaml
+# Using environment variables with fallbacks
+template: "{Major}.{Minor}.{Patch}-{env:RELEASE_STAGE ?? 'dev'}"
+assembly-informational-format: "{SemVer}+{env:BUILD_ID ?? 'local'}"
+```
+
+### Real-World Integration Examples
+
+Based on the actual test implementation:
+
+```yaml
+# Example from VariableProviderTests.cs
+assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}"
+# Result: "1.2.3-0042" when CommitsSinceVersionSource = 42
+
+# Branch-specific formatting
+branches:
+ feature:
+ label: "{BranchName:c}" # PascalCase conversion
+ hotfix:
+ label: "hotfix.{CommitsSinceVersionSource:00}"
+```
+
+## Invariant Culture Formatting
+
+The formatting system uses `CultureInfo.InvariantCulture` by default through the chained `TryFormat` overload implementation. This provides:
+
+- **Consistent results** across all environments and systems
+- **Predictable numeric formatting** with period (.) as decimal separator and comma (,) as thousands separator
+- **Standard date formatting** using English month names and formats
+- **No localization variations** regardless of system locale
+
+```csharp
+// All environments produce the same output:
+// {CommitsSinceVersionSource:N0} → "1,234"
+// {CommitDate:MMM dd, yyyy} → "Mar 15, 2024"
+// {Major:C} → "¤1.00" (generic currency symbol)
+```
+
+This ensures that version strings generated by GitVersion are consistent across different build environments, developer machines, and CI/CD systems.
+
+## Verified Examples
+
+The following examples are verified by actual unit tests in the GitVersion codebase:
+
+### Zero-Padded Numeric Formatting
+
+```yaml
+assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000}"
+```
+
+**Test**: `VariableProviderTests.Format_Allows_CSharp_FormatStrings()`
+**Input**: `CommitsSinceVersionSource = 42`
+**Output**: `"1.2.3-0042"`
+
+### String Case Transformations
+
+```csharp
+// From StringFormatterTests.cs
+[TestCase("hello world", "c", "HelloWorld")] // PascalCase
+[TestCase("hello", "u", "HELLO")] // Uppercase
+[TestCase("HELLO", "l", "hello")] // Lowercase
+[TestCase("hello world", "t", "Hello World")] // Title Case
+[TestCase("hELLO", "s", "Hello")] // Sentence Case
+```
+
+### Numeric Format Specifiers
+
+```csharp
+// From NumericFormatterTests.cs
+[TestCase("1234.5678", "n", "1,234.57")] // Number format
+[TestCase("1234.5678", "f2", "1234.57")] // Fixed-point format
+[TestCase("1234.5678", "f0", "1235")] // No decimals
+```
+
+### Date Formatting
+
+```csharp
+// From DateFormatterTests.cs
+[TestCase("2021-01-01", "yyyy-MM-dd", "2021-01-01")]
+[TestCase("2021-01-01T12:00:00Z", "yyyy-MM-ddTHH:mm:ssZ", "2021-01-01T12:00:00Z")]
+```
+
+### Currency and Percentage (InvariantCulture)
+
+```csharp
+// From FormattableFormatterTests.cs
+[TestCase(123.456, "C", "¤123.46")] // Generic currency symbol
+[TestCase(123.456, "P", "12,345.60 %")] // Percentage format
+[TestCase(1234567890, "N0", "1,234,567,890")] // Thousands separators
+```
+
+[reference-configuration]: /docs/reference/configuration
+[variables]: /docs/reference/variables
diff --git a/docs/input/docs/reference/mdsource/configuration.source.md b/docs/input/docs/reference/mdsource/configuration.source.md
index 1b29b89523..f08c69c816 100644
--- a/docs/input/docs/reference/mdsource/configuration.source.md
+++ b/docs/input/docs/reference/mdsource/configuration.source.md
@@ -78,6 +78,9 @@ while still updating the `AssemblyFileVersion` and `AssemblyInformationVersion`
attributes. Valid values: `MajorMinorPatchTag`, `MajorMinorPatch`, `MajorMinor`,
`Major`, `None`.
+For information on using format strings in these properties, see
+[Format Strings](/docs/reference/custom-formatting).
+
### assembly-file-versioning-scheme
When updating assembly info, `assembly-file-versioning-scheme` tells GitVersion
@@ -233,7 +236,7 @@ ignore:
- ^docs\/
```
##### *Monorepo*
-This ignore config can be used to filter only those commits that belong to a specific project in a monorepo.
+This ignore config can be used to filter only those commits that belong to a specific project in a monorepo.
As an example, consider a monorepo consisting of subdirectories for `ProjectA`, `ProjectB` and a shared `LibraryC`. For GitVersion to consider only commits that are part of `projectA` and shared library `LibraryC`, a regex that matches all paths except those starting with `ProjectA` or `LibraryC` can be used. Either one of the following configs would filter out `ProjectB`.
* Specific match on `/ProjectB/*`:
```yaml
@@ -256,7 +259,7 @@ A commit having changes only in `/ProjectB/*` path would be ignored. A commit ha
* `/ProductA/*` and `/ProductB/*` and `/LibraryC/*`
:::
-Note: The `ignore.paths` configuration is case-sensitive. This can lead to unexpected behavior on case-insensitive file systems, such as Windows. To ensure consistent matching regardless of case, you can prefix your regular expressions with the case-insensitive flag `(?i)`. For example, `(?i)^docs\/` will match both `docs/` and `Docs/`.
+Note: The `ignore.paths` configuration is case-sensitive. This can lead to unexpected behavior on case-insensitive file systems, such as Windows. To ensure consistent matching regardless of case, you can prefix your regular expressions with the case-insensitive flag `(?i)`. For example, `(?i)^docs\/` will match both `docs/` and `Docs/`.
:::
::: {.alert .alert-warning}
diff --git a/docs/input/docs/reference/variables.md b/docs/input/docs/reference/variables.md
index 8f963405fd..158891797a 100644
--- a/docs/input/docs/reference/variables.md
+++ b/docs/input/docs/reference/variables.md
@@ -74,4 +74,8 @@ within a [supported build server][build-servers]), the above version variables
may be exposed automatically as **environment variables** in the format
`GitVersion_FullSemVer`.
+## Formatting Variables
+
+GitVersion variables can be formatted using C# format strings. See [Format Strings](/docs/reference/custom-formatting) for details.
+
[build-servers]: ./build-servers/
diff --git a/docs/input/docs/usage/cli/arguments.md b/docs/input/docs/usage/cli/arguments.md
index b503a1f899..a406ca76a0 100644
--- a/docs/input/docs/usage/cli/arguments.md
+++ b/docs/input/docs/usage/cli/arguments.md
@@ -38,6 +38,7 @@ GitVersion [path]
- will output `1.2.3+beta.4`
/format Used in conjunction with /output json, will output a format
containing version variables.
+ Supports C# format strings - see [Format Strings](/docs/reference/custom-formatting) for details.
E.g. /output json /format {SemVer} - will output `1.2.3+beta.4`
/output json /format {Major}.{Minor} - will output `1.2`
/l Path to logfile.
diff --git a/docs/input/docs/usage/msbuild.md b/docs/input/docs/usage/msbuild.md
index 5b3c258173..5cacea82fd 100644
--- a/docs/input/docs/usage/msbuild.md
+++ b/docs/input/docs/usage/msbuild.md
@@ -98,6 +98,8 @@ Now, when you build:
appended to it.
* `AssemblyInformationalVersion` will be set to the `InformationalVersion` variable.
+Assembly version formatting can use C# format strings. See [Format Strings](/docs/reference/custom-formatting) for available options.
+
#### Other injected Variables
All other [variables](/docs/reference/variables) will be injected into an
diff --git a/new-cli/GitVersion.Common/GitVersion.Common.csproj b/new-cli/GitVersion.Common/GitVersion.Common.csproj
index 61737d8507..065fa33110 100644
--- a/new-cli/GitVersion.Common/GitVersion.Common.csproj
+++ b/new-cli/GitVersion.Common/GitVersion.Common.csproj
@@ -12,6 +12,7 @@
+
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..4de05822ac 100644
--- a/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs
+++ b/src/GitVersion.Core.Tests/Extensions/StringFormatWithExtensionTests.cs
@@ -1,5 +1,5 @@
using GitVersion.Core.Tests.Helpers;
-using GitVersion.Helpers;
+using GitVersion.Formatting;
namespace GitVersion.Core.Tests;
@@ -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/Formatting/DateFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs
new file mode 100644
index 0000000000..3e5215f4f3
--- /dev/null
+++ b/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs
@@ -0,0 +1,30 @@
+using GitVersion.Formatting;
+
+namespace GitVersion.Tests.Formatting;
+
+[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", "yyyy-MM-dd", "2021-01-01")]
+ [TestCase("2021-01-01T12:00:00Z", "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);
+ }
+}
diff --git a/src/GitVersion.Core.Tests/Formatting/EdgeCaseTests.cs b/src/GitVersion.Core.Tests/Formatting/EdgeCaseTests.cs
new file mode 100644
index 0000000000..5d8e0e49b3
--- /dev/null
+++ b/src/GitVersion.Core.Tests/Formatting/EdgeCaseTests.cs
@@ -0,0 +1,60 @@
+using GitVersion.Core.Tests.Extensions;
+using GitVersion.Formatting;
+
+namespace GitVersion.Tests.Formatting;
+
+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/Formatting/FormattableFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs
new file mode 100644
index 0000000000..1404c18b20
--- /dev/null
+++ b/src/GitVersion.Core.Tests/Formatting/FormattableFormatterTests.cs
@@ -0,0 +1,54 @@
+using System.Globalization;
+using GitVersion.Formatting;
+
+namespace GitVersion.Tests.Formatting;
+
+[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", CultureInfo.InvariantCulture, out var formatted);
+ result.ShouldBeFalse();
+ formatted.ShouldBeEmpty();
+ }
+
+ [TestCase(123.456, "F2", "123.46")]
+ [TestCase(1234.456, "F2", "1234.46")]
+ [TestCase(123.456, "C", "¤123.46")]
+ [TestCase(123.456, "P", "12,345.60 %")]
+ [TestCase(1234567890, "N0", "1,234,567,890")]
+ 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(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);
+ }
+
+ [TestCase(0, "0000", "0000")]
+ [TestCase(1, "0000", "0001")]
+ [TestCase(0, "-0000;;''", "")]
+ [TestCase(1, "-0000;;''", "-0001")]
+ public void ZeroFormatting(int value, string format, string expected)
+ {
+ var sut = new FormattableFormatter();
+ var result = sut.TryFormat(value, format, out var formatted);
+ result.ShouldBe(true);
+ formatted.ShouldBe(expected);
+ }
+}
diff --git a/src/GitVersion.Core.Tests/Formatting/InputSanitizerTests.cs b/src/GitVersion.Core.Tests/Formatting/InputSanitizerTests.cs
new file mode 100644
index 0000000000..73bf35dbe6
--- /dev/null
+++ b/src/GitVersion.Core.Tests/Formatting/InputSanitizerTests.cs
@@ -0,0 +1,82 @@
+using GitVersion.Core.Tests.Extensions;
+using GitVersion.Formatting;
+
+namespace GitVersion.Tests.Formatting;
+
+[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/Formatting/NumericFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/NumericFormatterTests.cs
new file mode 100644
index 0000000000..a7696607a1
--- /dev/null
+++ b/src/GitVersion.Core.Tests/Formatting/NumericFormatterTests.cs
@@ -0,0 +1,38 @@
+using GitVersion.Formatting;
+
+namespace GitVersion.Tests.Formatting;
+
+[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/Formatting/SanitizeEnvVarNameTests.cs b/src/GitVersion.Core.Tests/Formatting/SanitizeEnvVarNameTests.cs
new file mode 100644
index 0000000000..53f77a0a26
--- /dev/null
+++ b/src/GitVersion.Core.Tests/Formatting/SanitizeEnvVarNameTests.cs
@@ -0,0 +1,67 @@
+using GitVersion.Core.Tests.Extensions;
+using GitVersion.Formatting;
+
+namespace GitVersion.Tests.Formatting;
+
+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/Formatting/SanitizeMemberNameTests.cs b/src/GitVersion.Core.Tests/Formatting/SanitizeMemberNameTests.cs
new file mode 100644
index 0000000000..7f7aa87c1f
--- /dev/null
+++ b/src/GitVersion.Core.Tests/Formatting/SanitizeMemberNameTests.cs
@@ -0,0 +1,78 @@
+using GitVersion.Core.Tests.Extensions;
+using GitVersion.Formatting;
+
+namespace GitVersion.Tests.Formatting;
+
+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/Formatting/StringFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs
new file mode 100644
index 0000000000..b9f3c0a27d
--- /dev/null
+++ b/src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs
@@ -0,0 +1,76 @@
+using GitVersion.Formatting;
+
+namespace GitVersion.Tests.Formatting;
+
+[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/Formatting/ValueFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs
new file mode 100644
index 0000000000..ed1ffb0adc
--- /dev/null
+++ b/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs
@@ -0,0 +1,128 @@
+using System.Globalization;
+using GitVersion.Formatting;
+
+namespace GitVersion.Tests.Formatting;
+
+[TestFixture]
+public class ValueFormatterTests
+{
+ [Test]
+ public void TryFormat_NullValue_ReturnsFalse()
+ {
+ var result = ValueFormatter.Default.TryFormat(null, "any", out var formatted);
+ result.ShouldBeFalse();
+ formatted.ShouldBeEmpty();
+ }
+
+ [Test]
+ public void TryFormat_String_UsesStringFormatter()
+ {
+ var result = ValueFormatter.Default.TryFormat("hello", "u", out var formatted);
+ result.ShouldBeTrue();
+ formatted.ShouldBe("HELLO");
+ }
+
+ [Test]
+ public void TryFormat_Number_UsesNumericFormatter()
+ {
+ var result = ValueFormatter.Default.TryFormat(1234.5678, "n", out var formatted);
+ result.ShouldBeTrue();
+ formatted.ShouldBe("1,234.57");
+ }
+
+ [Test]
+ public void TryFormat_Date_UsesDateFormatter()
+ {
+ var date = new DateTime(2023, 12, 25);
+ var result = ValueFormatter.Default.TryFormat(date, "yyyy-MM-dd", out var formatted);
+ result.ShouldBeTrue();
+ formatted.ShouldBe("2023-12-25");
+ }
+
+ [Test]
+ public void TryFormat_FormattableObject_UsesFormattableFormatter()
+ {
+ var value = 123.456m;
+ var result = ValueFormatter.Default.TryFormat(value, "C", out var formatted);
+ result.ShouldBeTrue();
+ formatted.ShouldBe("¤123.46");
+ }
+
+ [Test]
+ public void TryFormat_InvalidFormat_ReturnsFalse()
+ {
+ var result = ValueFormatter.Default.TryFormat("test", "invalidformat", out var formatted);
+ result.ShouldBeFalse();
+ formatted.ShouldBeEmpty();
+ }
+
+ [Test]
+ public void RegisterFormatter_AddsNewFormatter()
+ {
+ var customFormatter = new TestFormatter { Priority = 0 };
+ IValueFormatterCombiner sut = new ValueFormatter();
+ sut.RegisterFormatter(customFormatter);
+ var result = sut.TryFormat("test", "custom", out var formatted);
+ result.ShouldBeTrue();
+ formatted.ShouldBe("CUSTOM:test");
+ }
+
+ [Test]
+ public void RemoveFormatter_RemovesExistingFormatter()
+ {
+ IValueFormatterCombiner sut = new ValueFormatter();
+ // First verify numeric formatting works
+ sut.TryFormat(123.45, "n1", out var before);
+ before.ShouldBe("123.5");
+
+ sut.RemoveFormatter();
+
+ // Now numeric formatting will still happen, but via the FormattableFormatter
+ var result = sut.TryFormat(123.45, "n1", out var afterFormatted);
+ result.ShouldBeTrue();
+ afterFormatted.ShouldBe("123.5");
+
+ sut.RemoveFormatter();
+
+ // Now numeric formatting will now not be handled by any formatter that remains
+ result = sut.TryFormat(123.45, "n1", out var afterNotFormatted);
+ result.ShouldBeFalse();
+ afterNotFormatted.ShouldBeEmpty();
+ }
+
+ [Test]
+ public void Formatters_ExecuteInPriorityOrder()
+ {
+ IValueFormatterCombiner sut = new ValueFormatter();
+ var highPriorityFormatter = new TestFormatter { Priority = 0 };
+ var lowPriorityFormatter = new TestFormatter { Priority = 99 };
+
+ sut.RegisterFormatter(lowPriorityFormatter);
+ sut.RegisterFormatter(highPriorityFormatter);
+ var result = sut.TryFormat("test", "custom", out var formatted);
+ result.ShouldBeTrue();
+
+ // Should use the high priority formatter first
+ formatted.ShouldBe("CUSTOM:test");
+ }
+
+ private class TestFormatter : IValueFormatter
+ {
+ public int Priority { get; init; }
+
+ public bool TryFormat(object? value, string format, out string result)
+ {
+ if (format == "custom" && value is string str)
+ {
+ result = $"CUSTOM:{str}";
+ return true;
+ }
+
+ result = string.Empty;
+ return false;
+ }
+
+ public bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result)
+ => TryFormat(value, format, out result);
+ }
+}
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..cb36b0833f 100644
--- a/src/GitVersion.Core/Extensions/StringExtensions.cs
+++ b/src/GitVersion.Core/Extensions/StringExtensions.cs
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
using GitVersion.Core;
namespace GitVersion.Extensions;
@@ -30,4 +31,29 @@ 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 ToPascalCase(this TextInfo textInfo, string input)
+ {
+ if (string.IsNullOrEmpty(input))
+ {
+ return 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 ? textInfo.ToUpper(c) : textInfo.ToLower(c));
+ capitalizeNext = false;
+ }
+
+ return sb.ToString();
+ }
}
diff --git a/src/GitVersion.Core/Formatting/DateFormatter.cs b/src/GitVersion.Core/Formatting/DateFormatter.cs
new file mode 100644
index 0000000000..670fba6d13
--- /dev/null
+++ b/src/GitVersion.Core/Formatting/DateFormatter.cs
@@ -0,0 +1,27 @@
+using System.Globalization;
+
+namespace GitVersion.Formatting;
+
+internal class DateFormatter : InvariantFormatter, IValueFormatter
+{
+ public int Priority => 2;
+
+ public override bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result)
+ {
+ result = string.Empty;
+
+ if (value is DateTime dt)
+ {
+ result = dt.ToString(format, cultureInfo);
+ return true;
+ }
+
+ if (value is string dateStr && DateTime.TryParse(dateStr, out var parsedDate))
+ {
+ result = parsedDate.ToString(format, cultureInfo);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/GitVersion.Core/Formatting/ExpressionCompiler.cs b/src/GitVersion.Core/Formatting/ExpressionCompiler.cs
new file mode 100644
index 0000000000..3425cca04f
--- /dev/null
+++ b/src/GitVersion.Core/Formatting/ExpressionCompiler.cs
@@ -0,0 +1,20 @@
+using System.Linq.Expressions;
+
+namespace GitVersion.Formatting;
+
+internal class ExpressionCompiler : IExpressionCompiler
+{
+ public Func