Commit 6957cdf5cc
6957cdf5cc508d89941c26e159b1b8b3ba1a9ce8
parent: 48eb4d9dbf
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-17 15:06 UTC
Add EntryID
Layout: unified · split
Sources/KeycaskCore/EntryID.swift
added
+45
| @@ -0,0 +1,45 @@ |
| 1 | public struct EntryID: Hashable, Sendable, CustomStringConvertible { |
| 2 | public static let alphabet: [Character] = Array("abcdefghijkmnpqrstuvwxyz23456789") |
| 3 | public static let length = 8 |
| 4 | |
| 5 | public let rawValue: String |
| 6 | |
| 7 | public init?(_ raw: String) { |
| 8 | guard raw.count == Self.length else { return nil } |
| 9 | let allowed = Set(Self.alphabet) |
| 10 | guard raw.allSatisfy({ allowed.contains($0) }) else { return nil } |
| 11 | rawValue = raw |
| 12 | } |
| 13 | |
| 14 | public static func random() -> EntryID { |
| 15 | var rng = SystemRandomNumberGenerator() |
| 16 | return random(using: &rng) |
| 17 | } |
| 18 | |
| 19 | public static func random(using rng: inout some RandomNumberGenerator) -> EntryID { |
| 20 | var chars: [Character] = [] |
| 21 | chars.reserveCapacity(length) |
| 22 | for _ in 0..<length { |
| 23 | chars.append(alphabet[Int(rng.next(upperBound: UInt32(alphabet.count)))]) |
| 24 | } |
| 25 | return EntryID(String(chars))! |
| 26 | } |
| 27 | |
| 28 | public var description: String { rawValue } |
| 29 | } |
| 30 | |
| 31 | extension EntryID: Codable { |
| 32 | public init(from decoder: any Decoder) throws { |
| 33 | let raw = try decoder.singleValueContainer().decode(String.self) |
| 34 | guard let id = EntryID(raw) else { |
| 35 | throw DecodingError.dataCorrupted( |
| 36 | .init(codingPath: decoder.codingPath, debugDescription: "invalid entry id \(raw)")) |
| 37 | } |
| 38 | self = id |
| 39 | } |
| 40 | |
| 41 | public func encode(to encoder: any Encoder) throws { |
| 42 | var container = encoder.singleValueContainer() |
| 43 | try container.encode(rawValue) |
| 44 | } |
| 45 | } |
Sources/KeycaskCore/KeycaskError.swift
+2 −2
| @@ -5,7 +5,7 @@ public enum KeycaskError: Error, Equatable, Sendable { |
| 5 | 5 | case corrupt(String) |
| 6 | 6 | case vaultExists(String) |
| 7 | 7 | case noVault(String) |
| 8 | | case duplicateID(String) |
| 8 | case duplicateID(EntryID) |
| 9 | 9 | case io(String) |
| 10 | 10 | case usage(String) |
| 11 | 11 | case failure(String) |
| @@ -29,7 +29,7 @@ public enum KeycaskError: Error, Equatable, Sendable { |
| 29 | 29 | case .corrupt(let why): "vault is corrupt: \(why)" |
| 30 | 30 | case .vaultExists(let path): "vault \(path) already exists" |
| 31 | 31 | case .noVault(let path): "vault \(path) not found (run `keycask init`)" |
| 32 | | case .duplicateID(let id): "duplicate id \(id)" |
| 32 | case .duplicateID(let id): "duplicate id \(id.rawValue)" |
| 33 | 33 | case .io(let why): why |
| 34 | 34 | case .usage(let why): why |
| 35 | 35 | case .failure(let why): why |
Tests/KeycaskCoreTests/EntryIDTests.swift
added
+55
| @@ -0,0 +1,55 @@ |
| 1 | import Foundation |
| 2 | import Testing |
| 3 | |
| 4 | @testable import KeycaskCore |
| 5 | |
| 6 | @Suite struct EntryIDTests { |
| 7 | @Test func randomIDsHaveLengthEightFromTheAlphabet() { |
| 8 | let allowed = Set(EntryID.alphabet) |
| 9 | for _ in 0..<200 { |
| 10 | let id = EntryID.random() |
| 11 | #expect(id.rawValue.count == 8) |
| 12 | #expect(id.rawValue.allSatisfy { allowed.contains($0) }) |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | @Test func alphabetExcludesAmbiguousCharacters() { |
| 17 | let alphabet = Set(EntryID.alphabet) |
| 18 | #expect(alphabet.count == 32) |
| 19 | for bad in ["l", "o", "0", "1"] { |
| 20 | #expect(!alphabet.contains(Character(bad))) |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | @Test func parsingValidatesLengthAndAlphabet() { |
| 25 | #expect(EntryID("abcd2345") != nil) |
| 26 | #expect(EntryID("abcd234") == nil) |
| 27 | #expect(EntryID("abcd23456") == nil) |
| 28 | #expect(EntryID("abcd234l") == nil) |
| 29 | #expect(EntryID("ABCD2345") == nil) |
| 30 | } |
| 31 | |
| 32 | @Test func codableIsABareString() throws { |
| 33 | let id = EntryID("abcd2345")! |
| 34 | let data = try JSONEncoder().encode([id]) |
| 35 | #expect(String(decoding: data, as: UTF8.self) == "[\"abcd2345\"]") |
| 36 | let back = try JSONDecoder().decode([EntryID].self, from: data) |
| 37 | #expect(back == [id]) |
| 38 | #expect(throws: DecodingError.self) { |
| 39 | try JSONDecoder().decode([EntryID].self, from: Data("[\"bad\"]".utf8)) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | @Test func seededGeneratorIsDeterministic() { |
| 44 | struct Counter: RandomNumberGenerator { |
| 45 | var n: UInt64 = 0 |
| 46 | mutating func next() -> UInt64 { |
| 47 | n += 1 |
| 48 | return n |
| 49 | } |
| 50 | } |
| 51 | var a = Counter() |
| 52 | var b = Counter() |
| 53 | #expect(EntryID.random(using: &a) == EntryID.random(using: &b)) |
| 54 | } |
| 55 | } |
Tests/KeycaskCoreTests/KeycaskErrorTests.swift
+1 −1
| @@ -8,7 +8,7 @@ import Testing |
| 8 | 8 | #expect(KeycaskError.io("x").exitCode == 1) |
| 9 | 9 | #expect(KeycaskError.corrupt("x").exitCode == 1) |
| 10 | 10 | #expect(KeycaskError.vaultExists("x").exitCode == 1) |
| 11 | | #expect(KeycaskError.duplicateID("abcd2345").exitCode == 1) |
| 11 | #expect(KeycaskError.duplicateID(EntryID("abcd2345")!).exitCode == 1) |
| 12 | 12 | #expect(KeycaskError.usage("x").exitCode == 2) |
| 13 | 13 | #expect(KeycaskError.notFound("x").exitCode == 3) |
| 14 | 14 | #expect(KeycaskError.noVault("/p").exitCode == 3) |