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();
+ }
+}