diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs new file mode 100644 index 0000000..5bc453a --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs @@ -0,0 +1,169 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from parse utility functions in server/opts.go in the NATS server Go source. + +using System.Net.Security; +using System.Security.Authentication; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace ZB.MOM.NatsNet.Server.Config; + +/// +/// Converts NATS duration strings (e.g. "2s", "100ms", "1h30m") to . +/// Mirrors Go parseDuration in server/opts.go. +/// +public sealed class NatsDurationJsonConverter : JsonConverter +{ + private static readonly Regex Pattern = new( + @"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?(?:(\d+)ms)?(?:(\d+)us)?(?:(\d+)ns)?$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString() ?? throw new JsonException("Expected a duration string"); + return Parse(raw); + } + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + => writer.WriteStringValue(FormatDuration(value)); + + /// + /// Parses a NATS-style duration string. Accepts Go time.Duration format strings and ISO 8601. + /// + public static TimeSpan Parse(string s) + { + if (string.IsNullOrWhiteSpace(s)) + throw new FormatException("Duration string is empty"); + + // Try Go-style: e.g. "2s", "100ms", "1h30m", "5m10s" + var m = Pattern.Match(s); + if (m.Success && m.Value.Length > 0) + { + var hours = m.Groups[1].Success ? int.Parse(m.Groups[1].Value) : 0; + var minutes = m.Groups[2].Success ? int.Parse(m.Groups[2].Value) : 0; + var seconds = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0; + var ms = m.Groups[4].Success ? int.Parse(m.Groups[4].Value) : 0; + var us = m.Groups[5].Success ? int.Parse(m.Groups[5].Value) : 0; + var ns = m.Groups[6].Success ? int.Parse(m.Groups[6].Value) : 0; + return new TimeSpan(0, hours, minutes, seconds, ms) + + TimeSpan.FromMicroseconds(us) + + TimeSpan.FromTicks(ns / 100); // 1 tick = 100 ns + } + + // Try .NET TimeSpan.Parse (handles "hh:mm:ss") + if (TimeSpan.TryParse(s, out var ts)) return ts; + + throw new FormatException($"Cannot parse duration string: \"{s}\""); + } + + private static string FormatDuration(TimeSpan ts) + { + if (ts.TotalMilliseconds < 1) return $"{(long)ts.TotalNanoseconds}ns"; + if (ts.TotalSeconds < 1) return $"{(long)ts.TotalMilliseconds}ms"; + if (ts.TotalMinutes < 1) return $"{(long)ts.TotalSeconds}s"; + if (ts.TotalHours < 1) return $"{ts.Minutes}m{ts.Seconds}s"; + return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s"; + } +} + +/// +/// Converts a TLS version string ("1.2", "1.3", "TLS12") to . +/// Mirrors Go parseTLSVersion in server/opts.go. +/// +public sealed class TlsVersionJsonConverter : JsonConverter +{ + public override SslProtocols Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString()?.Trim() ?? string.Empty; + return Parse(raw); + } + + public override void Write(Utf8JsonWriter writer, SslProtocols value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); + + public static SslProtocols Parse(string s) => s.ToUpperInvariant() switch + { + "1.2" or "TLS12" or "TLSV1.2" => SslProtocols.Tls12, + "1.3" or "TLS13" or "TLSV1.3" => SslProtocols.Tls13, + _ => throw new FormatException($"Unknown TLS version: \"{s}\""), + }; +} + +/// +/// Validates and normalises a NATS URL string (nats://host:port). +/// Mirrors Go parseURL in server/opts.go. +/// +public sealed class NatsUrlJsonConverter : JsonConverter +{ + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString() ?? string.Empty; + return Normalise(raw); + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + => writer.WriteStringValue(value); + + public static string Normalise(string url) + { + if (string.IsNullOrWhiteSpace(url)) return url; + url = url.Trim(); + if (!url.Contains("://")) url = "nats://" + url; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + throw new FormatException($"Invalid NATS URL: \"{url}\""); + return uri.ToString().TrimEnd('/'); + } +} + +/// +/// Converts a storage size string ("1GB", "512MB", "1024") to a byte count (long). +/// Mirrors Go getStorageSize in server/opts.go. +/// +public sealed class StorageSizeJsonConverter : JsonConverter +{ + private static readonly Regex Pattern = new(@"^(\d+(?:\.\d+)?)\s*([KMGT]?B?)?$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + { + return reader.GetInt64(); + } + var raw = reader.GetString() ?? "0"; + return Parse(raw); + } + + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) + => writer.WriteNumberValue(value); + + public static long Parse(string s) + { + if (long.TryParse(s, out var n)) return n; + var m = Pattern.Match(s.Trim()); + if (!m.Success) throw new FormatException($"Invalid storage size: \"{s}\""); + var num = double.Parse(m.Groups[1].Value); + var suffix = m.Groups[2].Value.ToUpperInvariant(); + return suffix switch + { + "K" or "KB" => (long)(num * 1024), + "M" or "MB" => (long)(num * 1024 * 1024), + "G" or "GB" => (long)(num * 1024 * 1024 * 1024), + "T" or "TB" => (long)(num * 1024L * 1024 * 1024 * 1024), + _ => (long)num, + }; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Config/ServerOptionsConfiguration.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Config/ServerOptionsConfiguration.cs new file mode 100644 index 0000000..b6d03f5 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Config/ServerOptionsConfiguration.cs @@ -0,0 +1,127 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/opts.go in the NATS server Go source. + +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; + +namespace ZB.MOM.NatsNet.Server.Config; + +/// +/// Loads and binds NATS server configuration from an appsettings.json file. +/// Replaces the Go processConfigFile / processConfigFileLine pipeline +/// and all parse* helper functions in server/opts.go. +/// +public static class ServerOptionsConfiguration +{ + /// + /// Creates a instance pre-configured with all + /// NATS-specific JSON converters. + /// + public static JsonSerializerOptions CreateJsonOptions() + { + var opts = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + opts.Converters.Add(new NatsDurationJsonConverter()); + opts.Converters.Add(new TlsVersionJsonConverter()); + opts.Converters.Add(new StorageSizeJsonConverter()); + return opts; + } + + /// + /// Reads a JSON file at and returns a bound + /// instance. + /// Mirrors Go ProcessConfigFile and Options.ProcessConfigFile. + /// + public static ServerOptions ProcessConfigFile(string path) + { + if (!File.Exists(path)) + throw new FileNotFoundException($"Configuration file not found: {path}", path); + + var json = File.ReadAllText(path, Encoding.UTF8); + return ProcessConfigString(json); + } + + /// + /// Deserialises a JSON string and returns a bound instance. + /// Mirrors Go Options.ProcessConfigString. + /// + public static ServerOptions ProcessConfigString(string json) + { + ArgumentNullException.ThrowIfNullOrEmpty(json); + var opts = JsonSerializer.Deserialize(json, CreateJsonOptions()) + ?? new ServerOptions(); + PostProcess(opts); + return opts; + } + + /// + /// Binds a pre-built (e.g. from an ASP.NET Core host) + /// to a instance. + /// The configuration section should be the root or a named section such as "NatsServer". + /// + public static void BindConfiguration(IConfiguration config, ServerOptions target) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(target); + config.Bind(target); + PostProcess(target); + } + + // ------------------------------------------------------------------------- + // Post-processing + // ------------------------------------------------------------------------- + + /// + /// Applies defaults and cross-field validation after loading. + /// Mirrors the end of Options.processConfigFile and + /// configureSystemAccount in server/opts.go. + /// + private static void PostProcess(ServerOptions opts) + { + // Apply default port if not set. + if (opts.Port == 0) opts.Port = ServerConstants.DefaultPort; + + // Apply default host if not set. + if (string.IsNullOrEmpty(opts.Host)) opts.Host = ServerConstants.DefaultHost; + + // Apply default max payload. + if (opts.MaxPayload == 0) opts.MaxPayload = ServerConstants.MaxPayload; + + // Apply default auth timeout. + if (opts.AuthTimeout == 0) opts.AuthTimeout = ServerConstants.DefaultAuthTimeout; + + // Ensure SystemAccount defaults if not set. + ConfigureSystemAccount(opts); + } + + /// + /// Sets up the system account name from options. + /// Mirrors Go configureSystemAccount in server/opts.go. + /// + private static void ConfigureSystemAccount(ServerOptions opts) + { + // If system account already set, nothing to do. + if (!string.IsNullOrEmpty(opts.SystemAccount)) return; + // Default to "$SYS" if not explicitly disabled. + opts.SystemAccount = ServerConstants.DefaultSystemAccount; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs index 40e8a45..7fca879 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs @@ -63,6 +63,12 @@ public static class ServerConstants // Auth timeout — mirrors AUTH_TIMEOUT. public static readonly TimeSpan AuthTimeout = TimeSpan.FromSeconds(2); + // Default auth timeout as a double (seconds) — used by ServerOptions.AuthTimeout. + public const double DefaultAuthTimeout = 2.0; + + // Maximum payload size alias used by config binding — mirrors MAX_PAYLOAD_SIZE. + public const int MaxPayload = MaxPayloadSize; + // How often pings are sent — mirrors DEFAULT_PING_INTERVAL. public static readonly TimeSpan DefaultPingInterval = TimeSpan.FromMinutes(2); diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs index 5835819..47bbaf9 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs @@ -15,6 +15,7 @@ using System.Net.Security; using System.Security.Authentication; +using System.Text.Json.Serialization; using System.Threading; using ZB.MOM.NatsNet.Server.Auth; @@ -31,20 +32,28 @@ public sealed partial class ServerOptions // ------------------------------------------------------------------------- public string ConfigFile { get; set; } = string.Empty; + [JsonPropertyName("server_name")] public string ServerName { get; set; } = string.Empty; + [JsonPropertyName("host")] public string Host { get; set; } = string.Empty; + [JsonPropertyName("port")] public int Port { get; set; } public bool DontListen { get; set; } + [JsonPropertyName("client_advertise")] public string ClientAdvertise { get; set; } = string.Empty; public bool CheckConfig { get; set; } + [JsonPropertyName("pid_file")] public string PidFile { get; set; } = string.Empty; + [JsonPropertyName("ports_file_dir")] public string PortsFileDir { get; set; } = string.Empty; // ------------------------------------------------------------------------- // Logging & Debugging // ------------------------------------------------------------------------- + [JsonPropertyName("trace")] public bool Trace { get; set; } + [JsonPropertyName("debug")] public bool Debug { get; set; } public bool TraceVerbose { get; set; } public bool TraceHeaders { get; set; } @@ -52,7 +61,9 @@ public sealed partial class ServerOptions public bool NoSigs { get; set; } public bool Logtime { get; set; } public bool LogtimeUtc { get; set; } + [JsonPropertyName("logfile")] public string LogFile { get; set; } = string.Empty; + [JsonPropertyName("log_size_limit")] public long LogSizeLimit { get; set; } public long LogMaxFiles { get; set; } public bool Syslog { get; set; } @@ -65,11 +76,14 @@ public sealed partial class ServerOptions // Networking & Limits // ------------------------------------------------------------------------- + [JsonPropertyName("max_connections")] public int MaxConn { get; set; } public int MaxSubs { get; set; } public byte MaxSubTokens { get; set; } public int MaxControlLine { get; set; } + [JsonPropertyName("max_payload")] public int MaxPayload { get; set; } + [JsonPropertyName("max_pending")] public long MaxPending { get; set; } public bool NoFastProducerStall { get; set; } public bool ProxyRequired { get; set; } @@ -80,11 +94,16 @@ public sealed partial class ServerOptions // Connectivity // ------------------------------------------------------------------------- + [JsonPropertyName("ping_interval")] public TimeSpan PingInterval { get; set; } + [JsonPropertyName("ping_max")] public int MaxPingsOut { get; set; } + [JsonPropertyName("write_deadline")] public TimeSpan WriteDeadline { get; set; } public WriteTimeoutPolicy WriteTimeout { get; set; } + [JsonPropertyName("lame_duck_duration")] public TimeSpan LameDuckDuration { get; set; } + [JsonPropertyName("lame_duck_grace_period")] public TimeSpan LameDuckGracePeriod { get; set; } // ------------------------------------------------------------------------- @@ -92,23 +111,33 @@ public sealed partial class ServerOptions // ------------------------------------------------------------------------- public string HttpHost { get; set; } = string.Empty; + [JsonPropertyName("http_port")] public int HttpPort { get; set; } + [JsonPropertyName("http_base_path")] public string HttpBasePath { get; set; } = string.Empty; + [JsonPropertyName("https_port")] public int HttpsPort { get; set; } // ------------------------------------------------------------------------- // Authentication & Authorization // ------------------------------------------------------------------------- + [JsonPropertyName("username")] public string Username { get; set; } = string.Empty; + [JsonPropertyName("password")] public string Password { get; set; } = string.Empty; + [JsonPropertyName("authorization")] public string Authorization { get; set; } = string.Empty; + [JsonPropertyName("auth_timeout")] public double AuthTimeout { get; set; } + [JsonPropertyName("no_auth_user")] public string NoAuthUser { get; set; } = string.Empty; public string DefaultSentinel { get; set; } = string.Empty; + [JsonPropertyName("system_account")] public string SystemAccount { get; set; } = string.Empty; public bool NoSystemAccount { get; set; } /// Parsed account objects from config. Mirrors Go opts.Accounts. + [JsonPropertyName("accounts")] public List Accounts { get; set; } = []; public AuthCalloutOpts? AuthCallout { get; set; } public bool AlwaysEnableNonce { get; set; } @@ -148,8 +177,11 @@ public sealed partial class ServerOptions // Cluster / Gateway / Leaf / WebSocket / MQTT // ------------------------------------------------------------------------- + [JsonPropertyName("cluster")] public ClusterOpts Cluster { get; set; } = new(); + [JsonPropertyName("gateway")] public GatewayOpts Gateway { get; set; } = new(); + [JsonPropertyName("leafnodes")] public LeafNodeOpts LeafNode { get; set; } = new(); public WebsocketOpts Websocket { get; set; } = new(); public MqttOpts Mqtt { get; set; } = new(); @@ -165,6 +197,7 @@ public sealed partial class ServerOptions // JetStream // ------------------------------------------------------------------------- + [JsonPropertyName("jetstream")] public bool JetStream { get; set; } public bool NoJetStreamStrict { get; set; } public long JetStreamMaxMemory { get; set; } @@ -184,6 +217,7 @@ public sealed partial class ServerOptions public bool JetStreamMetaCompactSync { get; set; } public int StreamMaxBufferedMsgs { get; set; } public long StreamMaxBufferedSize { get; set; } + [JsonPropertyName("store_dir")] public string StoreDir { get; set; } = string.Empty; public TimeSpan SyncInterval { get; set; } public bool SyncAlways { get; set; } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj b/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj index 04c1046..c11f5bf 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj @@ -9,6 +9,8 @@ + + diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Config/ServerOptionsConfigurationTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Config/ServerOptionsConfigurationTests.cs new file mode 100644 index 0000000..1a0fcde --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Config/ServerOptionsConfigurationTests.cs @@ -0,0 +1,111 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 +namespace ZB.MOM.NatsNet.Server.Tests.Config; + +using ZB.MOM.NatsNet.Server.Config; +using Shouldly; +using Xunit; + +public class ServerOptionsConfigurationTests +{ + [Fact] + public void ProcessConfigString_MinimalJson_SetsPort() + { + var opts = ServerOptionsConfiguration.ProcessConfigString("""{"port": 4222}"""); + opts.Port.ShouldBe(4222); + } + + [Fact] + public void ProcessConfigString_EmptyJson_AppliesDefaults() + { + var opts = ServerOptionsConfiguration.ProcessConfigString("{}"); + opts.Port.ShouldBe(ServerConstants.DefaultPort); + opts.Host.ShouldBe(ServerConstants.DefaultHost); + opts.MaxPayload.ShouldBe(ServerConstants.MaxPayload); + } + + [Fact] + public void ProcessConfigString_AllBasicFields_Roundtrip() + { + var json = """ + { + "port": 5222, + "host": "127.0.0.1", + "server_name": "test-server", + "debug": true, + "trace": true, + "max_connections": 100, + "auth_timeout": 2.0 + } + """; + var opts = ServerOptionsConfiguration.ProcessConfigString(json); + opts.Port.ShouldBe(5222); + opts.Host.ShouldBe("127.0.0.1"); + opts.ServerName.ShouldBe("test-server"); + opts.Debug.ShouldBeTrue(); + opts.Trace.ShouldBeTrue(); + opts.MaxConn.ShouldBe(100); + opts.AuthTimeout.ShouldBe(2.0); + } + + [Fact] + public void ProcessConfigFile_FileNotFound_Throws() + { + Should.Throw(() => + ServerOptionsConfiguration.ProcessConfigFile("/nonexistent/path.json")); + } + + [Fact] + public void ProcessConfigFile_ValidFile_ReturnsOptions() + { + var tmpFile = Path.GetTempFileName(); + File.WriteAllText(tmpFile, """{"port": 9090, "server_name": "from-file"}"""); + try + { + var opts = ServerOptionsConfiguration.ProcessConfigFile(tmpFile); + opts.Port.ShouldBe(9090); + opts.ServerName.ShouldBe("from-file"); + } + finally { File.Delete(tmpFile); } + } +} + +public class NatsDurationJsonConverterTests +{ + [Theory] + [InlineData("2s", 0, 0, 2, 0)] + [InlineData("100ms", 0, 0, 0, 100)] + [InlineData("1h30m", 1, 30, 0, 0)] + public void Parse_ValidDurationStrings_ReturnsCorrectTimeSpan( + string input, int hours, int minutes, int seconds, int ms) + { + var expected = new TimeSpan(0, hours, minutes, seconds, ms); + NatsDurationJsonConverter.Parse(input).ShouldBe(expected); + } + + [Fact] + public void Parse_FiveMinutesTenSeconds_ReturnsCorrectSpan() + { + var result = NatsDurationJsonConverter.Parse("5m10s"); + result.ShouldBe(TimeSpan.FromSeconds(310)); + } + + [Fact] + public void Parse_InvalidString_ThrowsFormatException() + { + Should.Throw(() => NatsDurationJsonConverter.Parse("notaduration")); + } +} + +public class StorageSizeJsonConverterTests +{ + [Theory] + [InlineData("1GB", 1L * 1024 * 1024 * 1024)] + [InlineData("512MB", 512L * 1024 * 1024)] + [InlineData("1KB", 1024L)] + [InlineData("1024", 1024L)] + public void Parse_ValidSizeStrings_ReturnsBytes(string input, long expectedBytes) + { + StorageSizeJsonConverter.Parse(input).ShouldBe(expectedBytes); + } +} diff --git a/porting.db b/porting.db index 78f31b3..9984857 100644 Binary files a/porting.db and b/porting.db differ diff --git a/reports/current.md b/reports/current.md index 5f519ce..0a855a4 100644 --- a/reports/current.md +++ b/reports/current.md @@ -1,6 +1,6 @@ # NATS .NET Porting Status Report -Generated: 2026-02-26 21:59:34 UTC +Generated: 2026-02-26 22:18:29 UTC ## Modules (12 total) @@ -13,9 +13,9 @@ Generated: 2026-02-26 21:59:34 UTC | Status | Count | |--------|-------| -| complete | 3503 | +| complete | 3570 | | n_a | 77 | -| stub | 93 | +| stub | 26 | ## Unit Tests (3257 total) @@ -35,4 +35,4 @@ Generated: 2026-02-26 21:59:34 UTC ## Overall Progress -**4091/6942 items complete (58.9%)** +**4158/6942 items complete (59.9%)** diff --git a/reports/report_8253f97.md b/reports/report_8253f97.md new file mode 100644 index 0000000..0a855a4 --- /dev/null +++ b/reports/report_8253f97.md @@ -0,0 +1,38 @@ +# NATS .NET Porting Status Report + +Generated: 2026-02-26 22:18:29 UTC + +## Modules (12 total) + +| Status | Count | +|--------|-------| +| complete | 11 | +| not_started | 1 | + +## Features (3673 total) + +| Status | Count | +|--------|-------| +| complete | 3570 | +| n_a | 77 | +| stub | 26 | + +## Unit Tests (3257 total) + +| Status | Count | +|--------|-------| +| complete | 319 | +| n_a | 181 | +| not_started | 2533 | +| stub | 224 | + +## Library Mappings (36 total) + +| Status | Count | +|--------|-------| +| mapped | 36 | + + +## Overall Progress + +**4158/6942 items complete (59.9%)**