diff --git a/Makefile b/Makefile index d5a3582..ac4c7d1 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build setup ghostty app release sign clean clean-all check help +.PHONY: build setup ghostty app release sign clean clean-all check test help FRAMEWORKS_DIR := Frameworks XCFW := $(FRAMEWORKS_DIR)/GhosttyKit.xcframework @@ -14,6 +14,7 @@ help: @echo " build Full build: submodules + ghostty + swift build (default)" @echo " setup Init submodules and check build prerequisites" @echo " check Verify all build and runtime prerequisites" + @echo " test Run unit tests for CrowCore and CrowPersistence" @echo " ghostty Build GhosttyKit framework" @echo " app Swift build only (debug)" @echo " release Release build + .app bundle" @@ -65,6 +66,12 @@ clean: clean-all: clean rm -rf $(FRAMEWORKS_DIR) +# --- Test --- + +test: + swift test --package-path Packages/CrowCore + swift test --package-path Packages/CrowPersistence + # --- Check --- check: setup diff --git a/Packages/CrowPersistence/Sources/CrowPersistence/JSONStore.swift b/Packages/CrowPersistence/Sources/CrowPersistence/JSONStore.swift index 6302084..11659a1 100644 --- a/Packages/CrowPersistence/Sources/CrowPersistence/JSONStore.swift +++ b/Packages/CrowPersistence/Sources/CrowPersistence/JSONStore.swift @@ -22,6 +22,17 @@ public struct StoreData: Codable, Sendable { } /// Thread-safe JSON file store for session persistence. +/// +/// Uses `NSLock` to serialize access to the in-memory `StoreData` and disk writes. +/// The `nonisolated(unsafe)` annotation on `_data` is safe because all reads and writes +/// go through the lock. An actor was not used because `mutate()` must be synchronous +/// to support callers on the MainActor without requiring `await`. +/// +/// On initialization, if `store.json` is corrupt (fails to decode), the file is backed up +/// to `store.json.bak` and the store starts fresh with empty data. +/// +/// Performs a one-time migration from the legacy "rm-ai-ide" application support directory +/// when no "crow" directory exists yet (via `AppSupportDirectory`). public final class JSONStore: Sendable { private let fileURL: URL private let lock = NSLock() @@ -60,10 +71,9 @@ public final class JSONStore: Sendable { public func mutate(_ transform: (inout StoreData) -> Void) { lock.lock() + defer { lock.unlock() } transform(&_data) - let snapshot = _data - lock.unlock() - Self.save(snapshot, to: fileURL) + Self.save(_data, to: fileURL) } private static func save(_ data: StoreData, to url: URL) { diff --git a/Packages/CrowPersistence/Sources/CrowPersistence/Repositories/SessionRepository.swift b/Packages/CrowPersistence/Sources/CrowPersistence/Repositories/SessionRepository.swift index bc67fb3..490a8aa 100644 --- a/Packages/CrowPersistence/Sources/CrowPersistence/Repositories/SessionRepository.swift +++ b/Packages/CrowPersistence/Sources/CrowPersistence/Repositories/SessionRepository.swift @@ -27,11 +27,13 @@ public struct SessionRepository: Sendable { } } + /// Delete a session and all related data (worktrees, links, terminals) in a single atomic mutation. public func delete(id: UUID) { store.mutate { data in data.sessions.removeAll { $0.id == id } data.worktrees.removeAll { $0.sessionID == id } data.links.removeAll { $0.sessionID == id } + data.terminals.removeAll { $0.sessionID == id } } } diff --git a/Packages/CrowPersistence/Tests/CrowPersistenceTests/JSONStoreTests.swift b/Packages/CrowPersistence/Tests/CrowPersistenceTests/JSONStoreTests.swift new file mode 100644 index 0000000..369713e --- /dev/null +++ b/Packages/CrowPersistence/Tests/CrowPersistenceTests/JSONStoreTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing +@testable import CrowPersistence +@testable import CrowCore + +@Test func emptyStoreCreation() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + + let store = JSONStore(directory: dir) + let data = store.data + #expect(data.sessions.isEmpty) + #expect(data.worktrees.isEmpty) + #expect(data.links.isEmpty) + #expect(data.terminals.isEmpty) +} + +@Test func mutatePersistsToFile() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + + let store = JSONStore(directory: dir) + let session = Session(name: "persist-test") + store.mutate { data in + data.sessions.append(session) + } + + // Create a new store from the same directory — data should survive + let reloaded = JSONStore(directory: dir) + #expect(reloaded.data.sessions.count == 1) + #expect(reloaded.data.sessions.first?.name == "persist-test") + #expect(reloaded.data.sessions.first?.id == session.id) +} + +@Test func concurrentMutatesAreConsistent() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + + let store = JSONStore(directory: dir) + let iterations = 50 + + // Dispatch concurrent mutates that each append a session + let group = DispatchGroup() + for i in 0..