diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs
index a356eb0..4319145 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs
@@ -14,7 +14,9 @@
// Adapted from server/opts.go in the NATS server Go source.
using System.Runtime.InteropServices;
+using System.Text.Json;
using System.Threading;
+using ZB.MOM.NatsNet.Server.Config;
namespace ZB.MOM.NatsNet.Server;
@@ -36,6 +38,12 @@ internal static class ConfigFlags
public sealed partial class ServerOptions
{
+ ///
+ /// Toggles unknown top-level field handling for config parsing.
+ /// Mirrors NoErrOnUnknownFields in opts.go.
+ ///
+ public static void NoErrOnUnknownFields(bool noError) => ConfigFlags.NoErrOnUnknownFields(noError);
+
///
/// Snapshot of command-line flags, populated during .
/// Mirrors FlagSnapshot in opts.go.
@@ -399,10 +407,804 @@ public sealed partial class ServerOptions
return null;
}
+ // -------------------------------------------------------------------------
+ // Batch 6: opts.go package-level parse/config helpers (F1)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Deep copies route/gateway URL lists.
+ /// Mirrors deepCopyURLs in opts.go.
+ ///
+ public static List? DeepCopyURLs(IReadOnlyList? urls)
+ {
+ if (urls == null)
+ return null;
+
+ var copied = new List(urls.Count);
+ foreach (var u in urls)
+ copied.Add(new Uri(u.ToString(), UriKind.Absolute));
+ return copied;
+ }
+
+ ///
+ /// Loads server options from a config file.
+ /// Mirrors package-level ProcessConfigFile in opts.go.
+ ///
+ public static ServerOptions ProcessConfigFile(string configFile) =>
+ ServerOptionsConfiguration.ProcessConfigFile(configFile);
+
+ ///
+ /// Normalizes token-like values to plain CLR values.
+ /// Mirrors unwrapValue intent from opts.go.
+ ///
+ public static object? UnwrapValue(object? value) => NormalizeConfigValue(value);
+
+ ///
+ /// Converts a recovered panic/exception to an error list entry.
+ /// Mirrors convertPanicToErrorList in opts.go.
+ ///
+ public static void ConvertPanicToErrorList(Exception? panic, ICollection? errors, string? context = null)
+ {
+ if (panic == null || errors == null)
+ return;
+
+ var message = string.IsNullOrWhiteSpace(context)
+ ? "encountered panic while processing config"
+ : $"encountered panic while processing {context}";
+ errors.Add(new InvalidOperationException(message, panic));
+ }
+
+ ///
+ /// Converts a recovered panic/exception to a single error output.
+ /// Mirrors convertPanicToError in opts.go.
+ ///
+ public static void ConvertPanicToError(Exception? panic, ref Exception? error, string? context = null)
+ {
+ if (panic == null || error != null)
+ return;
+
+ var message = string.IsNullOrWhiteSpace(context)
+ ? "encountered panic while processing config"
+ : $"encountered panic while processing {context}";
+ error = new InvalidOperationException(message, panic);
+ }
+
+ ///
+ /// Applies system_account/system config values.
+ /// Mirrors configureSystemAccount in opts.go.
+ ///
+ public static Exception? ConfigureSystemAccount(ServerOptions options, IReadOnlyDictionary config)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(config);
+
+ if (!TryGetFirst(config, ["system_account", "system"], out var value))
+ return null;
+
+ if (value is not string systemAccount)
+ return new InvalidOperationException("system account name must be a string");
+
+ options.SystemAccount = systemAccount;
+ return null;
+ }
+
+ ///
+ /// Builds a username/nkey identity map for duplicate detection.
+ /// Mirrors setupUsersAndNKeysDuplicateCheckMap in opts.go.
+ ///
+ public static HashSet SetupUsersAndNKeysDuplicateCheckMap(ServerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var identities = new HashSet(StringComparer.Ordinal);
+ if (options.Users != null)
+ {
+ foreach (var user in options.Users)
+ {
+ if (!string.IsNullOrWhiteSpace(user.Username))
+ identities.Add(user.Username);
+ }
+ }
+
+ if (options.Nkeys != null)
+ {
+ foreach (var user in options.Nkeys)
+ {
+ if (!string.IsNullOrWhiteSpace(user.Nkey))
+ identities.Add(user.Nkey);
+ }
+ }
+
+ return identities;
+ }
+
+ ///
+ /// Parses a duration from config value.
+ /// Mirrors parseDuration in opts.go.
+ ///
+ public static TimeSpan ParseDuration(
+ string field,
+ object? value,
+ ICollection? errors = null,
+ ICollection? warnings = null)
+ {
+ if (value is string s)
+ {
+ try
+ {
+ return NatsDurationJsonConverter.Parse(s);
+ }
+ catch (Exception ex)
+ {
+ errors?.Add(new InvalidOperationException($"error parsing {field}: {ex.Message}", ex));
+ return TimeSpan.Zero;
+ }
+ }
+
+ if (TryConvertToLong(value, out var legacySeconds))
+ {
+ warnings?.Add(new InvalidOperationException($"{field} should be converted to a duration"));
+ return TimeSpan.FromSeconds(legacySeconds);
+ }
+
+ errors?.Add(new InvalidOperationException($"{field} should be a duration string or number of seconds"));
+ return TimeSpan.Zero;
+ }
+
+ ///
+ /// Parses write timeout policy value.
+ /// Mirrors parseWriteDeadlinePolicy in opts.go.
+ ///
+ public static WriteTimeoutPolicy ParseWriteDeadlinePolicy(string value, ICollection? errors = null) =>
+ value.ToLowerInvariant() switch
+ {
+ "default" => WriteTimeoutPolicy.Default,
+ "close" => WriteTimeoutPolicy.Close,
+ "retry" => WriteTimeoutPolicy.Retry,
+ _ => ParseWriteDeadlinePolicyFallback(value, errors),
+ };
+
+ ///
+ /// Parses listen values (port or host:port).
+ /// Mirrors parseListen in opts.go.
+ ///
+ public static (string Host, int Port) ParseListen(object? value)
+ {
+ if (TryConvertToLong(value, out var portOnly))
+ return (string.Empty, checked((int)portOnly));
+
+ if (value is not string address)
+ throw new InvalidOperationException($"expected port or host:port, got {value?.GetType().Name ?? "null"}");
+
+ if (!TrySplitHostPort(address, out var host, out var port))
+ throw new InvalidOperationException($"could not parse address string \"{address}\"");
+
+ return (host, port);
+ }
+
+ ///
+ /// Parses cluster block config.
+ /// Mirrors parseCluster in opts.go.
+ ///
+ public static Exception? ParseCluster(
+ object? value,
+ ServerOptions options,
+ ICollection? errors = null,
+ ICollection? warnings = null)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (!TryGetMap(value, out var clusterMap))
+ return new InvalidOperationException($"Expected map to define cluster, got {value?.GetType().Name ?? "null"}");
+
+ foreach (var (rawKey, rawValue) in clusterMap)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+
+ switch (key)
+ {
+ case "name":
+ {
+ var name = entry as string ?? string.Empty;
+ if (name.Contains(' '))
+ {
+ errors?.Add(new InvalidOperationException(ServerErrors.ErrClusterNameHasSpaces.Message));
+ break;
+ }
+
+ options.Cluster.Name = name;
+ break;
+ }
+ case "listen":
+ {
+ try
+ {
+ var (host, port) = ParseListen(entry);
+ options.Cluster.Host = host;
+ options.Cluster.Port = port;
+ }
+ catch (Exception ex)
+ {
+ errors?.Add(ex);
+ }
+
+ break;
+ }
+ case "port":
+ if (TryConvertToLong(entry, out var clusterPort))
+ options.Cluster.Port = checked((int)clusterPort);
+ break;
+ case "host":
+ case "net":
+ options.Cluster.Host = entry as string ?? string.Empty;
+ break;
+ case "authorization":
+ {
+ var auth = ParseSimpleAuthorization(entry, errors, warnings);
+ if (auth == null)
+ break;
+ if (auth.HasUsers)
+ {
+ errors?.Add(new InvalidOperationException("Cluster authorization does not allow multiple users"));
+ break;
+ }
+
+ if (!string.IsNullOrEmpty(auth.Token))
+ {
+ errors?.Add(new InvalidOperationException("Cluster authorization does not support tokens"));
+ break;
+ }
+
+ if (auth.HasCallout)
+ {
+ errors?.Add(new InvalidOperationException("Cluster authorization does not support callouts"));
+ break;
+ }
+
+ options.Cluster.Username = auth.Username;
+ options.Cluster.Password = auth.Password;
+ if (auth.TimeoutSeconds > 0)
+ options.Cluster.AuthTimeout = auth.TimeoutSeconds;
+ break;
+ }
+ case "routes":
+ if (TryGetArray(entry, out var routes))
+ options.Routes = ParseURLs(routes, "route", warnings, errors);
+ break;
+ case "cluster_advertise":
+ case "advertise":
+ options.Cluster.Advertise = entry as string ?? string.Empty;
+ break;
+ case "no_advertise":
+ if (TryConvertToBool(entry, out var noAdvertise))
+ {
+ options.Cluster.NoAdvertise = noAdvertise;
+ TrackExplicitVal(options.InConfig, "Cluster.NoAdvertise", noAdvertise);
+ }
+
+ break;
+ case "connect_retries":
+ if (TryConvertToLong(entry, out var retries))
+ options.Cluster.ConnectRetries = checked((int)retries);
+ break;
+ case "connect_backoff":
+ if (TryConvertToBool(entry, out var connectBackoff))
+ options.Cluster.ConnectBackoff = connectBackoff;
+ break;
+ case "compression":
+ {
+ var parseError = ParseCompression(
+ options.Cluster.Compression,
+ CompressionModes.S2Fast,
+ "compression",
+ entry);
+ if (parseError != null)
+ errors?.Add(parseError);
+ break;
+ }
+ case "ping_interval":
+ options.Cluster.PingInterval = ParseDuration("ping_interval", entry, errors, warnings);
+ break;
+ case "ping_max":
+ if (TryConvertToLong(entry, out var pingMax))
+ options.Cluster.MaxPingsOut = checked((int)pingMax);
+ break;
+ case "write_deadline":
+ options.Cluster.WriteDeadline = ParseDuration("write_deadline", entry, errors, warnings);
+ break;
+ case "write_timeout":
+ options.Cluster.WriteTimeout = ParseWriteDeadlinePolicy(entry as string ?? string.Empty, errors);
+ break;
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
+ break;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Parses compression option values from bool/string/map forms.
+ /// Mirrors parseCompression in opts.go.
+ ///
+ public static Exception? ParseCompression(
+ CompressionOpts compression,
+ string chosenModeForOn,
+ string fieldName,
+ object? value)
+ {
+ ArgumentNullException.ThrowIfNull(compression);
+
+ switch (NormalizeConfigValue(value))
+ {
+ case string mode:
+ compression.Mode = mode;
+ return null;
+ case bool enabled:
+ compression.Mode = enabled ? chosenModeForOn : CompressionModes.Off;
+ return null;
+ default:
+ if (!TryGetMap(value, out var map))
+ return new InvalidOperationException(
+ $"field \"{fieldName}\" should be a boolean or a structure, got {value?.GetType().Name ?? "null"}");
+
+ foreach (var (rawKey, rawValue) in map)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "mode":
+ compression.Mode = entry as string ?? string.Empty;
+ break;
+ case "rtt_thresholds":
+ case "thresholds":
+ case "rtts":
+ case "rtt":
+ if (!TryGetArray(entry, out var thresholds))
+ return new InvalidOperationException("rtt_thresholds should be an array");
+
+ foreach (var threshold in thresholds)
+ {
+ if (threshold is not string thresholdValue)
+ return new InvalidOperationException("rtt_thresholds entries should be duration strings");
+ compression.RttThresholds.Add(NatsDurationJsonConverter.Parse(thresholdValue));
+ }
+
+ break;
+ default:
+ return new InvalidOperationException($"unknown field \"{rawKey}\"");
+ }
+ }
+
+ return null;
+ }
+ }
+
+ ///
+ /// Parses URL arrays with duplicate detection.
+ /// Mirrors parseURLs in opts.go.
+ ///
+ public static List ParseURLs(
+ IEnumerable