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 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/Formatting/FormattableFormatter.cs b/src/GitVersion.Core/Formatting/FormattableFormatter.cs new file mode 100644 index 0000000000..2ec2089ac3 --- /dev/null +++ b/src/GitVersion.Core/Formatting/FormattableFormatter.cs @@ -0,0 +1,32 @@ +using System.Globalization; + +namespace GitVersion.Formatting; + +internal class FormattableFormatter : InvariantFormatter, IValueFormatter +{ + public int Priority => 2; + + public override bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result) + { + result = string.Empty; + + if (string.IsNullOrWhiteSpace(format)) + return false; + + if (value is IFormattable formattable) + { + try + { + result = formattable.ToString(format, cultureInfo); + return true; + } + catch (FormatException) + { + result = $"Format '{format}' is not supported in {nameof(FormattableFormatter)}"; + return false; + } + } + + return false; + } +} diff --git a/src/GitVersion.Core/Formatting/IExpressionCompiler.cs b/src/GitVersion.Core/Formatting/IExpressionCompiler.cs new file mode 100644 index 0000000000..82be9811e9 --- /dev/null +++ b/src/GitVersion.Core/Formatting/IExpressionCompiler.cs @@ -0,0 +1,7 @@ +namespace GitVersion.Formatting +{ + internal interface IExpressionCompiler + { + Func CompileGetter(Type type, MemberInfo[] memberPath); + } +} diff --git a/src/GitVersion.Core/Formatting/IInputSanitizer.cs b/src/GitVersion.Core/Formatting/IInputSanitizer.cs new file mode 100644 index 0000000000..ead3dcedcc --- /dev/null +++ b/src/GitVersion.Core/Formatting/IInputSanitizer.cs @@ -0,0 +1,11 @@ +namespace GitVersion.Formatting +{ + internal interface IInputSanitizer + { + string SanitizeEnvVarName(string name); + + string SanitizeFormat(string format); + + string SanitizeMemberName(string memberName); + } +} diff --git a/src/GitVersion.Core/Formatting/IMemberResolver.cs b/src/GitVersion.Core/Formatting/IMemberResolver.cs new file mode 100644 index 0000000000..67ee347c83 --- /dev/null +++ b/src/GitVersion.Core/Formatting/IMemberResolver.cs @@ -0,0 +1,6 @@ +namespace GitVersion.Formatting; + +internal interface IMemberResolver +{ + MemberInfo[] ResolveMemberPath(Type type, string memberExpression); +} diff --git a/src/GitVersion.Core/Formatting/IValueFormatter.cs b/src/GitVersion.Core/Formatting/IValueFormatter.cs new file mode 100644 index 0000000000..45b93f77a7 --- /dev/null +++ b/src/GitVersion.Core/Formatting/IValueFormatter.cs @@ -0,0 +1,15 @@ +using System.Globalization; + +namespace GitVersion.Formatting; + +internal interface IValueFormatter +{ + bool TryFormat(object? value, string format, out string result); + + bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result); + + /// + /// Lower number = higher priority + /// + int Priority { get; } +} diff --git a/src/GitVersion.Core/Formatting/IValueFormatterCombiner.cs b/src/GitVersion.Core/Formatting/IValueFormatterCombiner.cs new file mode 100644 index 0000000000..b3a9c32921 --- /dev/null +++ b/src/GitVersion.Core/Formatting/IValueFormatterCombiner.cs @@ -0,0 +1,8 @@ +namespace GitVersion.Formatting; + +internal interface IValueFormatterCombiner : IValueFormatter +{ + void RegisterFormatter(IValueFormatter formatter); + + void RemoveFormatter() where T : IValueFormatter; +} diff --git a/src/GitVersion.Core/Formatting/InputSanitizer.cs b/src/GitVersion.Core/Formatting/InputSanitizer.cs new file mode 100644 index 0000000000..a676ec5540 --- /dev/null +++ b/src/GitVersion.Core/Formatting/InputSanitizer.cs @@ -0,0 +1,48 @@ +using GitVersion.Core; + +namespace GitVersion.Formatting; + +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/Formatting/InvariantFormatter.cs b/src/GitVersion.Core/Formatting/InvariantFormatter.cs new file mode 100644 index 0000000000..2d953d55ed --- /dev/null +++ b/src/GitVersion.Core/Formatting/InvariantFormatter.cs @@ -0,0 +1,11 @@ +using System.Globalization; + +namespace GitVersion.Formatting; + +internal abstract class InvariantFormatter +{ + public bool TryFormat(object? value, string format, out string result) + => TryFormat(value, format, CultureInfo.InvariantCulture, out result); + + public abstract bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result); +} diff --git a/src/GitVersion.Core/Formatting/MemberResolver.cs b/src/GitVersion.Core/Formatting/MemberResolver.cs new file mode 100644 index 0000000000..f1fde1bc55 --- /dev/null +++ b/src/GitVersion.Core/Formatting/MemberResolver.cs @@ -0,0 +1,70 @@ +namespace GitVersion.Formatting; + +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/Formatting/NumericFormatter.cs b/src/GitVersion.Core/Formatting/NumericFormatter.cs new file mode 100644 index 0000000000..44d14ecc21 --- /dev/null +++ b/src/GitVersion.Core/Formatting/NumericFormatter.cs @@ -0,0 +1,46 @@ +using System.Globalization; + +namespace GitVersion.Formatting; + +internal class NumericFormatter : InvariantFormatter, IValueFormatter +{ + public int Priority => 1; + + public override bool TryFormat(object? value, string format, CultureInfo cultureInfo, 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); + return true; + } + + // Hexadecimal formatting + if (format.StartsWith("X", StringComparison.OrdinalIgnoreCase) && int.TryParse(s, out var hex)) + { + result = hex.ToString(format, cultureInfo); + return true; + } + + // Floating point formatting + if ("FEGNCP".Contains(char.ToUpperInvariant(format[0])) && double.TryParse(s, out var d)) + { + result = d.ToString(format, cultureInfo); + return true; + } + + // Decimal formatting + if (decimal.TryParse(s, NumberStyles.Any, cultureInfo, out var dec)) + { + result = dec.ToString(format, cultureInfo); + return true; + } + + return false; + } +} diff --git a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs new file mode 100644 index 0000000000..0f061c4f0b --- /dev/null +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -0,0 +1,100 @@ +using System.Text.RegularExpressions; +using GitVersion.Core; + +namespace GitVersion.Formatting; + +internal static class StringFormatWithExtension +{ + private static readonly IExpressionCompiler ExpressionCompiler = new ExpressionCompiler(); + + private static readonly IInputSanitizer InputSanitizer = new InputSanitizer(); + + private static readonly IMemberResolver MemberResolver = new MemberResolver(); + + /// + /// Formats the , replacing each expression wrapped in curly braces + /// with the corresponding property from the or . + /// + /// The source template, which may contain expressions to be replaced, e.g '{Foo.Bar.CurrencySymbol} foo {Foo.Bar.Price}' + /// The source object to apply to the + /// + /// The is null. + /// An environment variable was null and no fallback was provided. + /// + /// An expression containing "." is treated as a property or field access on the . + /// An expression starting with "env:" is replaced with the value of the corresponding variable from the . + /// Each expression may specify a single hardcoded fallback value using the {Prop ?? "fallback"} syntax, which applies if the expression evaluates to null. + /// + /// + /// // replace an expression with a property value + /// "Hello {Name}".FormatWith(new { Name = "Fred" }, env); + /// "Hello {Name ?? \"Fred\"}".FormatWith(new { Name = GetNameOrNull() }, env); + /// // replace an expression with an environment variable + /// "{env:BUILD_NUMBER}".FormatWith(new { }, env); + /// "{env:BUILD_NUMBER ?? \"0\"}".FormatWith(new { }, env); + /// + public static string FormatWith(this string template, T? source, IEnvironment environment) + { + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(source); + + var result = new StringBuilder(); + var lastIndex = 0; + + foreach (var match in RegexPatterns.Common.ExpandTokensRegex().Matches(template).Cast()) + { + 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); + } + + 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 string EvaluateMember(T source, string member, string? format, string? fallback) + { + 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.Default.TryFormat( + value, + InputSanitizer.SanitizeFormat(format), + out var formatted)) + { + return formatted; + } + + return value.ToString() ?? fallback ?? string.Empty; + } +} diff --git a/src/GitVersion.Core/Formatting/StringFormatter.cs b/src/GitVersion.Core/Formatting/StringFormatter.cs new file mode 100644 index 0000000000..e2877ef573 --- /dev/null +++ b/src/GitVersion.Core/Formatting/StringFormatter.cs @@ -0,0 +1,48 @@ +using System.Globalization; +using GitVersion.Extensions; + +namespace GitVersion.Formatting; + +internal class StringFormatter : InvariantFormatter, IValueFormatter +{ + public int Priority => 2; + + public override bool TryFormat(object? value, string format, CultureInfo cultureInfo, 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 = cultureInfo.TextInfo.ToUpper(stringValue); + return true; + case "l": + result = cultureInfo.TextInfo.ToLower(stringValue); + return true; + case "t": + result = cultureInfo.TextInfo.ToTitleCase(cultureInfo.TextInfo.ToLower(stringValue)); + return true; + case "s": + result = stringValue.Length == 1 + ? cultureInfo.TextInfo.ToUpper(stringValue) + : cultureInfo.TextInfo.ToUpper(stringValue[0]) + cultureInfo.TextInfo.ToLower(stringValue[1..]); + return true; + case "c": + result = cultureInfo.TextInfo.ToPascalCase(stringValue); + return true; + default: + result = string.Empty; + return false; + } + } +} diff --git a/src/GitVersion.Core/Formatting/ValueFormatter.cs b/src/GitVersion.Core/Formatting/ValueFormatter.cs new file mode 100644 index 0000000000..0e0be49645 --- /dev/null +++ b/src/GitVersion.Core/Formatting/ValueFormatter.cs @@ -0,0 +1,42 @@ +using System.Globalization; + +namespace GitVersion.Formatting; + +internal class ValueFormatter : InvariantFormatter, IValueFormatterCombiner +{ + private readonly List formatters; + + internal static IValueFormatter Default { get; } = new ValueFormatter(); + + public int Priority => 0; + + internal ValueFormatter() + => formatters = + [ + new StringFormatter(), + new FormattableFormatter(), + new NumericFormatter(), + new DateFormatter() + ]; + + public override bool TryFormat(object? value, string format, CultureInfo cultureInfo, 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; + } + + void IValueFormatterCombiner.RegisterFormatter(IValueFormatter formatter) => formatters.Add(formatter); + + void IValueFormatterCombiner.RemoveFormatter() => formatters.RemoveAll(f => f is T); +} diff --git a/src/GitVersion.Core/Helpers/StringFormatWith.cs b/src/GitVersion.Core/Helpers/StringFormatWith.cs deleted file mode 100644 index d36d5df01d..0000000000 --- a/src/GitVersion.Core/Helpers/StringFormatWith.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Linq.Expressions; -using System.Text.RegularExpressions; -using GitVersion.Core; - -namespace GitVersion.Helpers; - -internal static class StringFormatWithExtension -{ - /// - /// Formats the , replacing each expression wrapped in curly braces - /// with the corresponding property from the or . - /// - /// The source template, which may contain expressions to be replaced, e.g '{Foo.Bar.CurrencySymbol} foo {Foo.Bar.Price}' - /// The source object to apply to the - /// - /// The is null. - /// An environment variable was null and no fallback was provided. - /// - /// An expression containing "." is treated as a property or field access on the . - /// An expression starting with "env:" is replaced with the value of the corresponding variable from the . - /// Each expression may specify a single hardcoded fallback value using the {Prop ?? "fallback"} syntax, which applies if the expression evaluates to null. - /// - /// - /// // replace an expression with a property value - /// "Hello {Name}".FormatWith(new { Name = "Fred" }, env); - /// "Hello {Name ?? \"Fred\"}".FormatWith(new { Name = GetNameOrNull() }, env); - /// // replace an expression with an environment variable - /// "{env:BUILD_NUMBER}".FormatWith(new { }, env); - /// "{env:BUILD_NUMBER ?? \"0\"}".FormatWith(new { }, env); - /// - public static string FormatWith(this string template, T? source, IEnvironment environment) - { - ArgumentNullException.ThrowIfNull(template); - ArgumentNullException.ThrowIfNull(source); - - 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); - } - - return template; - } - - private static Func CompileDataBinder(Type type, string expr) - { - 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(); - } -} diff --git a/src/GitVersion.Core/VersionCalculation/VariableProvider.cs b/src/GitVersion.Core/VersionCalculation/VariableProvider.cs index 0e677163a2..424c91711b 100644 --- a/src/GitVersion.Core/VersionCalculation/VariableProvider.cs +++ b/src/GitVersion.Core/VersionCalculation/VariableProvider.cs @@ -1,7 +1,7 @@ using GitVersion.Configuration; using GitVersion.Core; using GitVersion.Extensions; -using GitVersion.Helpers; +using GitVersion.Formatting; using GitVersion.OutputVariables; namespace GitVersion.VersionCalculation; diff --git a/src/GitVersion.Output/OutputGenerator/OutputGenerator.cs b/src/GitVersion.Output/OutputGenerator/OutputGenerator.cs index ddf3f23bbb..971e165c70 100644 --- a/src/GitVersion.Output/OutputGenerator/OutputGenerator.cs +++ b/src/GitVersion.Output/OutputGenerator/OutputGenerator.cs @@ -1,6 +1,7 @@ using System.IO.Abstractions; using GitVersion.Agents; using GitVersion.Extensions; +using GitVersion.Formatting; using GitVersion.Helpers; using GitVersion.Logging; using GitVersion.OutputVariables;