diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6851ca2..6c4bed6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,5 +14,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `IEnumerable` extension methods
- `IConfiguration` extension methods
- `IServiceCollection` extension methods
+- `String` extension methods
- `IFormFile` extension method (AspNetCore package only)
- `DbSet` extension methods for ISortableEntity interface (EntityFrameworkCore package only)
diff --git a/Neolution.Utilities.UnitTests/Extensions/StringExtensionsTests.cs b/Neolution.Utilities.UnitTests/Extensions/StringExtensionsTests.cs
new file mode 100644
index 0000000..3addce3
--- /dev/null
+++ b/Neolution.Utilities.UnitTests/Extensions/StringExtensionsTests.cs
@@ -0,0 +1,131 @@
+namespace Neolution.Utilities.UnitTests.Extensions;
+
+using Neolution.Utilities.Extensions;
+using Xunit;
+
+///
+/// Unit tests for .
+///
+public class StringExtensionsTests
+{
+ ///
+ /// Truncate returns the expected result.
+ ///
+ /// The value.
+ /// The maximum length.
+ /// The expected.
+ [Theory]
+ [InlineData(null, 5, null)]
+ [InlineData("", 5, "")]
+ [InlineData("abc", 5, "abc")]
+ [InlineData("abcdef", 3, "abc")]
+ [InlineData("abcdef", 6, "abcdef")]
+ public void Truncate_ReturnsExpectedResult(string? value, int maxLength, string? expected)
+ {
+ // Act
+ var result = value.Truncate(maxLength);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ ///
+ /// Truncate with suffix returns the expected result when suffix length does not exceed maxLength.
+ /// maxLength represents the total length of the resulting string.
+ ///
+ /// The value.
+ /// The maximum (final) length.
+ /// The suffix.
+ /// The expected.
+ [Theory]
+ [InlineData(null, 5, "...", null)]
+ [InlineData("", 5, "...", "")]
+ [InlineData("abc", 5, "...", "abc")] // no truncation because length <= maxLength
+ [InlineData("abcdef", 6, "...", "abcdef")] // no truncation
+ [InlineData("abcdef", 3, "...", "...")] // suffix length == maxLength => only suffix
+ [InlineData("abcdef", 4, "-", "abc-")] // keep 3 chars + '-'
+ [InlineData("abcdef", 4, "", "abcd")] // empty suffix behaves like plain truncate
+ [InlineData("abcdef", 0, "", "")] // zero length with empty suffix
+ public void Truncate_WithSuffix_ReturnsExpectedResult(string? value, int maxLength, string suffix, string? expected)
+ {
+ // Act
+ var result = value.Truncate(maxLength, suffix);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ ///
+ /// Truncating with a negative maxLength throws an .
+ ///
+ [Fact]
+ public void Truncate_NegativeMaxLength_Throws()
+ {
+ Assert.Throws(() => "abc".Truncate(-1));
+ Assert.Throws(() => "abc".Truncate(-5, ".."));
+ }
+
+ ///
+ /// Truncating with a null suffix throws an .
+ ///
+ [Fact]
+ public void Truncate_NullSuffix_Throws()
+ {
+ Assert.Throws(() => "abc".Truncate(2, null!));
+ }
+
+ ///
+ /// Truncating with a suffix longer than maxLength throws an .
+ ///
+ [Fact]
+ public void Truncate_SuffixLongerThanMaxLength_Throws()
+ {
+ Assert.Throws(() => "abcdef".Truncate(3, "...."));
+ Assert.Throws(() => "abcdef".Truncate(0, "."));
+ Assert.Throws(() => "abcdef".Truncate(2, "..."));
+ }
+
+ ///
+ /// When the value length equals maxLength and a suffix is provided (shorter than maxLength), the value is returned unchanged.
+ ///
+ [Fact]
+ public void Truncate_ValueLengthEqualsMaxLength_WithSuffix_ReturnsUnchanged()
+ {
+ var value = "12345";
+ var result = value.Truncate(5, "...");
+ Assert.Equal("12345", result);
+ }
+
+ ///
+ /// Result equals the suffix when suffix length == maxLength.
+ ///
+ [Fact]
+ public void Truncate_SuffixLengthEqualsMaxLength_ReturnsOnlySuffix()
+ {
+ var result = "abcdef".Truncate(3, "...");
+ Assert.Equal("...", result);
+ }
+
+ ///
+ /// Truncation works with Unicode surrogate pairs (keeping whole emoji, not splitting) when maxLength accommodates suffix + complete emoji.
+ ///
+ [Fact]
+ public void Truncate_UnicodeCharacters()
+ {
+ var value = "😀😃😄😁"; // 4 emoji (each surrogate pair)
+ var result = value.Truncate(5, "..."); // keep one emoji (2 code units) + '...'
+ Assert.Equal("😀...", result);
+ }
+
+ ///
+ /// Truncation of a long string returns the expected prefix plus suffix with total length == maxLength.
+ ///
+ [Fact]
+ public void Truncate_LongString()
+ {
+ var value = new string('x', 1000);
+ var result = value.Truncate(10, "..."); // keep 7 + '...'
+ Assert.Equal(new string('x', 7) + "...", result);
+ Assert.Equal(10, result!.Length);
+ }
+}
diff --git a/Neolution.Utilities/Extensions/StringExtensions.cs b/Neolution.Utilities/Extensions/StringExtensions.cs
new file mode 100644
index 0000000..7d4c5a6
--- /dev/null
+++ b/Neolution.Utilities/Extensions/StringExtensions.cs
@@ -0,0 +1,46 @@
+namespace Neolution.Utilities.Extensions;
+
+///
+/// String extensions.
+///
+public static class StringExtensions
+{
+ ///
+ /// Truncates the string to the specified maximum length.
+ ///
+ /// The value.
+ /// The maximum length.
+ /// The truncated string
+ public static string? Truncate(this string? value, int maxLength)
+ {
+ return value.Truncate(maxLength, string.Empty);
+ }
+
+ ///
+ /// Truncates the string to the specified maximum length, appending a suffix if necessary.
+ ///
+ /// The value.
+ /// The maximum length.
+ /// The suffix to append if the string is truncated. The suffix length must not exceed .
+ /// The truncated string.
+ /// Thrown when is negative.
+ /// Thrown when is null.
+ /// Thrown when is longer than .
+ public static string? Truncate(this string? value, int maxLength, string suffix)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(maxLength);
+ ArgumentNullException.ThrowIfNull(suffix);
+
+ if (suffix.Length > maxLength)
+ {
+ throw new ArgumentException("Suffix length must not exceed maxLength.", nameof(suffix));
+ }
+
+ if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
+ {
+ return value;
+ }
+
+ return $"{value[..(maxLength - suffix.Length)]}{suffix}";
+ }
+}