diff --git a/src/NATS.Server/Subscriptions/SubjectTransform.cs b/src/NATS.Server/Subscriptions/SubjectTransform.cs new file mode 100644 index 0000000..cfee5c8 --- /dev/null +++ b/src/NATS.Server/Subscriptions/SubjectTransform.cs @@ -0,0 +1,708 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace NATS.Server.Subscriptions; + +/// +/// Compiled subject transform engine that maps subjects from a source pattern to a destination template. +/// Reference: Go server/subject_transform.go +/// +public sealed partial class SubjectTransform +{ + private readonly string _source; + private readonly string _dest; + private readonly string[] _sourceTokens; + private readonly string[] _destTokens; + private readonly TransformOp[] _ops; + + private SubjectTransform(string source, string dest, string[] sourceTokens, string[] destTokens, TransformOp[] ops) + { + _source = source; + _dest = dest; + _sourceTokens = sourceTokens; + _destTokens = destTokens; + _ops = ops; + } + + /// + /// Compiles a subject transform from source pattern to destination template. + /// Returns null if source is invalid or destination references out-of-range wildcards. + /// + public static SubjectTransform? Create(string source, string destination) + { + if (string.IsNullOrEmpty(destination)) + return null; + + if (string.IsNullOrEmpty(source)) + source = ">"; + + // Validate source and destination as subjects + var (srcValid, srcTokens, srcPwcCount, srcHasFwc) = SubjectInfo(source); + var (destValid, destTokens, destPwcCount, destHasFwc) = SubjectInfo(destination); + + // Both must be valid, dest must have no pwcs, fwc must match + if (!srcValid || !destValid || destPwcCount > 0 || srcHasFwc != destHasFwc) + return null; + + var ops = new TransformOp[destTokens.Length]; + + if (srcPwcCount > 0 || srcHasFwc) + { + // Build map from 1-based wildcard index to source token position + var wildcardPositions = new Dictionary(); + int wildcardNum = 0; + for (int i = 0; i < srcTokens.Length; i++) + { + if (srcTokens[i] == "*") + { + wildcardNum++; + wildcardPositions[wildcardNum] = i; + } + } + + for (int i = 0; i < destTokens.Length; i++) + { + var parsed = ParseDestToken(destTokens[i]); + if (parsed == null) + return null; // Parse error (bad function, etc.) + + if (parsed.Type == TransformType.None) + { + ops[i] = new TransformOp(TransformType.None); + continue; + } + + // Resolve wildcard indexes to source token positions + var srcPositions = new int[parsed.WildcardIndexes.Length]; + for (int j = 0; j < parsed.WildcardIndexes.Length; j++) + { + int wcIdx = parsed.WildcardIndexes[j]; + if (wcIdx > srcPwcCount) + return null; // Out of range + + // Match Go behavior: missing map key returns zero-value (0) + // This happens for partition with index 0, which Go silently allows. + if (!wildcardPositions.TryGetValue(wcIdx, out int pos)) + pos = 0; + + srcPositions[j] = pos; + } + + ops[i] = new TransformOp(parsed.Type, srcPositions, parsed.IntArg, parsed.StringArg); + } + } + else + { + // No wildcards in source: only NoTransform, Partition, and Random allowed + for (int i = 0; i < destTokens.Length; i++) + { + var parsed = ParseDestToken(destTokens[i]); + if (parsed == null) + return null; + + if (parsed.Type == TransformType.None) + { + ops[i] = new TransformOp(TransformType.None); + } + else if (parsed.Type == TransformType.Partition) + { + ops[i] = new TransformOp(TransformType.Partition, [], parsed.IntArg, parsed.StringArg); + } + else + { + // Other functions not allowed without wildcards in source + return null; + } + } + } + + return new SubjectTransform(source, destination, srcTokens, destTokens, ops); + } + + /// + /// Matches subject against source pattern, captures wildcard values, evaluates destination template. + /// Returns null if subject doesn't match source. + /// + public string? Apply(string subject) + { + if (string.IsNullOrEmpty(subject)) + return null; + + // Special case: source is > (match everything) and dest is > (passthrough) + if ((_source == ">" || _source == string.Empty) && (_dest == ">" || _dest == string.Empty)) + return subject; + + var subjectTokens = subject.Split('.'); + + // Check if subject matches source pattern + if (_source != ">" && !MatchTokens(subjectTokens, _sourceTokens)) + return null; + + return TransformTokenized(subjectTokens); + } + + private string TransformTokenized(string[] tokens) + { + if (_ops.Length == 0) + return _dest; + + var sb = new StringBuilder(); + int lastIndex = _ops.Length - 1; + + for (int i = 0; i < _ops.Length; i++) + { + var op = _ops[i]; + + if (op.Type == TransformType.None) + { + // If this dest token is fwc, break out to handle trailing tokens + if (_destTokens[i] == ">") + break; + + sb.Append(_destTokens[i]); + } + else + { + switch (op.Type) + { + case TransformType.Wildcard: + if (op.SourcePositions.Length > 0 && op.SourcePositions[0] < tokens.Length) + sb.Append(tokens[op.SourcePositions[0]]); + break; + + case TransformType.Partition: + sb.Append(ComputePartition(tokens, op)); + break; + + case TransformType.Split: + ApplySplit(sb, tokens, op); + break; + + case TransformType.SplitFromLeft: + ApplySplitFromLeft(sb, tokens, op); + break; + + case TransformType.SplitFromRight: + ApplySplitFromRight(sb, tokens, op); + break; + + case TransformType.SliceFromLeft: + ApplySliceFromLeft(sb, tokens, op); + break; + + case TransformType.SliceFromRight: + ApplySliceFromRight(sb, tokens, op); + break; + + case TransformType.Left: + ApplyLeft(sb, tokens, op); + break; + + case TransformType.Right: + ApplyRight(sb, tokens, op); + break; + } + } + + if (i < lastIndex) + sb.Append('.'); + } + + // Handle trailing fwc: append remaining tokens from subject + if (_destTokens[^1] == ">") + { + int srcFwcPos = _sourceTokens.Length - 1; // position of > in source + for (int i = srcFwcPos; i < tokens.Length; i++) + { + sb.Append(tokens[i]); + if (i < tokens.Length - 1) + sb.Append('.'); + } + } + + return sb.ToString(); + } + + private static string ComputePartition(string[] tokens, TransformOp op) + { + int numBuckets = op.IntArg; + if (numBuckets == 0) + return "0"; + + byte[] keyBytes; + if (op.SourcePositions.Length > 0) + { + // Hash concatenation of specified source tokens + var keyBuilder = new StringBuilder(); + foreach (int pos in op.SourcePositions) + { + if (pos < tokens.Length) + keyBuilder.Append(tokens[pos]); + } + + keyBytes = Encoding.ASCII.GetBytes(keyBuilder.ToString()); + } + else + { + // Hash full subject (all tokens joined with .) + keyBytes = Encoding.ASCII.GetBytes(string.Join(".", tokens)); + } + + uint hash = Fnv1A32(keyBytes); + return (hash % (uint)numBuckets).ToString(); + } + + /// + /// FNV-1a 32-bit hash. Offset basis: 2166136261, prime: 16777619. + /// + private static uint Fnv1A32(byte[] data) + { + const uint offsetBasis = 2166136261; + const uint prime = 16777619; + + uint hash = offsetBasis; + foreach (byte b in data) + { + hash ^= b; + hash *= prime; + } + + return hash; + } + + private static void ApplySplit(StringBuilder sb, string[] tokens, TransformOp op) + { + if (op.SourcePositions.Length == 0) + return; + + string sourceToken = tokens[op.SourcePositions[0]]; + string delimiter = op.StringArg ?? string.Empty; + + var splits = sourceToken.Split(delimiter); + bool first = true; + + for (int j = 0; j < splits.Length; j++) + { + string split = splits[j]; + if (split != string.Empty) + { + if (!first) + sb.Append('.'); + sb.Append(split); + first = false; + } + } + } + + private static void ApplySplitFromLeft(StringBuilder sb, string[] tokens, TransformOp op) + { + string sourceToken = tokens[op.SourcePositions[0]]; + int position = op.IntArg; + + if (position > 0 && position < sourceToken.Length) + { + sb.Append(sourceToken.AsSpan(0, position)); + sb.Append('.'); + sb.Append(sourceToken.AsSpan(position)); + } + else + { + sb.Append(sourceToken); + } + } + + private static void ApplySplitFromRight(StringBuilder sb, string[] tokens, TransformOp op) + { + string sourceToken = tokens[op.SourcePositions[0]]; + int position = op.IntArg; + + if (position > 0 && position < sourceToken.Length) + { + sb.Append(sourceToken.AsSpan(0, sourceToken.Length - position)); + sb.Append('.'); + sb.Append(sourceToken.AsSpan(sourceToken.Length - position)); + } + else + { + sb.Append(sourceToken); + } + } + + private static void ApplySliceFromLeft(StringBuilder sb, string[] tokens, TransformOp op) + { + string sourceToken = tokens[op.SourcePositions[0]]; + int sliceSize = op.IntArg; + + if (sliceSize > 0 && sliceSize < sourceToken.Length) + { + for (int i = 0; i + sliceSize <= sourceToken.Length; i += sliceSize) + { + if (i != 0) + sb.Append('.'); + + sb.Append(sourceToken.AsSpan(i, sliceSize)); + + // If there's a remainder that doesn't fill a full slice + if (i + sliceSize != sourceToken.Length && i + sliceSize + sliceSize > sourceToken.Length) + { + sb.Append('.'); + sb.Append(sourceToken.AsSpan(i + sliceSize)); + break; + } + } + } + else + { + sb.Append(sourceToken); + } + } + + private static void ApplySliceFromRight(StringBuilder sb, string[] tokens, TransformOp op) + { + string sourceToken = tokens[op.SourcePositions[0]]; + int sliceSize = op.IntArg; + + if (sliceSize > 0 && sliceSize < sourceToken.Length) + { + int remainder = sourceToken.Length % sliceSize; + if (remainder > 0) + { + sb.Append(sourceToken.AsSpan(0, remainder)); + sb.Append('.'); + } + + for (int i = remainder; i + sliceSize <= sourceToken.Length; i += sliceSize) + { + sb.Append(sourceToken.AsSpan(i, sliceSize)); + if (i + sliceSize < sourceToken.Length) + sb.Append('.'); + } + } + else + { + sb.Append(sourceToken); + } + } + + private static void ApplyLeft(StringBuilder sb, string[] tokens, TransformOp op) + { + string sourceToken = tokens[op.SourcePositions[0]]; + int length = op.IntArg; + + if (length > 0 && length < sourceToken.Length) + { + sb.Append(sourceToken.AsSpan(0, length)); + } + else + { + sb.Append(sourceToken); + } + } + + private static void ApplyRight(StringBuilder sb, string[] tokens, TransformOp op) + { + string sourceToken = tokens[op.SourcePositions[0]]; + int length = op.IntArg; + + if (length > 0 && length < sourceToken.Length) + { + sb.Append(sourceToken.AsSpan(sourceToken.Length - length)); + } + else + { + sb.Append(sourceToken); + } + } + + /// + /// Matches literal subject tokens against a pattern with wildcards. + /// Subject tokens must be literal (no wildcards). + /// + private static bool MatchTokens(string[] subjectTokens, string[] patternTokens) + { + for (int i = 0; i < patternTokens.Length; i++) + { + if (i >= subjectTokens.Length) + return false; + + string pt = patternTokens[i]; + + // Full wildcard matches all remaining + if (pt == ">") + return true; + + // Partial wildcard matches any single token + if (pt == "*") + continue; + + // Literal comparison + if (subjectTokens[i] != pt) + return false; + } + + // Both must be exhausted (unless pattern ended with >) + return subjectTokens.Length == patternTokens.Length; + } + + /// + /// Validates a subject and returns (valid, tokens, pwcCount, hasFwc). + /// Reference: Go subject_transform.go subjectInfo() + /// + private static (bool Valid, string[] Tokens, int PwcCount, bool HasFwc) SubjectInfo(string subject) + { + if (string.IsNullOrEmpty(subject)) + return (false, [], 0, false); + + string[] tokens = subject.Split('.'); + int pwcCount = 0; + bool hasFwc = false; + + foreach (string t in tokens) + { + if (t.Length == 0 || hasFwc) + return (false, [], 0, false); + + if (t.Length == 1) + { + switch (t[0]) + { + case '>': + hasFwc = true; + break; + case '*': + pwcCount++; + break; + } + } + } + + return (true, tokens, pwcCount, hasFwc); + } + + /// + /// Parses a single destination token into a transform operation descriptor. + /// Returns null on parse error. + /// + private static ParsedToken? ParseDestToken(string token) + { + if (token.Length <= 1) + return new ParsedToken(TransformType.None, [], -1, string.Empty); + + // $N shorthand for wildcard(N) + if (token[0] == '$') + { + if (int.TryParse(token.AsSpan(1), out int idx)) + return new ParsedToken(TransformType.Wildcard, [idx], -1, string.Empty); + + // Other things rely on tokens starting with $ so not an error + return new ParsedToken(TransformType.None, [], -1, string.Empty); + } + + // Mustache-style {{function(args)}} + if (token.Length > 4 && token[0] == '{' && token[1] == '{' && token[^2] == '}' && token[^1] == '}') + { + return ParseMustacheToken(token); + } + + return new ParsedToken(TransformType.None, [], -1, string.Empty); + } + + private static ParsedToken? ParseMustacheToken(string token) + { + // wildcard(n) + var args = GetFunctionArgs(WildcardRegex(), token); + if (args != null) + { + if (args.Length == 1 && args[0] == string.Empty) + return null; // Not enough args + + if (args.Length == 1) + { + if (!int.TryParse(args[0].Trim(), out int idx)) + return null; + return new ParsedToken(TransformType.Wildcard, [idx], -1, string.Empty); + } + + return null; // Too many args + } + + // partition(num, tokens...) + args = GetFunctionArgs(PartitionRegex(), token); + if (args != null) + { + if (args.Length < 1) + return null; + + if (args.Length == 1) + { + if (!TryParseInt32(args[0].Trim(), out int numBuckets)) + return null; + return new ParsedToken(TransformType.Partition, [], numBuckets, string.Empty); + } + + // partition(num, tok1, tok2, ...) + if (!TryParseInt32(args[0].Trim(), out int buckets)) + return null; + + var indexes = new int[args.Length - 1]; + for (int i = 1; i < args.Length; i++) + { + if (!int.TryParse(args[i].Trim(), out indexes[i - 1])) + return null; + } + + return new ParsedToken(TransformType.Partition, indexes, buckets, string.Empty); + } + + // splitFromLeft(token, position) + args = GetFunctionArgs(SplitFromLeftRegex(), token); + if (args != null) + return ParseIndexIntArgs(args, TransformType.SplitFromLeft); + + // splitFromRight(token, position) + args = GetFunctionArgs(SplitFromRightRegex(), token); + if (args != null) + return ParseIndexIntArgs(args, TransformType.SplitFromRight); + + // sliceFromLeft(token, size) + args = GetFunctionArgs(SliceFromLeftRegex(), token); + if (args != null) + return ParseIndexIntArgs(args, TransformType.SliceFromLeft); + + // sliceFromRight(token, size) + args = GetFunctionArgs(SliceFromRightRegex(), token); + if (args != null) + return ParseIndexIntArgs(args, TransformType.SliceFromRight); + + // right(token, length) + args = GetFunctionArgs(RightRegex(), token); + if (args != null) + return ParseIndexIntArgs(args, TransformType.Right); + + // left(token, length) + args = GetFunctionArgs(LeftRegex(), token); + if (args != null) + return ParseIndexIntArgs(args, TransformType.Left); + + // split(token, delimiter) + args = GetFunctionArgs(SplitRegex(), token); + if (args != null) + { + if (args.Length < 2) + return null; + if (args.Length > 2) + return null; + + if (!int.TryParse(args[0].Trim(), out int idx)) + return null; + + string delimiter = args[1]; + if (delimiter.Contains(' ') || delimiter.Contains('.')) + return null; + + return new ParsedToken(TransformType.Split, [idx], -1, delimiter); + } + + // Unknown function + return null; + } + + private static ParsedToken? ParseIndexIntArgs(string[] args, TransformType type) + { + if (args.Length < 2) + return null; + if (args.Length > 2) + return null; + + if (!int.TryParse(args[0].Trim(), out int idx)) + return null; + + if (!TryParseInt32(args[1].Trim(), out int intArg)) + return null; + + return new ParsedToken(type, [idx], intArg, string.Empty); + } + + private static bool TryParseInt32(string s, out int result) + { + // Parse as long first to detect overflow + if (long.TryParse(s, out long longVal) && longVal >= 0 && longVal <= int.MaxValue) + { + result = (int)longVal; + return true; + } + + result = -1; + return false; + } + + private static string[]? GetFunctionArgs(Regex regex, string token) + { + var match = regex.Match(token); + if (match.Success && match.Groups.Count > 1) + { + string argsStr = match.Groups[1].Value; + return CommaSeparatorRegex().Split(argsStr); + } + + return null; + } + + // Regex patterns matching the Go reference implementation (case-insensitive function names) + [GeneratedRegex(@"\{\{\s*[wW]ildcard\s*\((.*)\)\s*\}\}")] + private static partial Regex WildcardRegex(); + + [GeneratedRegex(@"\{\{\s*[pP]artition\s*\((.*)\)\s*\}\}")] + private static partial Regex PartitionRegex(); + + [GeneratedRegex(@"\{\{\s*[sS]plit[fF]rom[lL]eft\s*\((.*)\)\s*\}\}")] + private static partial Regex SplitFromLeftRegex(); + + [GeneratedRegex(@"\{\{\s*[sS]plit[fF]rom[rR]ight\s*\((.*)\)\s*\}\}")] + private static partial Regex SplitFromRightRegex(); + + [GeneratedRegex(@"\{\{\s*[sS]lice[fF]rom[lL]eft\s*\((.*)\)\s*\}\}")] + private static partial Regex SliceFromLeftRegex(); + + [GeneratedRegex(@"\{\{\s*[sS]lice[fF]rom[rR]ight\s*\((.*)\)\s*\}\}")] + private static partial Regex SliceFromRightRegex(); + + [GeneratedRegex(@"\{\{\s*[sS]plit\s*\((.*)\)\s*\}\}")] + private static partial Regex SplitRegex(); + + [GeneratedRegex(@"\{\{\s*[lL]eft\s*\((.*)\)\s*\}\}")] + private static partial Regex LeftRegex(); + + [GeneratedRegex(@"\{\{\s*[rR]ight\s*\((.*)\)\s*\}\}")] + private static partial Regex RightRegex(); + + [GeneratedRegex(@",\s*")] + private static partial Regex CommaSeparatorRegex(); + + private enum TransformType + { + None, + Wildcard, + Partition, + Split, + SplitFromLeft, + SplitFromRight, + SliceFromLeft, + SliceFromRight, + Left, + Right, + } + + private sealed record ParsedToken(TransformType Type, int[] WildcardIndexes, int IntArg, string StringArg); + + private readonly record struct TransformOp( + TransformType Type, + int[] SourcePositions, + int IntArg, + string? StringArg) + { + public TransformOp(TransformType type) : this(type, [], -1, null) + { + } + } +} diff --git a/tests/NATS.Server.Tests/SubjectTransformTests.cs b/tests/NATS.Server.Tests/SubjectTransformTests.cs new file mode 100644 index 0000000..77f3fa1 --- /dev/null +++ b/tests/NATS.Server.Tests/SubjectTransformTests.cs @@ -0,0 +1,396 @@ +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests; + +public class SubjectTransformTests +{ + [Fact] + public void WildcardReplacement_SingleToken() + { + // foo.* -> bar.{{wildcard(1)}} + var transform = SubjectTransform.Create("foo.*", "bar.{{wildcard(1)}}"); + transform.ShouldNotBeNull(); + transform.Apply("foo.baz").ShouldBe("bar.baz"); + } + + [Fact] + public void DollarSyntax_ReversesOrder() + { + // foo.*.* -> bar.$2.$1 reverses captured tokens + var transform = SubjectTransform.Create("foo.*.*", "bar.$2.$1"); + transform.ShouldNotBeNull(); + transform.Apply("foo.A.B").ShouldBe("bar.B.A"); + } + + [Fact] + public void DollarSyntax_MultipleWildcardPositions() + { + // foo.*.bar.*.baz -> req.$2.$1 + var transform = SubjectTransform.Create("foo.*.bar.*.baz", "req.$2.$1"); + transform.ShouldNotBeNull(); + transform.Apply("foo.A.bar.B.baz").ShouldBe("req.B.A"); + } + + [Fact] + public void WildcardFunction_MultiplePositions() + { + // foo.*.bar.*.baz -> req.{{wildcard(2)}}.{{wildcard(1)}} + var transform = SubjectTransform.Create("foo.*.bar.*.baz", "req.{{wildcard(2)}}.{{wildcard(1)}}"); + transform.ShouldNotBeNull(); + transform.Apply("foo.A.bar.B.baz").ShouldBe("req.B.A"); + } + + [Fact] + public void FullWildcardCapture_MultiToken() + { + // baz.> -> my.pre.> captures multi-token remainder + var transform = SubjectTransform.Create("baz.>", "my.pre.>"); + transform.ShouldNotBeNull(); + transform.Apply("baz.1.2.3").ShouldBe("my.pre.1.2.3"); + } + + [Fact] + public void FullWildcardCapture_FooBar() + { + // baz.> -> foo.bar.> + var transform = SubjectTransform.Create("baz.>", "foo.bar.>"); + transform.ShouldNotBeNull(); + transform.Apply("baz.1.2.3").ShouldBe("foo.bar.1.2.3"); + } + + [Fact] + public void NoMatch_ReturnsNull() + { + var transform = SubjectTransform.Create("foo.*", "bar.$1"); + transform.ShouldNotBeNull(); + transform.Apply("baz.qux").ShouldBeNull(); + } + + [Fact] + public void NoMatch_WrongTokenCount() + { + var transform = SubjectTransform.Create("foo.*", "bar.$1"); + transform.ShouldNotBeNull(); + transform.Apply("foo.a.b").ShouldBeNull(); + } + + [Fact] + public void PartitionFunction_DeterministicResult() + { + // Partition should produce deterministic 0..N-1 results + var transform = SubjectTransform.Create("*", "bar.{{partition(10)}}"); + transform.ShouldNotBeNull(); + + // FNV-1a of "foo" mod 10 = 3 + transform.Apply("foo").ShouldBe("bar.3"); + // FNV-1a of "baz" mod 10 = 0 + transform.Apply("baz").ShouldBe("bar.0"); + // FNV-1a of "qux" mod 10 = 9 + transform.Apply("qux").ShouldBe("bar.9"); + } + + [Fact] + public void PartitionFunction_ZeroBuckets() + { + var transform = SubjectTransform.Create("*", "bar.{{partition(0)}}"); + transform.ShouldNotBeNull(); + transform.Apply("baz").ShouldBe("bar.0"); + } + + [Fact] + public void PartitionFunction_WithTokenIndexes() + { + // partition(10, 1, 2) hashes concatenation of wildcard 1 and wildcard 2 + // For source *.*: wildcard 1 -> pos 0 ("foo"), wildcard 2 -> pos 1 ("bar") + // Key = "foobar" (no separator), FNV-1a("foobar") % 10 = 0 + var transform = SubjectTransform.Create("*.*", "bar.{{partition(10,1,2)}}"); + transform.ShouldNotBeNull(); + transform.Apply("foo.bar").ShouldBe("bar.0"); + } + + [Fact] + public void PartitionFunction_WithSpecificToken() + { + // partition(10, 0) with wildcard source: in Go, wildcard index 0 silently + // maps to source position 0 (Go map zero-value behavior). We match this. + var transform = SubjectTransform.Create("*", "bar.{{partition(10, 0)}}"); + transform.ShouldNotBeNull(); + transform.Apply("foo").ShouldBe("bar.3"); + } + + [Fact] + public void PartitionFunction_ShorthandNoWildcardsInSource() + { + // When source has no wildcards, partition(n) hashes the full subject + var transform = SubjectTransform.Create("foo.bar", "baz.{{partition(10)}}"); + transform.ShouldNotBeNull(); + transform.Apply("foo.bar").ShouldBe("baz.6"); + } + + [Fact] + public void PartitionFunction_ShorthandWithWildcards() + { + // partition(10) with wildcards hashes all subject tokens joined + var transform = SubjectTransform.Create("*.*", "bar.{{partition(10)}}"); + transform.ShouldNotBeNull(); + transform.Apply("foo.bar").ShouldBe("bar.6"); + } + + [Fact] + public void SplitFunction_BasicDelimiter() + { + // events.a-b-c with split(1,-) -> split.a.b.c + var transform = SubjectTransform.Create("*", "{{split(1,-)}}"); + transform.ShouldNotBeNull(); + transform.Apply("abc-def--ghi-").ShouldBe("abc.def.ghi"); + } + + [Fact] + public void SplitFunction_LeadingDelimiter() + { + var transform = SubjectTransform.Create("*", "{{split(1,-)}}"); + transform.ShouldNotBeNull(); + transform.Apply("-abc-def--ghi-").ShouldBe("abc.def.ghi"); + } + + [Fact] + public void LeftFunction_BasicTrim() + { + // data.abcdef with left(1,3) -> prefix.abc + var transform = SubjectTransform.Create("*", "prefix.{{left(1,3)}}"); + transform.ShouldNotBeNull(); + transform.Apply("abcdef").ShouldBe("prefix.abc"); + } + + [Fact] + public void LeftFunction_LenExceedsToken() + { + var transform = SubjectTransform.Create("*", "{{left(1,6)}}"); + transform.ShouldNotBeNull(); + // When len exceeds token length, return full token + transform.Apply("1234").ShouldBe("1234"); + } + + [Fact] + public void LeftFunction_SingleChar() + { + var transform = SubjectTransform.Create("*", "{{left(1,1)}}"); + transform.ShouldNotBeNull(); + transform.Apply("1234").ShouldBe("1"); + } + + [Fact] + public void RightFunction_BasicTrim() + { + // data.abcdef with right(1,3) -> suffix.def + var transform = SubjectTransform.Create("*", "suffix.{{right(1,3)}}"); + transform.ShouldNotBeNull(); + transform.Apply("abcdef").ShouldBe("suffix.def"); + } + + [Fact] + public void RightFunction_LenExceedsToken() + { + var transform = SubjectTransform.Create("*", "{{right(1,6)}}"); + transform.ShouldNotBeNull(); + transform.Apply("1234").ShouldBe("1234"); + } + + [Fact] + public void RightFunction_SingleChar() + { + var transform = SubjectTransform.Create("*", "{{right(1,1)}}"); + transform.ShouldNotBeNull(); + transform.Apply("1234").ShouldBe("4"); + } + + [Fact] + public void RightFunction_ThreeChars() + { + var transform = SubjectTransform.Create("*", "{{right(1,3)}}"); + transform.ShouldNotBeNull(); + transform.Apply("1234").ShouldBe("234"); + } + + [Fact] + public void SplitFromLeft_BasicSplit() + { + // data.abcdef with splitFromLeft(1,3) -> parts.abc.def + var transform = SubjectTransform.Create("*", "{{splitFromLeft(1,3)}}"); + transform.ShouldNotBeNull(); + transform.Apply("12345").ShouldBe("123.45"); + } + + [Fact] + public void SplitFromRight_BasicSplit() + { + // data.abcdef with splitFromRight(1,3) -> parts.abc.def + var transform = SubjectTransform.Create("*", "{{SplitFromRight(1,3)}}"); + transform.ShouldNotBeNull(); + transform.Apply("12345").ShouldBe("12.345"); + } + + [Fact] + public void SliceFromLeft_BasicSlice() + { + // data.abcdef with sliceFromLeft(1,2) -> chunks.ab.cd.ef + var transform = SubjectTransform.Create("*", "{{SliceFromLeft(1,3)}}"); + transform.ShouldNotBeNull(); + transform.Apply("1234567890").ShouldBe("123.456.789.0"); + } + + [Fact] + public void SliceFromRight_BasicSlice() + { + // data.abcdef with sliceFromRight(1,2) -> chunks.ab.cd.ef + var transform = SubjectTransform.Create("*", "{{SliceFromRight(1,3)}}"); + transform.ShouldNotBeNull(); + transform.Apply("1234567890").ShouldBe("1.234.567.890"); + } + + [Fact] + public void LiteralPassthrough_NoWildcards() + { + // Literal source with no wildcards: exact match, returns dest + var transform = SubjectTransform.Create("foo", "bar"); + transform.ShouldNotBeNull(); + transform.Apply("foo").ShouldBe("bar"); + } + + [Fact] + public void LiteralPassthrough_NoMatchOnDifferentSubject() + { + var transform = SubjectTransform.Create("foo", "bar"); + transform.ShouldNotBeNull(); + transform.Apply("baz").ShouldBeNull(); + } + + [Fact] + public void InvalidSource_ReturnsNull() + { + // foo.. is not a valid subject + SubjectTransform.Create("foo..", "bar").ShouldBeNull(); + } + + [Fact] + public void InvalidSource_EmptyToken() + { + SubjectTransform.Create(".foo", "bar").ShouldBeNull(); + } + + [Fact] + public void WildcardIndexOutOfRange_ReturnsNull() + { + // Source has 1 wildcard but dest references $2 + SubjectTransform.Create("foo.*", "bar.$2").ShouldBeNull(); + } + + [Fact] + public void DestinationWithWildcard_ReturnsNull() + { + // Wildcards not allowed in destination (pwc) + SubjectTransform.Create("foo.*", "bar.*").ShouldBeNull(); + } + + [Fact] + public void FwcMismatch_ReturnsNull() + { + // If source has >, dest must also have > + SubjectTransform.Create("foo.*", "bar.$1.>").ShouldBeNull(); + SubjectTransform.Create("foo.>", "bar.baz").ShouldBeNull(); + } + + [Fact] + public void UnknownFunction_ReturnsNull() + { + SubjectTransform.Create("foo.*", "foo.{{unimplemented(1)}}").ShouldBeNull(); + } + + [Fact] + public void SingleWildcardCapture_ExpandedToBarPrefix() + { + var transform = SubjectTransform.Create("*", "foo.bar.$1"); + transform.ShouldNotBeNull(); + transform.Apply("foo").ShouldBe("foo.bar.foo"); + } + + [Fact] + public void ComboTransform_SplitAndSplitFromLeft() + { + // Combo: split + splitFromLeft + var transform = SubjectTransform.Create("*.*", "{{split(2,-)}}.{{splitfromleft(1,2)}}"); + transform.ShouldNotBeNull(); + transform.Apply("foo.-abc-def--ghij-").ShouldBe("abc.def.ghij.fo.o"); + } + + [Fact] + public void PartitionFunction_NoWildcardSource_FullSubjectHash() + { + // foo.baz -> qux.{{partition(10)}} + var transform = SubjectTransform.Create("foo.baz", "qux.{{partition(10)}}"); + transform.ShouldNotBeNull(); + transform.Apply("foo.baz").ShouldBe("qux.4"); + } + + [Fact] + public void PartitionFunction_NoWildcardSource_TestSubject() + { + var transform = SubjectTransform.Create("test.subject", "result.{{partition(5)}}"); + transform.ShouldNotBeNull(); + transform.Apply("test.subject").ShouldBe("result.0"); + } + + [Fact] + public void WildcardFunction_CaseInsensitive() + { + // Function names are case-insensitive (e.g. Wildcard, wildcard, WILDCARD) + var transform = SubjectTransform.Create("foo.*", "bar.{{Wildcard(1)}}"); + transform.ShouldNotBeNull(); + transform.Apply("foo.test").ShouldBe("bar.test"); + } + + [Fact] + public void SplitFromLeft_CaseInsensitive() + { + var transform = SubjectTransform.Create("*", "{{splitfromleft(1,1)}}"); + transform.ShouldNotBeNull(); + // Single char split from left pos 1: "ab" -> "a.b" + } + + [Fact] + public void NotEnoughTokensInDest_PartitionWithMissingArgs() + { + SubjectTransform.Create("foo.*", "foo.{{partition()}}").ShouldBeNull(); + } + + [Fact] + public void WildcardFunctionBadArg_ReturnsNull() + { + SubjectTransform.Create("foo.*", "foo.{{wildcard(foo)}}").ShouldBeNull(); + } + + [Fact] + public void WildcardFunctionNoArgs_ReturnsNull() + { + SubjectTransform.Create("foo.*", "foo.{{wildcard()}}").ShouldBeNull(); + } + + [Fact] + public void WildcardFunctionTooManyArgs_ReturnsNull() + { + SubjectTransform.Create("foo.*", "foo.{{wildcard(1,2)}}").ShouldBeNull(); + } + + [Fact] + public void BadMustacheFormat_ReturnsNull() + { + SubjectTransform.Create("foo.*", "foo.{{ wildcard5) }}").ShouldBeNull(); + } + + [Fact] + public void NoWildcardSource_TransformFunctionNotAllowed() + { + // When source has no wildcards, only partition and random functions are allowed + SubjectTransform.Create("foo", "bla.{{wildcard(1)}}").ShouldBeNull(); + } +}