diff --git a/.gitignore b/.gitignore index d6c4af4..c08a29b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .build .swiftpm +references Package.resolved .DS_Store +.cursor diff --git a/README.md b/README.md index a2365b0..40319b7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It gives you: - copy-on-write overlays - mounted multi-root workspaces - explicit permission checks for file operations -- a typed `Workspace` actor for reading, writing, walking trees, and applying batched edits +- one `Workspace` actor for reads, tracked writes, tree summaries, snapshots, checkpoints, rollback, branching, and merge `Workspace` is beta software and should be used at your own risk. It is useful for app and agent workflows, but it is not a hardened sandbox or a security boundary by itself. @@ -20,17 +20,19 @@ Many agent and tooling flows need more than plain disk I/O: - a shared scratch or memory area - the ability to read a real project without writing back to it - explicit approvals before reads or writes -- tree summaries, JSON helpers, and batched edits without shell parsing +- tree summaries, JSON helpers, batched edits, and checkpoints without shell parsing `Workspace` provides one model for those cases. You can back it with memory, a rooted directory on disk, an overlay snapshot, or a mounted combination of several filesystems. ## What It Provides -- `Workspace`: high-level actor API for common file operations and batch edits +- `Workspace`: high-level actor API for file operations, mutation tracking, snapshots, checkpoints, rollback, branch, and merge +- `Checkpoint` and `CheckpointEvent`: public checkpoint metadata and event stream values +- `Snapshot`: durable capture/restore of a subtree - `ChangeEvent`: structured change notifications emitted by `Workspace.watchChanges(at:recursive:)` -- `FileSystem`: low-level protocol for custom filesystem backends (see also `ReadableFileSystem` / `WritableFileSystem`) +- `FileSystem`: low-level protocol for custom filesystem backends - `ReadWriteFilesystem`: real disk access rooted to a configured directory -- `InMemoryFilesystem`: fully in-memory filesystem for isolated sessions and tests +- `InMemoryFilesystem`: fully in-memory filesystem for isolated workspaces and tests - `OverlayFilesystem`: snapshot a disk root and keep writes in memory - `MountableFilesystem`: compose multiple filesystems under one virtual tree - `PermissionedFileSystem`: wrap any filesystem with operation-level approvals @@ -54,57 +56,105 @@ Until this package is published to a remote, use it as a local SwiftPM dependenc ] ``` -## Quick Start +## Workspace + +Create an ephemeral workspace with the default initializer: ```swift import Workspace -let filesystem = InMemoryFilesystem() - -let workspace = Workspace(filesystem: filesystem) +let workspace = Workspace() try await workspace.writeFile("/notes/todo.txt", content: "ship it") let text = try await workspace.readFile("/notes/todo.txt") print(text) // ship it ``` -### Binary Data +Use a custom filesystem when the files should come from memory, disk, an overlay, a mount table, or a permission wrapper: ```swift -import Workspace +let filesystem = InMemoryFilesystem() +let workspace = Workspace(filesystem: filesystem) +``` -let workspace = Workspace(filesystem: InMemoryFilesystem()) -try await workspace.writeData(Data([0xDE, 0xAD, 0xBE, 0xEF]), to: "/blob.bin") +Persist checkpoint artifacts with file-backed storage: -let blob = try await workspace.readData(from: "/blob.bin") -print(blob.count) // 4 +```swift +let root = URL(fileURLWithPath: "/tmp/workspace-checkpoints", isDirectory: true) +let workspace = Workspace( + filesystem: InMemoryFilesystem(), + storage: .directory(at: root) +) ``` -### JSON Helpers +`Storage.directory(at:)` writes checkpoint and snapshot JSON plus a `mutations.jsonl` append log (one JSON record per line) under `//`. A legacy `mutations.json` array is migrated to JSONL on first access. The store assigns monotonic `sequence` numbers while holding `mutations.lock` (advisory `flock` where the OS supports it), so multiple `Workspace` instances that share a `workspaceId` and store do not collide on mutation sequence. The current checkpoint head is derived from the parent-id graph (unparented tips), not only from `createdAt` ordering, which reduces surprises when wall clocks differ between processes. Listing or loading mutations still reads the full log; very long histories may need application-level rotation. Coordinating multiple hosts or network disks that do not honor `flock` may still require extra synchronization. + +### Reads ```swift -import Workspace +let data = try await workspace.readData(from: "/blob.bin") +let text = try await workspace.readFile("/notes/todo.txt") +let exists = await workspace.exists("/notes/todo.txt") +let info = try await workspace.fileInfo(at: "/notes/todo.txt") +let entries = try await workspace.listDirectory(at: "/notes") +let matches = try await workspace.glob("/notes/*.txt", currentDirectory: "/") +let tree = try await workspace.walkTree("/") +let summary = try await workspace.summarizeTree("/") +``` +JSON helpers encode and decode through `Codable`: + +```swift struct Config: Codable { var name: String var enabled: Bool } -let filesystem = InMemoryFilesystem() - -let workspace = Workspace(filesystem: filesystem) try await workspace.writeJSON(Config(name: "demo", enabled: true), to: "/config.json") - -let config = try await workspace.readJSON(Config.self, from: "/config.json") +let config: Config = try await workspace.readJSON(from: "/config.json") print(config.enabled) // true ``` -### Change Watching +### Tracked Writes + +Every public write records an internal mutation: ```swift -import Workspace +try await workspace.writeFile("/notes/todo.txt", content: "one") +try await workspace.appendFile("/notes/todo.txt", content: " two") +try await workspace.writeData(Data([0xDE, 0xAD]), to: "/blob.bin") +try await workspace.createDirectory(at: "/docs") +try await workspace.copyItem(from: "/notes/todo.txt", to: "/docs/todo.txt") +try await workspace.moveItem(from: "/docs/todo.txt", to: "/docs/done.txt") +try await workspace.removeItem(at: "/docs/done.txt") +``` + +Batched edits and replacements can be previewed before execution: + +```swift +let preview = try await workspace.previewEdits([ + .createDirectory(path: "/src"), + .writeFile(path: "/src/a.txt", content: "one"), +]) + +let result = try await workspace.applyEdits([ + .appendFile(path: "/src/a.txt", content: " two"), +]) -let workspace = Workspace(filesystem: InMemoryFilesystem()) +let replacement = try await workspace.applyReplacement( + ReplacementRequest(pattern: "/src/*.txt", search: "one", replacement: "1") +) + +print(preview.mode) // preview +print(result.mode) // execution +print(replacement.mode) // execution +``` + +`applyEdits` and `applyReplacement` use logical rollback when `failurePolicy` is `.rollback`. Other policies may leave partial changes in place. + +### Watchers + +```swift let changes = await workspace.watchChanges(at: "/notes") Task { @@ -116,16 +166,65 @@ Task { try await workspace.writeFile("/notes/todo.txt", content: "ship it") ``` -## Common Patterns +Checkpoint events are separate from file change events: -### Rooted Disk Workspace +```swift +let checkpoints = try await workspace.watchCheckpointEvents() + +Task { + for await event in checkpoints { + print(event.kind, event.checkpoint.label ?? "") + } +} +``` -Use `ReadWriteFilesystem` when you want real file access under one root: +### Snapshots And Checkpoints + +Snapshots capture filesystem contents. Checkpoints persist a snapshot plus lineage and summary metadata. ```swift -import Foundation -import Workspace +try await workspace.writeFile("/readme.txt", content: "v1") +let checkpoint = try await workspace.createCheckpoint(label: "before edits") + +try await workspace.writeFile("/readme.txt", content: "v2") +let rollback = try await workspace.rollback(to: checkpoint.id, label: "restore v1") + +let all = try await workspace.listCheckpoints() +let snapshot = try await workspace.snapshot(for: checkpoint) + +print(rollback.rollbackSourceCheckpointId == checkpoint.id) // true +print(all.count) +print(snapshot.rootPath) +``` +Public `restoreSnapshot(_:)` is also tracked as a workspace mutation. Checkpoint rollback uses an internal untracked restore so the rollback is represented by the rollback checkpoint, not by a second restore mutation. + +### Branch And Merge + +Branches are isolated `Workspace` actors cloned from the parent's current snapshot. They share the checkpoint store but not the filesystem, watchers, or mutation sequence. By default `branch()` materializes the snapshot into a new `InMemoryFilesystem`; pass `filesystem:` to use another implementation (for example a `ReadWriteFilesystem` if the branch should live on disk). + +```swift +try await workspace.writeFile("/readme.txt", content: "base") +let base = try await workspace.createCheckpoint(label: "base") + +let branch = try await workspace.branch(label: "agent draft") +try await branch.writeFile("/readme.txt", content: "draft") +let branchHead = try await branch.createCheckpoint(label: "draft ready") + +let merged = try await workspace.merge(branch, label: "merge draft") + +print(merged.parentCheckpointId == base.id) // true +print(merged.mergedFromWorkspaceId == branch.workspaceId) // true +print(merged.mergedFromCheckpointId == branchHead.id) // true +``` + +`merge(_:)` is optimistic. If the parent workspace head changed after `branch()` was created, merge throws `WorkspaceError.mergeConflict(parentWorkspaceId:expectedBase:actualHead:)`. + +## Common Filesystem Patterns + +### Rooted Disk Workspace + +```swift let root = URL(fileURLWithPath: "/tmp/demo-workspace", isDirectory: true) let filesystem = try ReadWriteFilesystem(rootDirectory: root) let workspace = Workspace(filesystem: filesystem) @@ -136,12 +235,7 @@ try await workspace.writeFile("/src/main.swift", content: "print(\"hello\")\n") ### Overlay On Top Of A Real Project -Use `OverlayFilesystem` when you want to read a real project but keep writes isolated in memory: - ```swift -import Foundation -import Workspace - let projectRoot = URL(fileURLWithPath: "/path/to/project", isDirectory: true) let filesystem = try await OverlayFilesystem(rootDirectory: projectRoot) let workspace = Workspace(filesystem: filesystem) @@ -150,44 +244,28 @@ let preview = try await workspace.summarizeTree("/Sources", maxDepth: 2) try await workspace.writeFile("/SCRATCH.md", content: "overlay-only change\n") ``` -### Multiple Workspaces Plus Shared Memory - -Use `MountableFilesystem` to combine isolated roots and shared state in one virtual tree: +### Mounted Workspaces ```swift -import Workspace - -let workspaceA = InMemoryFilesystem() - -let workspaceB = InMemoryFilesystem() - -let sharedMemory = InMemoryFilesystem() - let mounted = MountableFilesystem( base: InMemoryFilesystem(), mounts: [ - .init(mountPoint: "/workspace-a", filesystem: workspaceA), - .init(mountPoint: "/workspace-b", filesystem: workspaceB), - .init(mountPoint: "/memory", filesystem: sharedMemory), + .init(mountPoint: "/workspace-a", filesystem: InMemoryFilesystem()), + .init(mountPoint: "/workspace-b", filesystem: InMemoryFilesystem()), + .init(mountPoint: "/memory", filesystem: InMemoryFilesystem()), ] ) let workspace = Workspace(filesystem: mounted) try await workspace.writeFile("/memory/plan.txt", content: "shared notes") -try await workspace.copyItem(from: "/memory/plan.txt", to: "/workspace-a/plan.txt", recursive: false) +try await workspace.copyItem(from: "/memory/plan.txt", to: "/workspace-a/plan.txt") ``` ### Operation-Level Permissions -Use `PermissionedFileSystem` when the host should decide which operations are allowed: - ```swift -import Workspace - -let base = InMemoryFilesystem() - let filesystem = PermissionedFileSystem( - base: base, + base: InMemoryFilesystem(), authorizer: PermissionAuthorizer { request in switch request.operation { case .readFile, .listDirectory, .stat: @@ -201,69 +279,19 @@ let filesystem = PermissionedFileSystem( let workspace = Workspace(filesystem: filesystem) ``` -## Batched Edits - -`Workspace` includes explicit preview and apply APIs for tool-driven mutations: - -```swift -let result = try await workspace.applyEdits([ - .createDirectory(path: "/src"), - .writeFile(path: "/src/a.txt", content: "one"), - .appendFile(path: "/src/a.txt", content: " two"), - .copy(from: "/src/a.txt", to: "/src/b.txt"), -]) - -let writeChange = result.edits[1].fileChanges[0] -print(writeChange.status) // applied -print(writeChange.diff?.hunks.count) // Optional(1) -``` - -You can preview a batch before executing it: - -```swift -let preview = try await workspace.previewEdits([ - .copy(from: "/docs/guide.txt", to: "/workspace/guide.txt"), - .appendFile(path: "/workspace/notes.txt", content: "\nnext") -]) - -let appendPreview = preview.edits[1].fileChanges[0] -print(appendPreview.status) // planned -print(appendPreview.diff?.hunks.count) // Optional(...) -``` - -Text replacements use a request type so scope, include, exclude, and matching strategy live in one value: - -```swift -let preview = try await workspace.previewReplacement( - ReplacementRequest( - pattern: "/src/*.txt", - search: .literal("foo"), - replacement: "bar" - ) -) - -let replacement = preview.changes[0] -print(replacement.status) // planned -print(replacement.replacements) // number of matched replacements -print(replacement.diff.hunks) // structured line-based diff hunks -``` - -`ReplacementRequest`, `ReplacementResult`, edit metadata, tree metadata, and diffs are `Codable`, which makes previews and results easy to serialize for agent or tool workflows. - ## Important Behavior -- `InMemoryFilesystem` is ready to use immediately after initialization. Call `await reset()` when you explicitly want to clear it. Actor isolation serializes access to the tree. -- `OverlayFilesystem` snapshots a real root into memory. Call `try await reload()` when you explicitly want to discard overlay edits and rebuild from disk. -- `ReadWriteFilesystem` and `OverlayFilesystem` normalize paths and enforce a rooted/jail model. -- `PermissionedFileSystem` sees normalized virtual paths, not raw user input paths. -- `FileSystem` provides default throwing implementations for advanced operations like symlinks, hard links, permission mutation, and real-path resolution, so minimal custom backends only need to implement the core read/write surface. +- Reads do not load checkpoint state. Writes, checkpoint calls, rollback, branch, and merge do. +- All checkpoint reads share the workspace actor barrier with file I/O, so they serialize behind in-flight writes. +- `writeJSON` ends the file with a single trailing newline; `readJSON` decodes the value as usual. Checkpoint event polling (when using `watchCheckpointEvents`) uses `Workspace.checkpointEventPollInterval` (500 ms by default; shorten in tests to reduce wait time). +- `.inMemory` storage still records one mutation per write in memory, including old-content capture for text diffs. +- Branches created with `.directory(at:)` share the same storage directory as the parent but are partitioned by `workspaceId`. - `walkTree` and `summarizeTree` return stable path ordering, which is useful for deterministic tool output. ## Limitations - `Workspace` is not a hardened sandbox. -- `applyEdits` and `applyReplacement` use logical rollback when `failurePolicy` is `.rollback`; other policies may leave partial changes in place. -- Rollback is not crash-safe and does not coordinate with external processes. +- Logical rollback is not crash-safe and does not coordinate with external processes. - `OverlayFilesystem` does not persist writes back to the original root. - Hard links across mounts are not supported. - Some filesystem types still use `@unchecked Sendable`; treat shared mutable class-based implementations carefully unless their synchronization guarantees are documented. diff --git a/Sources/Workspace/Checkpoint.swift b/Sources/Workspace/Checkpoint.swift new file mode 100644 index 0000000..bd3baae --- /dev/null +++ b/Sources/Workspace/Checkpoint.swift @@ -0,0 +1,113 @@ +import Foundation + +/// A labeled, parented moment in workspace history. +/// +/// A `Checkpoint` records when a workspace state was captured, the parent checkpoint it follows, +/// and a compact summary of the changes relative to that parent. The actual file contents live in a +/// separate ``Snapshot`` artifact that can be loaded on demand with ``Workspace/snapshot(for:)``. +public struct Checkpoint: Sendable, Codable, Equatable { + /// A lightweight summary of changes relative to the parent checkpoint. + public struct Summary: Sendable, Codable, Equatable { + /// The number of paths that differ from the parent checkpoint. + public var changeCount: Int + /// The paths that changed relative to the parent checkpoint. + public var touchedPaths: [WorkspacePath] + /// Whether any changed file paths involve UTF-8 decodable text. + public var hasTextDiffs: Bool + + /// Creates a checkpoint summary. + public init(changeCount: Int, touchedPaths: [WorkspacePath], hasTextDiffs: Bool) { + self.changeCount = changeCount + self.touchedPaths = touchedPaths + self.hasTextDiffs = hasTextDiffs + } + } + + /// The checkpoint identifier. + public var id: UUID + /// The workspace that owns this checkpoint. + public var workspaceId: UUID + /// An optional human-readable label. + public var label: String? + /// The checkpoint creation timestamp. + public var createdAt: Date + /// The previous checkpoint in the same workspace, when present. + public var parentCheckpointId: UUID? + /// The parent workspace's head checkpoint when this workspace was branched. + public var baseCheckpointId: UUID? + /// The workspace that was merged to create this checkpoint, when applicable. + public var mergedFromWorkspaceId: UUID? + /// The merged workspace's head checkpoint when this checkpoint was created. + public var mergedFromCheckpointId: UUID? + /// The source checkpoint a rollback restored from when applicable. + public var rollbackSourceCheckpointId: UUID? + /// A summary of what changed relative to the parent checkpoint. + public var summary: Summary + + var firstMutationSequence: Int? + var lastMutationSequence: Int? + var mutationCursor: Int + var snapshotId: UUID + + var inferredEventKind: CheckpointEvent.Kind { + if rollbackSourceCheckpointId != nil { return .rolledBack } + if mergedFromWorkspaceId != nil { return .merged } + return .created + } + + init( + id: UUID = UUID(), + workspaceId: UUID, + label: String?, + createdAt: Date = Date(), + parentCheckpointId: UUID?, + baseCheckpointId: UUID? = nil, + mergedFromWorkspaceId: UUID? = nil, + mergedFromCheckpointId: UUID? = nil, + rollbackSourceCheckpointId: UUID? = nil, + firstMutationSequence: Int?, + lastMutationSequence: Int?, + mutationCursor: Int, + snapshotId: UUID, + summary: Summary + ) { + self.id = id + self.workspaceId = workspaceId + self.label = label + self.createdAt = createdAt + self.parentCheckpointId = parentCheckpointId + self.baseCheckpointId = baseCheckpointId + self.mergedFromWorkspaceId = mergedFromWorkspaceId + self.mergedFromCheckpointId = mergedFromCheckpointId + self.rollbackSourceCheckpointId = rollbackSourceCheckpointId + self.firstMutationSequence = firstMutationSequence + self.lastMutationSequence = lastMutationSequence + self.mutationCursor = mutationCursor + self.snapshotId = snapshotId + self.summary = summary + } +} + +/// A checkpoint event emitted by ``Workspace``. +public struct CheckpointEvent: Sendable, Codable, Equatable { + /// The event kind. + public enum Kind: String, Sendable, Codable { + /// A checkpoint was created. + case created + /// A rollback restored a prior checkpoint. + case rolledBack + /// A branch workspace was merged. + case merged + } + + /// The event kind. + public var kind: Kind + /// The checkpoint that triggered this event. + public var checkpoint: Checkpoint + + /// Creates a checkpoint event. + public init(kind: Kind, checkpoint: Checkpoint) { + self.kind = kind + self.checkpoint = checkpoint + } +} diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift new file mode 100644 index 0000000..4ab9609 --- /dev/null +++ b/Sources/Workspace/CheckpointStore.swift @@ -0,0 +1,352 @@ +import Foundation + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +/// Persistence for workspace checkpoints, snapshots, and mutation logs. +/// +/// The store is the **source of truth** for `MutationRecord.sequence` values. Callers may pass +/// any placeholder sequence; ``appendMutation(_:)`` returns the record with the next persisted +/// monotonic number for that workspace, serialized under the mutations lock. +protocol CheckpointStore: AnyObject, Sendable { + func saveCheckpoint(_ checkpoint: Checkpoint) async throws + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? + func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? + @discardableResult + func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] +} + +/// An in-memory checkpoint store for tests and ephemeral workspaces. +actor InMemoryCheckpointStore: CheckpointStore { + private var checkpointsByWorkspace: [UUID: [Checkpoint]] = [:] + private var snapshotsByWorkspace: [UUID: [UUID: Snapshot]] = [:] + private var mutationsByWorkspace: [UUID: [MutationRecord]] = [:] + + init() {} + + func saveCheckpoint(_ checkpoint: Checkpoint) async throws { + var list = checkpointsByWorkspace[checkpoint.workspaceId] ?? [] + if let index = list.firstIndex(where: { $0.id == checkpoint.id }) { + list[index] = checkpoint + } else { + list.append(checkpoint) + } + checkpointsByWorkspace[checkpoint.workspaceId] = list + } + + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { + checkpointsByWorkspace[workspaceId]?.first { $0.id == id } + } + + func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { + (checkpointsByWorkspace[workspaceId] ?? []).sorted { + if $0.createdAt == $1.createdAt { + return $0.id.uuidString < $1.id.uuidString + } + return $0.createdAt < $1.createdAt + } + } + + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { + snapshotsByWorkspace[workspaceId, default: [:]][snapshot.id] = snapshot + } + + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + snapshotsByWorkspace[workspaceId]?[id] + } + + @discardableResult + func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord { + var list = mutationsByWorkspace[mutation.workspaceId, default: []] + let next = (list.map(\.sequence).max() ?? 0) + 1 + var record = mutation + record.sequence = next + list.append(record) + mutationsByWorkspace[mutation.workspaceId] = list + return record + } + + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + (mutationsByWorkspace[workspaceId] ?? []).sorted { $0.sequence < $1.sequence } + } +} + +/// A JSON file-backed checkpoint store. +/// +/// Mutations are stored as one JSON line per record in `mutations.jsonl` (append-friendly under +/// the lock) so each append rewrites a tiny tail instead of the entire log. A legacy +/// `mutations.json` array is migrated to JSONL on the next read or append. Writes are +/// synchronized through a persistent sidecar lockfile (`mutations.lock`) with an advisory +/// exclusive lock when the platform supports it (`flock`), so concurrent ``FileCheckpointStore`` +/// instances in the same process and across cooperating processes do not lose appends. Checkpoint +/// and snapshot writes are per-artifact atomic replaces. Coordinating writers on network +/// filesystems that do not honor `flock` may still require application-level serialization. +actor FileCheckpointStore: CheckpointStore { + private let rootDirectory: URL + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private let compactEncoder: JSONEncoder + + private var listCheckpointsCache: [UUID: (cacheKey: String, checkpoints: [Checkpoint])] = [:] + + init(rootDirectory: URL, fileManager: FileManager = .default) { + self.rootDirectory = rootDirectory.standardizedFileURL + self.fileManager = fileManager + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.encoder = encoder + self.decoder = JSONDecoder() + self.compactEncoder = JSONEncoder() + } + + func saveCheckpoint(_ checkpoint: Checkpoint) async throws { + listCheckpointsCache[checkpoint.workspaceId] = nil + try ensureWorkspaceDirectories(for: checkpoint.workspaceId) + try write(checkpoint, to: checkpointURL(id: checkpoint.id, workspaceId: checkpoint.workspaceId)) + } + + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { + let url = checkpointURL(id: id, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { + return nil + } + return try read(Checkpoint.self, from: url) + } + + func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { + let directoryURL = checkpointsDirectoryURL(workspaceId: workspaceId) + guard fileManager.fileExists(atPath: directoryURL.path) else { + listCheckpointsCache[workspaceId] = nil + return [] + } + if let key = try checkpointsDirectoryCacheKey(at: directoryURL), + let entry = listCheckpointsCache[workspaceId], entry.cacheKey == key { + return entry.checkpoints + } + + let result = try fileManager + .contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + .filter { $0.pathExtension == "json" } + .map { try read(Checkpoint.self, from: $0) } + .sorted { + if $0.createdAt == $1.createdAt { + return $0.id.uuidString < $1.id.uuidString + } + return $0.createdAt < $1.createdAt + } + if let key = try checkpointsDirectoryCacheKey(at: directoryURL) { + listCheckpointsCache[workspaceId] = (key, result) + } + return result + } + + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { + try ensureWorkspaceDirectories(for: workspaceId) + try write(snapshot, to: snapshotURL(id: snapshot.id, workspaceId: workspaceId)) + } + + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + let url = snapshotURL(id: id, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { + return nil + } + return try read(Snapshot.self, from: url) + } + + @discardableResult + func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord { + try ensureWorkspaceDirectories(for: mutation.workspaceId) + let jsonl = mutationsJsonlURL(workspaceId: mutation.workspaceId) + let legacy = legacyMutationsArrayURL(workspaceId: mutation.workspaceId) + let lockURL = mutationsLockURL(workspaceId: mutation.workspaceId) + return try Self.withMutationsExclusiveLock(at: lockURL) { + let existing = try loadAllMutations(jsonl: jsonl, legacy: legacy) + let next = (existing.map(\.sequence).max() ?? 0) + 1 + var record = mutation + record.sequence = next + + if fileManager.fileExists(atPath: jsonl.path) { + try appendJSONLLine(encode: record, to: jsonl) + } else { + try writeAllMutationsAsJSONL([record], to: jsonl) + } + return record + } + } + + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + let jsonl = mutationsJsonlURL(workspaceId: workspaceId) + let legacy = legacyMutationsArrayURL(workspaceId: workspaceId) + guard fileManager.fileExists(atPath: jsonl.path) || fileManager.fileExists(atPath: legacy.path) else { + return [] + } + let lockURL = mutationsLockURL(workspaceId: workspaceId) + return try Self.withMutationsExclusiveLock(at: lockURL) { + try loadAllMutations(jsonl: jsonl, legacy: legacy) + .sorted { $0.sequence < $1.sequence } + } + } + + private func loadAllMutations(jsonl: URL, legacy: URL) throws -> [MutationRecord] { + if fileManager.fileExists(atPath: jsonl.path) { + if fileManager.fileExists(atPath: legacy.path) { + try? fileManager.removeItem(at: legacy) + } + return try loadMutationsFromJSONL(at: jsonl) + } + if fileManager.fileExists(atPath: legacy.path) { + let data = try Data(contentsOf: legacy) + if data.isEmpty { return [] } + let records = try decoder.decode([MutationRecord].self, from: data) + if !records.isEmpty { + try fileManager.removeItem(at: legacy) + try writeAllMutationsAsJSONL(records, to: jsonl) + } + return records + } + return [] + } + + private func loadMutationsFromJSONL(at url: URL) throws -> [MutationRecord] { + let data = try Data(contentsOf: url) + if data.isEmpty { return [] } + let text = String(data: data, encoding: .utf8) ?? "" + var out: [MutationRecord] = [] + out.reserveCapacity(64) + for line in text.split(whereSeparator: \.isNewline) { + if line.isEmpty { continue } + out.append(try decoder.decode(MutationRecord.self, from: Data(String(line).utf8))) + } + return out + } + + private func writeAllMutationsAsJSONL(_ records: [MutationRecord], to url: URL) throws { + if records.isEmpty { + if fileManager.fileExists(atPath: url.path) { + try fileManager.removeItem(at: url) + } + return + } + var data = Data() + for record in records.sorted(by: { $0.sequence < $1.sequence }) { + var line = try compactEncoder.encode(record) + line.append(Data("\n".utf8)) + data.append(line) + } + try data.write(to: url, options: .atomic) + } + + private func appendJSONLLine(encode record: MutationRecord, to url: URL) throws { + var line = try compactEncoder.encode(record) + line.append(Data("\n".utf8)) + if fileManager.fileExists(atPath: url.path) { + let h = try FileHandle(forWritingTo: url) + defer { try? h.close() } + try h.seekToEnd() + try h.write(contentsOf: line) + } else { + try line.write(to: url, options: .atomic) + } + } + + private func checkpointsDirectoryCacheKey(at url: URL) throws -> String? { + let files = try fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + let count = files.filter { $0.pathExtension == "json" }.count + let mtime = try url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + return "\(mtime?.timeIntervalSince1970 ?? 0)-\(count)" + } + + private func ensureWorkspaceDirectories(for workspaceId: UUID) throws { + try fileManager.createDirectory(at: workspaceDirectoryURL(workspaceId: workspaceId), withIntermediateDirectories: true) + try fileManager.createDirectory(at: checkpointsDirectoryURL(workspaceId: workspaceId), withIntermediateDirectories: true) + try fileManager.createDirectory(at: snapshotsDirectoryURL(workspaceId: workspaceId), withIntermediateDirectories: true) + } + + private func workspaceDirectoryURL(workspaceId: UUID) -> URL { + rootDirectory.appendingPathComponent(workspaceId.uuidString, isDirectory: true) + } + + private func checkpointsDirectoryURL(workspaceId: UUID) -> URL { + workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("checkpoints", isDirectory: true) + } + + private func snapshotsDirectoryURL(workspaceId: UUID) -> URL { + workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("snapshots", isDirectory: true) + } + + private func checkpointURL(id: UUID, workspaceId: UUID) -> URL { + checkpointsDirectoryURL(workspaceId: workspaceId).appendingPathComponent("\(id.uuidString).json", isDirectory: false) + } + + private func snapshotURL(id: UUID, workspaceId: UUID) -> URL { + snapshotsDirectoryURL(workspaceId: workspaceId).appendingPathComponent("\(id.uuidString).json", isDirectory: false) + } + + private func mutationsJsonlURL(workspaceId: UUID) -> URL { + workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.jsonl", isDirectory: false) + } + + private func legacyMutationsArrayURL(workspaceId: UUID) -> URL { + workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.json", isDirectory: false) + } + + /// A persistent sidecar lockfile used by ``withMutationsExclusiveLock(at:_:)``. + /// + /// The mutations log is append-only; we still take the lock on a **stable** sidecar so the lock + /// is not taken on a file that is unlinked/renamed by atomic write helpers in other + /// subsystems. Mutations are written to `mutations.jsonl` (or created there after migrating + /// from a legacy `mutations.json` array on first access). + private func mutationsLockURL(workspaceId: UUID) -> URL { + workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.lock", isDirectory: false) + } + + private func write(_ value: T, to url: URL) throws { + try encoder.encode(value).write(to: url, options: .atomic) + } + + private func read(_ type: T.Type, from url: URL) throws -> T { + try decoder.decode(type, from: Data(contentsOf: url)) + } + + private struct MutationsFileError: Error { + var message: String + } + +#if canImport(Darwin) || canImport(Glibc) + private static func withMutationsExclusiveLock(at url: URL, _ body: () throws -> R) throws -> R { + let path = url.path + let fd = open(path, O_RDWR | O_CREAT, 0o644) + guard fd >= 0 else { + throw MutationsFileError(message: "could not open mutations file at \(path)") + } + defer { close(fd) } + while flock(fd, LOCK_EX) != 0 { + if errno != EINTR { + throw MutationsFileError(message: "could not acquire exclusive lock on mutations file at \(path)") + } + } + defer { _ = flock(fd, LOCK_UN) } + return try body() + } +#else + private static func withMutationsExclusiveLock(at url: URL, _ body: () throws -> R) throws -> R { + try body() + } +#endif +} diff --git a/Sources/Workspace/FS/FileSystem.swift b/Sources/Workspace/FS/FileSystem.swift index f4deef3..63f7e62 100644 --- a/Sources/Workspace/FS/FileSystem.swift +++ b/Sources/Workspace/FS/FileSystem.swift @@ -16,6 +16,14 @@ public enum WorkspaceError: Error, CustomStringConvertible, Sendable { case accessDenied(operation: String, message: String?) /// The requested operation is not supported or cannot be completed in the current environment. case unsupported(String) + /// The requested checkpoint does not exist for this workspace. + case checkpointNotFound(UUID) + /// The snapshot artifact for a checkpoint is missing. + case snapshotNotFound(UUID) + /// The parent workspace changed after a branch was created, so the branch cannot be merged. + case mergeConflict(parentWorkspaceId: UUID, expectedBase: UUID?, actualHead: UUID?) + /// A tracked workspace mutation could not be recorded. + case mutationFailed(String) /// A human-readable description of the error. public var description: String { @@ -37,6 +45,16 @@ public enum WorkspaceError: Error, CustomStringConvertible, Sendable { return message ?? "workspace access denied: \(operation)" case let .unsupported(message): return message + case let .checkpointNotFound(checkpointId): + return "workspace checkpoint not found: \(checkpointId.uuidString)" + case let .snapshotNotFound(snapshotId): + return "workspace snapshot not found: \(snapshotId.uuidString)" + case let .mergeConflict(parentWorkspaceId, expectedBase, actualHead): + let expected = expectedBase?.uuidString ?? "nil" + let actual = actualHead?.uuidString ?? "nil" + return "cannot merge branch into workspace \(parentWorkspaceId.uuidString): expected base \(expected), current head is \(actual)" + case let .mutationFailed(message): + return message } } } diff --git a/Sources/Workspace/FileEdit.swift b/Sources/Workspace/FileEdit.swift index ed2e77c..417a5b5 100644 --- a/Sources/Workspace/FileEdit.swift +++ b/Sources/Workspace/FileEdit.swift @@ -40,7 +40,7 @@ public enum FileEdit: Sendable, Equatable, Codable { } /// A file-level change contained within a batch edit result. - public struct FileChange: Sendable, Codable { + public struct FileChange: Sendable, Codable, Equatable { /// The affected file or symlink path. public var path: WorkspacePath /// The original path for move and copy operations when applicable. @@ -73,7 +73,7 @@ public enum FileEdit: Sendable, Equatable, Codable { } /// A preview or result entry for a single batch edit operation. - public struct Entry: Sendable, Codable { + public struct Entry: Sendable, Codable, Equatable { /// The requested edit. public var edit: FileEdit /// Whether the requested edit materially changes the workspace. @@ -102,7 +102,7 @@ public enum FileEdit: Sendable, Equatable, Codable { } /// A failure encountered while executing a single batch edit. - public struct Failure: Sendable, Codable { + public struct Failure: Sendable, Codable, Equatable { /// The index of the failed edit in the original request. public var index: Int /// The edit that failed. @@ -119,7 +119,7 @@ public enum FileEdit: Sendable, Equatable, Codable { } /// The result of applying a batch of workspace edits. - public struct BatchResult: Sendable, Codable { + public struct BatchResult: Sendable, Codable, Equatable { /// Whether the operation was previewed or executed. public var mode: MutationMode /// Canonicalized paths touched by the batch. diff --git a/Sources/Workspace/MutationRecord.swift b/Sources/Workspace/MutationRecord.swift new file mode 100644 index 0000000..5cd9e98 --- /dev/null +++ b/Sources/Workspace/MutationRecord.swift @@ -0,0 +1,46 @@ +import Foundation + +/// A recorded filesystem mutation emitted by ``Workspace``. +struct MutationRecord: Sendable, Codable, Equatable { + /// The coarse operation kind for filtering and tooling. + enum Kind: String, Sendable, Codable { + case writeFile + case appendFile + case writeData + case writeJSON + case createDirectory + case removeItem + case copyItem + case moveItem + case applyEdits + case applyReplacement + case restoreSnapshot + case mergeWorkspace + } + + var sequence: Int + var workspaceId: UUID + var timestamp: Date + var kind: Kind + var touchedPaths: [WorkspacePath] + var fileChanges: [FileEdit.FileChange] + var diff: TextDiff? + + init( + sequence: Int, + workspaceId: UUID, + timestamp: Date = Date(), + kind: Kind, + touchedPaths: [WorkspacePath], + fileChanges: [FileEdit.FileChange], + diff: TextDiff? = nil + ) { + self.sequence = sequence + self.workspaceId = workspaceId + self.timestamp = timestamp + self.kind = kind + self.touchedPaths = touchedPaths + self.fileChanges = fileChanges + self.diff = diff + } +} diff --git a/Sources/Workspace/Snapshot.swift b/Sources/Workspace/Snapshot.swift new file mode 100644 index 0000000..77bf122 --- /dev/null +++ b/Sources/Workspace/Snapshot.swift @@ -0,0 +1,373 @@ +import Foundation + +/// A durable, in-memory snapshot of a workspace subtree. +/// +/// A snapshot captures the file contents, symbolic links, directory structure, and +/// POSIX permissions of an entire tree at a single point in time. It is `Codable` +/// so it can be persisted, transmitted, or compared to other snapshots. +/// +/// Capture and restore snapshots through ``Workspace/captureSnapshot(at:)`` and +/// ``Workspace/restoreSnapshot(_:)``. To inspect the tree captured by a checkpoint, +/// use ``Workspace/snapshot(for:)``. +public struct Snapshot: Sendable, Codable, Equatable { + /// A node within a snapshot tree. + public indirect enum Entry: Sendable, Codable, Equatable { + /// A path that did not exist when the snapshot was captured. + case missing(Missing) + /// A regular file with captured contents and permissions. + case file(File) + /// A directory with captured permissions and recursively captured children. + case directory(Directory) + /// A symbolic link pointing at `target`. + case symlink(Symlink) + + /// The workspace path of the entry. + public var path: WorkspacePath { + switch self { + case let .missing(entry): entry.path + case let .file(entry): entry.path + case let .directory(entry): entry.path + case let .symlink(entry): entry.path + } + } + } + + /// A snapshot entry representing a path that does not exist. + public struct Missing: Sendable, Codable, Equatable { + /// The captured path. + public var path: WorkspacePath + + /// Creates a missing entry. + public init(path: WorkspacePath) { + self.path = path + } + } + + /// A snapshot entry representing a regular file. + public struct File: Sendable, Codable, Equatable { + /// The captured path. + public var path: WorkspacePath + /// The file's contents at the time of capture. + public var data: Data + /// The file's POSIX permissions at the time of capture. + public var permissions: POSIXPermissions + + /// Creates a file entry. + public init(path: WorkspacePath, data: Data, permissions: POSIXPermissions) { + self.path = path + self.data = data + self.permissions = permissions + } + } + + /// A snapshot entry representing a directory. + public struct Directory: Sendable, Codable, Equatable { + /// The captured path. + public var path: WorkspacePath + /// The directory's POSIX permissions at the time of capture. + public var permissions: POSIXPermissions + /// The directory's children, sorted by name. + public var children: [Entry] + + /// Creates a directory entry. + public init(path: WorkspacePath, permissions: POSIXPermissions, children: [Entry]) { + self.path = path + self.permissions = permissions + self.children = children + } + } + + /// A snapshot entry representing a symbolic link. + public struct Symlink: Sendable, Codable, Equatable { + /// The captured path. + public var path: WorkspacePath + /// The symbolic link's target string. + public var target: String + /// The symbolic link's POSIX permissions at the time of capture. + public var permissions: POSIXPermissions + + /// Creates a symlink entry. + public init(path: WorkspacePath, target: String, permissions: POSIXPermissions) { + self.path = path + self.target = target + self.permissions = permissions + } + } + + /// A stable identifier for the snapshot. + public var id: UUID + /// The path that was used as the snapshot root. + public var rootPath: WorkspacePath + /// The captured root entry. + public var entry: Entry + + /// Creates a snapshot from a previously captured tree. + public init(id: UUID = UUID(), rootPath: WorkspacePath, entry: Entry) { + self.id = id + self.rootPath = rootPath + self.entry = entry + } +} + +// MARK: - Summary + +extension Snapshot { + /// Returns a structural summary of the changes between this snapshot and `other`. + /// + /// Pass `nil` to summarize this snapshot as if every entry were newly created. + /// + /// This walks every node under the snapshot root twice (current and prior trees). For very large trees, + /// prefer caching summaries at checkpoint creation time rather than recomputing on hot paths. + /// + /// - Parameter other: The snapshot to compare against, typically a parent checkpoint. + /// - Returns: A summary describing how many paths changed and whether any of those + /// paths involve UTF-8 decodable text (and therefore have a meaningful textual diff). + public func summary(comparedTo other: Snapshot?) -> Checkpoint.Summary { + let currentNodes = Self.flattenNodes(entry, rootPath: rootPath) + let previousNodes = other.map { Self.flattenNodes($0.entry, rootPath: $0.rootPath) } ?? [:] + let allPaths = Set(currentNodes.keys).union(previousNodes.keys).sorted() + let changedPaths = allPaths.filter { currentNodes[$0] != previousNodes[$0] } + let hasTextDiffs = changedPaths.contains { Self.hasTextualChange(old: previousNodes[$0], new: currentNodes[$0]) } + + return Checkpoint.Summary( + changeCount: changedPaths.count, + touchedPaths: changedPaths, + hasTextDiffs: hasTextDiffs + ) + } + + private struct FlatNode: Equatable { + var kind: FileTree.Kind + var permissions: POSIXPermissions + var fileData: Data? + var symlinkTarget: String? + } + + private static func flattenNodes(_ entry: Entry, rootPath: WorkspacePath) -> [WorkspacePath: FlatNode] { + var nodes: [WorkspacePath: FlatNode] = [:] + collectNodes(entry, into: &nodes) + nodes.removeValue(forKey: rootPath) + return nodes + } + + private static func collectNodes(_ entry: Entry, into nodes: inout [WorkspacePath: FlatNode]) { + switch entry { + case .missing: + return + case let .file(f): + nodes[f.path] = FlatNode(kind: .file, permissions: f.permissions, fileData: f.data) + case let .symlink(s): + nodes[s.path] = FlatNode(kind: .symlink, permissions: s.permissions, symlinkTarget: s.target) + case let .directory(d): + nodes[d.path] = FlatNode(kind: .directory, permissions: d.permissions) + for child in d.children { + collectNodes(child, into: &nodes) + } + } + } + + private static func hasTextualChange(old: FlatNode?, new: FlatNode?) -> Bool { + let oldText: String? + if let data = old?.fileData { + oldText = String(data: data, encoding: .utf8) + } else if old == nil { + oldText = "" + } else { + oldText = nil + } + + let newText: String? + if let data = new?.fileData { + newText = String(data: data, encoding: .utf8) + } else if new == nil { + newText = "" + } else { + newText = nil + } + + guard let oldText, let newText else { return false } + return oldText != newText + } +} + +// MARK: - Capture / Restore + +extension Snapshot { + static func capture( + from target: any FileSystem, + at path: WorkspacePath = .root, + snapshotId: UUID = UUID() + ) async throws -> Snapshot { + Snapshot( + id: snapshotId, + rootPath: path, + entry: try await captureEntry(from: target, at: path) + ) + } + + static func restore(_ snapshot: Snapshot, to target: any FileSystem) async throws { + try await restoreEntry(snapshot.entry, to: target) + } + + private static func captureEntry(from target: any FileSystem, at path: WorkspacePath) async throws -> Entry { + guard await target.exists(path: path) else { + return .missing(.init(path: path)) + } + + let info = try await target.stat(path: path) + switch info.kind { + case .directory: + let entries = try await target.listDirectory(path: path) + let children = try await entries + .sorted { $0.name < $1.name } + .snapshotAsyncMap { entry in + try await captureEntry(from: target, at: path.appending(entry.name)) + } + return .directory(.init(path: path, permissions: info.permissions, children: children)) + case .symlink: + return .symlink( + .init( + path: path, + target: try await target.readSymlink(path: path), + permissions: info.permissions + ) + ) + case .file: + return .file( + .init( + path: path, + data: try await target.readFile(path: path), + permissions: info.permissions + ) + ) + } + } + + private static func restoreEntry(_ entry: Entry, to target: any FileSystem) async throws { + switch entry { + case let .missing(missing): + try await removeEntryIfPresent(at: missing.path, on: target) + case let .file(file): + try await ensureParentDirectory(for: file.path, on: target) + + let rewriteRequired: Bool + if await target.exists(path: file.path) { + let info = try await target.stat(path: file.path) + if info.kind == .file { + rewriteRequired = try await target.readFile(path: file.path) != file.data + } else { + try await target.remove(path: file.path, recursive: true) + rewriteRequired = true + } + } else { + rewriteRequired = true + } + + if rewriteRequired { + try await target.writeFile(path: file.path, data: file.data, append: false) + } + try await target.setPermissions(path: file.path, permissions: file.permissions) + case let .symlink(symlink): + try await ensureParentDirectory(for: symlink.path, on: target) + + var rewriteRequired = true + if await target.exists(path: symlink.path) { + let info = try await target.stat(path: symlink.path) + if info.kind == .symlink, try await target.readSymlink(path: symlink.path) == symlink.target { + rewriteRequired = false + } else { + try await target.remove(path: symlink.path, recursive: true) + } + } + + if rewriteRequired { + try await target.createSymlink(path: symlink.path, target: symlink.target) + } + try await target.setPermissions(path: symlink.path, permissions: symlink.permissions) + case let .directory(directory): + try await ensureDirectory(at: directory.path, permissions: directory.permissions, on: target) + try await syncChildren(directory.children, under: directory.path, on: target) + try await target.setPermissions(path: directory.path, permissions: directory.permissions) + } + } + + private static func ensureParentDirectory(for path: WorkspacePath, on target: any FileSystem) async throws { + let parent = path.dirname + guard !parent.isRoot else { + return + } + try await ensureDirectory(at: parent, permissions: .defaultDirectory, on: target) + } + + private static func ensureDirectory( + at path: WorkspacePath, + permissions: POSIXPermissions, + on target: any FileSystem + ) async throws { + if await target.exists(path: path) { + let info = try await target.stat(path: path) + if info.kind == .directory { + return + } + try await target.remove(path: path, recursive: true) + } + + if !path.isRoot { + try await target.createDirectory(path: path, recursive: true) + } + try await target.setPermissions(path: path, permissions: permissions) + } + + private static func syncChildren( + _ expectedChildren: [Entry], + under parentPath: WorkspacePath, + on target: any FileSystem + ) async throws { + let expectedNames = Set(expectedChildren.map { $0.path.basename }) + let existingEntries = (try? await target.listDirectory(path: parentPath)) ?? [] + + for entry in existingEntries where !expectedNames.contains(entry.name) { + try await target.remove(path: parentPath.appending(entry.name), recursive: true) + } + + for child in expectedChildren.sorted(by: { $0.path < $1.path }) { + try await restoreEntry(child, to: target) + } + } + + private static func removeEntryIfPresent(at path: WorkspacePath, on target: any FileSystem) async throws { + guard await target.exists(path: path) else { + return + } + + if path.isRoot { + let existingEntries = try await target.listDirectory(path: path) + for entry in existingEntries { + try await target.remove(path: path.appending(entry.name), recursive: true) + } + return + } + + let info = try await target.stat(path: path) + if info.kind == .directory { + let existingEntries = try await target.listDirectory(path: path) + for entry in existingEntries { + try await target.remove(path: path.appending(entry.name), recursive: true) + } + } + try await target.remove(path: path, recursive: true) + } +} + +private extension Array { + func snapshotAsyncMap( + _ transform: @escaping @Sendable (Element) async throws -> T + ) async throws -> [T] { + var values: [T] = [] + values.reserveCapacity(count) + for element in self { + values.append(try await transform(element)) + } + return values + } +} diff --git a/Sources/Workspace/TextDiff.swift b/Sources/Workspace/TextDiff.swift index e6bd9a2..39059d3 100644 --- a/Sources/Workspace/TextDiff.swift +++ b/Sources/Workspace/TextDiff.swift @@ -78,3 +78,147 @@ public struct TextDiff: Sendable, Equatable, Codable { self.hunks = hunks } } + +// MARK: - Line-based diff (shared with ``Workspace`` previews) + +extension TextDiff { + /// A line-based diff between two UTF-8 text blobs, using the same algorithm as + /// ``Workspace`` replacement and batch-edit previews. + public static func lineBased(from originalContent: String, to updatedContent: String) -> TextDiff { + let originalLines = diffLineTokens(in: originalContent) + let updatedLines = diffLineTokens(in: updatedContent) + let changes = Array(updatedLines.difference(from: originalLines)) + + let removals = Dictionary(grouping: changes.compactMap { change -> (Int, DiffLineToken)? in + if case let .remove(offset, element, _) = change { + return (offset, element) + } + return nil + }, by: \.0) + + let insertions = Dictionary(grouping: changes.compactMap { change -> (Int, DiffLineToken)? in + if case let .insert(offset, element, _) = change { + return (offset, element) + } + return nil + }, by: \.0) + + var lines: [Line] = [] + var originalIndex = 0 + var updatedIndex = 0 + var originalLineNumber = 1 + var updatedLineNumber = 1 + + while originalIndex < originalLines.count || updatedIndex < updatedLines.count { + if let removed = removals[originalIndex] { + for (_, token) in removed { + lines.append( + Line( + kind: .removed, + text: token.text, + hasTrailingNewline: token.hasTrailingNewline, + oldLineNumber: originalLineNumber + ) + ) + originalIndex += 1 + originalLineNumber += 1 + } + continue + } + + if let inserted = insertions[updatedIndex] { + for (_, token) in inserted { + lines.append( + Line( + kind: .added, + text: token.text, + hasTrailingNewline: token.hasTrailingNewline, + newLineNumber: updatedLineNumber + ) + ) + updatedIndex += 1 + updatedLineNumber += 1 + } + continue + } + + guard originalIndex < originalLines.count, updatedIndex < updatedLines.count else { + break + } + + let updatedLine = updatedLines[updatedIndex] + lines.append( + Line( + kind: .context, + text: updatedLine.text, + hasTrailingNewline: updatedLine.hasTrailingNewline, + oldLineNumber: originalLineNumber, + newLineNumber: updatedLineNumber + ) + ) + originalIndex += 1 + updatedIndex += 1 + originalLineNumber += 1 + updatedLineNumber += 1 + } + + let changedIndices = lines.indices.filter { lines[$0].kind != .context } + guard !changedIndices.isEmpty else { + return TextDiff(hunks: []) + } + + var ranges: [Range] = [] + for index in changedIndices { + let lowerBound = max(0, index - 3) + let upperBound = min(lines.count, index + 4) + if let last = ranges.last, lowerBound <= last.upperBound { + ranges[ranges.count - 1] = last.lowerBound.. [DiffLineToken] { + guard !text.isEmpty else { + return [] + } + + var tokens: [DiffLineToken] = [] + var current = "" + for character in text { + if character == "\n" { + tokens.append(DiffLineToken(text: current, hasTrailingNewline: true)) + current.removeAll(keepingCapacity: true) + } else { + current.append(character) + } + } + + if !current.isEmpty || text.last != "\n" { + tokens.append(DiffLineToken(text: current, hasTrailingNewline: false)) + } + + return tokens +} diff --git a/Sources/Workspace/Workspace+Branching.swift b/Sources/Workspace/Workspace+Branching.swift new file mode 100644 index 0000000..1be6729 --- /dev/null +++ b/Sources/Workspace/Workspace+Branching.swift @@ -0,0 +1,106 @@ +import Foundation + +extension Workspace { + /// Creates an isolated branch workspace from the current filesystem state. + /// + /// Branches share only the checkpoint store with their parent. Filesystem state, watchers, and mutation + /// sequences are isolated to the returned workspace. + /// - Parameters: + /// - label: Optional label for the branch's seed checkpoint. + /// - filesystem: Filesystem the branch will use; defaults to a new `InMemoryFilesystem` when + /// you omit this value. + public func branch( + label: String? = nil, + filesystem: (any FileSystem)? = nil + ) async throws -> Workspace { + try await ensureLoaded() + try await reconcileCheckpointsWithStore() + + let baseCheckpointId = headCheckpointId + let snapshot = try await captureSnapshot() + let branchFilesystem = filesystem ?? InMemoryFilesystem() + try await Snapshot.restore(snapshot, to: branchFilesystem) + + let branch = Workspace( + workspaceId: UUID(), + filesystem: branchFilesystem, + store: store, + baseCheckpointId: baseCheckpointId + ) + _ = try await branch.seedBranchCheckpoint( + snapshot: snapshot, + label: label, + baseCheckpointId: baseCheckpointId + ) + return branch + } + + /// Merges another workspace into this workspace when this workspace still points at the other's base. + public func merge(_ other: Workspace, label: String? = nil) async throws -> Checkpoint { + try await ensureLoaded() + try await reconcileCheckpointsWithStore() + try await other.reconcileCheckpointsWithStore() + + let expectedBase = await other.mergeBaseCheckpointId() + guard headCheckpointId == expectedBase else { + throw WorkspaceError.mergeConflict( + parentWorkspaceId: workspaceId, + expectedBase: expectedBase, + actualHead: headCheckpointId + ) + } + + let previousSnapshot = try await captureSnapshot() + let incomingSnapshot = try await other.captureSnapshot() + let incomingHead = try await other.currentHeadCheckpointId() + let delta = snapshotDelta(from: previousSnapshot.entry, to: incomingSnapshot.entry) + + try await untrackedRestore(incomingSnapshot) + + if delta.hasChanges { + try await appendMutation( + kind: .mergeWorkspace, + touchedPaths: Array(delta.touchedPaths).sorted(), + fileChanges: delta.fileChanges, + diff: delta.fileChanges.count == 1 ? delta.fileChanges[0].diff : nil + ) + } + + let mergedSnapshot = try await captureSnapshot() + return try await persistCheckpoint( + snapshot: mergedSnapshot, + label: label, + parentCheckpointId: headCheckpointId, + baseCheckpointId: baseCheckpointId, + mergedFromWorkspaceId: other.workspaceId, + mergedFromCheckpointId: incomingHead, + rollbackSourceCheckpointId: nil, + eventKind: .merged + ) + } + + func seedBranchCheckpoint( + snapshot: Snapshot, + label: String?, + baseCheckpointId: UUID? + ) async throws -> Checkpoint { + try await ensureLoaded() + return try await persistCheckpoint( + snapshot: snapshot, + label: label, + parentCheckpointId: nil, + baseCheckpointId: baseCheckpointId, + eventKind: .created, + comparisonSnapshot: snapshot + ) + } + + func mergeBaseCheckpointId() -> UUID? { + baseCheckpointId + } + + func currentHeadCheckpointId() async throws -> UUID? { + try await ensureLoaded() + return headCheckpointId + } +} diff --git a/Sources/Workspace/Workspace+Checkpoints.swift b/Sources/Workspace/Workspace+Checkpoints.swift new file mode 100644 index 0000000..def4594 --- /dev/null +++ b/Sources/Workspace/Workspace+Checkpoints.swift @@ -0,0 +1,87 @@ +import Foundation + +extension Workspace { + /// Creates a checkpoint for the workspace's current filesystem state. + public func createCheckpoint(label: String? = nil) async throws -> Checkpoint { + try await ensureLoaded() + try await reconcileCheckpointsWithStore() + let snapshot = try await captureSnapshot() + return try await persistCheckpoint( + snapshot: snapshot, + label: label, + parentCheckpointId: headCheckpointId, + baseCheckpointId: baseCheckpointId, + rollbackSourceCheckpointId: nil, + eventKind: .created + ) + } + + /// Restores the workspace to a prior checkpoint and records the rollback as a new checkpoint. + public func rollback(to checkpointId: UUID, label: String? = nil) async throws -> Checkpoint { + try await ensureLoaded() + try await reconcileCheckpointsWithStore() + let checkpoint = try checkpointOrThrow(id: checkpointId) + let targetSnapshot = try await loadSnapshotOrThrow( + id: checkpoint.snapshotId, + workspaceId: checkpoint.workspaceId + ) + try await untrackedRestore(targetSnapshot) + let restoredSnapshot = try await captureSnapshot() + return try await persistCheckpoint( + snapshot: restoredSnapshot, + label: label, + parentCheckpointId: headCheckpointId, + baseCheckpointId: baseCheckpointId, + rollbackSourceCheckpointId: checkpoint.id, + eventKind: .rolledBack + ) + } + + /// Lists checkpoints owned by this workspace. + public func listCheckpoints() async throws -> [Checkpoint] { + try await ensureLoaded() + return checkpoints + } + + /// Returns one checkpoint owned by this workspace when present. + public func checkpoint(id: UUID) async throws -> Checkpoint? { + try await ensureLoaded() + return checkpoints.first(where: { $0.id == id }) + } + + /// Loads the snapshot artifact persisted alongside `checkpoint`. + public func snapshot(for checkpoint: Checkpoint) async throws -> Snapshot { + try await ensureLoaded() + return try await loadSnapshotOrThrow(id: checkpoint.snapshotId, workspaceId: checkpoint.workspaceId) + } + + /// Watches for checkpoint events, including checkpoints created by other workspace instances + /// with the same `workspaceId` and shared store. Polling uses ``Workspace/checkpointEventPollInterval`` + /// (500 ms by default) while the stream is active; lower it in tests to reduce wait time. + public func watchCheckpointEvents() async throws -> AsyncStream { + try await ensureLoaded() + + let watcherId = UUID() + let deliveredIds = Set(checkpoints.map(\.id)) + var continuation: AsyncStream.Continuation? + let stream = AsyncStream { + continuation = $0 + } + + guard let continuation else { + return stream + } + + checkpointWatchers[watcherId] = CheckpointWatcher( + deliveredCheckpointIds: deliveredIds, + continuation: continuation + ) + continuation.onTermination = { [weak self] _ in + Task { + await self?.removeCheckpointWatcher(id: watcherId) + } + } + ensureCheckpointPolling() + return stream + } +} diff --git a/Sources/Workspace/Workspace+Internals.swift b/Sources/Workspace/Workspace+Internals.swift new file mode 100644 index 0000000..bb99eb6 --- /dev/null +++ b/Sources/Workspace/Workspace+Internals.swift @@ -0,0 +1,483 @@ +import Foundation + +extension Workspace { + struct SnapshotDelta { + var touchedPaths: Set = [] + var fileChanges: [FileEdit.FileChange] = [] + + var hasChanges: Bool { + !touchedPaths.isEmpty || !fileChanges.isEmpty + } + } + + func ensureLoaded() async throws { + if didLoadStoreState { + return + } + if let loadTask { + try await loadTask.value + return + } + let task = Task { + try await self.loadStoreState() + } + loadTask = task + do { + try await task.value + loadTask = nil + } catch { + loadTask = nil + throw error + } + } + + func loadStoreState() async throws { + checkpoints = try await store.listCheckpoints(workspaceId: workspaceId) + mutations = try await store.listMutationRecords(workspaceId: workspaceId) + nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 + headCheckpointId = Self.lineageHeadId(in: checkpoints) + didLoadStoreState = true + } + + /// Merges in-memory checkpoint state with the shared store, then recomputes the head from the + /// parent chain (leaves) instead of wall-clock order alone, so a remote checkpoint does not + /// silently reorder lineage when `createdAt` is skewed. + func reconcileCheckpointsWithStore() async throws { + try await ensureLoaded() + let fromStore = try await store.listCheckpoints(workspaceId: workspaceId) + checkpoints = fromStore.sorted(by: checkpointSort) + headCheckpointId = Self.lineageHeadId(in: checkpoints) + } + + /// Picks the current checkpoint "tip": checkpoints whose ids never appear as + /// ``Checkpoint/parentCheckpointId`` (DAG leaves). If the graph is forked, the leaf with the + /// latest `(createdAt, id)` is used. + static func lineageHeadId(in checkpoints: [Checkpoint]) -> UUID? { + guard !checkpoints.isEmpty else { + return nil + } + let parentReferences = Set(checkpoints.compactMap(\.parentCheckpointId)) + let tips = checkpoints.filter { !parentReferences.contains($0.id) } + if tips.isEmpty { + return checkpoints.max(by: orderCheckpoints)?.id + } + if tips.count == 1 { + return tips[0].id + } + return tips.max(by: orderCheckpoints)?.id + } + + private static func orderCheckpoints(_ lhs: Checkpoint, _ rhs: Checkpoint) -> Bool { + if lhs.createdAt == rhs.createdAt { + return lhs.id.uuidString < rhs.id.uuidString + } + return lhs.createdAt < rhs.createdAt + } + + func checkpointOrThrow(id: UUID) throws -> Checkpoint { + guard let checkpoint = checkpoints.first(where: { $0.id == id }) else { + throw WorkspaceError.checkpointNotFound(id) + } + return checkpoint + } + + func loadSnapshotOrThrow(id: UUID, workspaceId: UUID? = nil) async throws -> Snapshot { + let ownerWorkspaceId = workspaceId ?? self.workspaceId + guard let snapshot = try await store.loadSnapshot(id: id, workspaceId: ownerWorkspaceId) else { + throw WorkspaceError.snapshotNotFound(id) + } + return snapshot + } + + func performDirectEdit( + kind: MutationRecord.Kind, + edit: FileEdit, + apply: () async throws -> Void + ) async throws { + try await ensureLoaded() + let preview = try await previewEdits([edit]) + try await apply() + + let appliedFileChanges = markApplied(preview.edits.first?.fileChanges ?? []) + try await appendMutation( + kind: kind, + touchedPaths: preview.touchedPaths, + fileChanges: appliedFileChanges, + diff: appliedFileChanges.count == 1 ? appliedFileChanges[0].diff : nil + ) + } + + func performBinaryWrite(data: Data, path: WorkspacePath) async throws { + let effect: FileEdit.Effect + let kind: FileTree.Kind + + if await exists(path) { + let info = try await fileInfo(at: path) + if info.kind == .directory { + throw WorkspaceError.unsupported("cannot write data to a directory: \(path)") + } + kind = info.kind + effect = try await readData(from: path) == data ? .unchanged : .modified + } else { + kind = .file + effect = .created + } + + try await untrackedWriteData(data, to: path) + try await appendMutation( + kind: .writeData, + touchedPaths: [path], + fileChanges: [ + FileEdit.FileChange( + path: path, + kind: kind, + effect: effect, + status: .applied, + diff: nil + ), + ], + diff: nil + ) + } + + func persistCheckpoint( + snapshot: Snapshot, + label: String?, + parentCheckpointId: UUID?, + baseCheckpointId: UUID?, + mergedFromWorkspaceId: UUID? = nil, + mergedFromCheckpointId: UUID? = nil, + rollbackSourceCheckpointId: UUID? = nil, + eventKind: CheckpointEvent.Kind, + comparisonSnapshot: Snapshot? = nil + ) async throws -> Checkpoint { + try await store.saveSnapshot(snapshot, workspaceId: workspaceId) + + let parentCheckpoint = parentCheckpointId.flatMap { id in + checkpoints.first(where: { $0.id == id }) + } + + let previousSnapshot: Snapshot? + if let comparisonSnapshot { + previousSnapshot = comparisonSnapshot + } else if let parentCheckpoint { + previousSnapshot = try? await store.loadSnapshot(id: parentCheckpoint.snapshotId, workspaceId: workspaceId) + } else { + previousSnapshot = nil + } + let summary = snapshot.summary(comparedTo: previousSnapshot) + + let previousCursor = parentCheckpoint?.mutationCursor ?? 0 + let currentCursor = latestMutationSequence() + let checkpoint = Checkpoint( + workspaceId: workspaceId, + label: label, + parentCheckpointId: parentCheckpointId, + baseCheckpointId: baseCheckpointId, + mergedFromWorkspaceId: mergedFromWorkspaceId, + mergedFromCheckpointId: mergedFromCheckpointId, + rollbackSourceCheckpointId: rollbackSourceCheckpointId, + firstMutationSequence: currentCursor > previousCursor ? previousCursor + 1 : nil, + lastMutationSequence: currentCursor > previousCursor ? currentCursor : nil, + mutationCursor: currentCursor, + snapshotId: snapshot.id, + summary: summary + ) + + try await store.saveCheckpoint(checkpoint) + + checkpoints.append(checkpoint) + checkpoints.sort(by: checkpointSort) + headCheckpointId = checkpoint.id + + emitCheckpointEvent(CheckpointEvent(kind: eventKind, checkpoint: checkpoint)) + return checkpoint + } + + func appendMutation( + kind: MutationRecord.Kind, + touchedPaths: [WorkspacePath], + fileChanges: [FileEdit.FileChange], + diff: TextDiff? + ) async throws { + let provisional = MutationRecord( + sequence: 0, + workspaceId: workspaceId, + kind: kind, + touchedPaths: Array(Set(touchedPaths)).sorted(), + fileChanges: fileChanges.sorted(by: fileChangeSort), + diff: diff + ) + let persisted = try await store.appendMutation(provisional) + mutations.append(persisted) + mutations.sort { $0.sequence < $1.sequence } + nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 + } + + func latestMutationSequence() -> Int { + mutations.map(\.sequence).max() ?? 0 + } + + func mutationRecords() async throws -> [MutationRecord] { + try await ensureLoaded() + return mutations + } + + /// Encodes `value` to bytes and a UTF-8 `String` with a **single trailing newline** for + /// line-oriented / POSIX-friendly file endings. Decoding via ``Workspace/readJSON(from:)`` + /// still works because the JSON comes first; keep this in mind if you read raw bytes with a + /// different decoder. + func encodedJSONString(for value: T, prettyPrinted: Bool) throws -> String { + let encoder = JSONEncoder() + if prettyPrinted { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + + var data = try encoder.encode(value) + data.append(Data("\n".utf8)) + guard let string = String(data: data, encoding: .utf8) else { + throw WorkspaceError.mutationFailed("encoded JSON is not valid UTF-8") + } + return string + } + + func markApplied(_ fileChanges: [FileEdit.FileChange]) -> [FileEdit.FileChange] { + fileChanges.map { change in + var copy = change + copy.status = .applied + return copy + } + } + + func untrackedRestore(_ snapshot: Snapshot) async throws { + try await Snapshot.restore(snapshot, to: filesystem) + } + + func removeCheckpointWatcher(id: UUID) { + checkpointWatchers.removeValue(forKey: id) + if checkpointWatchers.isEmpty { + checkpointPollingTask?.cancel() + checkpointPollingTask = nil + } + } + + func ensureCheckpointPolling() { + guard checkpointPollingTask == nil else { + return + } + checkpointPollingTask = Task { [weak self] in + while !Task.isCancelled, let workspace = self { + let interval = await workspace.checkpointEventPollInterval + try? await Task.sleep(for: interval) + await workspace.pollCheckpointEvents() + } + } + } + + func pollCheckpointEvents() async { + guard !checkpointWatchers.isEmpty else { + return + } + guard let loaded = try? await store.listCheckpoints(workspaceId: workspaceId) else { + return + } + + let newCheckpoints = loaded.filter { cp in + !checkpoints.contains(where: { $0.id == cp.id }) + } + + if !newCheckpoints.isEmpty { + checkpoints.append(contentsOf: newCheckpoints) + checkpoints.sort(by: checkpointSort) + headCheckpointId = Self.lineageHeadId(in: checkpoints) + + if let refreshedMutations = try? await store.listMutationRecords(workspaceId: workspaceId) { + mutations = refreshedMutations.sorted { $0.sequence < $1.sequence } + nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 + } + } + + for checkpoint in newCheckpoints.sorted(by: checkpointSort) { + emitCheckpointEvent(CheckpointEvent(kind: checkpoint.inferredEventKind, checkpoint: checkpoint)) + } + } + + func emitCheckpointEvent(_ event: CheckpointEvent) { + guard !checkpointWatchers.isEmpty else { + return + } + + for id in Array(checkpointWatchers.keys) { + guard var watcher = checkpointWatchers[id] else { + continue + } + guard watcher.deliveredCheckpointIds.insert(event.checkpoint.id).inserted else { + continue + } + watcher.continuation.yield(event) + checkpointWatchers[id] = watcher + } + } + + func snapshotDelta(from original: Snapshot.Entry, to updated: Snapshot.Entry) -> SnapshotDelta { + var delta = SnapshotDelta() + collectDelta(from: original, to: updated, into: &delta) + delta.fileChanges.sort(by: fileChangeSort) + return delta + } + + func collectDelta( + from original: Snapshot.Entry, + to updated: Snapshot.Entry, + into delta: inout SnapshotDelta + ) { + switch (original, updated) { + case (.missing, .missing): + return + case (.missing, _): + collectCreated(updated, into: &delta) + case (_, .missing): + collectDeleted(original, into: &delta) + case let (.file(lhs), .file(rhs)): + if lhs.data != rhs.data || lhs.permissions != rhs.permissions { + delta.touchedPaths.insert(rhs.path) + let diff = lhs.data != rhs.data ? utf8TextFileDiff(oldData: lhs.data, newData: rhs.data) : nil + delta.fileChanges.append( + FileEdit.FileChange( + path: rhs.path, + kind: .file, + effect: .modified, + status: .applied, + diff: diff + ) + ) + } + case let (.symlink(lhs), .symlink(rhs)): + if lhs.target != rhs.target || lhs.permissions != rhs.permissions { + delta.touchedPaths.insert(rhs.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: rhs.path, + kind: .symlink, + effect: .modified, + status: .applied, + diff: nil + ) + ) + } + case let (.directory(lhs), .directory(rhs)): + if lhs.permissions != rhs.permissions { + delta.touchedPaths.insert(rhs.path) + } + + let lhsChildren = Dictionary(uniqueKeysWithValues: lhs.children.map { ($0.path.basename, $0) }) + let rhsChildren = Dictionary(uniqueKeysWithValues: rhs.children.map { ($0.path.basename, $0) }) + let names = Set(lhsChildren.keys).union(rhsChildren.keys).sorted() + + for name in names { + switch (lhsChildren[name], rhsChildren[name]) { + case let (lhs?, rhs?): + collectDelta(from: lhs, to: rhs, into: &delta) + case let (lhs?, nil): + collectDeleted(lhs, into: &delta) + case let (nil, rhs?): + collectCreated(rhs, into: &delta) + case (nil, nil): + break + } + } + default: + collectDeleted(original, into: &delta) + collectCreated(updated, into: &delta) + } + } + + func collectCreated(_ entry: Snapshot.Entry, into delta: inout SnapshotDelta) { + switch entry { + case let .missing(entry): + delta.touchedPaths.insert(entry.path) + case let .file(entry): + delta.touchedPaths.insert(entry.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: entry.path, + kind: .file, + effect: .created, + status: .applied, + diff: utf8TextFileDiff(oldData: Data(), newData: entry.data) + ) + ) + case let .directory(entry): + delta.touchedPaths.insert(entry.path) + for child in entry.children { + collectCreated(child, into: &delta) + } + case let .symlink(entry): + delta.touchedPaths.insert(entry.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: entry.path, + kind: .symlink, + effect: .created, + status: .applied, + diff: nil + ) + ) + } + } + + func collectDeleted(_ entry: Snapshot.Entry, into delta: inout SnapshotDelta) { + switch entry { + case let .missing(entry): + delta.touchedPaths.insert(entry.path) + case let .file(entry): + delta.touchedPaths.insert(entry.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: entry.path, + kind: .file, + effect: .deleted, + status: .applied, + diff: utf8TextFileDiff(oldData: entry.data, newData: Data()) + ) + ) + case let .directory(entry): + delta.touchedPaths.insert(entry.path) + for child in entry.children { + collectDeleted(child, into: &delta) + } + case let .symlink(entry): + delta.touchedPaths.insert(entry.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: entry.path, + kind: .symlink, + effect: .deleted, + status: .applied, + diff: nil + ) + ) + } + } + + func utf8TextFileDiff(oldData: Data, newData: Data) -> TextDiff? { + guard let oldString = String(data: oldData, encoding: .utf8), + let newString = String(data: newData, encoding: .utf8) + else { + return nil + } + let diff = TextDiff.lineBased(from: oldString, to: newString) + return diff.hunks.isEmpty ? nil : diff + } + + func checkpointSort(lhs: Checkpoint, rhs: Checkpoint) -> Bool { + Self.orderCheckpoints(lhs, rhs) + } + + func fileChangeSort(lhs: FileEdit.FileChange, rhs: FileEdit.FileChange) -> Bool { + if lhs.path == rhs.path { + return lhs.effect.rawValue < rhs.effect.rawValue + } + return lhs.path < rhs.path + } +} diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 473cca7..4459a68 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -2,7 +2,46 @@ import Foundation /// A high-level API for reading, editing, and summarizing a workspace. public actor Workspace { - private let filesystem: any FileSystem + /// Where to persist checkpoint and snapshot data. + public enum Storage: Sendable { + /// In-memory storage that does not survive process exit. + case inMemory + /// File-backed JSON storage rooted at `url`. + /// + /// Checkpoints and snapshots are individual JSON files; mutations are recorded in + /// `mutations.jsonl` (append-friendly, one record per line). A legacy `mutations.json` array is + /// migrated automatically. The store assigns monotonic `MutationRecord.sequence` values under + /// `mutations.lock` using an advisory lock where the OS supports it, so writers sharing a + /// `workspaceId` do not reuse sequence numbers. Multiple processes should still treat the + /// directory as a single-writer domain when the underlying filesystem does not honor advisory locks. + case directory(at: URL) + } + + struct CheckpointWatcher { + var deliveredCheckpointIds: Set + var continuation: AsyncStream.Continuation + } + + public nonisolated let workspaceId: UUID + + let filesystem: any FileSystem + let store: any CheckpointStore + let baseCheckpointId: UUID? + + var loadTask: Task? + var didLoadStoreState = false + var checkpoints: [Checkpoint] = [] + var mutations: [MutationRecord] = [] + var headCheckpointId: UUID? + var nextMutationSequence = 1 + var checkpointWatchers: [UUID: CheckpointWatcher] = [:] + var checkpointPollingTask: Task? + + /// Sleep interval between polling the shared store for new checkpoints when + /// ``watchCheckpointEvents()`` has active subscribers. Tests may set a short value to reduce + /// wall-clock wait time. + public var checkpointEventPollInterval: Duration = .milliseconds(500) + private var watchers: [UUID: WatchedChangeStream] = [:] private struct WatchedChangeStream { @@ -11,9 +50,44 @@ public actor Workspace { var continuation: AsyncStream.Continuation } + /// Creates an in-memory workspace suitable for tests and ephemeral work. + public init() { + self.init(filesystem: InMemoryFilesystem()) + } + /// Creates a workspace backed by `filesystem`. - public init(filesystem: any FileSystem) { + public init( + workspaceId: UUID = UUID(), + filesystem: any FileSystem, + storage: Storage = .inMemory + ) { + self.init( + workspaceId: workspaceId, + filesystem: filesystem, + store: Self.makeStore(for: storage), + baseCheckpointId: nil + ) + } + + init( + workspaceId: UUID = UUID(), + filesystem: any FileSystem, + store: any CheckpointStore, + baseCheckpointId: UUID? = nil + ) { + self.workspaceId = workspaceId self.filesystem = filesystem + self.store = store + self.baseCheckpointId = baseCheckpointId + } + + static func makeStore(for storage: Storage) -> any CheckpointStore { + switch storage { + case .inMemory: + InMemoryCheckpointStore() + case .directory(at: let url): + FileCheckpointStore(rootDirectory: url) + } } /// Reads raw file contents from the workspace. @@ -23,8 +97,13 @@ public actor Workspace { /// Writes raw file data to the workspace, replacing any existing contents. public func writeData(_ data: Data, to path: WorkspacePath) async throws { - let events = try await plannedWriteEvents(path: path, data: data, append: false, on: filesystem) - try await filesystem.writeFile(path: path, data: data, append: false) + try await ensureLoaded() + try await performBinaryWrite(data: data, path: path) + } + + func untrackedWriteData(_ data: Data, to path: WorkspacePath, append: Bool = false) async throws { + let events = try await plannedWriteEvents(path: path, data: data, append: append, on: filesystem) + try await filesystem.writeFile(path: path, data: data, append: append) emit(events) } @@ -39,14 +118,29 @@ public actor Workspace { /// Writes UTF-8 text to a file, replacing any existing contents. public func writeFile(_ path: WorkspacePath, content: String) async throws { - let data = Data(content.utf8) - let events = try await plannedWriteEvents(path: path, data: data, append: false, on: filesystem) - try await filesystem.writeFile(path: path, data: data, append: false) - emit(events) + try await performDirectEdit( + kind: .writeFile, + edit: .writeFile(path: path, content: content) + ) { + try await self.untrackedWriteFile(path, content: content) + } + } + + func untrackedWriteFile(_ path: WorkspacePath, content: String) async throws { + try await untrackedWriteData(Data(content.utf8), to: path, append: false) } /// Appends UTF-8 text to a file. public func appendFile(_ path: WorkspacePath, content: String) async throws { + try await performDirectEdit( + kind: .appendFile, + edit: .appendFile(path: path, content: content) + ) { + try await self.untrackedAppendFile(path, content: content) + } + } + + func untrackedAppendFile(_ path: WorkspacePath, content: String) async throws { let data = Data(content.utf8) let events = try await plannedWriteEvents(path: path, data: data, append: true, on: filesystem) try await filesystem.writeFile(path: path, data: data, append: true) @@ -63,17 +157,16 @@ public actor Workspace { } } - /// Encodes a value as JSON and writes it to a file. + /// Encodes a value as JSON and writes it to a file. The on-disk file includes a single trailing + /// newline after the JSON for line-oriented tools; ``readJSON`` still decodes the value correctly. public func writeJSON(_ value: T, to path: WorkspacePath, prettyPrinted: Bool = true) async throws { - let encoder = JSONEncoder() - if prettyPrinted { - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - } - var data = try encoder.encode(value) - data.append(Data("\n".utf8)) - let events = try await plannedWriteEvents(path: path, data: data, append: false, on: filesystem) - try await filesystem.writeFile(path: path, data: data, append: false) - emit(events) + let content = try encodedJSONString(for: value, prettyPrinted: prettyPrinted) + try await performDirectEdit( + kind: .writeJSON, + edit: .writeFile(path: path, content: content) + ) { + try await self.untrackedWriteFile(path, content: content) + } } /// Watches for future changes affecting `path`. @@ -126,6 +219,15 @@ public actor Workspace { /// Creates a directory at `path`. public func createDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { + try await performDirectEdit( + kind: .createDirectory, + edit: .createDirectory(path: path, recursive: recursive) + ) { + try await self.untrackedCreateDirectory(at: path, recursive: recursive) + } + } + + func untrackedCreateDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { let events = try await plannedDirectoryCreationEvents(path: path, recursive: recursive, on: filesystem) try await filesystem.createDirectory(path: path, recursive: recursive) emit(events) @@ -133,6 +235,15 @@ public actor Workspace { /// Removes the entry at `path`. public func removeItem(at path: WorkspacePath, recursive: Bool = true) async throws { + try await performDirectEdit( + kind: .removeItem, + edit: .delete(path: path, recursive: recursive) + ) { + try await self.untrackedRemoveItem(at: path, recursive: recursive) + } + } + + func untrackedRemoveItem(at path: WorkspacePath, recursive: Bool = true) async throws { let events = try await plannedDeletionEvents(at: path, on: filesystem) try await filesystem.remove(path: path, recursive: recursive) emit(events) @@ -141,6 +252,17 @@ public actor Workspace { /// Copies an entry from `source` to `destination`. public func copyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) async throws + { + try await performDirectEdit( + kind: .copyItem, + edit: .copy(from: source, to: destination, recursive: recursive) + ) { + try await self.untrackedCopyItem(from: source, to: destination, recursive: recursive) + } + } + + func untrackedCopyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) + async throws { let events = try await plannedTransferEvents( from: source, @@ -158,6 +280,15 @@ public actor Workspace { /// Moves or renames an entry from `source` to `destination`. public func moveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { + try await performDirectEdit( + kind: .moveItem, + edit: .move(from: source, to: destination) + ) { + try await self.untrackedMoveItem(from: source, to: destination) + } + } + + func untrackedMoveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { let events = try await plannedTransferEvents( from: source, to: destination, @@ -181,6 +312,33 @@ public actor Workspace { try await buildSummary(path: path, depth: 0, maxDepth: maxDepth) } + /// Captures a durable snapshot of the subtree rooted at `path`. + /// + /// The returned ``Snapshot`` records file contents, symlinks, directory structure, + /// and POSIX permissions, and can later be replayed with ``restoreSnapshot(_:)``. + /// + /// - Parameter path: The subtree to capture. Defaults to the workspace root. + public func captureSnapshot(at path: WorkspacePath = .root) async throws -> Snapshot { + try await Snapshot.capture(from: filesystem, at: path) + } + + /// Restores `snapshot` onto the workspace, removing any extra entries beneath the + /// snapshot's root so that the workspace exactly matches the captured tree. + public func restoreSnapshot(_ snapshot: Snapshot) async throws { + try await ensureLoaded() + let previousSnapshot = try await captureSnapshot() + try await untrackedRestore(snapshot) + let restoredSnapshot = try await captureSnapshot() + let delta = snapshotDelta(from: previousSnapshot.entry, to: restoredSnapshot.entry) + let topDiff: TextDiff? = delta.fileChanges.count == 1 ? delta.fileChanges[0].diff : nil + try await appendMutation( + kind: .restoreSnapshot, + touchedPaths: Array(delta.touchedPaths).sorted(), + fileChanges: delta.fileChanges, + diff: topDiff + ) + } + private struct PlannedReplacement { var change: ReplacementResult.Change var updatedContent: String @@ -193,11 +351,6 @@ public actor Workspace { var changeEvents: [ChangeEvent] } - private struct DiffToken: Hashable { - var text: String - var hasTrailingNewline: Bool - } - /// Returns a preview of a replacement request without mutating the workspace. public func previewReplacement(_ request: ReplacementRequest) async throws -> ReplacementResult { let changes = try await plannedReplacementChanges(for: request).map(\.change) @@ -217,6 +370,31 @@ public actor Workspace { public func applyReplacement( _ request: ReplacementRequest, failurePolicy: MutationFailurePolicy = .rollback + ) async throws -> ReplacementResult { + try await ensureLoaded() + let result = try await untrackedApplyReplacement(request, failurePolicy: failurePolicy) + if !result.changes.isEmpty || !result.failures.isEmpty { + try await appendMutation( + kind: .applyReplacement, + touchedPaths: result.touchedPaths, + fileChanges: result.changes.map { + FileEdit.FileChange( + path: $0.path, + kind: .file, + effect: .modified, + status: $0.status, + diff: $0.diff + ) + }, + diff: result.changes.count == 1 ? result.changes[0].diff : nil + ) + } + return result + } + + func untrackedApplyReplacement( + _ request: ReplacementRequest, + failurePolicy: MutationFailurePolicy = .rollback ) async throws -> ReplacementResult { let plannedChanges = try await plannedReplacementChanges(for: request) var changes = plannedChanges.map(\.change) @@ -232,7 +410,7 @@ public actor Workspace { ) } - let snapshots = failurePolicy == .rollback ? try await snapshotPaths(touchedPaths) : [] + let captures = failurePolicy == .rollback ? try await rollbackCaptures(for: touchedPaths) : [] var failures: [ReplacementResult.Failure] = [] var appliedIndices: [Int] = [] @@ -246,7 +424,7 @@ public actor Workspace { changes[index].status = .failed let failure = ReplacementResult.Failure(path: plannedChange.change.path, message: describe(error)) if failurePolicy == .rollback { - try await rollback(snapshots) + try await rollback(captures) for appliedIndex in appliedIndices { changes[appliedIndex].status = .rolledBack } @@ -308,6 +486,29 @@ public actor Workspace { public func applyEdits( _ edits: [FileEdit], failurePolicy: MutationFailurePolicy = .rollback + ) async throws -> FileEdit.BatchResult { + try await ensureLoaded() + let result = try await untrackedApplyEdits(edits, failurePolicy: failurePolicy) + if !edits.isEmpty { + let topDiff: TextDiff? = + if result.edits.count == 1, let single = result.edits.first, single.fileChanges.count == 1 { + single.fileChanges[0].diff + } else { + nil + } + try await appendMutation( + kind: .applyEdits, + touchedPaths: result.touchedPaths, + fileChanges: result.edits.flatMap(\.fileChanges), + diff: topDiff + ) + } + return result + } + + func untrackedApplyEdits( + _ edits: [FileEdit], + failurePolicy: MutationFailurePolicy = .rollback ) async throws -> FileEdit.BatchResult { let touchedPaths = canonicalizedTouchedPaths(for: edits) let plannedEdits = try await planBatchEdits(edits) @@ -323,7 +524,7 @@ public actor Workspace { ) } - let snapshots = failurePolicy == .rollback ? try await snapshotPaths(touchedPaths) : [] + let captures = failurePolicy == .rollback ? try await rollbackCaptures(for: touchedPaths) : [] var failures: [FileEdit.Failure] = [] var appliedIndices: [Int] = [] @@ -341,7 +542,7 @@ public actor Workspace { message: describe(error) ) if failurePolicy == .rollback { - try await rollback(snapshots) + try await rollback(captures) for appliedIndex in appliedIndices { setStatus(.rolledBack, for: &executionEntries[appliedIndex]) } @@ -678,13 +879,13 @@ public actor Workspace { let planningFilesystem = InMemoryFilesystem() let ancestors = Set(paths.flatMap(ancestorPaths(for:))).sorted() for ancestor in ancestors { - let snapshot = try await shallowSnapshot(path: ancestor, on: filesystem) - try await restore(snapshot, on: planningFilesystem) + let capture = try await shallowRollbackCapture(path: ancestor, on: filesystem) + try await restore(capture, on: planningFilesystem) } - let snapshots = try await snapshotPaths(paths) - for snapshot in snapshots { - try await restore(snapshot, on: planningFilesystem) + let captures = try await rollbackCaptures(for: paths) + for capture in captures { + try await restore(capture, on: planningFilesystem) } return planningFilesystem } @@ -982,8 +1183,8 @@ public actor Workspace { at path: WorkspacePath, on target: any FileSystem ) async throws -> [ChangeEvent] { - let snapshot = try await snapshot(path: path, on: target) - return flattenDeletionEvents(from: snapshot) + let capture = try await rollbackCapture(path: path, on: target) + return flattenDeletionEvents(from: capture) } private func plannedTransferEvents( @@ -992,9 +1193,9 @@ public actor Workspace { kind: ChangeEvent.Kind, on target: any FileSystem ) async throws -> [ChangeEvent] { - let snapshot = try await snapshot(path: sourcePath, on: target) + let capture = try await rollbackCapture(path: sourcePath, on: target) return flattenTransferEvents( - from: snapshot, + from: capture, sourceRoot: sourcePath, destinationRoot: destinationPath, kind: kind @@ -1015,8 +1216,8 @@ public actor Workspace { ] } - private func flattenDeletionEvents(from snapshot: SnapshotEntry) -> [ChangeEvent] { - switch snapshot { + private func flattenDeletionEvents(from capture: RollbackCapture) -> [ChangeEvent] { + switch capture { case .missing: return [] case let .file(path, _, _): @@ -1040,12 +1241,12 @@ public actor Workspace { } private func flattenTransferEvents( - from snapshot: SnapshotEntry, + from capture: RollbackCapture, sourceRoot: WorkspacePath, destinationRoot: WorkspacePath, kind: ChangeEvent.Kind ) -> [ChangeEvent] { - switch snapshot { + switch capture { case .missing: return [] case let .file(path, _, _): @@ -1135,136 +1336,7 @@ public actor Workspace { } private func textDiff(from originalContent: String, to updatedContent: String) -> TextDiff { - let originalLines = diffTokens(in: originalContent) - let updatedLines = diffTokens(in: updatedContent) - let changes = Array(updatedLines.difference(from: originalLines)) - - let removals = Dictionary(grouping: changes.compactMap { change in - if case let .remove(offset, element, _) = change { - return (offset, element) - } - return nil - }, by: \.0) - - let insertions = Dictionary(grouping: changes.compactMap { change in - if case let .insert(offset, element, _) = change { - return (offset, element) - } - return nil - }, by: \.0) - - var lines: [TextDiff.Line] = [] - var originalIndex = 0 - var updatedIndex = 0 - var originalLineNumber = 1 - var updatedLineNumber = 1 - - while originalIndex < originalLines.count || updatedIndex < updatedLines.count { - if let removed = removals[originalIndex] { - for (_, token) in removed { - lines.append( - TextDiff.Line( - kind: .removed, - text: token.text, - hasTrailingNewline: token.hasTrailingNewline, - oldLineNumber: originalLineNumber - ) - ) - originalIndex += 1 - originalLineNumber += 1 - } - continue - } - - if let inserted = insertions[updatedIndex] { - for (_, token) in inserted { - lines.append( - TextDiff.Line( - kind: .added, - text: token.text, - hasTrailingNewline: token.hasTrailingNewline, - newLineNumber: updatedLineNumber - ) - ) - updatedIndex += 1 - updatedLineNumber += 1 - } - continue - } - - guard originalIndex < originalLines.count, updatedIndex < updatedLines.count else { - break - } - - let updatedLine = updatedLines[updatedIndex] - lines.append( - TextDiff.Line( - kind: .context, - text: updatedLine.text, - hasTrailingNewline: updatedLine.hasTrailingNewline, - oldLineNumber: originalLineNumber, - newLineNumber: updatedLineNumber - ) - ) - originalIndex += 1 - updatedIndex += 1 - originalLineNumber += 1 - updatedLineNumber += 1 - } - - let changedIndices = lines.indices.filter { lines[$0].kind != .context } - guard !changedIndices.isEmpty else { - return TextDiff(hunks: []) - } - - var ranges: [Range] = [] - for index in changedIndices { - let lowerBound = max(0, index - 3) - let upperBound = min(lines.count, index + 4) - if let last = ranges.last, lowerBound <= last.upperBound { - ranges[ranges.count - 1] = last.lowerBound.. [DiffToken] { - guard !text.isEmpty else { - return [] - } - - var tokens: [DiffToken] = [] - var current = "" - for character in text { - if character == "\n" { - tokens.append(DiffToken(text: current, hasTrailingNewline: true)) - current.removeAll(keepingCapacity: true) - } else { - current.append(character) - } - } - - if !current.isEmpty || text.last != "\n" { - tokens.append(DiffToken(text: current, hasTrailingNewline: false)) - } - - return tokens + TextDiff.lineBased(from: originalContent, to: updatedContent) } private func describe(_ error: any Error) -> String { @@ -1274,27 +1346,28 @@ public actor Workspace { return String(describing: error) } - private enum SnapshotEntry: Sendable { + /// A short-lived capture used only to restore failed batched mutations. + private enum RollbackCapture: Sendable { case missing(path: WorkspacePath) case file(path: WorkspacePath, data: Data, permissions: POSIXPermissions) - case directory(path: WorkspacePath, permissions: POSIXPermissions, children: [SnapshotEntry]) + case directory(path: WorkspacePath, permissions: POSIXPermissions, children: [RollbackCapture]) case symlink(path: WorkspacePath, target: String, permissions: POSIXPermissions) } - private func snapshotPaths(_ paths: [WorkspacePath]) async throws -> [SnapshotEntry] { - try await snapshotPaths(paths, on: filesystem) + private func rollbackCaptures(for paths: [WorkspacePath]) async throws -> [RollbackCapture] { + try await rollbackCaptures(paths, on: filesystem) } - private func snapshotPaths( + private func rollbackCaptures( _ paths: [WorkspacePath], on target: any FileSystem - ) async throws -> [SnapshotEntry] { + ) async throws -> [RollbackCapture] { try await paths.asyncMap { path in - try await self.snapshot(path: path, on: target) + try await self.rollbackCapture(path: path, on: target) } } - private func shallowSnapshot(path: WorkspacePath, on target: any FileSystem) async throws -> SnapshotEntry { + private func shallowRollbackCapture(path: WorkspacePath, on target: any FileSystem) async throws -> RollbackCapture { guard await target.exists(path: path) else { return .missing(path: path) } @@ -1311,7 +1384,7 @@ public actor Workspace { } } - private func snapshot(path: WorkspacePath, on target: any FileSystem) async throws -> SnapshotEntry { + private func rollbackCapture(path: WorkspacePath, on target: any FileSystem) async throws -> RollbackCapture { guard await target.exists(path: path) else { return .missing(path: path) } @@ -1322,7 +1395,7 @@ public actor Workspace { let children = try await entries .sorted { $0.name < $1.name } .asyncMap { [self] entry in - try await self.snapshot(path: path.appending(entry.name), on: target) + try await self.rollbackCapture(path: path.appending(entry.name), on: target) } return .directory(path: path, permissions: info.permissions, children: children) } @@ -1339,14 +1412,14 @@ public actor Workspace { ) } - private func rollback(_ snapshots: [SnapshotEntry]) async throws { - for snapshot in snapshots.sorted(by: { path(of: $0).string.count < path(of: $1).string.count }) { - try await restore(snapshot, on: filesystem) + private func rollback(_ captures: [RollbackCapture]) async throws { + for capture in captures.sorted(by: { path(of: $0).string.count < path(of: $1).string.count }) { + try await restore(capture, on: filesystem) } } - private func restore(_ snapshot: SnapshotEntry, on target: any FileSystem) async throws { - switch snapshot { + private func restore(_ capture: RollbackCapture, on target: any FileSystem) async throws { + switch capture { case let .missing(path): if await target.exists(path: path) { try await target.remove(path: path, recursive: true) @@ -1370,6 +1443,20 @@ public actor Workspace { try await target.createSymlink(path: path, target: symlinkTarget) try await target.setPermissions(path: path, permissions: permissions) case let .directory(path, permissions, children): + if path.isRoot { + if await target.exists(path: path) { + let entries = try await target.listDirectory(path: path) + for entry in entries { + try await target.remove(path: path.appending(entry.name), recursive: true) + } + } + try await target.setPermissions(path: path, permissions: permissions) + for child in children { + try await restore(child, on: target) + } + return + } + if await target.exists(path: path) { try await target.remove(path: path, recursive: true) } @@ -1381,8 +1468,8 @@ public actor Workspace { } } - private func path(of snapshot: SnapshotEntry) -> WorkspacePath { - switch snapshot { + private func path(of capture: RollbackCapture) -> WorkspacePath { + switch capture { case let .missing(path), let .file(path, _, _), let .directory(path, _, _), diff --git a/Tests/WorkspaceTests/CheckpointStoreTests.swift b/Tests/WorkspaceTests/CheckpointStoreTests.swift new file mode 100644 index 0000000..00b03bd --- /dev/null +++ b/Tests/WorkspaceTests/CheckpointStoreTests.swift @@ -0,0 +1,412 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("CheckpointStore") +struct CheckpointStoreTests { + @Test + func `InMemoryCheckpointStore persists checkpoints snapshots and mutations per workspace`() async throws { + let store = InMemoryCheckpointStore() + let workspaceA = UUID() + let workspaceB = UUID() + let snapshotId = UUID() + let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + + let snapshot = Snapshot( + id: snapshotId, + rootPath: .root, + entry: .directory( + Snapshot.Directory(path: .root, permissions: .defaultDirectory, children: []) + ) + ) + + let cpEarly = Checkpoint( + id: UUID(), + workspaceId: workspaceA, + label: "a", + createdAt: Date(timeIntervalSince1970: 100), + parentCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + + let cpLate = Checkpoint( + id: UUID(), + workspaceId: workspaceA, + label: "b", + createdAt: Date(timeIntervalSince1970: 200), + parentCheckpointId: cpEarly.id, + firstMutationSequence: 1, + lastMutationSequence: 1, + mutationCursor: 1, + snapshotId: snapshotId, + summary: summary + ) + + try await store.saveSnapshot(snapshot, workspaceId: workspaceA) + try await store.saveCheckpoint(cpLate) + try await store.saveCheckpoint(cpEarly) + + let listed = try await store.listCheckpoints(workspaceId: workspaceA) + #expect(listed.map(\.id) == [cpEarly.id, cpLate.id]) + + let loadedEarly = try await store.loadCheckpoint(id: cpEarly.id, workspaceId: workspaceA) + #expect(loadedEarly == cpEarly) + + let loadedMissing = try await store.loadCheckpoint(id: UUID(), workspaceId: workspaceA) + #expect(loadedMissing == nil) + + #expect(try await store.listCheckpoints(workspaceId: workspaceB).isEmpty) + + let m3 = MutationRecord( + sequence: 3, + workspaceId: workspaceA, + kind: .writeFile, + touchedPaths: ["/x"], + fileChanges: [] + ) + let m1 = MutationRecord( + sequence: 1, + workspaceId: workspaceA, + kind: .writeFile, + touchedPaths: ["/y"], + fileChanges: [] + ) + try await store.appendMutation(m3) + try await store.appendMutation(m1) + + let mutations = try await store.listMutationRecords(workspaceId: workspaceA) + #expect(mutations.map(\.sequence) == [1, 2]) + + let reloadedSnapshot = try await store.loadSnapshot(id: snapshotId, workspaceId: workspaceA) + #expect(reloadedSnapshot == snapshot) + #expect(try await store.loadSnapshot(id: UUID(), workspaceId: workspaceA) == nil) + } + + @Test + func `FileCheckpointStore roundtrips data on disk across actor instances`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let snapshotId = UUID() + let summary = Checkpoint.Summary(changeCount: 1, touchedPaths: ["/f"], hasTextDiffs: true) + + let snapshot = Snapshot( + id: snapshotId, + rootPath: .root, + entry: .file( + Snapshot.File(path: "/f", data: Data("hi".utf8), permissions: .defaultFile) + ) + ) + + let checkpoint = Checkpoint( + workspaceId: workspaceId, + label: "disk", + parentCheckpointId: nil, + firstMutationSequence: 1, + lastMutationSequence: 2, + mutationCursor: 2, + snapshotId: snapshotId, + summary: summary + ) + + let mutation = MutationRecord( + sequence: 1, + workspaceId: workspaceId, + kind: .writeFile, + touchedPaths: ["/f"], + fileChanges: [] + ) + + let writer = FileCheckpointStore(rootDirectory: root) + try await writer.saveSnapshot(snapshot, workspaceId: workspaceId) + try await writer.saveCheckpoint(checkpoint) + try await writer.appendMutation(mutation) + + let reader = FileCheckpointStore(rootDirectory: root) + let checkpoints = try await reader.listCheckpoints(workspaceId: workspaceId) + #expect(checkpoints == [checkpoint]) + + let loaded = try await reader.loadCheckpoint(id: checkpoint.id, workspaceId: workspaceId) + #expect(loaded == checkpoint) + + let loadedSnap = try await reader.loadSnapshot(id: snapshotId, workspaceId: workspaceId) + #expect(loadedSnap == snapshot) + + let mutations = try await reader.listMutationRecords(workspaceId: workspaceId) + #expect(mutations == [mutation]) + + #expect(try await reader.listCheckpoints(workspaceId: UUID()).isEmpty) + #expect(try await reader.listMutationRecords(workspaceId: UUID()).isEmpty) + } + + @Test + func `InMemoryCheckpointStore saveSnapshot replaces an existing snapshot id`() async throws { + let store = InMemoryCheckpointStore() + let workspaceId = UUID() + let snapshotId = UUID() + + let first = Snapshot( + id: snapshotId, + rootPath: .root, + entry: .file(Snapshot.File(path: "/a", data: Data("1".utf8), permissions: .defaultFile)) + ) + let second = Snapshot( + id: snapshotId, + rootPath: .root, + entry: .file(Snapshot.File(path: "/a", data: Data("2".utf8), permissions: .defaultFile)) + ) + + try await store.saveSnapshot(first, workspaceId: workspaceId) + try await store.saveSnapshot(second, workspaceId: workspaceId) + + let loaded = try await store.loadSnapshot(id: snapshotId, workspaceId: workspaceId) + #expect(loaded == second) + } + + @Test + func `InMemoryCheckpointStore saveCheckpoint overwrites an existing checkpoint id`() async throws { + let store = InMemoryCheckpointStore() + let workspaceId = UUID() + let sharedId = UUID() + let snapshotId = UUID() + let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + + let first = Checkpoint( + id: sharedId, + workspaceId: workspaceId, + label: "first", + createdAt: Date(timeIntervalSince1970: 50), + parentCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + var second = first + second.label = "second" + second.createdAt = Date(timeIntervalSince1970: 150) + + try await store.saveCheckpoint(first) + try await store.saveCheckpoint(second) + + let loaded = try await store.loadCheckpoint(id: sharedId, workspaceId: workspaceId) + #expect(loaded == second) + + let listed = try await store.listCheckpoints(workspaceId: workspaceId) + #expect(listed.count == 1) + #expect(listed[0].label == "second") + } + + @Test + func `FileCheckpointStore appendMutation merges and sorts existing log on disk`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let storeA = FileCheckpointStore(rootDirectory: root) + + let m2 = MutationRecord( + sequence: 2, + workspaceId: workspaceId, + kind: .writeFile, + touchedPaths: ["/b"], + fileChanges: [] + ) + let m1 = MutationRecord( + sequence: 1, + workspaceId: workspaceId, + kind: .writeFile, + touchedPaths: ["/a"], + fileChanges: [] + ) + + try await storeA.appendMutation(m2) + try await storeA.appendMutation(m1) + + let storeB = FileCheckpointStore(rootDirectory: root) + let merged = try await storeB.listMutationRecords(workspaceId: workspaceId) + #expect(merged.map(\.sequence) == [1, 2]) + #expect(merged.map(\.touchedPaths) == [["/b"], ["/a"]]) + + let m3 = MutationRecord( + sequence: 3, + workspaceId: workspaceId, + kind: .appendFile, + touchedPaths: ["/c"], + fileChanges: [] + ) + try await storeB.appendMutation(m3) + + let storeC = FileCheckpointStore(rootDirectory: root) + #expect(try await storeC.listMutationRecords(workspaceId: workspaceId).map(\.sequence) == [1, 2, 3]) + } + + @Test + func `FileCheckpointStore saveCheckpoint overwrites the same id file`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let checkpointId = UUID() + let snapshotId = UUID() + let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + + let first = Checkpoint( + id: checkpointId, + workspaceId: workspaceId, + label: "v1", + parentCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + var second = first + second.label = "v2" + + let writer = FileCheckpointStore(rootDirectory: root) + try await writer.saveCheckpoint(first) + try await writer.saveCheckpoint(second) + + let reader = FileCheckpointStore(rootDirectory: root) + let loaded = try await reader.loadCheckpoint(id: checkpointId, workspaceId: workspaceId) + #expect(loaded?.label == "v2") + + let listed = try await reader.listCheckpoints(workspaceId: workspaceId) + #expect(listed.count == 1) + #expect(listed[0].label == "v2") + } + + @Test + func `FileCheckpointStore listMutationRecords returns empty array when no log file exists`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let store = FileCheckpointStore(rootDirectory: root) + + let mutations = try await store.listMutationRecords(workspaceId: workspaceId) + #expect(mutations.isEmpty) + + let workspaceRoot = root.appendingPathComponent(workspaceId.uuidString, isDirectory: true) + let mutationsFile = workspaceRoot.appendingPathComponent("mutations.jsonl") + #expect(!FileManager.default.fileExists(atPath: mutationsFile.path)) + } + + @Test + func `FileCheckpointStore migrates legacy mutations json array to jsonl`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let workspaceRoot = root.appendingPathComponent(workspaceId.uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: workspaceRoot, withIntermediateDirectories: true) + let legacyURL = workspaceRoot.appendingPathComponent("mutations.json", isDirectory: false) + let jsonlURL = workspaceRoot.appendingPathComponent("mutations.jsonl", isDirectory: false) + + let stored = MutationRecord( + sequence: 99, + workspaceId: workspaceId, + kind: .writeFile, + touchedPaths: ["/legacy.txt"], + fileChanges: [] + ) + let data = try JSONEncoder().encode([stored]) + try data.write(to: legacyURL) + + let store = FileCheckpointStore(rootDirectory: root) + let loaded = try await store.listMutationRecords(workspaceId: workspaceId) + #expect(loaded.count == 1) + #expect(loaded[0].touchedPaths == ["/legacy.txt"]) + #expect(loaded[0].sequence == 99) + + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + #expect(FileManager.default.fileExists(atPath: jsonlURL.path)) + } + + @Test + func `FileCheckpointStore listMutationRecords treats an empty mutations file as no records`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let workspaceRoot = root.appendingPathComponent(workspaceId.uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: workspaceRoot, withIntermediateDirectories: true) + let mutationsFile = workspaceRoot.appendingPathComponent("mutations.jsonl") + try Data().write(to: mutationsFile) + + let store = FileCheckpointStore(rootDirectory: root) + + let mutations = try await store.listMutationRecords(workspaceId: workspaceId) + #expect(mutations.isEmpty) + + let appended = MutationRecord( + sequence: 0, + workspaceId: workspaceId, + kind: .writeFile, + touchedPaths: ["/x"], + fileChanges: [] + ) + let written = try await store.appendMutation(appended) + + let after = try await store.listMutationRecords(workspaceId: workspaceId) + #expect(after == [written]) + #expect(written.sequence == 1) + } + + @Test + func `FileCheckpointStore appendMutation serializes concurrent writers without losing records`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let writerCount = 8 + let perWriter = 25 + let stores = (0.. URL { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let url = base.appendingPathComponent("CheckpointStoreTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func removeTempDirectory(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } +} diff --git a/Tests/WorkspaceTests/CheckpointTests.swift b/Tests/WorkspaceTests/CheckpointTests.swift new file mode 100644 index 0000000..3e45d4a --- /dev/null +++ b/Tests/WorkspaceTests/CheckpointTests.swift @@ -0,0 +1,126 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("Checkpoint") +struct CheckpointTests { + @Test + func `Checkpoint inferredEventKind classifies created rollback and merge`() async throws { + let workspaceId = UUID() + let snapshotId = UUID() + let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + + let created = Checkpoint( + workspaceId: workspaceId, + label: nil, + parentCheckpointId: nil, + baseCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + #expect(created.inferredEventKind == .created) + + let rolledBack = Checkpoint( + workspaceId: workspaceId, + label: nil, + parentCheckpointId: nil, + baseCheckpointId: nil, + rollbackSourceCheckpointId: UUID(), + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + #expect(rolledBack.inferredEventKind == .rolledBack) + + let merged = Checkpoint( + workspaceId: workspaceId, + label: nil, + parentCheckpointId: nil, + baseCheckpointId: nil, + mergedFromWorkspaceId: UUID(), + mergedFromCheckpointId: UUID(), + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + #expect(merged.inferredEventKind == .merged) + } + + @Test + func `CheckpointEvent and MutationRecord roundtrip through Codable`() async throws { + let workspaceId = UUID() + let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + let checkpoint = Checkpoint( + workspaceId: workspaceId, + label: "x", + parentCheckpointId: nil, + baseCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: UUID(), + summary: summary + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let event = CheckpointEvent(kind: .created, checkpoint: checkpoint) + #expect(try decoder.decode(CheckpointEvent.self, from: encoder.encode(event)) == event) + + let kinds: [MutationRecord.Kind] = [ + .writeFile, + .appendFile, + .writeData, + .writeJSON, + .createDirectory, + .removeItem, + .copyItem, + .moveItem, + .applyEdits, + .applyReplacement, + .restoreSnapshot, + .mergeWorkspace, + ] + + for kind in kinds { + let mutation = MutationRecord( + sequence: 1, + workspaceId: workspaceId, + kind: kind, + touchedPaths: ["/a.txt"], + fileChanges: [] + ) + let decoded = try decoder.decode(MutationRecord.self, from: encoder.encode(mutation)) + #expect(decoded == mutation) + } + } + + @Test + func `WorkspaceError descriptions are stable and include identifiers`() async throws { + let checkpointId = UUID() + let snapshotId = UUID() + let workspaceId = UUID() + let headId = UUID() + + let cases: [(WorkspaceError, String)] = [ + (.checkpointNotFound(checkpointId), checkpointId.uuidString), + (.snapshotNotFound(snapshotId), snapshotId.uuidString), + (.mergeConflict(parentWorkspaceId: workspaceId, expectedBase: nil, actualHead: headId), workspaceId.uuidString), + (.mergeConflict(parentWorkspaceId: workspaceId, expectedBase: nil, actualHead: headId), headId.uuidString), + (.mutationFailed("boom"), "boom"), + ] + + for (error, needle) in cases { + let description = String(describing: error) + #expect(description.contains(needle), "missing '\(needle)' in: \(description)") + } + } +} diff --git a/Tests/WorkspaceTests/WorkspaceTests.swift b/Tests/WorkspaceTests/CoreTests.swift similarity index 98% rename from Tests/WorkspaceTests/WorkspaceTests.swift rename to Tests/WorkspaceTests/CoreTests.swift index 594668d..706e928 100644 --- a/Tests/WorkspaceTests/WorkspaceTests.swift +++ b/Tests/WorkspaceTests/CoreTests.swift @@ -2,8 +2,8 @@ import Foundation import Testing @testable import Workspace -private enum WorkspaceTestSupport { - static func makeTempDirectory(prefix: String = "WorkspaceTests") throws -> URL { +private enum CoreTestSupport { + static func makeTempDirectory(prefix: String = "CoreTests") throws -> URL { let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) @@ -123,7 +123,7 @@ private final class NSErrorFailOnceFilesystem: FileSystem, @unchecked Sendable { func writeFile(path: WorkspacePath, data: Data, append: Bool) async throws { if failingWritePaths.contains(path), !failedWritePaths.contains(path) { failedWritePaths.insert(path) - throw NSError(domain: "WorkspaceTests", code: 42, userInfo: [NSLocalizedDescriptionKey: "forced NSError write failure"]) + throw NSError(domain: "CoreTests", code: 42, userInfo: [NSLocalizedDescriptionKey: "forced NSError write failure"]) } try await base.writeFile(path: path, data: data, append: append) } @@ -312,8 +312,8 @@ private func assertBasicWatchSemantics( ) } -@Suite("Workspace") -struct WorkspaceTests { +@Suite("Core") +struct CoreTests { @Test func `workspace module reexports core filesystem primitives`() async throws { let workspaceFilesystem: any FileSystem = InMemoryFilesystem() @@ -615,8 +615,8 @@ struct WorkspaceTests { watchedPath: "/note.txt" ) - let overlayRoot = try WorkspaceTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayWatch") - defer { WorkspaceTestSupport.removeDirectory(overlayRoot) } + let overlayRoot = try CoreTestSupport.makeTempDirectory(prefix: "OverlayWatch") + defer { CoreTestSupport.removeDirectory(overlayRoot) } let overlayWorkspace = Workspace(filesystem: try await OverlayFilesystem(rootDirectory: overlayRoot)) try await assertBasicWatchSemantics( workspace: overlayWorkspace, @@ -912,7 +912,7 @@ struct WorkspaceTests { #expect(!result.rolledBack) #expect(result.failures.count == 1) #expect(result.failures.first?.path == "/src/b.txt") - #expect(result.failures.first?.message.contains("WorkspaceTests") == true) + #expect(result.failures.first?.message.contains("CoreTests") == true) #expect(result.changes.map(\.status) == [.applied, .failed, .skipped]) #expect(try await base.readFile(path: "/src/a.txt") == Data("bar".utf8)) #expect(try await base.readFile(path: "/src/b.txt") == Data("foo".utf8)) @@ -1098,7 +1098,7 @@ struct WorkspaceTests { #expect(!result.rolledBack) #expect(result.failures.count == 1) #expect(result.failures.first?.index == 1) - #expect(result.failures.first?.message.contains("WorkspaceTests") == true) + #expect(result.failures.first?.message.contains("CoreTests") == true) #expect(result.edits.map(\.status) == [.applied, .failed, .skipped]) #expect(!(await base.exists(path: "/old.txt"))) #expect(!(await base.exists(path: "/blocked.txt"))) @@ -1196,11 +1196,11 @@ struct WorkspaceTests { @Test(.tags(.edits)) func `applyEdits works with overlay and mountable filesystems`() async throws { - let workspaceRoot = try WorkspaceTestSupport.makeTempDirectory(prefix: "WorkspaceMountRoot") - defer { WorkspaceTestSupport.removeDirectory(workspaceRoot) } + let workspaceRoot = try CoreTestSupport.makeTempDirectory(prefix: "MountRoot") + defer { CoreTestSupport.removeDirectory(workspaceRoot) } - let docsRoot = try WorkspaceTestSupport.makeTempDirectory(prefix: "WorkspaceDocsRoot") - defer { WorkspaceTestSupport.removeDirectory(docsRoot) } + let docsRoot = try CoreTestSupport.makeTempDirectory(prefix: "DocsRoot") + defer { CoreTestSupport.removeDirectory(docsRoot) } try Data("guide".utf8).write(to: docsRoot.appendingPathComponent("guide.txt")) let mountable = MountableFilesystem( diff --git a/Tests/WorkspaceTests/WorkspaceFilesystemTests.swift b/Tests/WorkspaceTests/FilesystemTests.swift similarity index 82% rename from Tests/WorkspaceTests/WorkspaceFilesystemTests.swift rename to Tests/WorkspaceTests/FilesystemTests.swift index fe950d6..2fd2c1d 100644 --- a/Tests/WorkspaceTests/WorkspaceFilesystemTests.swift +++ b/Tests/WorkspaceTests/FilesystemTests.swift @@ -14,8 +14,8 @@ private actor PermissionRecorder { } } -private enum WorkspaceFilesystemTestSupport { - static func makeTempDirectory(prefix: String = "WorkspaceFilesystemTests") throws -> URL { +private enum FilesystemTestSupport { + static func makeTempDirectory(prefix: String = "FilesystemTests") throws -> URL { let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) @@ -30,7 +30,7 @@ private enum WorkspaceFilesystemTestSupport { Data(value.utf8) } - static func uniqueSuiteName(prefix: String = "WorkspaceFilesystemTests") -> String { + static func uniqueSuiteName(prefix: String = "FilesystemTests") -> String { "\(prefix).\(UUID().uuidString)" } } @@ -55,8 +55,8 @@ extension Tag { @Tag static var tree: Self } -@Suite("Workspace Filesystem") -struct WorkspaceFilesystemTests { +@Suite("Filesystem") +struct FilesystemTests { @Test(.tags(.permissions)) func `permissioned filesystem normalizes paths and blocks denied writes`() async throws { let base = InMemoryFilesystem() @@ -134,11 +134,11 @@ struct WorkspaceFilesystemTests { @Test(.tags(.readWrite)) func `read-write filesystem rejects symlink escapes outside root`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceFilesystemRoot") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "FilesystemRoot") + defer { FilesystemTestSupport.removeDirectory(root) } - let outside = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceFilesystemOutside") - defer { WorkspaceFilesystemTestSupport.removeDirectory(outside) } + let outside = try FilesystemTestSupport.makeTempDirectory(prefix: "FilesystemOutside") + defer { FilesystemTestSupport.removeDirectory(outside) } let outsideFile = outside.appendingPathComponent("outside.txt") try Data("secret".utf8).write(to: outsideFile) @@ -169,8 +169,8 @@ struct WorkspaceFilesystemTests { @Test(.tags(.overlay)) func `overlay reload restores source snapshot`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayRoot") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "OverlayRoot") + defer { FilesystemTestSupport.removeDirectory(root) } let fileURL = root.appendingPathComponent("note.txt") try Data("disk".utf8).write(to: fileURL) @@ -317,18 +317,79 @@ struct WorkspaceFilesystemTests { } } + @Test(.tags(.inMemory)) + func `in-memory filesystem detects symlink cycles when resolving paths`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.createSymlink(path: "/loop", target: "/loop") + + do { + _ = try await filesystem.readFile(path: "/loop") + Issue.record("expected ELOOP") + } catch let error as NSError { + #expect(error.domain == NSPOSIXErrorDomain) + #expect(error.code == Int(ELOOP)) + } + } + + @Test(.tags(.inMemory)) + func `in-memory filesystem move no-op when source and destination are identical`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.writeFile(path: "/a.txt", data: Data("x".utf8), append: false) + try await filesystem.move(from: "/a.txt", to: "/a.txt") + #expect(try await filesystem.readFile(path: "/a.txt") == Data("x".utf8)) + } + + @Test(.tags(.inMemory)) + func `in-memory filesystem remove is silent when the path is missing`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.remove(path: "/missing.txt", recursive: false) + #expect(!(await filesystem.exists(path: "/missing.txt"))) + } + + @Test(.tags(.inMemory)) + func `in-memory filesystem glob returns matching paths sorted`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.createDirectory(path: "/docs", recursive: true) + try await filesystem.writeFile(path: "/docs/a.txt", data: Data(), append: false) + try await filesystem.writeFile(path: "/docs/b.txt", data: Data(), append: false) + try await filesystem.writeFile(path: "/docs/readme.md", data: Data(), append: false) + + let matches = try await filesystem.glob(pattern: "/docs/*.txt", currentDirectory: "/") + #expect(matches == ["/docs/a.txt", "/docs/b.txt"]) + } + + @Test(.tags(.inMemory)) + func `in-memory filesystem setPermissions updates stat results`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.writeFile(path: "/x.txt", data: Data("y".utf8), append: false) + + try await filesystem.setPermissions(path: "/x.txt", permissions: POSIXPermissions(0o600)) + let info = try await filesystem.stat(path: "/x.txt") + #expect(info.permissions == POSIXPermissions(0o600)) + } + + @Test(.tags(.inMemory)) + func `in-memory filesystem stat reports symlink kind for the link path`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.writeFile(path: "/target.txt", data: Data("z".utf8), append: false) + try await filesystem.createSymlink(path: "/link.txt", target: "target.txt") + + let info = try await filesystem.stat(path: "/link.txt") + #expect(info.kind == .symlink) + } + @Test(.tags(.readWrite)) func `read-write filesystem supports file metadata links globbing and recursive copies`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceReadWriteRoot") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "ReadWriteRoot") + defer { FilesystemTestSupport.removeDirectory(root) } let filesystem = try ReadWriteFilesystem(rootDirectory: root) try await filesystem.createDirectory(path: "/docs", recursive: false) - try await filesystem.writeFile(path: "/docs/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) - try await filesystem.writeFile(path: "/docs/note.txt", data: WorkspaceFilesystemTestSupport.data(" world"), append: true) + try await filesystem.writeFile(path: "/docs/note.txt", data: FilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/docs/note.txt", data: FilesystemTestSupport.data(" world"), append: true) - #expect(try await filesystem.readFile(path: "/docs/note.txt") == WorkspaceFilesystemTestSupport.data("hello world")) + #expect(try await filesystem.readFile(path: "/docs/note.txt") == FilesystemTestSupport.data("hello world")) let fileInfo = try await filesystem.stat(path: "/docs/note.txt") #expect(fileInfo.size == 11) @@ -340,9 +401,9 @@ struct WorkspaceFilesystemTests { #expect(directoryEntries.map(\.name) == ["note.txt"]) try await filesystem.copy(from: "/docs/note.txt", to: "/docs/replaced.txt", recursive: false) - try await filesystem.writeFile(path: "/docs/replaced.txt", data: WorkspaceFilesystemTestSupport.data("stale"), append: false) + try await filesystem.writeFile(path: "/docs/replaced.txt", data: FilesystemTestSupport.data("stale"), append: false) try await filesystem.copy(from: "/docs/note.txt", to: "/docs/replaced.txt", recursive: false) - #expect(try await filesystem.readFile(path: "/docs/replaced.txt") == WorkspaceFilesystemTestSupport.data("hello world")) + #expect(try await filesystem.readFile(path: "/docs/replaced.txt") == FilesystemTestSupport.data("hello world")) try await filesystem.move(from: "/docs/replaced.txt", to: "/docs/moved.txt") #expect(!(await filesystem.exists(path: "/docs/replaced.txt"))) @@ -354,16 +415,16 @@ struct WorkspaceFilesystemTests { #expect(try await filesystem.stat(path: "/docs/link.txt").kind == .symlink) try await filesystem.createHardLink(path: "/docs/hard.txt", target: "/docs/note.txt") - #expect(try await filesystem.readFile(path: "/docs/hard.txt") == WorkspaceFilesystemTestSupport.data("hello world")) + #expect(try await filesystem.readFile(path: "/docs/hard.txt") == FilesystemTestSupport.data("hello world")) try await filesystem.setPermissions(path: "/docs/note.txt", permissions: POSIXPermissions(0o600)) let updatedInfo = try await filesystem.stat(path: "/docs/note.txt") #expect(updatedInfo.permissions == POSIXPermissions(0o600)) try await filesystem.createDirectory(path: "/tree/sub", recursive: true) - try await filesystem.writeFile(path: "/tree/sub/deep.txt", data: WorkspaceFilesystemTestSupport.data("nested"), append: false) + try await filesystem.writeFile(path: "/tree/sub/deep.txt", data: FilesystemTestSupport.data("nested"), append: false) try await filesystem.copy(from: "/tree", to: "/tree-copy", recursive: true) - #expect(try await filesystem.readFile(path: "/tree-copy/sub/deep.txt") == WorkspaceFilesystemTestSupport.data("nested")) + #expect(try await filesystem.readFile(path: "/tree-copy/sub/deep.txt") == FilesystemTestSupport.data("nested")) let globbed = try await filesystem.glob(pattern: "/docs/*.txt", currentDirectory: "/") #expect(globbed.contains("/docs/note.txt")) @@ -392,12 +453,12 @@ struct WorkspaceFilesystemTests { #expect(!(await unconfigured.exists(path: "/\u{0}"))) - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceReadWriteErrors") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "ReadWriteErrors") + defer { FilesystemTestSupport.removeDirectory(root) } let filesystem = try ReadWriteFilesystem(rootDirectory: root) try await filesystem.createDirectory(path: "/dir", recursive: false) - try await filesystem.writeFile(path: "/dir/file.txt", data: WorkspaceFilesystemTestSupport.data("x"), append: false) + try await filesystem.writeFile(path: "/dir/file.txt", data: FilesystemTestSupport.data("x"), append: false) do { _ = try await filesystem.listDirectory(path: "/dir/file.txt") @@ -429,14 +490,14 @@ struct WorkspaceFilesystemTests { @Test(.tags(.overlay)) func `overlay filesystem imports disk state and proxies mutations`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayCoverage") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "OverlayCoverage") + defer { FilesystemTestSupport.removeDirectory(root) } let dirURL = root.appendingPathComponent("dir", isDirectory: true) try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) let fileURL = dirURL.appendingPathComponent("file.txt") - try WorkspaceFilesystemTestSupport.data("disk").write(to: fileURL) + try FilesystemTestSupport.data("disk").write(to: fileURL) try FileManager.default.setAttributes([.posixPermissions: 0o640], ofItemAtPath: fileURL.path) let symlinkURL = root.appendingPathComponent("alias.txt") @@ -446,12 +507,12 @@ struct WorkspaceFilesystemTests { #expect((try await filesystem.listDirectory(path: "/")).map(\.name) == ["alias.txt", "dir"]) #expect((try await filesystem.listDirectory(path: "/dir")).map(\.name) == ["file.txt"]) - #expect(try await filesystem.readFile(path: "/alias.txt") == WorkspaceFilesystemTestSupport.data("disk")) + #expect(try await filesystem.readFile(path: "/alias.txt") == FilesystemTestSupport.data("disk")) #expect(try await filesystem.readSymlink(path: "/alias.txt") == "dir/file.txt") #expect(try await filesystem.stat(path: "/dir/file.txt").permissions == POSIXPermissions(0o640)) try await filesystem.createDirectory(path: "/scratch", recursive: true) - try await filesystem.writeFile(path: "/scratch/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/scratch/note.txt", data: FilesystemTestSupport.data("hello"), append: false) try await filesystem.copy(from: "/scratch/note.txt", to: "/scratch/copy.txt", recursive: false) try await filesystem.move(from: "/scratch/copy.txt", to: "/scratch/moved.txt") try await filesystem.createSymlink(path: "/scratch/link.txt", target: "note.txt") @@ -460,7 +521,7 @@ struct WorkspaceFilesystemTests { #expect(try await filesystem.readSymlink(path: "/scratch/link.txt") == "note.txt") #expect(try await filesystem.resolveRealPath(path: "/scratch/link.txt") == "/scratch/note.txt") - #expect(try await filesystem.readFile(path: "/scratch/hard.txt") == WorkspaceFilesystemTestSupport.data("hello")) + #expect(try await filesystem.readFile(path: "/scratch/hard.txt") == FilesystemTestSupport.data("hello")) #expect((try await filesystem.glob(pattern: "/scratch/*.txt", currentDirectory: "/")).contains("/scratch/moved.txt")) try await filesystem.remove(path: "/scratch/moved.txt", recursive: false) @@ -478,7 +539,7 @@ struct WorkspaceFilesystemTests { #expect(error.description.contains("requires rootDirectory")) } - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayMissingRoot") + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "OverlayMissingRoot") try FileManager.default.removeItem(at: root) let filesystem = OverlayFilesystem() @@ -498,7 +559,7 @@ struct WorkspaceFilesystemTests { } let filesystem = PermissionedFileSystem(base: base, authorizer: authorizer) - try await filesystem.writeFile(path: "/dir/../note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/dir/../note.txt", data: FilesystemTestSupport.data("hello"), append: false) try await filesystem.createDirectory(path: "/links", recursive: true) try await filesystem.copy(from: "/note.txt", to: "/copy.txt", recursive: false) try await filesystem.move(from: "/copy.txt", to: "/moved.txt") @@ -545,8 +606,8 @@ struct WorkspaceFilesystemTests { @Test(.tags(.permissions)) func `permissioned filesystem forwards configuration and denied remove operations`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspacePermissionedConfig") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "PermissionedConfig") + defer { FilesystemTestSupport.removeDirectory(root) } let base = ReadWriteFilesystem() let filesystem = PermissionedFileSystem( @@ -560,7 +621,7 @@ struct WorkspaceFilesystemTests { ) try await filesystem.configure(rootDirectory: root) - try await filesystem.writeFile(path: "/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/note.txt", data: FilesystemTestSupport.data("hello"), append: false) do { try await filesystem.remove(path: "/note.txt", recursive: false) @@ -580,12 +641,12 @@ struct WorkspaceFilesystemTests { @Test(.tags(.sandbox)) func `sandbox filesystem rooted at a URL supports filesystem operations`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSandboxURL") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "SandboxURL") + defer { FilesystemTestSupport.removeDirectory(root) } let filesystem = try SandboxFilesystem(root: .url(root)) - try await filesystem.writeFile(path: "/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/note.txt", data: FilesystemTestSupport.data("hello"), append: false) try await filesystem.createDirectory(path: "/dir", recursive: true) try await filesystem.copy(from: "/note.txt", to: "/copy.txt", recursive: false) try await filesystem.move(from: "/copy.txt", to: "/moved.txt") @@ -593,7 +654,7 @@ struct WorkspaceFilesystemTests { try await filesystem.createHardLink(path: "/hard.txt", target: "/note.txt") try await filesystem.setPermissions(path: "/note.txt", permissions: POSIXPermissions(0o600)) - #expect(try await filesystem.readFile(path: "/note.txt") == WorkspaceFilesystemTestSupport.data("hello")) + #expect(try await filesystem.readFile(path: "/note.txt") == FilesystemTestSupport.data("hello")) #expect(try await filesystem.readSymlink(path: "/link.txt") == "note.txt") #expect(try await filesystem.resolveRealPath(path: "/link.txt") == "/note.txt") #expect(try await filesystem.stat(path: "/note.txt").permissions == POSIXPermissions(0o600)) @@ -634,26 +695,26 @@ struct WorkspaceFilesystemTests { #expect(error.description.contains("app group container unavailable")) } - let firstRoot = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSandboxFirst") - defer { WorkspaceFilesystemTestSupport.removeDirectory(firstRoot) } + let firstRoot = try FilesystemTestSupport.makeTempDirectory(prefix: "SandboxFirst") + defer { FilesystemTestSupport.removeDirectory(firstRoot) } - let secondRoot = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSandboxSecond") - defer { WorkspaceFilesystemTestSupport.removeDirectory(secondRoot) } + let secondRoot = try FilesystemTestSupport.makeTempDirectory(prefix: "SandboxSecond") + defer { FilesystemTestSupport.removeDirectory(secondRoot) } let filesystem = try SandboxFilesystem(root: .url(firstRoot)) try await filesystem.configure(rootDirectory: secondRoot) - try await filesystem.writeFile(path: "/configured.txt", data: WorkspaceFilesystemTestSupport.data("configured"), append: false) + try await filesystem.writeFile(path: "/configured.txt", data: FilesystemTestSupport.data("configured"), append: false) #expect(FileManager.default.fileExists(atPath: secondRoot.appendingPathComponent("configured.txt").path)) } @Test(.tags(.bookmarks)) func `user defaults bookmark store persists and deletes suite values`() async throws { - let suiteName = WorkspaceFilesystemTestSupport.uniqueSuiteName() + let suiteName = FilesystemTestSupport.uniqueSuiteName() defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } let store = UserDefaultsBookmarkStore(suiteName: suiteName, keyPrefix: "workspace.tests.") - let data = WorkspaceFilesystemTestSupport.data("bookmark") + let data = FilesystemTestSupport.data("bookmark") try await store.saveBookmark(data, for: "demo") #expect(try await store.loadBookmark(for: "demo") == data) @@ -666,7 +727,7 @@ struct WorkspaceFilesystemTests { func `user defaults bookmark store supports standard defaults`() async throws { let id = "standard-\(UUID().uuidString)" let store = UserDefaultsBookmarkStore(keyPrefix: "workspace.tests.standard.") - let data = WorkspaceFilesystemTestSupport.data("bookmark") + let data = FilesystemTestSupport.data("bookmark") defer { UserDefaults.standard.removeObject(forKey: "workspace.tests.standard." + id) } try await store.saveBookmark(data, for: id) @@ -679,15 +740,15 @@ struct WorkspaceFilesystemTests { #if os(macOS) @Test(.tags(.securityScoped)) func `security-scoped filesystem supports url access reconfiguration and read-only mode`() async throws { - let firstRoot = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSecurityScopedFirst") - defer { WorkspaceFilesystemTestSupport.removeDirectory(firstRoot) } + let firstRoot = try FilesystemTestSupport.makeTempDirectory(prefix: "SecurityScopedFirst") + defer { FilesystemTestSupport.removeDirectory(firstRoot) } - let secondRoot = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSecurityScopedSecond") - defer { WorkspaceFilesystemTestSupport.removeDirectory(secondRoot) } + let secondRoot = try FilesystemTestSupport.makeTempDirectory(prefix: "SecurityScopedSecond") + defer { FilesystemTestSupport.removeDirectory(secondRoot) } let filesystem = try SecurityScopedFilesystem(url: firstRoot, mode: .readWrite) - try await filesystem.writeFile(path: "/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/note.txt", data: FilesystemTestSupport.data("hello"), append: false) try await filesystem.createDirectory(path: "/dir", recursive: true) try await filesystem.copy(from: "/note.txt", to: "/copy.txt", recursive: false) try await filesystem.move(from: "/copy.txt", to: "/moved.txt") @@ -695,7 +756,7 @@ struct WorkspaceFilesystemTests { try await filesystem.createHardLink(path: "/hard.txt", target: "/note.txt") try await filesystem.setPermissions(path: "/note.txt", permissions: POSIXPermissions(0o600)) - #expect(try await filesystem.readFile(path: "/note.txt") == WorkspaceFilesystemTestSupport.data("hello")) + #expect(try await filesystem.readFile(path: "/note.txt") == FilesystemTestSupport.data("hello")) #expect(try await filesystem.readSymlink(path: "/link.txt") == "note.txt") #expect(try await filesystem.resolveRealPath(path: "/link.txt") == "/note.txt") #expect(try await filesystem.stat(path: "/note.txt").permissions == POSIXPermissions(0o600)) @@ -709,14 +770,14 @@ struct WorkspaceFilesystemTests { try await filesystem.configure(rootDirectory: secondRoot) #expect(!(await filesystem.exists(path: "/note.txt"))) - try await filesystem.writeFile(path: "/fresh.txt", data: WorkspaceFilesystemTestSupport.data("fresh"), append: false) + try await filesystem.writeFile(path: "/fresh.txt", data: FilesystemTestSupport.data("fresh"), append: false) #expect(FileManager.default.fileExists(atPath: secondRoot.appendingPathComponent("fresh.txt").path)) let readOnly = try SecurityScopedFilesystem(url: secondRoot, mode: .readOnly) - #expect(try await readOnly.readFile(path: "/fresh.txt") == WorkspaceFilesystemTestSupport.data("fresh")) + #expect(try await readOnly.readFile(path: "/fresh.txt") == FilesystemTestSupport.data("fresh")) do { - try await readOnly.writeFile(path: "/blocked.txt", data: WorkspaceFilesystemTestSupport.data("x"), append: false) + try await readOnly.writeFile(path: "/blocked.txt", data: FilesystemTestSupport.data("x"), append: false) Issue.record("expected read-only rejection") } catch let error as WorkspaceError { #expect(error.description.contains("read-only")) @@ -725,7 +786,7 @@ struct WorkspaceFilesystemTests { @Test(.tags(.securityScoped, .bookmarks)) func `security-scoped filesystem reports missing stored bookmarks`() async throws { - let suiteName = WorkspaceFilesystemTestSupport.uniqueSuiteName() + let suiteName = FilesystemTestSupport.uniqueSuiteName() defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } let store = UserDefaultsBookmarkStore(suiteName: suiteName, keyPrefix: "workspace.tests.") @@ -740,11 +801,11 @@ struct WorkspaceFilesystemTests { @Test(.tags(.securityScoped, .bookmarks)) func `security-scoped filesystem rejects invalid stored bookmark data`() async throws { - let suiteName = WorkspaceFilesystemTestSupport.uniqueSuiteName() + let suiteName = FilesystemTestSupport.uniqueSuiteName() defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } let store = UserDefaultsBookmarkStore(suiteName: suiteName, keyPrefix: "workspace.tests.") - try await store.saveBookmark(WorkspaceFilesystemTestSupport.data("not-a-bookmark"), for: "invalid") + try await store.saveBookmark(FilesystemTestSupport.data("not-a-bookmark"), for: "invalid") do { _ = try await SecurityScopedFilesystem.loadBookmark(id: "invalid", store: store) @@ -758,10 +819,10 @@ struct WorkspaceFilesystemTests { @Test(.tags(.securityScoped, .bookmarks)) func `security-scoped filesystem bookmark creation either saves or reports Cocoa errors`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSecurityScopedBookmark") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "SecurityScopedBookmark") + defer { FilesystemTestSupport.removeDirectory(root) } - let suiteName = WorkspaceFilesystemTestSupport.uniqueSuiteName() + let suiteName = FilesystemTestSupport.uniqueSuiteName() defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } let filesystem = try SecurityScopedFilesystem(url: root, mode: .readWrite) diff --git a/Tests/WorkspaceTests/WorkspaceMountingTests.swift b/Tests/WorkspaceTests/MountingTests.swift similarity index 72% rename from Tests/WorkspaceTests/WorkspaceMountingTests.swift rename to Tests/WorkspaceTests/MountingTests.swift index 4cf8257..d02d09b 100644 --- a/Tests/WorkspaceTests/WorkspaceMountingTests.swift +++ b/Tests/WorkspaceTests/MountingTests.swift @@ -2,8 +2,8 @@ import Foundation import Testing @testable import Workspace -private enum WorkspaceMountingTestSupport { - static func makeTempDirectory(prefix: String = "WorkspaceMountingTests") throws -> URL { +private enum MountingTestSupport { + static func makeTempDirectory(prefix: String = "MountingTests") throws -> URL { let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) @@ -19,8 +19,8 @@ private enum WorkspaceMountingTestSupport { } } -@Suite("Workspace Mounting") -struct WorkspaceMountingTests { +@Suite("Mounting") +struct MountingTests { @Test func `multiple isolated mounts can share a memory workspace`() async throws { let workspaceA = InMemoryFilesystem() @@ -122,11 +122,11 @@ struct WorkspaceMountingTests { func `mountable filesystem merges base entries with mounted directories`() async throws { let base = InMemoryFilesystem() try await base.createDirectory(path: "/docs", recursive: true) - try await base.writeFile(path: "/docs/local.txt", data: WorkspaceMountingTestSupport.data("local"), append: false) - try await base.writeFile(path: "/base.txt", data: WorkspaceMountingTestSupport.data("base"), append: false) + try await base.writeFile(path: "/docs/local.txt", data: MountingTestSupport.data("local"), append: false) + try await base.writeFile(path: "/base.txt", data: MountingTestSupport.data("base"), append: false) let mountedDocs = InMemoryFilesystem() - try await mountedDocs.writeFile(path: "/guide.txt", data: WorkspaceMountingTestSupport.data("guide"), append: false) + try await mountedDocs.writeFile(path: "/guide.txt", data: MountingTestSupport.data("guide"), append: false) let filesystem = MountableFilesystem( base: base, @@ -136,7 +136,7 @@ struct WorkspaceMountingTests { let docsInfo = try await filesystem.stat(path: "/docs") #expect(docsInfo.kind == .directory) #expect(await filesystem.exists(path: "/docs")) - #expect(try await filesystem.readFile(path: "/base.txt") == WorkspaceMountingTestSupport.data("base")) + #expect(try await filesystem.readFile(path: "/base.txt") == MountingTestSupport.data("base")) let docsEntries = try await filesystem.listDirectory(path: "/docs") #expect(docsEntries.map(\.name) == ["external", "local.txt"]) @@ -144,7 +144,7 @@ struct WorkspaceMountingTests { let mountedEntries = try await filesystem.listDirectory(path: "/docs/external") #expect(mountedEntries.map(\.name) == ["guide.txt"]) - try await filesystem.writeFile(path: "/docs/external/new.txt", data: WorkspaceMountingTestSupport.data("new"), append: false) + try await filesystem.writeFile(path: "/docs/external/new.txt", data: MountingTestSupport.data("new"), append: false) try await filesystem.createDirectory(path: "/docs/external/sub", recursive: true) try await filesystem.createSymlink(path: "/docs/external/link.txt", target: "guide.txt") try await filesystem.createHardLink(path: "/docs/external/hard.txt", target: "/docs/external/guide.txt") @@ -152,8 +152,8 @@ struct WorkspaceMountingTests { #expect(try await filesystem.readSymlink(path: "/docs/external/link.txt") == "guide.txt") #expect(try await filesystem.resolveRealPath(path: "/docs/external/link.txt") == "/docs/external/guide.txt") - #expect(try await mountedDocs.readFile(path: "/new.txt") == WorkspaceMountingTestSupport.data("new")) - #expect(try await mountedDocs.readFile(path: "/hard.txt") == WorkspaceMountingTestSupport.data("guide")) + #expect(try await mountedDocs.readFile(path: "/new.txt") == MountingTestSupport.data("new")) + #expect(try await mountedDocs.readFile(path: "/hard.txt") == MountingTestSupport.data("guide")) #expect(try await mountedDocs.stat(path: "/guide.txt").permissions == POSIXPermissions(0o600)) let globbed = try await filesystem.glob(pattern: "/docs/*.txt", currentDirectory: "/") @@ -176,7 +176,7 @@ struct WorkspaceMountingTests { func `mountable filesystem supports directory copy and move across mounts`() async throws { let source = InMemoryFilesystem() try await source.createDirectory(path: "/tree/sub", recursive: true) - try await source.writeFile(path: "/tree/sub/file.txt", data: WorkspaceMountingTestSupport.data("nested"), append: false) + try await source.writeFile(path: "/tree/sub/file.txt", data: MountingTestSupport.data("nested"), append: false) try await source.createSymlink(path: "/tree/link.txt", target: "sub/file.txt") let destination = InMemoryFilesystem() @@ -197,12 +197,12 @@ struct WorkspaceMountingTests { } try await filesystem.copy(from: "/src/tree", to: "/dst/tree", recursive: true) - #expect(try await destination.readFile(path: "/tree/sub/file.txt") == WorkspaceMountingTestSupport.data("nested")) + #expect(try await destination.readFile(path: "/tree/sub/file.txt") == MountingTestSupport.data("nested")) #expect(try await destination.readSymlink(path: "/tree/link.txt") == "sub/file.txt") try await filesystem.move(from: "/src/tree/sub/file.txt", to: "/dst/moved.txt") #expect(!(await source.exists(path: "/tree/sub/file.txt"))) - #expect(try await destination.readFile(path: "/moved.txt") == WorkspaceMountingTestSupport.data("nested")) + #expect(try await destination.readFile(path: "/moved.txt") == MountingTestSupport.data("nested")) do { try await filesystem.createHardLink(path: "/dst/cross-hard.txt", target: "/src/tree/link.txt") @@ -214,8 +214,8 @@ struct WorkspaceMountingTests { @Test func `mountable filesystem supports dynamic mounts and configurable base storage`() async throws { - let baseRoot = try WorkspaceMountingTestSupport.makeTempDirectory(prefix: "WorkspaceMountableBase") - defer { WorkspaceMountingTestSupport.removeDirectory(baseRoot) } + let baseRoot = try MountingTestSupport.makeTempDirectory(prefix: "MountableBase") + defer { MountingTestSupport.removeDirectory(baseRoot) } let base = ReadWriteFilesystem() let filesystem = MountableFilesystem(base: base) @@ -224,10 +224,73 @@ struct WorkspaceMountingTests { let memory = InMemoryFilesystem() filesystem.mount("/memory", filesystem: memory) - try await filesystem.writeFile(path: "/root.txt", data: WorkspaceMountingTestSupport.data("root"), append: false) - try await filesystem.writeFile(path: "/memory/note.txt", data: WorkspaceMountingTestSupport.data("memo"), append: false) + try await filesystem.writeFile(path: "/root.txt", data: MountingTestSupport.data("root"), append: false) + try await filesystem.writeFile(path: "/memory/note.txt", data: MountingTestSupport.data("memo"), append: false) - #expect(try await filesystem.readFile(path: "/root.txt") == WorkspaceMountingTestSupport.data("root")) - #expect(try await memory.readFile(path: "/note.txt") == WorkspaceMountingTestSupport.data("memo")) + #expect(try await filesystem.readFile(path: "/root.txt") == MountingTestSupport.data("root")) + #expect(try await memory.readFile(path: "/note.txt") == MountingTestSupport.data("memo")) + } + + @Test + func `mountable filesystem prefers the longest matching mount prefix`() async throws { + let outer = InMemoryFilesystem() + let inner = InMemoryFilesystem() + try await outer.writeFile(path: "/outer.txt", data: MountingTestSupport.data("outer"), append: false) + try await inner.writeFile(path: "/inner.txt", data: MountingTestSupport.data("inner"), append: false) + + let filesystem = MountableFilesystem( + base: InMemoryFilesystem(), + mounts: [ + .init(mountPoint: "/mnt", filesystem: outer), + .init(mountPoint: "/mnt/deep", filesystem: inner), + ] + ) + + #expect(try await filesystem.readFile(path: "/mnt/outer.txt") == MountingTestSupport.data("outer")) + #expect(try await filesystem.readFile(path: "/mnt/deep/inner.txt") == MountingTestSupport.data("inner")) + } + + @Test + func `mountable filesystem exposes synthetic parents for nested mount points`() async throws { + let nested = InMemoryFilesystem() + try await nested.writeFile(path: "/leaf.txt", data: MountingTestSupport.data("leaf"), append: false) + + let filesystem = MountableFilesystem( + base: InMemoryFilesystem(), + mounts: [.init(mountPoint: "/data/repo", filesystem: nested)] + ) + + #expect(await filesystem.exists(path: "/data")) + #expect(await filesystem.exists(path: "/data/repo")) + + let dataInfo = try await filesystem.stat(path: "/data") + #expect(dataInfo.kind == .directory) + #expect(dataInfo.path == "/data") + + let dataEntries = try await filesystem.listDirectory(path: "/data") + #expect(dataEntries.map(\.name) == ["repo"]) + + let repoEntries = try await filesystem.listDirectory(path: "/data/repo") + #expect(repoEntries.map(\.name) == ["leaf.txt"]) + + #expect(try await filesystem.readFile(path: "/data/repo/leaf.txt") == MountingTestSupport.data("leaf")) + } + + @Test + func `mountable filesystem glob aggregates paths from base and mounts`() async throws { + let mounted = InMemoryFilesystem() + try await mounted.writeFile(path: "/b.txt", data: MountingTestSupport.data("mb"), append: false) + + let base = InMemoryFilesystem() + try await base.writeFile(path: "/a.txt", data: MountingTestSupport.data("ba"), append: false) + + let filesystem = MountableFilesystem( + base: base, + mounts: [.init(mountPoint: "/m", filesystem: mounted)] + ) + + let matches = try await filesystem.glob(pattern: "/*.txt", currentDirectory: "/") + #expect(matches.contains("/a.txt")) + #expect(matches.contains("/m/b.txt")) } } diff --git a/Tests/WorkspaceTests/SnapshotTests.swift b/Tests/WorkspaceTests/SnapshotTests.swift new file mode 100644 index 0000000..8331c64 --- /dev/null +++ b/Tests/WorkspaceTests/SnapshotTests.swift @@ -0,0 +1,176 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("Snapshot") +struct SnapshotTests { + @Test + func `capture and restore roundtrips tree contents symlinks and permissions`() async throws { + let source = InMemoryFilesystem() + try await source.createDirectory(path: "/docs/nested", recursive: true) + try await source.writeFile(path: "/docs/nested/note.txt", data: Data("hello".utf8), append: false) + try await source.createSymlink(path: "/docs/link.txt", target: "/docs/nested/note.txt") + try await source.setPermissions(path: "/docs", permissions: POSIXPermissions(0o750)) + try await source.setPermissions(path: "/docs/nested", permissions: POSIXPermissions(0o700)) + try await source.setPermissions(path: "/docs/nested/note.txt", permissions: POSIXPermissions(0o600)) + try await source.setPermissions(path: "/docs/link.txt", permissions: POSIXPermissions(0o777)) + + let snapshotId = UUID() + let snapshot = try await Snapshot.capture(from: source, snapshotId: snapshotId) + + let target = InMemoryFilesystem() + try await target.createDirectory(path: "/docs", recursive: true) + try await target.writeFile(path: "/docs/stale.txt", data: Data("stale".utf8), append: false) + try await target.writeFile(path: "/stale-root.txt", data: Data("stale".utf8), append: false) + + try await Snapshot.restore(snapshot, to: target) + + #expect(snapshot.id == snapshotId) + #expect(snapshot.rootPath == .root) + #expect(try await target.readFile(path: "/docs/nested/note.txt") == Data("hello".utf8)) + #expect(try await target.readSymlink(path: "/docs/link.txt") == "/docs/nested/note.txt") + #expect(try await target.stat(path: "/docs").permissions == POSIXPermissions(0o750)) + #expect(try await target.stat(path: "/docs/nested").permissions == POSIXPermissions(0o700)) + #expect(try await target.stat(path: "/docs/nested/note.txt").permissions == POSIXPermissions(0o600)) + #expect(try await target.stat(path: "/docs/link.txt").permissions == POSIXPermissions(0o777)) + #expect(!(await target.exists(path: "/docs/stale.txt"))) + #expect(!(await target.exists(path: "/stale-root.txt"))) + } + + @Test + func `empty root and missing subtree snapshots restore expected absence`() async throws { + let source = InMemoryFilesystem() + let emptyRoot = try await Snapshot.capture(from: source) + let emptySummary = emptyRoot.summary(comparedTo: nil) + + let target = InMemoryFilesystem() + try await target.writeFile(path: "/stale.txt", data: Data("stale".utf8), append: false) + try await Snapshot.restore(emptyRoot, to: target) + + #expect(emptySummary.changeCount == 0) + #expect(emptySummary.touchedPaths.isEmpty) + #expect(emptySummary.hasTextDiffs == false) + #expect(!(await target.exists(path: "/stale.txt"))) + + let missing = try await Snapshot.capture(from: source, at: "/missing") + let missingTarget = InMemoryFilesystem() + try await missingTarget.createDirectory(path: "/missing", recursive: true) + try await missingTarget.writeFile(path: "/missing/file.txt", data: Data("remove me".utf8), append: false) + try await missingTarget.writeFile(path: "/kept.txt", data: Data("kept".utf8), append: false) + + try await Snapshot.restore(missing, to: missingTarget) + + guard case let .missing(entry) = missing.entry else { + Issue.record("expected missing snapshot entry") + return + } + #expect(entry.path == "/missing") + #expect(!(await missingTarget.exists(path: "/missing"))) + #expect(try await missingTarget.readFile(path: "/kept.txt") == Data("kept".utf8)) + } + + @Test + func `summary reports stable changed paths and text diff availability`() async throws { + let base = try await summaryFilesystem(text: "old", binary: Data([0xFF, 0xFE])) + let baseSnapshot = try await Snapshot.capture(from: base) + + let unchanged = try await Snapshot.capture(from: try await summaryFilesystem(text: "old", binary: Data([0xFF, 0xFE]))) + let unchangedSummary = unchanged.summary(comparedTo: baseSnapshot) + + let textChanged = try await Snapshot.capture(from: try await summaryFilesystem(text: "new", binary: Data([0xFF, 0xFE]))) + let textSummary = textChanged.summary(comparedTo: baseSnapshot) + + let binaryChanged = try await Snapshot.capture(from: try await summaryFilesystem(text: "old", binary: Data([0x00, 0xFF]))) + let binarySummary = binaryChanged.summary(comparedTo: baseSnapshot) + + #expect(unchangedSummary.changeCount == 0) + #expect(unchangedSummary.touchedPaths.isEmpty) + #expect(unchangedSummary.hasTextDiffs == false) + + #expect(textSummary.changeCount == 1) + #expect(textSummary.touchedPaths == [WorkspacePath("/text.txt")]) + #expect(textSummary.hasTextDiffs == true) + + #expect(binarySummary.changeCount == 1) + #expect(binarySummary.touchedPaths == [WorkspacePath("/binary.dat")]) + #expect(binarySummary.hasTextDiffs == false) + } + + @Test + func `entry path accessor reflects each captured node kind`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.createDirectory(path: "/dir", recursive: true) + try await filesystem.writeFile(path: "/dir/note.txt", data: Data("hi".utf8), append: false) + try await filesystem.createSymlink(path: "/dir/link", target: "/dir/note.txt") + + let snapshot = try await Snapshot.capture(from: filesystem) + guard case let .directory(rootDir) = snapshot.entry else { + Issue.record("expected directory root entry") + return + } + guard case let .directory(dir) = rootDir.children.first(where: { $0.path == "/dir" }) ?? .missing(.init(path: .root)) else { + Issue.record("expected /dir directory entry") + return + } + + #expect(snapshot.entry.path == .root) + #expect(rootDir.path == .root) + #expect(dir.children.map(\.path).sorted() == ["/dir/link", "/dir/note.txt"]) + + let missing = try await Snapshot.capture(from: filesystem, at: "/missing") + #expect(missing.entry.path == "/missing") + } + + @Test + func `Workspace capture and restore round trips through the public API`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + try await workspace.createDirectory(at: "/notes") + try await workspace.writeFile("/notes/a.txt", content: "alpha") + try await workspace.writeFile("/notes/b.txt", content: "beta") + try await workspace.createDirectory(at: "/empty") + + let snapshot = try await workspace.captureSnapshot() + + try await workspace.writeFile("/notes/a.txt", content: "alpha-updated") + try await workspace.removeItem(at: "/notes/b.txt") + try await workspace.writeFile("/notes/c.txt", content: "gamma") + + try await workspace.restoreSnapshot(snapshot) + + #expect(try await workspace.readFile("/notes/a.txt") == "alpha") + #expect(try await workspace.readFile("/notes/b.txt") == "beta") + #expect(!(await workspace.exists("/notes/c.txt"))) + #expect(await workspace.exists("/empty")) + } + + @Test + func `subtree snapshot summary excludes the captured root and Codable round-trips`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.createDirectory(path: "/proj/src", recursive: true) + try await filesystem.writeFile(path: "/proj/src/main.swift", data: Data("print(\"hi\")".utf8), append: false) + try await filesystem.writeFile(path: "/proj/README.md", data: Data("docs".utf8), append: false) + + let baseSubtree = try await Snapshot.capture(from: filesystem, at: "/proj") + try await filesystem.writeFile(path: "/proj/src/main.swift", data: Data("print(\"updated\")".utf8), append: false) + let updatedSubtree = try await Snapshot.capture(from: filesystem, at: "/proj") + let summary = updatedSubtree.summary(comparedTo: baseSubtree) + + let encoded = try JSONEncoder().encode(updatedSubtree) + let decoded = try JSONDecoder().decode(Snapshot.self, from: encoded) + + #expect(summary.changeCount == 1) + #expect(summary.touchedPaths == [WorkspacePath("/proj/src/main.swift")]) + #expect(summary.hasTextDiffs == true) + #expect(decoded == updatedSubtree) + #expect(decoded.rootPath == "/proj") + } + + private func summaryFilesystem(text: String, binary: Data) async throws -> InMemoryFilesystem { + let filesystem = InMemoryFilesystem() + try await filesystem.writeFile(path: "/text.txt", data: Data(text.utf8), append: false) + try await filesystem.writeFile(path: "/binary.dat", data: binary, append: false) + try await filesystem.createDirectory(path: "/docs", recursive: true) + try await filesystem.createSymlink(path: "/link.txt", target: "/text.txt") + return filesystem + } +} diff --git a/Tests/WorkspaceTests/TextDiffTests.swift b/Tests/WorkspaceTests/TextDiffTests.swift new file mode 100644 index 0000000..0e30fcb --- /dev/null +++ b/Tests/WorkspaceTests/TextDiffTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("TextDiff") +struct TextDiffTests { + @Test + func `lineBased returns no hunks for identical content`() { + let diff = TextDiff.lineBased(from: "alpha\nbravo\n", to: "alpha\nbravo\n") + #expect(diff.hunks.isEmpty) + } + + @Test + func `lineBased returns no hunks for two empty strings`() { + let diff = TextDiff.lineBased(from: "", to: "") + #expect(diff.hunks.isEmpty) + } + + @Test + func `lineBased treats creation from empty as a single added hunk`() { + let diff = TextDiff.lineBased(from: "", to: "hello\nworld\n") + let hunk = try! #require(diff.hunks.first) + #expect(diff.hunks.count == 1) + + let added = hunk.lines.filter { $0.kind == .added } + #expect(added.map(\.text) == ["hello", "world"]) + #expect(added.allSatisfy { $0.hasTrailingNewline }) + #expect(added.compactMap(\.newLineNumber) == [1, 2]) + #expect(hunk.lines.allSatisfy { $0.kind != .removed }) + } + + @Test + func `lineBased treats deletion to empty as a single removed hunk`() { + let diff = TextDiff.lineBased(from: "keep\nme\n", to: "") + let hunk = try! #require(diff.hunks.first) + #expect(diff.hunks.count == 1) + + let removed = hunk.lines.filter { $0.kind == .removed } + #expect(removed.map(\.text) == ["keep", "me"]) + #expect(removed.compactMap(\.oldLineNumber) == [1, 2]) + #expect(hunk.lines.allSatisfy { $0.kind != .added }) + } + + @Test + func `lineBased emits add and remove with surrounding context for a single-line change`() { + let original = """ + one + two + three + four + five + """ + let updated = """ + one + two + THREE + four + five + """ + + let diff = TextDiff.lineBased(from: original, to: updated) + let hunk = try! #require(diff.hunks.first) + #expect(diff.hunks.count == 1) + + let removedTexts = hunk.lines.filter { $0.kind == .removed }.map(\.text) + let addedTexts = hunk.lines.filter { $0.kind == .added }.map(\.text) + #expect(removedTexts == ["three"]) + #expect(addedTexts == ["THREE"]) + + let contextTexts = hunk.lines.filter { $0.kind == .context }.map(\.text) + #expect(contextTexts == ["one", "two", "four", "five"]) + } + + @Test + func `lineBased preserves trailing-newline metadata on the last line`() { + let withNewline = TextDiff.lineBased(from: "", to: "x\n") + let withoutNewline = TextDiff.lineBased(from: "", to: "x") + + let firstWithNewline = try! #require(withNewline.hunks.first?.lines.first) + let firstWithoutNewline = try! #require(withoutNewline.hunks.first?.lines.first) + + #expect(firstWithNewline.hasTrailingNewline == true) + #expect(firstWithoutNewline.hasTrailingNewline == false) + } + + @Test + func `lineBased matches Workspace previewReplacement diff for the same edit`() async throws { + let original = """ + red + green + blue + """ + let updated = """ + red + teal + blue + """ + + let workspace = Workspace(filesystem: InMemoryFilesystem()) + try await workspace.writeFile("/colors.txt", content: original) + + let preview = try await workspace.previewReplacement( + ReplacementRequest(pattern: "/colors.txt", search: "green", replacement: "teal") + ) + let workspaceDiff = try #require(preview.changes.first?.diff) + + let utilityDiff = TextDiff.lineBased(from: original, to: updated) + + #expect(workspaceDiff == utilityDiff) + } +} diff --git a/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift b/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift new file mode 100644 index 0000000..e859d53 --- /dev/null +++ b/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift @@ -0,0 +1,370 @@ +import Foundation +import Testing +@testable import Workspace + +private actor CheckpointEventRecorder { + private var events: [CheckpointEvent] = [] + + func append(_ event: CheckpointEvent) { + events.append(event) + } + + func snapshot() -> [CheckpointEvent] { + events + } +} + +private func startCheckpointRecording( + _ stream: AsyncStream, + into recorder: CheckpointEventRecorder +) -> Task { + Task { + for await event in stream { + await recorder.append(event) + } + } +} + +private func waitForCheckpointEvents( + _ expectedCount: Int, + recorder: CheckpointEventRecorder, + timeout: Duration = .seconds(1) +) async throws -> [CheckpointEvent] { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while clock.now < deadline { + let snapshot = await recorder.snapshot() + if snapshot.count >= expectedCount { + return Array(snapshot.prefix(expectedCount)) + } + try await Task.sleep(for: .milliseconds(10)) + } + + return await recorder.snapshot() +} + +private actor FlakySnapshotCheckpointStore: CheckpointStore { + private let base = InMemoryCheckpointStore() + private var snapshotIdsReturningNil: Set = [] + + func breakLoadingSnapshot(id: UUID) { + snapshotIdsReturningNil.insert(id) + } + + func saveCheckpoint(_ checkpoint: Checkpoint) async throws { + try await base.saveCheckpoint(checkpoint) + } + + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { + try await base.loadCheckpoint(id: id, workspaceId: workspaceId) + } + + func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { + try await base.listCheckpoints(workspaceId: workspaceId) + } + + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { + try await base.saveSnapshot(snapshot, workspaceId: workspaceId) + } + + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + if snapshotIdsReturningNil.contains(id) { + return nil + } + return try await base.loadSnapshot(id: id, workspaceId: workspaceId) + } + + func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord { + try await base.appendMutation(mutation) + } + + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + try await base.listMutationRecords(workspaceId: workspaceId) + } +} + +@Suite("Workspace checkpoints") +struct WorkspaceCheckpointTests { + @Test + func `tracked writes persist mutation ranges and checkpoint summaries`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + + try await workspace.writeFile("/note.txt", content: "one") + try await workspace.appendFile("/note.txt", content: " two") + try await workspace.createDirectory(at: "/docs") + + let checkpoint = try await workspace.createCheckpoint(label: "draft") + let mutations = try await workspace.mutationRecords() + + #expect(checkpoint.firstMutationSequence == 1) + #expect(checkpoint.lastMutationSequence == 3) + #expect(checkpoint.mutationCursor == 3) + #expect(checkpoint.label == "draft") + #expect(checkpoint.summary.changeCount == 2) + #expect(checkpoint.summary.touchedPaths.contains("/note.txt")) + #expect(checkpoint.summary.touchedPaths.contains("/docs")) + #expect(checkpoint.summary.hasTextDiffs == true) + #expect(mutations.map(\.kind) == [.writeFile, .appendFile, .createDirectory]) + #expect(mutations[0].diff?.hunks.isEmpty == false) + #expect(mutations[1].diff?.hunks.isEmpty == false) + #expect(mutations[2].diff == nil) + #expect(try await workspace.readFile("/note.txt") == "one two") + } + + @Test + func `rollback restores the tree and emits a rollback event`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + + try await workspace.writeFile("/shared.txt", content: "one") + let checkpoint = try await workspace.createCheckpoint(label: "v1") + try await workspace.writeFile("/shared.txt", content: "two") + + let recorder = CheckpointEventRecorder() + let stream = try await workspace.watchCheckpointEvents() + let task = startCheckpointRecording(stream, into: recorder) + defer { task.cancel() } + + let rollbackCheckpoint = try await workspace.rollback(to: checkpoint.id, label: "revert") + let events = try await waitForCheckpointEvents(1, recorder: recorder) + + #expect(try await workspace.readFile("/shared.txt") == "one") + #expect(rollbackCheckpoint.rollbackSourceCheckpointId == checkpoint.id) + #expect(events.count == 1) + #expect(events[0].kind == .rolledBack) + #expect(events[0].checkpoint == rollbackCheckpoint) + } + + @Test + func `branch writes are isolated and merge creates one merge checkpoint`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + try await workspace.writeFile("/note.txt", content: "base") + let base = try await workspace.createCheckpoint(label: "base") + + let branch = try await workspace.branch(label: "branch start") + try await branch.writeFile("/note.txt", content: "branch") + try await branch.writeFile("/new.txt", content: "new") + let branchHead = try await branch.createCheckpoint(label: "branch head") + + #expect(try await workspace.readFile("/note.txt") == "base") + #expect(!(await workspace.exists("/new.txt"))) + + let branchCheckpoints = try await branch.listCheckpoints() + #expect(branchCheckpoints.count == 2) + #expect(branchCheckpoints[0].baseCheckpointId == base.id) + #expect(branchHead.parentCheckpointId == branchCheckpoints[0].id) + #expect((try await workspace.listCheckpoints()).map(\.id) == [base.id]) + + let merged = try await workspace.merge(branch, label: "merge branch") + let mutations = try await workspace.mutationRecords() + + #expect(try await workspace.readFile("/note.txt") == "branch") + #expect(try await workspace.readFile("/new.txt") == "new") + #expect(merged.parentCheckpointId == base.id) + #expect(merged.mergedFromWorkspaceId == branch.workspaceId) + #expect(merged.mergedFromCheckpointId == branchHead.id) + #expect(merged.summary.touchedPaths.contains("/note.txt")) + #expect(merged.summary.touchedPaths.contains("/new.txt")) + #expect(Array(mutations.map(\.kind).suffix(1)) == [.mergeWorkspace]) + } + + @Test + func `merge conflicts when parent head advanced after branch`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + try await workspace.writeFile("/note.txt", content: "base") + let base = try await workspace.createCheckpoint(label: "base") + + let branch = try await workspace.branch() + try await branch.writeFile("/note.txt", content: "branch") + + try await workspace.writeFile("/note.txt", content: "main") + let advanced = try await workspace.createCheckpoint(label: "main") + + do { + _ = try await workspace.merge(branch) + Issue.record("expected merge conflict") + } catch let error as WorkspaceError { + guard case let .mergeConflict(parentWorkspaceId, expectedBase, actualHead) = error else { + Issue.record("unexpected workspace error: \(error)") + return + } + #expect(parentWorkspaceId == workspace.workspaceId) + #expect(expectedBase == base.id) + #expect(actualHead == advanced.id) + } + } + + @Test + func `file backed storage reloads checkpoint snapshot artifacts`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let workspace = Workspace( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + storage: .directory(at: root) + ) + + try await workspace.writeFile("/note.txt", content: "checkpoint") + let checkpoint = try await workspace.createCheckpoint(label: "saved") + try await workspace.writeFile("/note.txt", content: "current") + + let reloaded = Workspace( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + storage: .directory(at: root) + ) + let persistedCheckpoint = try #require(try await reloaded.checkpoint(id: checkpoint.id)) + let snapshot = try await reloaded.snapshot(for: persistedCheckpoint) + let restored = InMemoryFilesystem() + + try await Snapshot.restore(snapshot, to: restored) + + #expect(try await workspace.readFile("/note.txt") == "current") + #expect(try await restored.readFile(path: "/note.txt") == Data("checkpoint".utf8)) + #expect(snapshot.summary(comparedTo: nil) == checkpoint.summary) + } + + @Test + func `all public write surfaces append mutation records`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + + try await workspace.writeFile("/base.txt", content: "x") + try await workspace.appendFile("/base.txt", content: "y") + try await workspace.writeData(Data([0, 1, 2]), to: "/bin.dat") + try await workspace.writeJSON(["a": 1], to: "/config.json", prettyPrinted: false) + try await workspace.createDirectory(at: "/nested/deep", recursive: true) + try await workspace.writeFile("/nested/deep/a.txt", content: "a") + try await workspace.copyItem(from: "/nested/deep/a.txt", to: "/nested/deep/b.txt") + try await workspace.moveItem(from: "/nested/deep/b.txt", to: "/moved.txt") + try await workspace.removeItem(at: "/nested", recursive: true) + + _ = try await workspace.applyEdits([ + .writeFile(path: "/batch.txt", content: "line\n"), + ]) + try await workspace.writeFile("/replace.txt", content: "hello old world") + _ = try await workspace.applyReplacement( + ReplacementRequest(pattern: "*.txt", search: "old", replacement: "new") + ) + + let beforeSnapshot = try await workspace.captureSnapshot() + try await workspace.writeFile("/temporary.txt", content: "gone") + try await workspace.restoreSnapshot(beforeSnapshot) + + let mutations = try await workspace.mutationRecords() + let kinds = mutations.map(\.kind) + #expect( + kinds == [ + .writeFile, + .appendFile, + .writeData, + .writeJSON, + .createDirectory, + .writeFile, + .copyItem, + .moveItem, + .removeItem, + .applyEdits, + .writeFile, + .applyReplacement, + .writeFile, + .restoreSnapshot, + ] + ) + #expect(try await workspace.readFile("/base.txt") == "xy") + #expect(try await workspace.readFile("/moved.txt") == "a") + #expect(try await workspace.readFile("/replace.txt") == "hello new world") + #expect(!(await workspace.exists("/temporary.txt"))) + } + + @Test + func `snapshot and rollback errors use WorkspaceError`() async throws { + let store = FlakySnapshotCheckpointStore() + let workspace = Workspace(filesystem: InMemoryFilesystem(), store: store) + + try await workspace.writeFile("/note.txt", content: "v1") + let checkpoint = try await workspace.createCheckpoint(label: "has-snapshot") + + await store.breakLoadingSnapshot(id: checkpoint.snapshotId) + + do { + _ = try await workspace.snapshot(for: checkpoint) + Issue.record("expected snapshotNotFound") + } catch let error as WorkspaceError { + guard case let .snapshotNotFound(id) = error else { + Issue.record("unexpected error: \(error)") + return + } + #expect(id == checkpoint.snapshotId) + } + + do { + _ = try await workspace.rollback(to: UUID()) + Issue.record("expected checkpointNotFound") + } catch let error as WorkspaceError { + guard case .checkpointNotFound = error else { + Issue.record("unexpected error: \(error)") + return + } + } + } + + @Test + func `watchCheckpointEvents receives checkpoints from another workspace sharing the store`() async throws { + let store = InMemoryCheckpointStore() + let workspaceId = UUID() + + let observer = Workspace(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) + let producer = Workspace(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) + + let recorder = CheckpointEventRecorder() + let stream = try await observer.watchCheckpointEvents() + let task = startCheckpointRecording(stream, into: recorder) + defer { task.cancel() } + + try await producer.writeFile("/remote.txt", content: "remote") + let created = try await producer.createCheckpoint(label: "other-instance") + + let events = try await waitForCheckpointEvents(1, recorder: recorder, timeout: .seconds(2)) + #expect(events.count == 1) + #expect(events[0].kind == .created) + #expect(events[0].checkpoint.id == created.id) + + try await observer.writeFile("/local.txt", content: "local") + let observerCheckpoint = try await observer.createCheckpoint(label: "from-observer") + #expect(observerCheckpoint.parentCheckpointId == created.id) + } + + @Test + func `merge mutation includes per-file text diffs`() async throws { + let workspace = Workspace() + try await workspace.writeFile("/a.txt", content: "a1\n") + try await workspace.writeFile("/b.txt", content: "b1\n") + _ = try await workspace.createCheckpoint(label: "base") + + let branch = try await workspace.branch() + try await branch.writeFile("/a.txt", content: "a2\n") + try await branch.writeFile("/b.txt", content: "b2\n") + + _ = try await workspace.merge(branch, label: "merge") + let mutations = try await workspace.mutationRecords() + let mergeMutation = try #require(mutations.last) + + #expect(mergeMutation.kind == .mergeWorkspace) + #expect(mergeMutation.fileChanges.map(\.path) == ["/a.txt", "/b.txt"]) + #expect(mergeMutation.fileChanges.allSatisfy { $0.diff?.hunks.isEmpty == false }) + #expect(mergeMutation.diff == nil) + } + + private func makeTempDirectory() throws -> URL { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let url = base.appendingPathComponent("WorkspaceCheckpointTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func removeTempDirectory(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } +} diff --git a/Tests/WorkspaceTests/WorkspaceInternalsTests.swift b/Tests/WorkspaceTests/WorkspaceInternalsTests.swift new file mode 100644 index 0000000..79b3d87 --- /dev/null +++ b/Tests/WorkspaceTests/WorkspaceInternalsTests.swift @@ -0,0 +1,284 @@ +import Foundation +import Testing +@testable import Workspace + +private actor CountingCheckpointStore: CheckpointStore { + private let checkpoints: [Checkpoint] + private let snapshots: [UUID: Snapshot] + private let mutations: [MutationRecord] + private var checkpointListCount = 0 + private var mutationListCount = 0 + + init( + checkpoints: [Checkpoint] = [], + snapshots: [UUID: Snapshot] = [:], + mutations: [MutationRecord] = [] + ) { + self.checkpoints = checkpoints + self.snapshots = snapshots + self.mutations = mutations + } + + func saveCheckpoint(_ checkpoint: Checkpoint) async throws {} + + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { + checkpoints.first { $0.id == id && $0.workspaceId == workspaceId } + } + + func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { + checkpointListCount += 1 + try await Task.sleep(for: .milliseconds(20)) + return checkpoints.filter { $0.workspaceId == workspaceId } + } + + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws {} + + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + snapshots[id] + } + + func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord { + mutation + } + + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + mutationListCount += 1 + try await Task.sleep(for: .milliseconds(20)) + return mutations.filter { $0.workspaceId == workspaceId } + } + + func listCounts() -> (checkpoints: Int, mutations: Int) { + (checkpointListCount, mutationListCount) + } +} + +@Suite("Workspace internals") +struct WorkspaceInternalsTests { + @Test + func `ensureLoaded coalesces concurrent store loads`() async throws { + let workspaceId = UUID() + let store = CountingCheckpointStore() + let workspace = Workspace( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + store: store + ) + + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<8 { + group.addTask { + try await workspace.ensureLoaded() + } + } + try await group.waitForAll() + } + + let counts = await store.listCounts() + #expect(counts.checkpoints == 1) + #expect(counts.mutations == 1) + } + + @Test + func `appendMutation canonicalizes touched paths and file changes`() async throws { + let workspace = Workspace() + try await workspace.ensureLoaded() + + try await workspace.appendMutation( + kind: .applyEdits, + touchedPaths: ["/b.txt", "/a.txt", "/b.txt"], + fileChanges: [ + FileEdit.FileChange(path: "/b.txt", kind: .file, effect: .modified), + FileEdit.FileChange(path: "/a.txt", kind: .file, effect: .created), + FileEdit.FileChange(path: "/a.txt", kind: .file, effect: .deleted), + ], + diff: nil + ) + + let mutation = try #require(try await workspace.mutationRecords().first) + #expect(mutation.sequence == 1) + #expect(mutation.touchedPaths == ["/a.txt", "/b.txt"]) + #expect(mutation.fileChanges.map(\.path) == ["/a.txt", "/a.txt", "/b.txt"]) + #expect(mutation.fileChanges.map(\.effect) == [.created, .deleted, .modified]) + } + + @Test + func `pollCheckpointEvents refreshes mutation cursor before the next local checkpoint`() async throws { + let store = InMemoryCheckpointStore() + let workspaceId = UUID() + let observer = Workspace(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) + let producer = Workspace(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) + + let stream = try await observer.watchCheckpointEvents() + let task = Task { + for await _ in stream {} + } + defer { task.cancel() } + + try await producer.writeFile("/remote.txt", content: "remote") + let remote = try await producer.createCheckpoint(label: "remote") + + await observer.pollCheckpointEvents() + try await observer.writeFile("/local.txt", content: "local") + let local = try await observer.createCheckpoint(label: "local") + let localMutation = try #require(try await observer.mutationRecords().last) + + #expect(local.parentCheckpointId == remote.id) + #expect(local.firstMutationSequence == 2) + #expect(local.lastMutationSequence == 2) + #expect(localMutation.sequence == 2) + } + + @Test + func `snapshotDelta captures type changes symlink updates and directory permission changes`() async throws { + let workspace = Workspace() + let original = Snapshot.Entry.directory( + Snapshot.Directory( + path: .root, + permissions: .defaultDirectory, + children: [ + .file(Snapshot.File(path: "/node", data: Data("old\n".utf8), permissions: .defaultFile)), + .symlink(Snapshot.Symlink(path: "/link", target: "/node", permissions: .defaultFile)), + .directory(Snapshot.Directory(path: "/docs", permissions: POSIXPermissions(0o755), children: [])), + ] + ) + ) + let updated = Snapshot.Entry.directory( + Snapshot.Directory( + path: .root, + permissions: .defaultDirectory, + children: [ + .directory( + Snapshot.Directory( + path: "/node", + permissions: .defaultDirectory, + children: [ + .file( + Snapshot.File( + path: "/node/child.txt", + data: Data("new\n".utf8), + permissions: .defaultFile + ) + ), + ] + ) + ), + .symlink(Snapshot.Symlink(path: "/link", target: "/node/child.txt", permissions: .defaultFile)), + .directory(Snapshot.Directory(path: "/docs", permissions: POSIXPermissions(0o700), children: [])), + ] + ) + ) + + let delta = await workspace.snapshotDelta(from: original, to: updated) + let changesByPath = Dictionary(uniqueKeysWithValues: delta.fileChanges.map { ($0.path, $0) }) + + #expect(delta.hasChanges) + #expect(delta.touchedPaths == ["/docs", "/link", "/node", "/node/child.txt"]) + #expect(delta.fileChanges.map(\.path) == ["/link", "/node", "/node/child.txt"]) + #expect(changesByPath["/link"]?.kind == .symlink) + #expect(changesByPath["/link"]?.effect == .modified) + #expect(changesByPath["/link"]?.diff == nil) + #expect(changesByPath["/node"]?.effect == .deleted) + #expect(changesByPath["/node"]?.diff?.hunks.isEmpty == false) + #expect(changesByPath["/node/child.txt"]?.effect == .created) + #expect(changesByPath["/node/child.txt"]?.diff?.hunks.isEmpty == false) + } + + @Test + func `snapshotDelta captures created and deleted symlinks without text diffs`() async throws { + let workspace = Workspace() + let original = Snapshot.Entry.directory( + Snapshot.Directory( + path: .root, + permissions: .defaultDirectory, + children: [ + .file(Snapshot.File(path: "/target.txt", data: Data("target\n".utf8), permissions: .defaultFile)), + .symlink(Snapshot.Symlink(path: "/deleted-link", target: "/target.txt", permissions: .defaultFile)), + ] + ) + ) + let updated = Snapshot.Entry.directory( + Snapshot.Directory( + path: .root, + permissions: .defaultDirectory, + children: [ + .file(Snapshot.File(path: "/target.txt", data: Data("target\n".utf8), permissions: .defaultFile)), + .symlink(Snapshot.Symlink(path: "/created-link", target: "/target.txt", permissions: .defaultFile)), + ] + ) + ) + + let delta = await workspace.snapshotDelta(from: original, to: updated) + let changesByPath = Dictionary(uniqueKeysWithValues: delta.fileChanges.map { ($0.path, $0) }) + + #expect(delta.touchedPaths == ["/created-link", "/deleted-link"]) + #expect(delta.fileChanges.map(\.path) == ["/created-link", "/deleted-link"]) + #expect(changesByPath["/created-link"]?.kind == .symlink) + #expect(changesByPath["/created-link"]?.effect == .created) + #expect(changesByPath["/created-link"]?.diff == nil) + #expect(changesByPath["/deleted-link"]?.kind == .symlink) + #expect(changesByPath["/deleted-link"]?.effect == .deleted) + #expect(changesByPath["/deleted-link"]?.diff == nil) + } + + @Test + func `writeData to a directory path throws with unsupported`() async throws { + let workspace = Workspace() + try await workspace.createDirectory(at: "/out", recursive: true) + do { + try await workspace.writeData(Data([1]), to: "/out") + Issue.record("expected error when writing to directory") + } catch let error as WorkspaceError { + guard case .unsupported = error else { + Issue.record("unexpected workspace error: \(error)") + return + } + } + } + + @Test + func `performBinaryWrite through an existing symlink records symlink metadata and updates target`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.writeFile(path: "/target.bin", data: Data([1, 2, 3]), append: false) + try await filesystem.createSymlink(path: "/alias.bin", target: "/target.bin") + let workspace = Workspace(filesystem: filesystem) + + try await workspace.writeData(Data([4, 5, 6]), to: "/alias.bin") + try await workspace.writeData(Data([4, 5, 6]), to: "/alias.bin") + + let mutations = try await workspace.mutationRecords() + let first = try #require(mutations.first) + let second = try #require(mutations.last) + + #expect(try await filesystem.readFile(path: "/target.bin") == Data([4, 5, 6])) + #expect(try await filesystem.readSymlink(path: "/alias.bin") == "/target.bin") + #expect(first.kind == .writeData) + #expect(first.touchedPaths == ["/alias.bin"]) + #expect(first.fileChanges.count == 1) + #expect(first.fileChanges[0].path == "/alias.bin") + #expect(first.fileChanges[0].kind == .symlink) + #expect(first.fileChanges[0].effect == .modified) + #expect(first.fileChanges[0].status == .applied) + #expect(first.fileChanges[0].diff == nil) + #expect(second.sequence == 2) + #expect(second.fileChanges[0].kind == .symlink) + #expect(second.fileChanges[0].effect == .unchanged) + } + + @Test + func `untrackedRestore restores root snapshots without appending a mutation`() async throws { + let workspace = Workspace() + + try await workspace.writeFile("/kept.txt", content: "one") + let snapshot = try await workspace.captureSnapshot() + try await workspace.writeFile("/extra.txt", content: "two") + let before = try await workspace.mutationRecords().count + + try await workspace.untrackedRestore(snapshot) + let after = try await workspace.mutationRecords().count + + #expect(before == 2) + #expect(after == before) + #expect(try await workspace.readFile("/kept.txt") == "one") + #expect(!(await workspace.exists("/extra.txt"))) + } +}