Sources/OrgDocument/Saving.swift
91 lines · 4014 bytes
1import Foundation
2import OrgCore
3
4public protocol FileSystem: Sendable {
5 /// The file's bytes, or nil if it doesn't exist.
6 func read(_ url: URL) throws -> [UInt8]?
7 /// Replaces the file through a temporary file in the same folder. Returns the bytes that
8 /// were replaced, read from the replaced file itself, or nil if there was none.
9 func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]?
10}
11
12public protocol RecoveryStore: Sendable {
13 func keep(_ bytes: [UInt8], for url: URL, label: String) throws
14}
15
16public enum SaveOutcome: Sendable, Equatable {
17 case saved
18 /// The file had changed since it was read; the change merged cleanly into the buffer and
19 /// the merge was written.
20 case mergedAndSaved([TextEdit])
21 /// The file had changed and the change conflicts. Nothing was written; the buffer is
22 /// unchanged.
23 case conflict([MergeConflict])
24 /// Another writer replaced the file between our last check and our write. Ours is on disk;
25 /// theirs is in recovery and was merged into the buffer where possible.
26 case overwroteExternalChange(DocumentState.ExternalChange)
27 /// Another writer changed the file right after our write. Theirs is on disk; ours is in
28 /// recovery.
29 case changedAfterWrite(DocumentState.ExternalChange)
30}
31
32public enum SaveError: Error, Equatable {
33 case fileKeepsChanging
34}
35
36/// The save sequence from the design: read, merge if the file moved, check again, replace,
37/// read back. Emacs and Syncthing don't coordinate, so the sequence can't lock them out; it
38/// narrows the window and makes sure every version it displaces lands in recovery.
39public struct Saver: Sendable {
40 public let fileSystem: FileSystem
41 public let recovery: RecoveryStore
42 public var maxAttempts = 3
43
44 public init(fileSystem: FileSystem, recovery: RecoveryStore) {
45 self.fileSystem = fileSystem
46 self.recovery = recovery
47 }
48
49 public func save(_ state: inout DocumentState, to url: URL) throws -> SaveOutcome {
50 guard state.isEditable else { throw DocumentState.EditError.readOnly }
51 for _ in 0..<maxAttempts {
52 let disk = try fileSystem.read(url)
53 var merged: [TextEdit]?
54 if let disk, disk != state.mergeBase {
55 try recovery.keep(disk, for: url, label: "external")
56 try recovery.keep(state.encodedText(), for: url, label: "local")
57 switch state.diskChanged(to: disk) {
58 case .conflict(let conflicts): return .conflict(conflicts)
59 case .merged(let edits), .reloaded(let edits): merged = edits
60 case .unchanged: break
61 }
62 }
63 let bytes = try state.encodedText()
64 guard try fileSystem.read(url) == disk else { continue }
65
66 let replaced = try fileSystem.replace(url, with: bytes)
67 state.didWrite(bytes)
68 if replaced != disk, let replaced {
69 try recovery.keep(replaced, for: url, label: "external")
70 return .overwroteExternalChange(state.mergeOverwritten(replaced, base: disk ?? []))
71 }
72
73 if let after = try fileSystem.read(url), after != bytes {
74 try recovery.keep(bytes, for: url, label: "local")
75 return .changedAfterWrite(state.diskChanged(to: after))
76 }
77 return merged.map { .mergedAndSaved($0) } ?? .saved
78 }
79 throw SaveError.fileKeepsChanging
80 }
81
82 /// Writes the buffer over whatever is on disk, for keeping our version over a conflicting
83 /// one. The replaced version goes to recovery unless it is the merge base.
84 public func overwrite(_ state: inout DocumentState, to url: URL) throws {
85 let bytes = try state.encodedText()
86 if let replaced = try fileSystem.replace(url, with: bytes), replaced != state.mergeBase {
87 try recovery.keep(replaced, for: url, label: "external")
88 }
89 state.didWrite(bytes)
90 }
91}