krz/keycask

Password manager: Swift core library, CLI for macOS/Linux/Windows, iOS/macOS app. cli password-manager swift

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 @@
1import Foundation
2
3public 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 @@
1public enum KeycaskError: Error, Equatable, Sendable { 1public 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 @@
1import Foundation
2import 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}