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 | public enum KeycaskError: Error, Equatable, Sendable { |
1 | public enum KeycaskError: Error, Equatable, Sendable { |
| 2 | case notFound(String) |
2 | case notFound(String) |
| 3 | case ambiguous(name: String, candidates: [String]) |
3 | case ambiguous(name: String, candidates: [Entry]) |
| 4 | case cannotDecrypt |
4 | case cannotDecrypt |
| 5 | case corrupt(String) |
5 | case corrupt(String) |
| 6 | case vaultExists(String) |
6 | case vaultExists(String) |
| @@ -24,7 +24,9 @@ public enum KeycaskError: Error, Equatable, Sendable { |
| 24 | switch self { |
24 | switch self { |
| 25 | case .notFound(let what): "\(what): not found" |
25 | case .notFound(let what): "\(what): not found" |
| 26 | case .ambiguous(let name, let candidates): |
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 | case .cannotDecrypt: "cannot decrypt: wrong passphrase or damaged vault" |
30 | case .cannotDecrypt: "cannot decrypt: wrong passphrase or damaged vault" |
| 29 | case .corrupt(let why): "vault is corrupt: \(why)" |
31 | case .corrupt(let why): "vault is corrupt: \(why)" |
| 30 | case .vaultExists(let path): "vault \(path) already exists" |
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 | == "cannot decrypt: wrong passphrase or damaged vault") |
25 | == "cannot decrypt: wrong passphrase or damaged vault") |
| 26 | #expect(KeycaskError.corrupt("bad json").message == "vault is corrupt: bad json") |
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 | } |