// Full Go-parity MQTT topic ↔ NATS subject translation.
// Go reference: golang/nats-server/server/mqtt.go mqttToNATSSubjectConversion ~line 2200
//
// Rules:
// MQTT → NATS:
// '/' → '.' (separator)
// '+' → '*' (single-level wildcard)
// '#' → '>' (multi-level wildcard)
// '.' in MQTT topics must be escaped (replaced with a placeholder)
// Empty levels (leading/trailing/consecutive slashes) produce empty tokens
// '$' prefix topics are protected from wildcard matching per MQTT spec [MQTT-4.7.2-1]
//
// NATS → MQTT (reverse):
// '.' → '/'
// '*' → '+'
// '>' → '#'
using System.Text;
namespace NATS.Server.Mqtt;
///
/// Translates MQTT topics/filters to NATS subjects and vice versa with full Go parity.
/// Go reference: mqtt.go mqttToNATSSubjectConversion, mqttNATSToMQTTSubjectConversion.
///
public static class MqttTopicMapper
{
// Escape sequence for dots that appear in MQTT topic names.
// Go uses _DOT_ internally to represent a literal dot in the NATS subject.
private const string DotEscape = "_DOT_";
private const string DotEscapeReverse = ".";
///
/// Translates an MQTT topic or filter to a NATS subject.
/// Handles wildcards, dot escaping, empty levels, and '$' prefix protection.
///
public static string MqttToNats(string mqttTopic)
{
if (mqttTopic.Length == 0)
return string.Empty;
var sb = new StringBuilder(mqttTopic.Length);
for (var i = 0; i < mqttTopic.Length; i++)
{
switch (mqttTopic[i])
{
case '/':
sb.Append('.');
break;
case '+':
sb.Append('*');
break;
case '#':
sb.Append('>');
break;
case '.':
// Dots in MQTT topic names must be escaped for NATS
sb.Append(DotEscape);
break;
default:
sb.Append(mqttTopic[i]);
break;
}
}
return sb.ToString();
}
///
/// Translates a NATS subject back to an MQTT topic.
/// Reverses the mapping: '.' → '/', '*' → '+', '>' → '#', '_DOT_' → '.'.
///
public static string NatsToMqtt(string natsSubject)
{
if (natsSubject.Length == 0)
return string.Empty;
// First, replace _DOT_ escape sequences back to dots
var working = natsSubject.Replace(DotEscape, "\x00");
var sb = new StringBuilder(working.Length);
for (var i = 0; i < working.Length; i++)
{
switch (working[i])
{
case '.':
sb.Append('/');
break;
case '*':
sb.Append('+');
break;
case '>':
sb.Append('#');
break;
case '\x00':
sb.Append('.');
break;
default:
sb.Append(working[i]);
break;
}
}
return sb.ToString();
}
///
/// Returns true if an MQTT topic starts with '$', which means it should
/// NOT be matched by wildcard subscriptions (MQTT spec [MQTT-4.7.2-1]).
/// Topics starting with '$' are reserved for system/server use.
///
public static bool IsDollarTopic(string mqttTopic)
=> mqttTopic.Length > 0 && mqttTopic[0] == '$';
///
/// Returns true if an MQTT topic filter starts with '$', indicating
/// it explicitly targets system topics.
///
public static bool IsDollarFilter(string mqttFilter)
=> mqttFilter.Length > 0 && mqttFilter[0] == '$';
///
/// Checks if a wildcard filter would match a '$' topic.
/// Per MQTT spec, wildcard filters (starting with '#' or '+') must NOT
/// match topics beginning with '$'. Only explicit '$' filters match '$' topics.
///
public static bool WildcardMatchesDollarTopic(string mqttFilter, string mqttTopic)
{
if (!IsDollarTopic(mqttTopic))
return true; // non-$ topics are always matchable
// $ topics only matched by filters that also start with $
return IsDollarFilter(mqttFilter);
}
}