diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 3fe5978..15c5624 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -33,7 +33,7 @@ When working through this file, mark increments complete by changing `[ ]` to `[ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.Tests/`. -### [ ] 1.1 `MacAddress` +### [x] 1.1 `MacAddress` - `record struct` storing 6 bytes. - `static bool TryParse(string, out MacAddress)` accepting `AA:BB:CC:DD:EE:FF`, `aa-bb-cc-dd-ee-ff`, `AABBCCDDEEFF`, surrounding whitespace. - `ToString()` → canonical lowercase no-separator (`aabbccddeeff`). diff --git a/src/FrameProcessor/Domain/MacAddress.cs b/src/FrameProcessor/Domain/MacAddress.cs new file mode 100644 index 0000000..ebb88bc --- /dev/null +++ b/src/FrameProcessor/Domain/MacAddress.cs @@ -0,0 +1,156 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FrameProcessor.Domain; + +/// +/// A 48-bit MAC address. Canonical form is lowercase hex without separators (e.g. aabbccddeeff); +/// parsing accepts colon, hyphen, or unseparated hex with arbitrary surrounding whitespace. +/// +[JsonConverter(typeof(MacAddressJsonConverter))] +public readonly record struct MacAddress : IParsable, ISpanParsable +{ + private readonly byte _b0; + private readonly byte _b1; + private readonly byte _b2; + private readonly byte _b3; + private readonly byte _b4; + private readonly byte _b5; + + public MacAddress(ReadOnlySpan bytes) + { + if (bytes.Length != 6) + { + throw new ArgumentException("MAC address must be exactly 6 bytes.", nameof(bytes)); + } + + _b0 = bytes[0]; + _b1 = bytes[1]; + _b2 = bytes[2]; + _b3 = bytes[3]; + _b4 = bytes[4]; + _b5 = bytes[5]; + } + + public override string ToString() => string.Create(12, this, static (span, mac) => + { + WriteHex(span[..2], mac._b0); + WriteHex(span.Slice(2, 2), mac._b1); + WriteHex(span.Slice(4, 2), mac._b2); + WriteHex(span.Slice(6, 2), mac._b3); + WriteHex(span.Slice(8, 2), mac._b4); + WriteHex(span.Slice(10, 2), mac._b5); + }); + + public static bool TryParse(string? s, out MacAddress result) + { + if (s is null) + { + result = default; + return false; + } + + return TryParse(s.AsSpan(), out result); + } + + public static bool TryParse(ReadOnlySpan s, out MacAddress result) + { + result = default; + s = s.Trim(); + if (s.IsEmpty) + { + return false; + } + + Span hex = stackalloc char[12]; + var written = 0; + foreach (var c in s) + { + if (c is ':' or '-') + { + continue; + } + + if (written == 12) + { + return false; + } + + if (!IsHexDigit(c)) + { + return false; + } + + hex[written++] = c; + } + + if (written != 12) + { + return false; + } + + Span bytes = stackalloc byte[6]; + for (var i = 0; i < 6; i++) + { + if (!byte.TryParse(hex.Slice(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b)) + { + return false; + } + + bytes[i] = b; + } + + result = new MacAddress(bytes); + return true; + } + + public static MacAddress Parse(string s) + { + ArgumentNullException.ThrowIfNull(s); + return TryParse(s, out var result) + ? result + : throw new FormatException($"'{s}' is not a valid MAC address."); + } + + public static MacAddress Parse(string s, IFormatProvider? provider) => Parse(s); + + public static bool TryParse(string? s, IFormatProvider? provider, out MacAddress result) + => TryParse(s, out result); + + public static MacAddress Parse(ReadOnlySpan s, IFormatProvider? provider) + => TryParse(s, out var result) + ? result + : throw new FormatException($"'{s}' is not a valid MAC address."); + + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out MacAddress result) + => TryParse(s, out result); + + private static bool IsHexDigit(char c) + => (uint)(c - '0') <= 9 || (uint)(c - 'a') <= 5 || (uint)(c - 'A') <= 5; + + private static void WriteHex(Span dest, byte value) + { + dest[0] = HexChar(value >> 4); + dest[1] = HexChar(value & 0xF); + } + + private static char HexChar(int nibble) => (char)(nibble < 10 ? '0' + nibble : 'a' + (nibble - 10)); +} + +internal sealed class MacAddressJsonConverter : JsonConverter +{ + public override MacAddress Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var s = reader.GetString(); + if (!MacAddress.TryParse(s, out var mac)) + { + throw new JsonException($"'{s}' is not a valid MAC address."); + } + + return mac; + } + + public override void Write(Utf8JsonWriter writer, MacAddress value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); +} diff --git a/tests/FrameProcessor.Tests/MacAddressTests.cs b/tests/FrameProcessor.Tests/MacAddressTests.cs new file mode 100644 index 0000000..56ae400 --- /dev/null +++ b/tests/FrameProcessor.Tests/MacAddressTests.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using FrameProcessor.Domain; + +namespace FrameProcessor.Tests; + +public class MacAddressTests +{ + private static readonly byte[] ExpectedBytes = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; + private const string Canonical = "aabbccddeeff"; + + [Theory] + [InlineData("AA:BB:CC:DD:EE:FF")] + [InlineData("aa:bb:cc:dd:ee:ff")] + [InlineData("AA-BB-CC-DD-EE-FF")] + [InlineData("aa-bb-cc-dd-ee-ff")] + [InlineData("AABBCCDDEEFF")] + [InlineData("aabbccddeeff")] + [InlineData(" AA:BB:CC:DD:EE:FF ")] + [InlineData("\tAA-bb-CC-dd-EE-ff\n")] + public void TryParse_AcceptedForms_ProduceCanonicalString(string input) + { + Assert.True(MacAddress.TryParse(input, out var mac)); + Assert.Equal(Canonical, mac.ToString()); + } + + [Fact] + public void Equality_IsByteWise_AcrossInputForms() + { + Assert.True(MacAddress.TryParse("AA:BB:CC:DD:EE:FF", out var a)); + Assert.True(MacAddress.TryParse("aa-bb-cc-dd-ee-ff", out var b)); + Assert.True(MacAddress.TryParse("AABBCCDDEEFF", out var c)); + + Assert.Equal(a, b); + Assert.Equal(b, c); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void Equality_DiffersOnAnySingleByte() + { + Assert.True(MacAddress.TryParse("aabbccddeeff", out var a)); + Assert.True(MacAddress.TryParse("aabbccddeefe", out var b)); + + Assert.NotEqual(a, b); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-mac")] + [InlineData("AA:BB:CC:DD:EE")] // too short + [InlineData("AA:BB:CC:DD:EE:FF:00")] // too long + [InlineData("ZZ:BB:CC:DD:EE:FF")] // non-hex digit + [InlineData("AA:BB:CC:DD:EE:F")] // odd hex count + [InlineData("AA BB CC DD EE FF")] // unsupported space separator + public void TryParse_RejectsBadInput(string input) + { + Assert.False(MacAddress.TryParse(input, out _)); + } + + [Fact] + public void TryParse_Null_ReturnsFalse() + { + Assert.False(MacAddress.TryParse((string?)null, out _)); + } + + [Fact] + public void Parse_Throws_OnBadInput() + { + Assert.Throws(() => MacAddress.Parse("nope")); + } + + [Fact] + public void Constructor_FromBytes_RoundTripsThroughToString() + { + var mac = new MacAddress(ExpectedBytes); + Assert.Equal(Canonical, mac.ToString()); + } + + [Fact] + public void Constructor_FromWrongLength_Throws() + { + Assert.Throws(() => new MacAddress(new byte[] { 0x00 })); + Assert.Throws(() => new MacAddress(new byte[7])); + } + + [Fact] + public void JsonRoundTrip_NormalizesToCanonicalForm() + { + var json = "\"AA:BB:CC:DD:EE:FF\""; + var mac = JsonSerializer.Deserialize(json); + var written = JsonSerializer.Serialize(mac); + + Assert.Equal($"\"{Canonical}\"", written); + } + + [Fact] + public void JsonDeserialize_BadInput_Throws() + { + Assert.Throws(() => JsonSerializer.Deserialize("\"not-a-mac\"")); + } + + [Fact] + public void IParsable_Hookup_Works() + { + var mac = MacAddress.Parse("AA:BB:CC:DD:EE:FF", provider: null); + Assert.Equal(Canonical, mac.ToString()); + + Assert.True(MacAddress.TryParse("AA:BB:CC:DD:EE:FF", provider: null, out var mac2)); + Assert.Equal(mac, mac2); + } +} diff --git a/tests/FrameProcessor.Tests/UnitTest1.cs b/tests/FrameProcessor.Tests/UnitTest1.cs index 5b2f905..e43dfbc 100644 --- a/tests/FrameProcessor.Tests/UnitTest1.cs +++ b/tests/FrameProcessor.Tests/UnitTest1.cs @@ -1,10 +1 @@ -namespace FrameProcessor.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} +namespace FrameProcessor.Tests;