diff --git a/.gitignore b/.gitignore index f22758cad2..f6bd1f104c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .DS_Store -/.build +.build /.index-build /Packages /*.xcodeproj diff --git a/Sources/NIOCore/EventLoop.swift b/Sources/NIOCore/EventLoop.swift index 1b619a3b0f..3a3fa4a6c0 100644 --- a/Sources/NIOCore/EventLoop.swift +++ b/Sources/NIOCore/EventLoop.swift @@ -551,6 +551,88 @@ public struct TimeAmount: Hashable, Sendable { } } +/// Contains the logic for parsing time amounts from strings, +/// and printing pretty strings to represent time amounts. +extension TimeAmount: CustomStringConvertible { + + /// Errors thrown when parsint a TimeAmount from a string + internal enum ValidationError: Error, Equatable { + /// Can't parse the provided unit + case unsupportedUnit(String) + + /// Can't parse the number into a Double + case invalidNumber(String) + } + + /// Creates a TimeAmount from a string representation with an optional default unit. + /// + /// Supports formats like: + /// - "5s" (5 seconds) + /// - "100ms" (100 milliseconds) + /// - "42" (42 of default unit) + /// - "1 hr" (1 hour) + /// + /// This function only supports one pair of the number and units, i.e. "5s" or "100ms" but not "5s 100ms". + /// + /// Supported units: + /// - h, hr, hrs (hours) + /// - m, min (minutes) + /// - s, sec, secs (seconds) + /// - ms, millis (milliseconds) + /// - us, µs, micros (microseconds) + /// - ns, nanos (nanoseconds) + /// + /// - Parameters: + /// - userProvidedString: The string to parse + /// + /// - Throws: ValidationError if the string cannot be parsed + public init(_ userProvidedString: String) throws { + let lowercased = String(userProvidedString.filter { !$0.isWhitespace }).lowercased() + let parsedNumbers = lowercased.prefix(while: { $0.isWholeNumber || $0 == "," || $0 == "." }) + let parsedUnit = String(lowercased.dropFirst(parsedNumbers.count)) + + guard let numbers = Int64(parsedNumbers) else { + throw ValidationError.invalidNumber("'\(userProvidedString)' cannot be parsed as number and unit") + } + + switch parsedUnit { + case "h", "hr", "hrs": + self = .hours(numbers) + case "m", "min": + self = .minutes(numbers) + case "s", "sec", "secs": + self = .seconds(numbers) + case "ms", "millis": + self = .milliseconds(numbers) + case "us", "µs", "micros": + self = .microseconds(numbers) + case "ns", "nanos": + self = .nanoseconds(numbers) + default: + throw ValidationError.unsupportedUnit("Unknown unit '\(parsedUnit)' in '\(userProvidedString)'") + } + } + + /// Returns a human-readable string representation of the time amount + /// using the most appropriate unit + public var description: String { + let fullNS = self.nanoseconds + let (fullUS, remUS) = fullNS.quotientAndRemainder(dividingBy: 1_000) + let (fullMS, remMS) = fullNS.quotientAndRemainder(dividingBy: 1_000_000) + let (fullS, remS) = fullNS.quotientAndRemainder(dividingBy: 1_000_000_000) + + if remS == 0 { + return "\(fullS) s" + } else if remMS == 0 { + return "\(fullMS) ms" + } else if remUS == 0 { + return "\(fullUS) us" + } else { + return "\(fullNS) ns" + } + } +} + extension TimeAmount: Comparable { @inlinable public static func < (lhs: TimeAmount, rhs: TimeAmount) -> Bool { diff --git a/Tests/NIOCoreTests/TimeAmountTests.swift b/Tests/NIOCoreTests/TimeAmountTests.swift index 0cf762a1dd..1285b75849 100644 --- a/Tests/NIOCoreTests/TimeAmountTests.swift +++ b/Tests/NIOCoreTests/TimeAmountTests.swift @@ -11,9 +11,11 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// -import NIOCore + import XCTest +@testable import NIOCore + class TimeAmountTests: XCTestCase { func testTimeAmountConversion() { XCTAssertEqual(TimeAmount.nanoseconds(3), .nanoseconds(3)) @@ -61,4 +63,103 @@ class TimeAmountTests: XCTestCase { XCTAssertEqual(TimeAmount.minutes(.min), underflowCap) XCTAssertEqual(TimeAmount.hours(.min), underflowCap) } + + func testTimeAmountParsing() throws { + // Test all supported hour formats + XCTAssertEqual(try TimeAmount("2h"), .hours(2)) + XCTAssertEqual(try TimeAmount("2hr"), .hours(2)) + XCTAssertEqual(try TimeAmount("2hrs"), .hours(2)) + + // Test all supported minute formats + XCTAssertEqual(try TimeAmount("3m"), .minutes(3)) + XCTAssertEqual(try TimeAmount("3min"), .minutes(3)) + + // Test all supported second formats + XCTAssertEqual(try TimeAmount("4s"), .seconds(4)) + XCTAssertEqual(try TimeAmount("4sec"), .seconds(4)) + XCTAssertEqual(try TimeAmount("4secs"), .seconds(4)) + + // Test all supported millisecond formats + XCTAssertEqual(try TimeAmount("5ms"), .milliseconds(5)) + XCTAssertEqual(try TimeAmount("5millis"), .milliseconds(5)) + + // Test all supported microsecond formats + XCTAssertEqual(try TimeAmount("6us"), .microseconds(6)) + XCTAssertEqual(try TimeAmount("6µs"), .microseconds(6)) + XCTAssertEqual(try TimeAmount("6micros"), .microseconds(6)) + + // Test all supported nanosecond formats + XCTAssertEqual(try TimeAmount("7ns"), .nanoseconds(7)) + XCTAssertEqual(try TimeAmount("7nanos"), .nanoseconds(7)) + } + + func testTimeAmountParsingWithWhitespace() throws { + XCTAssertEqual(try TimeAmount("5 s"), .seconds(5)) + XCTAssertEqual(try TimeAmount("100 ms"), .milliseconds(100)) + XCTAssertEqual(try TimeAmount("42 ns"), .nanoseconds(42)) + XCTAssertEqual(try TimeAmount(" 5s "), .seconds(5)) + } + + func testTimeAmountParsingCaseInsensitive() throws { + XCTAssertEqual(try TimeAmount("5S"), .seconds(5)) + XCTAssertEqual(try TimeAmount("100MS"), .milliseconds(100)) + XCTAssertEqual(try TimeAmount("1HR"), .hours(1)) + XCTAssertEqual(try TimeAmount("30MIN"), .minutes(30)) + } + + func testTimeAmountParsingInvalidInput() throws { + // Empty string + XCTAssertThrowsError(try TimeAmount("")) { error in + XCTAssertEqual( + error as? TimeAmount.ValidationError, + TimeAmount.ValidationError.invalidNumber("'' cannot be parsed as number and unit") + ) + } + + // Invalid number + XCTAssertThrowsError(try TimeAmount("abc")) { error in + XCTAssertEqual( + error as? TimeAmount.ValidationError, + TimeAmount.ValidationError.invalidNumber("'abc' cannot be parsed as number and unit") + ) + } + + // Unknown unit + XCTAssertThrowsError(try TimeAmount("5x")) { error in + XCTAssertEqual( + error as? TimeAmount.ValidationError, + TimeAmount.ValidationError.unsupportedUnit("Unknown unit 'x' in '5x'") + ) + } + + // Missing number + XCTAssertThrowsError(try TimeAmount("ms")) { error in + XCTAssertEqual( + error as? TimeAmount.ValidationError, + TimeAmount.ValidationError.invalidNumber("'ms' cannot be parsed as number and unit") + ) + } + } + + func testTimeAmountPrettyPrint() { + // Basic formatting + XCTAssertEqual(TimeAmount.seconds(5).description, "5 s") + XCTAssertEqual(TimeAmount.milliseconds(100).description, "100 ms") + XCTAssertEqual(TimeAmount.microseconds(250).description, "250 us") + XCTAssertEqual(TimeAmount.nanoseconds(42).description, "42 ns") + + // Unit selection based on value + XCTAssertEqual(TimeAmount.nanoseconds(1_000).description, "1 us") + XCTAssertEqual(TimeAmount.nanoseconds(1_000_000).description, "1 ms") + XCTAssertEqual(TimeAmount.nanoseconds(1_000_000_000).description, "1 s") + + // Values with remainders + XCTAssertEqual(TimeAmount.nanoseconds(1_500).description, "1500 ns") + XCTAssertEqual(TimeAmount.nanoseconds(1_500_000).description, "1500 us") + XCTAssertEqual(TimeAmount.nanoseconds(1_500_000_000).description, "1500 ms") + + // Negative values + XCTAssertEqual(TimeAmount.seconds(-5).description, "-5 s") + XCTAssertEqual(TimeAmount.milliseconds(-100).description, "-100 ms") + } }