From 48c30a72076a1e65439d62e9b63397bebead45a3 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 2 Mar 2026 13:47:47 -0800 Subject: [PATCH 1/3] add semver targeting to local evaluation --- src/PostHog/Api/ComparisonOperator.cs | 56 +- src/PostHog/Features/LocalEvaluator.cs | 9 + src/PostHog/Json/PropertyFilterValue.cs | 102 +++ src/PostHog/Library/SemanticVersion.cs | 333 +++++++++ .../UnitTests/Features/LocalEvaluatorTests.cs | 641 ++++++++++++++++++ .../UnitTests/Library/SemanticVersionTests.cs | 578 ++++++++++++++++ 6 files changed, 1718 insertions(+), 1 deletion(-) create mode 100644 src/PostHog/Library/SemanticVersion.cs create mode 100644 tests/UnitTests/Library/SemanticVersionTests.cs diff --git a/src/PostHog/Api/ComparisonOperator.cs b/src/PostHog/Api/ComparisonOperator.cs index 881b50fd..1ecd985f 100644 --- a/src/PostHog/Api/ComparisonOperator.cs +++ b/src/PostHog/Api/ComparisonOperator.cs @@ -103,5 +103,59 @@ public enum ComparisonOperator /// Matches if the flag condition evaluates to the specified value. /// [JsonStringEnumMemberName("flag_evaluates_to")] - FlagEvaluatesTo + FlagEvaluatesTo, + + /// + /// Matches if the version exactly equals the filter version. + /// + [JsonStringEnumMemberName("semver_eq")] + SemverEquals, + + /// + /// Matches if the version does not equal the filter version. + /// + [JsonStringEnumMemberName("semver_neq")] + SemverNotEquals, + + /// + /// Matches if the version is greater than the filter version. + /// + [JsonStringEnumMemberName("semver_gt")] + SemverGreaterThan, + + /// + /// Matches if the version is greater than or equal to the filter version. + /// + [JsonStringEnumMemberName("semver_gte")] + SemverGreaterThanOrEquals, + + /// + /// Matches if the version is less than the filter version. + /// + [JsonStringEnumMemberName("semver_lt")] + SemverLessThan, + + /// + /// Matches if the version is less than or equal to the filter version. + /// + [JsonStringEnumMemberName("semver_lte")] + SemverLessThanOrEquals, + + /// + /// Matches if the version is within the tilde range (~X.Y.Z means >=X.Y.Z and <X.Y+1.0). + /// + [JsonStringEnumMemberName("semver_tilde")] + SemverTilde, + + /// + /// Matches if the version is within the caret range (^X.Y.Z is compatible-with per semver spec). + /// + [JsonStringEnumMemberName("semver_caret")] + SemverCaret, + + /// + /// Matches if the version matches the wildcard pattern (e.g., "1.2.*" means >=1.2.0 and <1.3.0). + /// + [JsonStringEnumMemberName("semver_wildcard")] + SemverWildcard } \ No newline at end of file diff --git a/src/PostHog/Features/LocalEvaluator.cs b/src/PostHog/Features/LocalEvaluator.cs index c7d1d427..688aa651 100644 --- a/src/PostHog/Features/LocalEvaluator.cs +++ b/src/PostHog/Features/LocalEvaluator.cs @@ -584,6 +584,15 @@ bool MatchProperty(PropertyFilter propertyFilter, string distinctId, Dictionary< ComparisonOperator.IsSet => true, // We already checked to see that the key exists. ComparisonOperator.IsDateBefore => value.IsDateBefore(overrideValue, _timeProvider.GetUtcNow()), ComparisonOperator.IsDateAfter => !value.IsDateBefore(overrideValue, _timeProvider.GetUtcNow()), + ComparisonOperator.SemverEquals => value.CompareSemver(overrideValue) == 0, + ComparisonOperator.SemverNotEquals => value.CompareSemver(overrideValue) != 0, + ComparisonOperator.SemverGreaterThan => value.CompareSemver(overrideValue) > 0, + ComparisonOperator.SemverGreaterThanOrEquals => value.CompareSemver(overrideValue) >= 0, + ComparisonOperator.SemverLessThan => value.CompareSemver(overrideValue) < 0, + ComparisonOperator.SemverLessThanOrEquals => value.CompareSemver(overrideValue) <= 0, + ComparisonOperator.SemverTilde => value.IsSemverTildeMatch(overrideValue), + ComparisonOperator.SemverCaret => value.IsSemverCaretMatch(overrideValue), + ComparisonOperator.SemverWildcard => value.IsSemverWildcardMatch(overrideValue), null => true, // If no operator is specified, just return true. _ => throw new InconclusiveMatchException($"Unknown operator: {propertyFilter.Operator}") }; diff --git a/src/PostHog/Json/PropertyFilterValue.cs b/src/PostHog/Json/PropertyFilterValue.cs index eb0392f7..ffaae98b 100644 --- a/src/PostHog/Json/PropertyFilterValue.cs +++ b/src/PostHog/Json/PropertyFilterValue.cs @@ -6,6 +6,7 @@ using PostHog.Api; using PostHog.Library; using static PostHog.Library.Ensure; +using static PostHog.Library.SemanticVersion; namespace PostHog.Json; @@ -254,6 +255,107 @@ or DateOnly public static bool operator >=(PropertyFilterValue left, object? right) => NotNull(left).CompareTo(right) >= 0; public static bool operator <=(PropertyFilterValue left, object? right) => NotNull(left).CompareTo(right) <= 0; + /// + /// Compares the override value as a semantic version against this filter value. + /// + /// The version value from person/group properties. + /// A comparison result: negative if override < filter, zero if equal, positive if override > filter. + /// Thrown if either value cannot be parsed as a valid semver. + public int CompareSemver(object? overrideValue) + { + var overrideVersionString = overrideValue?.ToString(); + + if (!SemanticVersion.TryParse(overrideVersionString, out var overrideVersion)) + { + throw new InconclusiveMatchException($"Cannot parse override value '{overrideVersionString}' as a semantic version"); + } + + if (!SemanticVersion.TryParse(StringValue, out var filterVersion)) + { + throw new InconclusiveMatchException($"Cannot parse filter value '{StringValue}' as a semantic version"); + } + + return overrideVersion.Value.CompareTo(filterVersion.Value); + } + + /// + /// Checks if the override value is within the tilde range specified by this filter value. + /// ~X.Y.Z means >=X.Y.Z and <X.Y+1.0 + /// + /// The version value from person/group properties. + /// true if the override version is within the tilde range. + /// Thrown if either value cannot be parsed as a valid semver. + public bool IsSemverTildeMatch(object? overrideValue) + { + var overrideVersionString = overrideValue?.ToString(); + + if (!SemanticVersion.TryParse(overrideVersionString, out var overrideVersion)) + { + throw new InconclusiveMatchException($"Cannot parse override value '{overrideVersionString}' as a semantic version"); + } + + if (!SemanticVersion.TryParse(StringValue, out var filterVersion)) + { + throw new InconclusiveMatchException($"Cannot parse filter value '{StringValue}' as a semantic version"); + } + + var (lower, upper) = filterVersion.Value.GetTildeBounds(); + return overrideVersion.Value.IsInRange(lower, upper); + } + + /// + /// Checks if the override value is within the caret range specified by this filter value. + /// ^X.Y.Z is compatible-with per semver spec: + /// - ^1.2.3 means >=1.2.3 <2.0.0 (major > 0) + /// - ^0.2.3 means >=0.2.3 <0.3.0 (major = 0, minor > 0) + /// - ^0.0.3 means >=0.0.3 <0.0.4 (major = 0, minor = 0) + /// + /// The version value from person/group properties. + /// true if the override version is within the caret range. + /// Thrown if either value cannot be parsed as a valid semver. + public bool IsSemverCaretMatch(object? overrideValue) + { + var overrideVersionString = overrideValue?.ToString(); + + if (!SemanticVersion.TryParse(overrideVersionString, out var overrideVersion)) + { + throw new InconclusiveMatchException($"Cannot parse override value '{overrideVersionString}' as a semantic version"); + } + + if (!SemanticVersion.TryParse(StringValue, out var filterVersion)) + { + throw new InconclusiveMatchException($"Cannot parse filter value '{StringValue}' as a semantic version"); + } + + var (lower, upper) = filterVersion.Value.GetCaretBounds(); + return overrideVersion.Value.IsInRange(lower, upper); + } + + /// + /// Checks if the override value matches the wildcard pattern specified by this filter value. + /// "X.*" or "X" means >=X.0.0 <X+1.0.0 + /// "X.Y.*" means >=X.Y.0 <X.Y+1.0 + /// + /// The version value from person/group properties. + /// true if the override version matches the wildcard pattern. + /// Thrown if either value cannot be parsed. + public bool IsSemverWildcardMatch(object? overrideValue) + { + var overrideVersionString = overrideValue?.ToString(); + + if (!SemanticVersion.TryParse(overrideVersionString, out var overrideVersion)) + { + throw new InconclusiveMatchException($"Cannot parse override value '{overrideVersionString}' as a semantic version"); + } + + if (!TryParseWildcard(StringValue, out var lower, out var upper)) + { + throw new InconclusiveMatchException($"Cannot parse filter value '{StringValue}' as a wildcard pattern"); + } + + return overrideVersion.Value.IsInRange(lower.Value, upper.Value); + } + public override string ToString() { return this switch diff --git a/src/PostHog/Library/SemanticVersion.cs b/src/PostHog/Library/SemanticVersion.cs new file mode 100644 index 00000000..7e86b079 --- /dev/null +++ b/src/PostHog/Library/SemanticVersion.cs @@ -0,0 +1,333 @@ +using System.Diagnostics.CodeAnalysis; + +namespace PostHog.Library; + +/// +/// Represents a semantic version (major.minor.patch) for comparison purposes. +/// +internal readonly record struct SemanticVersion : IComparable +{ + /// + /// The major version component. + /// + public int Major { get; } + + /// + /// The minor version component. + /// + public int Minor { get; } + + /// + /// The patch version component. + /// + public int Patch { get; } + + /// + /// Creates a new with the specified components. + /// + public SemanticVersion(int major, int minor, int patch) + { + Major = major; + Minor = minor; + Patch = patch; + } + + /// + /// Tries to parse a version string into a . + /// + /// The version string to parse. + /// The resulting if parsing succeeds. + /// true if parsing was successful; otherwise false. + /// + /// Parsing rules: + /// 1. Strip leading/trailing whitespace + /// 2. Strip 'v' or 'V' prefix + /// 3. Strip pre-release and build metadata (split on '-' or '+', take first part) + /// 4. Split on '.' and parse first 3 components as integers + /// 5. Default missing components to 0 (e.g., "1.2" → (1, 2, 0), "1" → (1, 0, 0)) + /// 6. Ignore extra components beyond the third (e.g., "1.2.3.4" → (1, 2, 3)) + /// 7. Return false for truly invalid input (empty string, non-numeric parts, leading dot) + /// + public static bool TryParse(string? value, [NotNullWhen(returnValue: true)] out SemanticVersion? version) + { + version = null; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + // Strip leading/trailing whitespace + var trimmed = value.Trim(); + + // Strip 'v' or 'V' prefix + if (trimmed.Length > 0 && (trimmed[0] == 'v' || trimmed[0] == 'V')) + { + trimmed = trimmed[1..]; + } + + if (string.IsNullOrEmpty(trimmed)) + { + return false; + } + + // Strip pre-release and build metadata (split on '-' or '+', take first part) + var hyphenIndex = trimmed.IndexOf('-', StringComparison.Ordinal); + var plusIndex = trimmed.IndexOf('+', StringComparison.Ordinal); + + var metadataIndex = -1; + if (hyphenIndex >= 0 && plusIndex >= 0) + { + metadataIndex = Math.Min(hyphenIndex, plusIndex); + } + else if (hyphenIndex >= 0) + { + metadataIndex = hyphenIndex; + } + else if (plusIndex >= 0) + { + metadataIndex = plusIndex; + } + + if (metadataIndex >= 0) + { + trimmed = trimmed[..metadataIndex]; + } + + if (string.IsNullOrEmpty(trimmed)) + { + return false; + } + + // Check for leading dot (invalid) + if (trimmed[0] == '.') + { + return false; + } + + // Split on '.' and parse components + var parts = trimmed.Split('.'); + + // Parse major (required) + if (!int.TryParse(parts[0], out var major)) + { + return false; + } + + // Parse minor (optional, defaults to 0) + var minor = 0; + if (parts.Length > 1 && !string.IsNullOrEmpty(parts[1]) && !int.TryParse(parts[1], out minor)) + { + return false; + } + + // Parse patch (optional, defaults to 0) + var patch = 0; + if (parts.Length > 2 && !string.IsNullOrEmpty(parts[2]) && !int.TryParse(parts[2], out patch)) + { + return false; + } + + version = new SemanticVersion(major, minor, patch); + return true; + } + + /// + /// Compares this version to another version. + /// + public int CompareTo(SemanticVersion other) + { + var majorComparison = Major.CompareTo(other.Major); + if (majorComparison != 0) + { + return majorComparison; + } + + var minorComparison = Minor.CompareTo(other.Minor); + if (minorComparison != 0) + { + return minorComparison; + } + + return Patch.CompareTo(other.Patch); + } + + /// + /// Computes the tilde range bounds for this version. + /// ~X.Y.Z means >=X.Y.Z and <X.Y+1.0 + /// + /// A tuple of (lower, upper) bounds where lower is inclusive and upper is exclusive. + public (SemanticVersion Lower, SemanticVersion Upper) GetTildeBounds() + { + var lower = this; + var upper = new SemanticVersion(Major, Minor + 1, 0); + return (lower, upper); + } + + /// + /// Computes the caret range bounds for this version. + /// ^X.Y.Z is compatible-with per semver spec: + /// - ^1.2.3 means >=1.2.3 <2.0.0 (major > 0) + /// - ^0.2.3 means >=0.2.3 <0.3.0 (major = 0, minor > 0) + /// - ^0.0.3 means >=0.0.3 <0.0.4 (major = 0, minor = 0) + /// + /// A tuple of (lower, upper) bounds where lower is inclusive and upper is exclusive. + public (SemanticVersion Lower, SemanticVersion Upper) GetCaretBounds() + { + var lower = this; + SemanticVersion upper; + + if (Major > 0) + { + // ^1.2.3 → >=1.2.3 <2.0.0 + upper = new SemanticVersion(Major + 1, 0, 0); + } + else if (Minor > 0) + { + // ^0.2.3 → >=0.2.3 <0.3.0 + upper = new SemanticVersion(0, Minor + 1, 0); + } + else + { + // ^0.0.3 → >=0.0.3 <0.0.4 + upper = new SemanticVersion(0, 0, Patch + 1); + } + + return (lower, upper); + } + + /// + /// Tries to parse a wildcard pattern and compute its bounds. + /// "X.*" or "X" means >=X.0.0 <X+1.0.0 + /// "X.Y.*" means >=X.Y.0 <X.Y+1.0 + /// + /// The wildcard pattern to parse. + /// The lower bound (inclusive). + /// The upper bound (exclusive). + /// true if the pattern was successfully parsed; otherwise false. + public static bool TryParseWildcard( + string? pattern, + [NotNullWhen(returnValue: true)] out SemanticVersion? lower, + [NotNullWhen(returnValue: true)] out SemanticVersion? upper) + { + lower = null; + upper = null; + + if (string.IsNullOrWhiteSpace(pattern)) + { + return false; + } + + var trimmed = pattern.Trim(); + + // Strip 'v' or 'V' prefix + if (trimmed.Length > 0 && (trimmed[0] == 'v' || trimmed[0] == 'V')) + { + trimmed = trimmed[1..]; + } + + if (string.IsNullOrEmpty(trimmed)) + { + return false; + } + + var parts = trimmed.Split('.'); + + // Check for leading dot (invalid) + if (trimmed[0] == '.') + { + return false; + } + + // Parse based on the pattern structure + if (parts.Length == 1) + { + // Could be "X" or "X.*" pattern without the dot + // Actually "1" is valid and means "1.*" + if (!int.TryParse(parts[0], out var major)) + { + // Check if it's a wildcard itself + if (parts[0] == "*") + { + // "*" alone is invalid for our purposes + return false; + } + return false; + } + + lower = new SemanticVersion(major, 0, 0); + upper = new SemanticVersion(major + 1, 0, 0); + return true; + } + else if (parts.Length == 2) + { + // "X.Y" or "X.*" pattern + if (!int.TryParse(parts[0], out var major)) + { + return false; + } + + if (parts[1] == "*") + { + // "X.*" pattern + lower = new SemanticVersion(major, 0, 0); + upper = new SemanticVersion(major + 1, 0, 0); + return true; + } + + if (!int.TryParse(parts[1], out var minor)) + { + return false; + } + + // "X.Y" without wildcard - treat as "X.Y.*" + lower = new SemanticVersion(major, minor, 0); + upper = new SemanticVersion(major, minor + 1, 0); + return true; + } + else if (parts.Length >= 3) + { + // "X.Y.Z" or "X.Y.*" pattern + if (!int.TryParse(parts[0], out var major)) + { + return false; + } + + if (!int.TryParse(parts[1], out var minor)) + { + return false; + } + + if (parts[2] == "*") + { + // "X.Y.*" pattern + lower = new SemanticVersion(major, minor, 0); + upper = new SemanticVersion(major, minor + 1, 0); + return true; + } + + // "X.Y.Z" is not a wildcard pattern + return false; + } + + return false; + } + + /// + /// Checks if this version is within the specified range [lower, upper). + /// + /// The lower bound (inclusive). + /// The upper bound (exclusive). + /// true if this version is >= lower and < upper. + public bool IsInRange(SemanticVersion lower, SemanticVersion upper) + { + return CompareTo(lower) >= 0 && CompareTo(upper) < 0; + } + + public static bool operator <(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) < 0; + public static bool operator >(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) > 0; + public static bool operator <=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) <= 0; + public static bool operator >=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) >= 0; + + public override string ToString() => $"{Major}.{Minor}.{Patch}"; +} diff --git a/tests/UnitTests/Features/LocalEvaluatorTests.cs b/tests/UnitTests/Features/LocalEvaluatorTests.cs index b5933cf3..fe594cde 100644 --- a/tests/UnitTests/Features/LocalEvaluatorTests.cs +++ b/tests/UnitTests/Features/LocalEvaluatorTests.cs @@ -1640,6 +1640,647 @@ public void PropertyFilterEqualityIsSymmetricForDependencyChain() } } +public class TheSemverOperators +{ + static LocalEvaluationApiResult CreateFlags(string key, IReadOnlyList properties) + { + return new LocalEvaluationApiResult + { + Flags = [ + new LocalFeatureFlag + { + Id = 42, + TeamId = 23, + Name = $"{key}-feature-flag", + Key = key, + Filters = new FeatureFlagFilters { + Groups = [ + new FeatureFlagGroup + { + Properties = properties + } + ] + } + } + ], + GroupTypeMapping = new Dictionary() + }; + } + + [Theory] + // Basic equality tests + [InlineData("1.2.3", ComparisonOperator.SemverEquals, "1.2.3", true)] + [InlineData("1.2.3", ComparisonOperator.SemverEquals, "1.2.4", false)] + [InlineData("1.2.3", ComparisonOperator.SemverEquals, "1.2.2", false)] + [InlineData("v1.2.3", ComparisonOperator.SemverEquals, "1.2.3", true)] + [InlineData("1.2.3-alpha", ComparisonOperator.SemverEquals, "1.2.3", true)] // Pre-release stripped + [InlineData("1.2.3", ComparisonOperator.SemverEquals, "1.2.3-beta", true)] // Pre-release stripped + public void HandlesSemverEqualsOperator(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue(filterValue), + Operator = comparison + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Not equal tests + [InlineData("1.2.3", ComparisonOperator.SemverNotEquals, "1.2.3", false)] + [InlineData("1.2.3", ComparisonOperator.SemverNotEquals, "1.2.4", true)] + [InlineData("1.2.3", ComparisonOperator.SemverNotEquals, "1.2.2", true)] + [InlineData("2.0.0", ComparisonOperator.SemverNotEquals, "1.0.0", true)] + public void HandlesSemverNotEqualsOperator(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue(filterValue), + Operator = comparison + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Greater than tests + [InlineData("1.2.4", ComparisonOperator.SemverGreaterThan, "1.2.3", true)] + [InlineData("1.2.3", ComparisonOperator.SemverGreaterThan, "1.2.3", false)] + [InlineData("1.2.2", ComparisonOperator.SemverGreaterThan, "1.2.3", false)] + [InlineData("2.0.0", ComparisonOperator.SemverGreaterThan, "1.9.9", true)] + [InlineData("1.3.0", ComparisonOperator.SemverGreaterThan, "1.2.99", true)] + public void HandlesSemverGreaterThanOperator(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue(filterValue), + Operator = comparison + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Greater than or equal tests + [InlineData("1.2.4", ComparisonOperator.SemverGreaterThanOrEquals, "1.2.3", true)] + [InlineData("1.2.3", ComparisonOperator.SemverGreaterThanOrEquals, "1.2.3", true)] + [InlineData("1.2.2", ComparisonOperator.SemverGreaterThanOrEquals, "1.2.3", false)] + public void HandlesSemverGreaterThanOrEqualsOperator(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue(filterValue), + Operator = comparison + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Less than tests + [InlineData("1.2.2", ComparisonOperator.SemverLessThan, "1.2.3", true)] + [InlineData("1.2.3", ComparisonOperator.SemverLessThan, "1.2.3", false)] + [InlineData("1.2.4", ComparisonOperator.SemverLessThan, "1.2.3", false)] + [InlineData("1.9.9", ComparisonOperator.SemverLessThan, "2.0.0", true)] + public void HandlesSemverLessThanOperator(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue(filterValue), + Operator = comparison + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Less than or equal tests + [InlineData("1.2.2", ComparisonOperator.SemverLessThanOrEquals, "1.2.3", true)] + [InlineData("1.2.3", ComparisonOperator.SemverLessThanOrEquals, "1.2.3", true)] + [InlineData("1.2.4", ComparisonOperator.SemverLessThanOrEquals, "1.2.3", false)] + public void HandlesSemverLessThanOrEqualsOperator(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue(filterValue), + Operator = comparison + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Tilde operator tests: ~X.Y.Z means >=X.Y.Z and + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Caret operator tests for major > 0: ^X.Y.Z means >=X.Y.Z and + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Caret operator tests for major = 0, minor > 0: ^0.Y.Z means >=0.Y.Z and <0.Y+1.0 + [InlineData("0.2.3", ComparisonOperator.SemverCaret, "0.2.3", true)] // At lower bound + [InlineData("0.2.4", ComparisonOperator.SemverCaret, "0.2.3", true)] // Within range + [InlineData("0.2.99", ComparisonOperator.SemverCaret, "0.2.3", true)] // Within range + [InlineData("0.3.0", ComparisonOperator.SemverCaret, "0.2.3", false)] // At upper bound (exclusive) + [InlineData("0.2.2", ComparisonOperator.SemverCaret, "0.2.3", false)] // Below range + [InlineData("1.0.0", ComparisonOperator.SemverCaret, "0.2.3", false)] // Above range + public void HandlesSemverCaretOperatorWithMajorZeroMinorGreaterThanZero(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue(filterValue), + Operator = comparison + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Caret operator tests for major = 0, minor = 0: ^0.0.Z means >=0.0.Z and <0.0.Z+1 + [InlineData("0.0.3", ComparisonOperator.SemverCaret, "0.0.3", true)] // At lower bound + [InlineData("0.0.4", ComparisonOperator.SemverCaret, "0.0.3", false)] // At upper bound (exclusive) + [InlineData("0.0.2", ComparisonOperator.SemverCaret, "0.0.3", false)] // Below range + [InlineData("0.1.0", ComparisonOperator.SemverCaret, "0.0.3", false)] // Above range + public void HandlesSemverCaretOperatorWithMajorAndMinorZero(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue(filterValue), + Operator = comparison + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Wildcard operator tests: "X.*" means >=X.0.0 and + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Wildcard operator tests: "X.Y.*" means >=X.Y.0 and + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Special version parsing tests + [InlineData("v1.2.3", ComparisonOperator.SemverEquals, "1.2.3", true)] // v-prefix + [InlineData("1.2.3", ComparisonOperator.SemverEquals, "v1.2.3", true)] // v-prefix in filter + [InlineData("1.2.3-alpha", ComparisonOperator.SemverEquals, "1.2.3", true)] // Pre-release stripped + [InlineData("1.2.3+build", ComparisonOperator.SemverEquals, "1.2.3", true)] // Build metadata stripped + [InlineData(" 1.2.3 ", ComparisonOperator.SemverEquals, "1.2.3", true)] // Whitespace stripped + [InlineData("01.02.03", ComparisonOperator.SemverEquals, "1.2.3", true)] // Leading zeros + [InlineData("1.2", ComparisonOperator.SemverEquals, "1.2.0", true)] // Partial version + [InlineData("1", ComparisonOperator.SemverEquals, "1.0.0", true)] // Partial version + [InlineData("1.2.3.4", ComparisonOperator.SemverEquals, "1.2.3", true)] // Extra parts ignored + public void HandlesSpecialVersionFormats(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue(filterValue), + Operator = comparison + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + Assert.Equal(expected, result); + } + + [Theory] + // Invalid version in property value - should throw InconclusiveMatchException + [InlineData("not-a-version", ComparisonOperator.SemverEquals)] + [InlineData("", ComparisonOperator.SemverEquals)] + [InlineData("abc.def.ghi", ComparisonOperator.SemverEquals)] + [InlineData(".1.2.3", ComparisonOperator.SemverEquals)] + [InlineData("not-a-version", ComparisonOperator.SemverGreaterThan)] + [InlineData("", ComparisonOperator.SemverGreaterThan)] + public void ThrowsInconclusiveMatchExceptionForInvalidOverrideVersion(string overrideValue, ComparisonOperator comparison) + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue("1.2.3"), + Operator = comparison + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = overrideValue + }; + var localEvaluator = new LocalEvaluator(flags); + + Assert.Throws(() => + localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties)); + } + + [Theory] + // Invalid version in filter value - should throw InconclusiveMatchException + [InlineData("not-a-version")] + [InlineData("")] + [InlineData("abc.def.ghi")] + [InlineData(".1.2.3")] + public void ThrowsInconclusiveMatchExceptionForInvalidFilterVersion(string filterValue) + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue(filterValue), + Operator = ComparisonOperator.SemverEquals + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = "1.2.3" + }; + var localEvaluator = new LocalEvaluator(flags); + + Assert.Throws(() => + localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties)); + } + + [Theory] + // Invalid wildcard patterns - should throw InconclusiveMatchException + [InlineData("*")] + [InlineData("1.2.3")] // Not a wildcard pattern + [InlineData("abc.*")] + public void ThrowsInconclusiveMatchExceptionForInvalidWildcardPattern(string filterValue) + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue(filterValue), + Operator = ComparisonOperator.SemverWildcard + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = "1.2.3" + }; + var localEvaluator = new LocalEvaluator(flags); + + Assert.Throws(() => + localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties)); + } + + [Fact] + public void ReturnsFalseWhenPropertyValueIsNull() + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue("1.2.3"), + Operator = ComparisonOperator.SemverEquals + } + ] + ); + var properties = new Dictionary + { + ["app_version"] = null + }; + var localEvaluator = new LocalEvaluator(flags); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties); + + // Null values return false before reaching operator logic + Assert.False(result.Value); + } + + [Fact] + public void ThrowsInconclusiveMatchExceptionWhenPropertyKeyMissing() + { + var flags = CreateFlags( + key: "version", + properties: [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "app_version", + Value = new PropertyFilterValue("1.2.3"), + Operator = ComparisonOperator.SemverEquals + } + ] + ); + var properties = new Dictionary + { + ["other_property"] = "1.2.3" + }; + var localEvaluator = new LocalEvaluator(flags); + + Assert.Throws(() => + localEvaluator.EvaluateFeatureFlag( + key: "version", + distinctId: "distinct-id", + personProperties: properties)); + } +} + public class TheMatchesDependencyValueMethod { [Theory] diff --git a/tests/UnitTests/Library/SemanticVersionTests.cs b/tests/UnitTests/Library/SemanticVersionTests.cs new file mode 100644 index 00000000..f62acb13 --- /dev/null +++ b/tests/UnitTests/Library/SemanticVersionTests.cs @@ -0,0 +1,578 @@ +using PostHog.Library; + +namespace SemanticVersionTests; + +public class TheTryParseMethod +{ + [Theory] + // Basic valid versions + [InlineData("1.2.3", 1, 2, 3)] + [InlineData("0.0.0", 0, 0, 0)] + [InlineData("10.20.30", 10, 20, 30)] + [InlineData("999.999.999", 999, 999, 999)] + public void ParsesValidVersions(string input, int expectedMajor, int expectedMinor, int expectedPatch) + { + var result = SemanticVersion.TryParse(input, out var version); + + Assert.True(result); + Assert.NotNull(version); + Assert.Equal(expectedMajor, version.Value.Major); + Assert.Equal(expectedMinor, version.Value.Minor); + Assert.Equal(expectedPatch, version.Value.Patch); + } + + [Theory] + // v-prefix handling + [InlineData("v1.2.3", 1, 2, 3)] + [InlineData("V1.2.3", 1, 2, 3)] + [InlineData("v0.0.1", 0, 0, 1)] + public void StripsVPrefix(string input, int expectedMajor, int expectedMinor, int expectedPatch) + { + var result = SemanticVersion.TryParse(input, out var version); + + Assert.True(result); + Assert.NotNull(version); + Assert.Equal(expectedMajor, version.Value.Major); + Assert.Equal(expectedMinor, version.Value.Minor); + Assert.Equal(expectedPatch, version.Value.Patch); + } + + [Theory] + // Whitespace handling + [InlineData(" 1.2.3 ", 1, 2, 3)] + [InlineData("\t1.2.3\t", 1, 2, 3)] + [InlineData(" v1.2.3 ", 1, 2, 3)] + public void StripsWhitespace(string input, int expectedMajor, int expectedMinor, int expectedPatch) + { + var result = SemanticVersion.TryParse(input, out var version); + + Assert.True(result); + Assert.NotNull(version); + Assert.Equal(expectedMajor, version.Value.Major); + Assert.Equal(expectedMinor, version.Value.Minor); + Assert.Equal(expectedPatch, version.Value.Patch); + } + + [Theory] + // Pre-release and build metadata stripping + [InlineData("1.2.3-alpha", 1, 2, 3)] + [InlineData("1.2.3-alpha.1", 1, 2, 3)] + [InlineData("1.2.3+build", 1, 2, 3)] + [InlineData("1.2.3+build.123", 1, 2, 3)] + [InlineData("1.2.3-alpha+build", 1, 2, 3)] + [InlineData("1.2.3-beta.2+build.456", 1, 2, 3)] + [InlineData("v1.2.3-rc1", 1, 2, 3)] + public void StripsPreReleaseAndBuildMetadata(string input, int expectedMajor, int expectedMinor, int expectedPatch) + { + var result = SemanticVersion.TryParse(input, out var version); + + Assert.True(result); + Assert.NotNull(version); + Assert.Equal(expectedMajor, version.Value.Major); + Assert.Equal(expectedMinor, version.Value.Minor); + Assert.Equal(expectedPatch, version.Value.Patch); + } + + [Theory] + // Partial versions (missing components default to 0) + [InlineData("1", 1, 0, 0)] + [InlineData("1.2", 1, 2, 0)] + [InlineData("v1", 1, 0, 0)] + [InlineData("v1.2", 1, 2, 0)] + public void DefaultsMissingComponentsToZero(string input, int expectedMajor, int expectedMinor, int expectedPatch) + { + var result = SemanticVersion.TryParse(input, out var version); + + Assert.True(result); + Assert.NotNull(version); + Assert.Equal(expectedMajor, version.Value.Major); + Assert.Equal(expectedMinor, version.Value.Minor); + Assert.Equal(expectedPatch, version.Value.Patch); + } + + [Theory] + // Extra components beyond the third are ignored + [InlineData("1.2.3.4", 1, 2, 3)] + [InlineData("1.2.3.4.5", 1, 2, 3)] + [InlineData("1.2.3.4.5.6", 1, 2, 3)] + public void IgnoresExtraComponents(string input, int expectedMajor, int expectedMinor, int expectedPatch) + { + var result = SemanticVersion.TryParse(input, out var version); + + Assert.True(result); + Assert.NotNull(version); + Assert.Equal(expectedMajor, version.Value.Major); + Assert.Equal(expectedMinor, version.Value.Minor); + Assert.Equal(expectedPatch, version.Value.Patch); + } + + [Theory] + // Leading zeros are parsed as integers + [InlineData("01.02.03", 1, 2, 3)] + [InlineData("001.002.003", 1, 2, 3)] + public void ParsesLeadingZeros(string input, int expectedMajor, int expectedMinor, int expectedPatch) + { + var result = SemanticVersion.TryParse(input, out var version); + + Assert.True(result); + Assert.NotNull(version); + Assert.Equal(expectedMajor, version.Value.Major); + Assert.Equal(expectedMinor, version.Value.Minor); + Assert.Equal(expectedPatch, version.Value.Patch); + } + + [Theory] + // Invalid inputs + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("v")] + [InlineData("V")] + [InlineData(".1.2.3")] + [InlineData("abc")] + [InlineData("1.2.abc")] + [InlineData("abc.2.3")] + [InlineData("1.abc.3")] + [InlineData("not-a-version")] + [InlineData("..")] + [InlineData("...")] + public void ReturnsFalseForInvalidInput(string? input) + { + var result = SemanticVersion.TryParse(input, out var version); + + Assert.False(result); + Assert.Null(version); + } +} + +public class TheCompareToMethod +{ + [Theory] + // Equal versions + [InlineData("1.2.3", "1.2.3", 0)] + [InlineData("0.0.0", "0.0.0", 0)] + [InlineData("v1.2.3", "1.2.3", 0)] + [InlineData("1.2.3-alpha", "1.2.3", 0)] // Pre-release stripped, so equal + public void ReturnsZeroForEqualVersions(string left, string right, int expected) + { + Assert.True(SemanticVersion.TryParse(left, out var leftVersion)); + Assert.True(SemanticVersion.TryParse(right, out var rightVersion)); + + Assert.NotNull(leftVersion); + Assert.NotNull(rightVersion); + Assert.Equal(expected, leftVersion.Value.CompareTo(rightVersion.Value)); + } + + [Theory] + // Greater than comparisons + [InlineData("2.0.0", "1.0.0", 1)] + [InlineData("1.2.0", "1.1.0", 1)] + [InlineData("1.2.4", "1.2.3", 1)] + [InlineData("2.0.0", "1.9.9", 1)] + [InlineData("1.0.0", "0.9.9", 1)] + public void ReturnsPositiveWhenLeftIsGreater(string left, string right, int expected) + { + Assert.True(SemanticVersion.TryParse(left, out var leftVersion)); + Assert.True(SemanticVersion.TryParse(right, out var rightVersion)); + + Assert.NotNull(leftVersion); + Assert.NotNull(rightVersion); + Assert.Equal(expected, Math.Sign(leftVersion.Value.CompareTo(rightVersion.Value))); + } + + [Theory] + // Less than comparisons + [InlineData("1.0.0", "2.0.0", -1)] + [InlineData("1.1.0", "1.2.0", -1)] + [InlineData("1.2.3", "1.2.4", -1)] + [InlineData("1.9.9", "2.0.0", -1)] + [InlineData("0.9.9", "1.0.0", -1)] + public void ReturnsNegativeWhenLeftIsLess(string left, string right, int expected) + { + Assert.True(SemanticVersion.TryParse(left, out var leftVersion)); + Assert.True(SemanticVersion.TryParse(right, out var rightVersion)); + + Assert.NotNull(leftVersion); + Assert.NotNull(rightVersion); + Assert.Equal(expected, Math.Sign(leftVersion.Value.CompareTo(rightVersion.Value))); + } +} + +public class TheGetTildeBoundsMethod +{ + [Theory] + // ~X.Y.Z means >=X.Y.Z and 0 → >=X.Y.Z 0 → >=0.Y.Z <0.Y+1.0 + [InlineData("0.2.3", "0.2.3", "0.3.0")] + [InlineData("0.1.0", "0.1.0", "0.2.0")] + [InlineData("0.5.10", "0.5.10", "0.6.0")] + public void CalculatesCorrectCaretBoundsForMajorZeroMinorGreaterThanZero(string input, string expectedLower, string expectedUpper) + { + Assert.True(SemanticVersion.TryParse(input, out var version)); + Assert.True(SemanticVersion.TryParse(expectedLower, out var expectedLowerVersion)); + Assert.True(SemanticVersion.TryParse(expectedUpper, out var expectedUpperVersion)); + + Assert.NotNull(version); + Assert.NotNull(expectedLowerVersion); + Assert.NotNull(expectedUpperVersion); + + var (lower, upper) = version.Value.GetCaretBounds(); + + Assert.Equal(expectedLowerVersion.Value, lower); + Assert.Equal(expectedUpperVersion.Value, upper); + } + + [Theory] + // ^0.0.Z → >=0.0.Z <0.0.Z+1 + [InlineData("0.0.3", "0.0.3", "0.0.4")] + [InlineData("0.0.0", "0.0.0", "0.0.1")] + [InlineData("0.0.10", "0.0.10", "0.0.11")] + public void CalculatesCorrectCaretBoundsForMajorAndMinorZero(string input, string expectedLower, string expectedUpper) + { + Assert.True(SemanticVersion.TryParse(input, out var version)); + Assert.True(SemanticVersion.TryParse(expectedLower, out var expectedLowerVersion)); + Assert.True(SemanticVersion.TryParse(expectedUpper, out var expectedUpperVersion)); + + Assert.NotNull(version); + Assert.NotNull(expectedLowerVersion); + Assert.NotNull(expectedUpperVersion); + + var (lower, upper) = version.Value.GetCaretBounds(); + + Assert.Equal(expectedLowerVersion.Value, lower); + Assert.Equal(expectedUpperVersion.Value, upper); + } + + [Theory] + // Test range matching for caret with major > 0 + [InlineData("1.2.3", "1.2.3", true)] // At lower bound + [InlineData("1.2.3", "1.2.4", true)] // Within range + [InlineData("1.2.3", "1.9.9", true)] // Within range + [InlineData("1.2.3", "2.0.0", false)] // At upper bound (exclusive) + [InlineData("1.2.3", "1.2.2", false)] // Below range + [InlineData("1.2.3", "3.0.0", false)] // Above range + public void CaretBoundsMatchCorrectlyForMajorGreaterThanZero(string baseVersion, string testVersion, bool expectedInRange) + { + Assert.True(SemanticVersion.TryParse(baseVersion, out var baseVer)); + Assert.True(SemanticVersion.TryParse(testVersion, out var testVer)); + + Assert.NotNull(baseVer); + Assert.NotNull(testVer); + + var (lower, upper) = baseVer.Value.GetCaretBounds(); + var inRange = testVer.Value.IsInRange(lower, upper); + + Assert.Equal(expectedInRange, inRange); + } + + [Theory] + // Test range matching for caret with major = 0, minor > 0 + [InlineData("0.2.3", "0.2.3", true)] // At lower bound + [InlineData("0.2.3", "0.2.4", true)] // Within range + [InlineData("0.2.3", "0.2.99", true)] // Within range + [InlineData("0.2.3", "0.3.0", false)] // At upper bound (exclusive) + [InlineData("0.2.3", "0.2.2", false)] // Below range + [InlineData("0.2.3", "1.0.0", false)] // Above range + public void CaretBoundsMatchCorrectlyForMajorZeroMinorGreaterThanZero(string baseVersion, string testVersion, bool expectedInRange) + { + Assert.True(SemanticVersion.TryParse(baseVersion, out var baseVer)); + Assert.True(SemanticVersion.TryParse(testVersion, out var testVer)); + + Assert.NotNull(baseVer); + Assert.NotNull(testVer); + + var (lower, upper) = baseVer.Value.GetCaretBounds(); + var inRange = testVer.Value.IsInRange(lower, upper); + + Assert.Equal(expectedInRange, inRange); + } + + [Theory] + // Test range matching for caret with major = 0, minor = 0 + [InlineData("0.0.3", "0.0.3", true)] // At lower bound + [InlineData("0.0.3", "0.0.4", false)] // At upper bound (exclusive) + [InlineData("0.0.3", "0.0.2", false)] // Below range + [InlineData("0.0.3", "0.1.0", false)] // Above range + public void CaretBoundsMatchCorrectlyForMajorAndMinorZero(string baseVersion, string testVersion, bool expectedInRange) + { + Assert.True(SemanticVersion.TryParse(baseVersion, out var baseVer)); + Assert.True(SemanticVersion.TryParse(testVersion, out var testVer)); + + Assert.NotNull(baseVer); + Assert.NotNull(testVer); + + var (lower, upper) = baseVer.Value.GetCaretBounds(); + var inRange = testVer.Value.IsInRange(lower, upper); + + Assert.Equal(expectedInRange, inRange); + } +} + +public class TheTryParseWildcardMethod +{ + [Theory] + // "X.*" pattern → >=X.0.0 =X.Y.0 =X.0.0 =X.Y.0 rightVersion.Value); + } + + [Theory] + [InlineData("1.2.4", "1.2.3", true)] + [InlineData("1.2.3", "1.2.3", true)] + [InlineData("1.2.3", "1.2.4", false)] + public void GreaterThanOrEqualOperatorWorks(string left, string right, bool expected) + { + Assert.True(SemanticVersion.TryParse(left, out var leftVersion)); + Assert.True(SemanticVersion.TryParse(right, out var rightVersion)); + + Assert.NotNull(leftVersion); + Assert.NotNull(rightVersion); + Assert.Equal(expected, leftVersion.Value >= rightVersion.Value); + } +} + +public class TheToStringMethod +{ + [Theory] + [InlineData(1, 2, 3, "1.2.3")] + [InlineData(0, 0, 0, "0.0.0")] + [InlineData(10, 20, 30, "10.20.30")] + public void ReturnsCorrectFormat(int major, int minor, int patch, string expected) + { + var version = new SemanticVersion(major, minor, patch); + + Assert.Equal(expected, version.ToString()); + } +} From 106691529ac1de078dddd8b707a9300da13720b0 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 2 Mar 2026 17:03:00 -0800 Subject: [PATCH 2/3] fix: address CI build errors in SemanticVersion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix nullable reference warnings by using null-forgiving operator after IsNullOrWhiteSpace checks - Use string.IndexOf with StringComparison.Ordinal to satisfy CA1307 - Suppress CA1865 (conflicting with CA1307) for single-char searches 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/PostHog/Library/SemanticVersion.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/PostHog/Library/SemanticVersion.cs b/src/PostHog/Library/SemanticVersion.cs index 7e86b079..dad768aa 100644 --- a/src/PostHog/Library/SemanticVersion.cs +++ b/src/PostHog/Library/SemanticVersion.cs @@ -58,7 +58,8 @@ public static bool TryParse(string? value, [NotNullWhen(returnValue: true)] out } // Strip leading/trailing whitespace - var trimmed = value.Trim(); + // value is guaranteed non-null here since IsNullOrWhiteSpace returned false + var trimmed = value!.Trim(); // Strip 'v' or 'V' prefix if (trimmed.Length > 0 && (trimmed[0] == 'v' || trimmed[0] == 'V')) @@ -72,8 +73,10 @@ public static bool TryParse(string? value, [NotNullWhen(returnValue: true)] out } // Strip pre-release and build metadata (split on '-' or '+', take first part) - var hyphenIndex = trimmed.IndexOf('-', StringComparison.Ordinal); - var plusIndex = trimmed.IndexOf('+', StringComparison.Ordinal); +#pragma warning disable CA1865 // Use char overload - but CA1307 requires StringComparison + var hyphenIndex = trimmed.IndexOf("-", StringComparison.Ordinal); + var plusIndex = trimmed.IndexOf("+", StringComparison.Ordinal); +#pragma warning restore CA1865 var metadataIndex = -1; if (hyphenIndex >= 0 && plusIndex >= 0) @@ -218,7 +221,8 @@ public static bool TryParseWildcard( return false; } - var trimmed = pattern.Trim(); + // pattern is guaranteed non-null here since IsNullOrWhiteSpace returned false + var trimmed = pattern!.Trim(); // Strip 'v' or 'V' prefix if (trimmed.Length > 0 && (trimmed[0] == 'v' || trimmed[0] == 'V')) From 4e8af01c3aa1ba0823518d90b3ec82dbf434c09c Mon Sep 17 00:00:00 2001 From: dylan Date: Tue, 3 Mar 2026 13:00:00 -0800 Subject: [PATCH 3/3] address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use IndexOfAny for metadata separator detection - Improve negative version handling: detect hyphens that follow digits as pre-release separators vs hyphens that precede digits as invalid - Simplify wildcard parsing logic - Extract duplicate semver parsing into helper methods - Add tests for negative version components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/PostHog/Json/PropertyFilterValue.cs | 76 ++++++++----------- src/PostHog/Library/SemanticVersion.cs | 48 ++++++------ .../UnitTests/Library/SemanticVersionTests.cs | 3 + 3 files changed, 56 insertions(+), 71 deletions(-) diff --git a/src/PostHog/Json/PropertyFilterValue.cs b/src/PostHog/Json/PropertyFilterValue.cs index ffaae98b..24a24c6c 100644 --- a/src/PostHog/Json/PropertyFilterValue.cs +++ b/src/PostHog/Json/PropertyFilterValue.cs @@ -255,27 +255,36 @@ or DateOnly public static bool operator >=(PropertyFilterValue left, object? right) => NotNull(left).CompareTo(right) >= 0; public static bool operator <=(PropertyFilterValue left, object? right) => NotNull(left).CompareTo(right) <= 0; - /// - /// Compares the override value as a semantic version against this filter value. - /// - /// The version value from person/group properties. - /// A comparison result: negative if override < filter, zero if equal, positive if override > filter. - /// Thrown if either value cannot be parsed as a valid semver. - public int CompareSemver(object? overrideValue) + static SemanticVersion ParseOverrideSemver(object? overrideValue) { var overrideVersionString = overrideValue?.ToString(); - - if (!SemanticVersion.TryParse(overrideVersionString, out var overrideVersion)) + if (!SemanticVersion.TryParse(overrideVersionString, out var version)) { throw new InconclusiveMatchException($"Cannot parse override value '{overrideVersionString}' as a semantic version"); } + return version.Value; + } - if (!SemanticVersion.TryParse(StringValue, out var filterVersion)) + SemanticVersion ParseFilterSemver() + { + if (!SemanticVersion.TryParse(StringValue, out var version)) { throw new InconclusiveMatchException($"Cannot parse filter value '{StringValue}' as a semantic version"); } + return version.Value; + } - return overrideVersion.Value.CompareTo(filterVersion.Value); + /// + /// Compares the override value as a semantic version against this filter value. + /// + /// The version value from person/group properties. + /// A comparison result: negative if override < filter, zero if equal, positive if override > filter. + /// Thrown if either value cannot be parsed as a valid semver. + public int CompareSemver(object? overrideValue) + { + var overrideVersion = ParseOverrideSemver(overrideValue); + var filterVersion = ParseFilterSemver(); + return overrideVersion.CompareTo(filterVersion); } /// @@ -287,20 +296,10 @@ public int CompareSemver(object? overrideValue) /// Thrown if either value cannot be parsed as a valid semver. public bool IsSemverTildeMatch(object? overrideValue) { - var overrideVersionString = overrideValue?.ToString(); - - if (!SemanticVersion.TryParse(overrideVersionString, out var overrideVersion)) - { - throw new InconclusiveMatchException($"Cannot parse override value '{overrideVersionString}' as a semantic version"); - } - - if (!SemanticVersion.TryParse(StringValue, out var filterVersion)) - { - throw new InconclusiveMatchException($"Cannot parse filter value '{StringValue}' as a semantic version"); - } - - var (lower, upper) = filterVersion.Value.GetTildeBounds(); - return overrideVersion.Value.IsInRange(lower, upper); + var overrideVersion = ParseOverrideSemver(overrideValue); + var filterVersion = ParseFilterSemver(); + var (lower, upper) = filterVersion.GetTildeBounds(); + return overrideVersion.IsInRange(lower, upper); } /// @@ -315,20 +314,10 @@ public bool IsSemverTildeMatch(object? overrideValue) /// Thrown if either value cannot be parsed as a valid semver. public bool IsSemverCaretMatch(object? overrideValue) { - var overrideVersionString = overrideValue?.ToString(); - - if (!SemanticVersion.TryParse(overrideVersionString, out var overrideVersion)) - { - throw new InconclusiveMatchException($"Cannot parse override value '{overrideVersionString}' as a semantic version"); - } - - if (!SemanticVersion.TryParse(StringValue, out var filterVersion)) - { - throw new InconclusiveMatchException($"Cannot parse filter value '{StringValue}' as a semantic version"); - } - - var (lower, upper) = filterVersion.Value.GetCaretBounds(); - return overrideVersion.Value.IsInRange(lower, upper); + var overrideVersion = ParseOverrideSemver(overrideValue); + var filterVersion = ParseFilterSemver(); + var (lower, upper) = filterVersion.GetCaretBounds(); + return overrideVersion.IsInRange(lower, upper); } /// @@ -341,19 +330,14 @@ public bool IsSemverCaretMatch(object? overrideValue) /// Thrown if either value cannot be parsed. public bool IsSemverWildcardMatch(object? overrideValue) { - var overrideVersionString = overrideValue?.ToString(); - - if (!SemanticVersion.TryParse(overrideVersionString, out var overrideVersion)) - { - throw new InconclusiveMatchException($"Cannot parse override value '{overrideVersionString}' as a semantic version"); - } + var overrideVersion = ParseOverrideSemver(overrideValue); if (!TryParseWildcard(StringValue, out var lower, out var upper)) { throw new InconclusiveMatchException($"Cannot parse filter value '{StringValue}' as a wildcard pattern"); } - return overrideVersion.Value.IsInRange(lower.Value, upper.Value); + return overrideVersion.IsInRange(lower.Value, upper.Value); } public override string ToString() diff --git a/src/PostHog/Library/SemanticVersion.cs b/src/PostHog/Library/SemanticVersion.cs index dad768aa..7d40088d 100644 --- a/src/PostHog/Library/SemanticVersion.cs +++ b/src/PostHog/Library/SemanticVersion.cs @@ -72,26 +72,24 @@ public static bool TryParse(string? value, [NotNullWhen(returnValue: true)] out return false; } - // Strip pre-release and build metadata (split on '-' or '+', take first part) -#pragma warning disable CA1865 // Use char overload - but CA1307 requires StringComparison - var hyphenIndex = trimmed.IndexOf("-", StringComparison.Ordinal); - var plusIndex = trimmed.IndexOf("+", StringComparison.Ordinal); -#pragma warning restore CA1865 - + // Strip pre-release and build metadata + // For '-', only treat as separator if it follows a digit (e.g., "1.2.3-alpha") + // This avoids stripping negative signs (e.g., "1.-2.3" should fail, not become "1.") + // For '+', always treat as build metadata separator var metadataIndex = -1; - if (hyphenIndex >= 0 && plusIndex >= 0) - { - metadataIndex = Math.Min(hyphenIndex, plusIndex); - } - else if (hyphenIndex >= 0) + for (var i = 0; i < trimmed.Length; i++) { - metadataIndex = hyphenIndex; - } - else if (plusIndex >= 0) - { - metadataIndex = plusIndex; + if (trimmed[i] == '+') + { + metadataIndex = i; + break; + } + if (trimmed[i] == '-' && i > 0 && char.IsDigit(trimmed[i - 1])) + { + metadataIndex = i; + break; + } } - if (metadataIndex >= 0) { trimmed = trimmed[..metadataIndex]; @@ -131,6 +129,12 @@ public static bool TryParse(string? value, [NotNullWhen(returnValue: true)] out return false; } + // Reject negative version components + if (major < 0 || minor < 0 || patch < 0) + { + return false; + } + version = new SemanticVersion(major, minor, patch); return true; } @@ -246,16 +250,10 @@ public static bool TryParseWildcard( // Parse based on the pattern structure if (parts.Length == 1) { - // Could be "X" or "X.*" pattern without the dot - // Actually "1" is valid and means "1.*" + // "X" pattern - treat as "X.*" + // Bare wildcards like "*" and non-numeric values are invalid if (!int.TryParse(parts[0], out var major)) { - // Check if it's a wildcard itself - if (parts[0] == "*") - { - // "*" alone is invalid for our purposes - return false; - } return false; } diff --git a/tests/UnitTests/Library/SemanticVersionTests.cs b/tests/UnitTests/Library/SemanticVersionTests.cs index f62acb13..981b678b 100644 --- a/tests/UnitTests/Library/SemanticVersionTests.cs +++ b/tests/UnitTests/Library/SemanticVersionTests.cs @@ -136,6 +136,9 @@ public void ParsesLeadingZeros(string input, int expectedMajor, int expectedMino [InlineData("not-a-version")] [InlineData("..")] [InlineData("...")] + [InlineData("1.-2.3")] // Negative minor + [InlineData("-1.2.3")] // Negative major + [InlineData("1.2.-3")] // Negative patch public void ReturnsFalseForInvalidInput(string? input) { var result = SemanticVersion.TryParse(input, out var version);