Commit 091bf37c55
091bf37c554c6daafb1b8a6dfc0362cde73d76d3
parent: 6957cdf5cc
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-17 15:11 UTC
Add Entry with tag normalization and search
Layout: unified · split
Sources/KeycaskCore/Entry.swift
added
+65
| @@ -0,0 +1,65 @@ |
| 1 | import Foundation |
| 2 | |
| 3 | public struct Entry: Codable, Equatable, Sendable { |
| 4 | public let id: EntryID |
| 5 | public var name: String |
| 6 | public var username: String? |
| 7 | public var password: String |
| 8 | public var url: String? |
| 9 | public var notes: String? |
| 10 | public var tags: [String] |
| 11 | public let created: Date |
| 12 | public var updated: Date |
| 13 | |
| 14 | public init( |
| 15 | id: EntryID = .random(), |
| 16 | name: String, |
| 17 | username: String? = nil, |
| 18 | password: String, |
| 19 | url: String? = nil, |
| 20 | notes: String? = nil, |
| 21 | tags: [String] = [], |
| 22 | now: Date = .now |
| 23 | ) { |
| 24 | self.id = id |
| 25 | self.name = name |
| 26 | self.username = username |
| 27 | self.password = password |
| 28 | self.url = url |
| 29 | self.notes = notes |
| 30 | self.tags = Self.normalize(tags: tags) |
| 31 | let stamp = Self.truncateToSeconds(now) |
| 32 | created = stamp |
| 33 | updated = stamp |
| 34 | } |
| 35 | |
| 36 | public static func normalize(tags: [String]) -> [String] { |
| 37 | var seen: Set<String> = [] |
| 38 | var out: [String] = [] |
| 39 | for raw in tags { |
| 40 | let tag = raw.trimmingCharacters(in: .whitespaces) |
| 41 | guard !tag.isEmpty, seen.insert(tag.lowercased()).inserted else { continue } |
| 42 | out.append(tag) |
| 43 | } |
| 44 | return out.sorted { a, b in |
| 45 | let (la, lb) = (a.lowercased(), b.lowercased()) |
| 46 | return la == lb ? a < b : la < lb |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | public static func truncateToSeconds(_ date: Date) -> Date { |
| 51 | Date(timeIntervalSince1970: date.timeIntervalSince1970.rounded(.down)) |
| 52 | } |
| 53 | |
| 54 | public func hasTag(_ tag: String) -> Bool { |
| 55 | let needle = tag.lowercased() |
| 56 | return tags.contains { $0.lowercased() == needle } |
| 57 | } |
| 58 | |
| 59 | public func matches(_ query: String) -> Bool { |
| 60 | let needle = query.lowercased() |
| 61 | guard !needle.isEmpty else { return false } |
| 62 | let haystacks = [name, username ?? "", url ?? "", notes ?? ""] + tags |
| 63 | return haystacks.contains { $0.lowercased().contains(needle) } |
| 64 | } |
| 65 | } |
Sources/KeycaskCore/KeycaskError.swift
+4 −2
| @@ -1,6 +1,6 @@ |
| 1 | 1 | public enum KeycaskError: Error, Equatable, Sendable { |
| 2 | 2 | case notFound(String) |
| 3 | | case ambiguous(name: String, candidates: [String]) |
| 3 | case ambiguous(name: String, candidates: [Entry]) |
| 4 | 4 | case cannotDecrypt |
| 5 | 5 | case corrupt(String) |
| 6 | 6 | case vaultExists(String) |
| @@ -24,7 +24,9 @@ public enum KeycaskError: Error, Equatable, Sendable { |
| 24 | 24 | switch self { |
| 25 | 25 | case .notFound(let what): "\(what): not found" |
| 26 | 26 | case .ambiguous(let name, let candidates): |
| 27 | | (["\(name): ambiguous, use an id:"] + candidates).joined(separator: "\n") |
| 27 | (["\(name): ambiguous, use an id:"] |
| 28 | + candidates.map { " \($0.id.rawValue) \($0.username ?? "") \($0.url ?? "")" }) |
| 29 | .joined(separator: "\n") |
| 28 | 30 | case .cannotDecrypt: "cannot decrypt: wrong passphrase or damaged vault" |
| 29 | 31 | case .corrupt(let why): "vault is corrupt: \(why)" |
| 30 | 32 | case .vaultExists(let path): "vault \(path) already exists" |
Tests/KeycaskCoreTests/EntryTests.swift
added
+40
| @@ -0,0 +1,40 @@ |
| 1 | import Foundation |
| 2 | import Testing |
| 3 | |
| 4 | @testable import KeycaskCore |
| 5 | |
| 6 | @Suite struct EntryTests { |
| 7 | @Test func initNormalizesTagsAndTruncatesDates() { |
| 8 | let now = Date(timeIntervalSince1970: 1_700_000_000.75) |
| 9 | let e = Entry( |
| 10 | name: "gh", password: "p", tags: [" work", "Dev", "dev", "", "alpha"], now: now) |
| 11 | #expect(e.tags == ["alpha", "Dev", "work"]) |
| 12 | #expect(e.created == Date(timeIntervalSince1970: 1_700_000_000)) |
| 13 | #expect(e.updated == e.created) |
| 14 | } |
| 15 | |
| 16 | @Test func normalizeSortsCaseInsensitivelyAndKeepsFirstSpelling() { |
| 17 | #expect(Entry.normalize(tags: ["b", "A", "a", "B"]) == ["A", "b"]) |
| 18 | #expect(Entry.normalize(tags: []) == []) |
| 19 | } |
| 20 | |
| 21 | @Test func hasTagIsCaseInsensitive() { |
| 22 | let e = Entry(name: "gh", password: "p", tags: ["Dev"]) |
| 23 | #expect(e.hasTag("dev")) |
| 24 | #expect(e.hasTag("DEV")) |
| 25 | #expect(!e.hasTag("ops")) |
| 26 | } |
| 27 | |
| 28 | @Test func matchesSearchesEveryTextFieldExceptPassword() { |
| 29 | let e = Entry( |
| 30 | name: "GitHub", username: "cmc", password: "hunter2", url: "https://github.com", |
| 31 | notes: "downtown office", tags: ["Dev"]) |
| 32 | #expect(e.matches("github")) |
| 33 | #expect(e.matches("CMC")) |
| 34 | #expect(e.matches("github.com")) |
| 35 | #expect(e.matches("downtown")) |
| 36 | #expect(e.matches("dev")) |
| 37 | #expect(!e.matches("hunter2")) |
| 38 | #expect(!e.matches("nothing")) |
| 39 | } |
| 40 | } |
Tests/KeycaskCoreTests/KeycaskErrorTests.swift
+10
| @@ -25,4 +25,14 @@ import Testing |
| 25 | 25 | == "cannot decrypt: wrong passphrase or damaged vault") |
| 26 | 26 | #expect(KeycaskError.corrupt("bad json").message == "vault is corrupt: bad json") |
| 27 | 27 | } |
| 28 | |
| 29 | @Test func ambiguousListsCandidateIDs() { |
| 30 | let a = Entry(id: EntryID("aaaa2222")!, name: "gh", username: "one", password: "p") |
| 31 | let b = Entry(id: EntryID("bbbb3333")!, name: "gh", password: "p", url: "https://x") |
| 32 | let m = KeycaskError.ambiguous(name: "gh", candidates: [a, b]).message |
| 33 | #expect(m.hasPrefix("gh: ambiguous, use an id:\n")) |
| 34 | #expect(m.contains("aaaa2222")) |
| 35 | #expect(m.contains("bbbb3333")) |
| 36 | #expect(m.contains("https://x")) |
| 37 | } |
| 28 | 38 | } |