Initial commit.

This commit is contained in:
2026-08-19 23:19:08 +02:00
commit 22e737d9c2
153 changed files with 11680 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
// swift-tools-version: 6.3
import PackageDescription
let package = Package(
name: "Utility",
platforms: [
.macOS(.v15),
],
products: [
.library(
name: "Utility",
targets: [
"Utility"
]
)
],
targets: [
.target(
name: "Utility",
path: "Sources",
),
.testTarget(
name: "UtilityTests",
dependencies: [
.byName(name: "Utility")
],
path: "Tests"
),
]
)
+30
View File
@@ -0,0 +1,30 @@
# Utility
The general-purpose helpers the **Loud** services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework.
## Overview
| Role | Types |
| --- | --- |
| Validation | `NormalizeEmail`, which reduces a submitted email address to its canonical form |
## Design rules
- **Dependency-free.** A helper belongs here only while it needs nothing beyond Foundation; one that grows a framework dependency belongs in the package owning that framework's concerns (e.g. `Infrastructure` for Hummingbird).
- **Method structs.** Helpers hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
## Layout
Sources are split by visibility, then by kind, one type per file:
```
Sources/
└── Public/
└── Methods/ NormalizeEmail
Tests/
├── Cases/ the test suites, mirroring the Sources/ layout
└── Utils/ the suite Tag constants
```
## Testing
Every suite carries a tag for the kind of API it exercises — `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
- No package dependencies — Foundation only.
@@ -0,0 +1,49 @@
import Foundation
/// A validator reducing a submitted email address to its canonical form.
///
/// Trims surrounding whitespace, lowercases the address (so one mailbox cannot register once per spelling), caps it at the 254 bytes an address can
/// be, and checks its shape: something before the `@`, and a domain with a dot. Control characters are rejected separately: the shape only excludes
/// whitespace, which would let non-whitespace controls (such as `NUL`) into the stored address.
public struct NormalizeEmail: Sendable {
// MARK: Initializers
/// Creates an email normalization method.
public init() {}
// MARK: Functions
/// Validates and normalizes a submitted email address.
/// - Parameter email: the submitted email address, if any.
/// - Returns: the normalized address, or `nil` when the submission is missing or invalid.
public func callAsFunction(
_ email: String?
) -> String? {
guard
let email = email?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased(),
email.utf8.count <= Constant.Length.maxEmail,
!email.unicodeScalars.contains(where: {
$0.properties.generalCategory == .control
}),
email.wholeMatch(of: /[^\s@]+@[^\s@]+\.[^\s@]+/) != nil
else {
return nil
}
return email
}
}
// MARK: - Constants
private enum Constant {
/// A namespace for the length constants.
enum Length {
/// The longest an email address can be, in bytes, per RFC 5321's path limit.
static let maxEmail = 254
}
}
@@ -0,0 +1,57 @@
import Testing
@testable import Utility
@Suite(
"NormalizeEmail method",
.tags(.method)
)
struct NormalizeEmailTests {
// MARK: Properties
private let normalize = NormalizeEmail()
// MARK: Functional tests
@Test(arguments: [
("fan@loudmail.nl", "fan@loudmail.nl"),
("Fan@LoudMail.NL", "fan@loudmail.nl"),
(" fan@loudmail.nl\n", "fan@loudmail.nl"),
("fan+gigs@loudmail.nl", "fan+gigs@loudmail.nl"),
// Exactly 254 bytes: the longest address the validator accepts.
(String(repeating: "a", count: 242) + "@loudmail.nl", String(repeating: "a", count: 242) + "@loudmail.nl"),
])
func `normalizes a valid address`(
submitted: String,
expected: String
) {
#expect(normalize(submitted) == expected)
}
@Test(arguments: [
nil,
"",
" ",
"not-an-email",
"missing@dot",
"@loudmail.nl",
"fan@",
"fan@.nl",
"spaced out@loudmail.nl",
"fan@loud mail.nl",
// One byte over the 254-byte limit.
String(repeating: "a", count: 243) + "@loudmail.nl",
// Few enough characters, but multibyte ones put it over the byte limit.
String(repeating: "é", count: 130) + "@loudmail.nl",
// Control characters are not whitespace, so only the dedicated check catches them.
"fan\u{00}@loudmail.nl",
"fan@loudmail.nl\u{7F}",
] as [String?])
func `rejects a missing or invalid address`(
submitted: String?
) {
#expect(normalize(submitted) == nil)
}
}
@@ -0,0 +1,6 @@
import Testing
extension Tag {
/// Tests exercising a method of the Utility package.
@Tag static var method: Tag
}