A type safe swift ResultBuilder DSL for structured directories
0

Configure Feed

Select the types of activity you want to include in your feed.

Add optional file writing support

author
Woodrow Melling
date (Jun 11, 2026, 5:12 PM -0600) commit 9a3f2dc4 parent c2111ea4 change-id soltwlqv
+216 -3
+9 -1
Sources/FileTree/Components/Directory.swift
··· 91 91 @TaskLocal 92 92 public var writingToEmptyDirectory = false 93 93 94 + @TaskLocal 95 + public var deletingNilOptionalWrites = false 96 + 94 97 95 98 extension Directory { 96 99 public struct Many: FileTreeReader { ··· 314 317 to url: URL, 315 318 context: FileTreeWriteContext 316 319 ) throws -> FileTreeResult<Component.Content?> { 320 + let directoryURL = url.appending(component: self.path.description) 321 + 317 322 guard let content else { 323 + if deletingNilOptionalWrites, 324 + FileManager.default.fileExists(atPath: directoryURL.path()) { 325 + try FileManager.default.removeItem(at: directoryURL) 326 + } 318 327 return .value(nil) 319 328 } 320 329 321 - let directoryURL = url.appending(component: self.path.description) 322 330 if !FileManager.default.fileExists(atPath: directoryURL.path()) { 323 331 try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: false) 324 332 }
+27 -1
Sources/FileTree/Components/File.swift
··· 160 160 161 161 162 162 extension File { 163 - public struct Optional: FileTreeReader { 163 + public struct Optional: FileTreeReader, FileTreeWriter { 164 164 public typealias Content = Data? 165 165 166 166 public init(_ fileName: StaticString, _ fileType: FileExtension) { ··· 180 180 do { 181 181 return .value( 182 182 try Data(contentsOf: fileUrl), 183 + graph: .node(.file(SourceGraph.Path(rawValue: "\(fileName.description).\(fileType.rawValue)"))) 184 + ) 185 + } catch { 186 + throw Error(fileName: self.fileName.description, fileType: self.fileType, error: error) 187 + } 188 + } 189 + 190 + public func write(_ data: Data?, to url: URL) throws -> FileTreeResult<Data?> { 191 + let fileUrl = url.appendingPathComponent(fileName.description, withType: fileType) 192 + 193 + guard let data else { 194 + do { 195 + if deletingNilOptionalWrites, 196 + FileManager.default.fileExists(atPath: fileUrl.path()) { 197 + try FileManager.default.removeItem(at: fileUrl) 198 + } 199 + return .value(nil) 200 + } catch { 201 + throw Error(fileName: self.fileName.description, fileType: self.fileType, error: error) 202 + } 203 + } 204 + 205 + do { 206 + try data.write(to: fileUrl) 207 + return .value( 208 + data, 183 209 graph: .node(.file(SourceGraph.Path(rawValue: "\(fileName.description).\(fileType.rawValue)"))) 184 210 ) 185 211 } catch {
+46
Sources/FileTree/Conversions.swift
··· 585 585 public typealias Content = Downstream.Output? 586 586 } 587 587 588 + extension _OptionalConvertedFileTreeReader: FileTreeWriter where Upstream: FileTreeWriter { 589 + public func write( 590 + _ content: Downstream.Output?, 591 + to url: URL 592 + ) throws -> FileTreeResult<Downstream.Output?> { 593 + try write(content, to: url, context: .empty) 594 + } 595 + 596 + public func write( 597 + _ content: Downstream.Output?, 598 + to url: URL, 599 + context: FileTreeWriteContext 600 + ) throws -> FileTreeResult<Downstream.Output?> { 601 + let conversionContext = context.contextForUpstream(upstream, at: url) 602 + 603 + guard let content else { 604 + let upstreamResult = try upstream.write(nil, to: url, context: conversionContext) 605 + switch upstreamResult.output { 606 + case .value where !upstreamResult.diagnostics.hasErrors: 607 + return .value(nil, graph: upstreamResult.graph, diagnostics: upstreamResult.diagnostics) 608 + case .value, .invalid: 609 + return .invalid(graph: upstreamResult.graph, diagnostics: upstreamResult.diagnostics) 610 + } 611 + } 612 + 613 + let conversionResult = downstream.unapply(content, in: conversionContext) 614 + var diagnostics = conversionResult.diagnostics 615 + 616 + switch conversionResult.output { 617 + case let .value(upstreamContent) where !diagnostics.hasErrors: 618 + let upstreamResult = try upstream.write(.some(upstreamContent), to: url, context: conversionContext) 619 + diagnostics.append(contentsOf: upstreamResult.diagnostics) 620 + 621 + switch upstreamResult.output { 622 + case .value where !diagnostics.hasErrors: 623 + return .value(.some(content), graph: upstreamResult.graph, diagnostics: diagnostics) 624 + case .value, .invalid: 625 + return .invalid(graph: upstreamResult.graph, diagnostics: diagnostics) 626 + } 627 + 628 + case .value, .invalid: 629 + return .invalid(diagnostics: diagnostics) 630 + } 631 + } 632 + } 633 + 588 634 extension File.Optional { 589 635 public func convert<C>(_ conversion: C) -> _OptionalConvertedFileTreeReader<Self, C> 590 636 where Content == C.Input?, C: Conversion, C.Output: Sendable {
+59
Tests/FileTreeTests/RewriteTests.swift
··· 120 120 ) 121 121 } 122 122 123 + @Test func optionalConvertedFileWritePrintsPresentValue() throws { 124 + let directory = try RewriteTemporaryDirectory() 125 + 126 + let tree = File.Optional("event", "txt") 127 + .convert(RewriteNameConversion()) 128 + 129 + let result = try tree.write("New", to: directory.url) 130 + 131 + #expect(try result.output.requiredValue == "New") 132 + #expect(try directory.text(at: "event.txt") == "name: New\n") 133 + } 134 + 135 + @Test func optionalConvertedFileWriteSkipsNilValue() throws { 136 + let directory = try RewriteTemporaryDirectory() 137 + try Data("name: Old\n# keep\n".utf8) 138 + .write(to: directory.url.appendingPathComponent("event", withType: "txt")) 139 + 140 + let tree = File.Optional("event", "txt") 141 + .convert(RewriteNameConversion()) 142 + 143 + let result = try tree.write(nil, to: directory.url) 144 + 145 + #expect(try result.output.requiredValue == nil) 146 + #expect(try directory.text(at: "event.txt") == "name: Old\n# keep\n") 147 + } 148 + 149 + @Test func optionalConvertedFileWriteDeletesNilValueWhenEnabled() throws { 150 + let directory = try RewriteTemporaryDirectory() 151 + try Data("name: Old\n# keep\n".utf8) 152 + .write(to: directory.url.appendingPathComponent("event", withType: "txt")) 153 + 154 + let tree = File.Optional("event", "txt") 155 + .convert(RewriteNameConversion()) 156 + 157 + let result = try $deletingNilOptionalWrites.withValue(true) { 158 + try tree.write(nil, to: directory.url) 159 + } 160 + 161 + #expect(try result.output.requiredValue == nil) 162 + #expect(!FileManager.default.fileExists(atPath: directory.url.appendingPathComponent("event", withType: "txt").path())) 163 + } 164 + 165 + @Test func optionalConvertedFileRewritePassesExistingDataToConvertedWriter() throws { 166 + let directory = try RewriteTemporaryDirectory() 167 + try Data("name: Old\n# keep\n".utf8) 168 + .write(to: directory.url.appendingPathComponent("event", withType: "txt")) 169 + 170 + let tree = File.Optional("event", "txt") 171 + .convert(RewriteNameConversion()) 172 + 173 + let result = try tree.rewrite("New", at: directory.url) 174 + 175 + #expect(try result.output.requiredValue == "New") 176 + #expect( 177 + try directory.text(at: "event.txt") 178 + == "name: New\n# keep\n" 179 + ) 180 + } 181 + 123 182 @Test func writeWithoutSourceContextUsesCanonicalOutput() throws { 124 183 let directory = try RewriteTemporaryDirectory() 125 184 let tree = File("event", "txt")
+75 -1
Tests/FileTreeTests/TupleWriteTests.swift
··· 173 173 #expect(result.graph.containsNode(.file("winter-market/venue.txt"))) 174 174 } 175 175 176 + @Test func optionalFileWriteWritesPresentData() throws { 177 + let directory = try TupleWriteTemporaryDirectory() 178 + 179 + let result = try File.Optional("event", "txt") 180 + .write(Data("Wicked Woods".utf8), to: directory.url) 181 + 182 + let event = try String( 183 + decoding: Data(contentsOf: directory.url.appendingPathComponent("event", withType: "txt")), 184 + as: UTF8.self 185 + ) 186 + 187 + let output = try result.output.requiredValue 188 + #expect(output == Data("Wicked Woods".utf8)) 189 + #expect(event == "Wicked Woods") 190 + #expect(result.graph.containsNode(.file("event.txt"))) 191 + } 192 + 193 + @Test func optionalFileWriteSkipsNilFile() throws { 194 + let directory = try TupleWriteTemporaryDirectory() 195 + try Data("Existing".utf8) 196 + .write(to: directory.url.appendingPathComponent("event", withType: "txt")) 197 + 198 + let result = try File.Optional("event", "txt") 199 + .write(nil, to: directory.url) 200 + 201 + let output = try result.output.requiredValue 202 + let event = try String( 203 + decoding: Data(contentsOf: directory.url.appendingPathComponent("event", withType: "txt")), 204 + as: UTF8.self 205 + ) 206 + #expect(output == nil) 207 + #expect(event == "Existing") 208 + #expect(result.graph.nodes.isEmpty) 209 + } 210 + 211 + @Test func optionalFileWriteDeletesNilFileWhenEnabled() throws { 212 + let directory = try TupleWriteTemporaryDirectory() 213 + try Data("Existing".utf8) 214 + .write(to: directory.url.appendingPathComponent("event", withType: "txt")) 215 + 216 + let result = try $deletingNilOptionalWrites.withValue(true) { 217 + try File.Optional("event", "txt") 218 + .write(nil, to: directory.url) 219 + } 220 + 221 + let output = try result.output.requiredValue 222 + #expect(output == nil) 223 + #expect(!FileManager.default.fileExists(atPath: directory.url.appendingPathComponent("event", withType: "txt").path())) 224 + #expect(result.graph.nodes.isEmpty) 225 + } 226 + 176 227 @Test func optionalDirectoryWriteSkipsNilDirectory() throws { 177 228 let directory = try TupleWriteTemporaryDirectory() 229 + let stagesURL = directory.url.appending(component: "stages") 230 + try FileManager.default.createDirectory(at: stagesURL, withIntermediateDirectories: true) 231 + try Data("Existing".utf8) 232 + .write(to: stagesURL.appendingPathComponent("meadow", withType: "txt")) 178 233 179 234 let result = try Directory.Optional("stages") { 180 235 File.Many(withExtension: "txt") ··· 183 238 184 239 let output = try result.output.requiredValue 185 240 #expect(output == nil) 186 - #expect(!FileManager.default.fileExists(atPath: directory.url.appending(component: "stages").path())) 241 + #expect(FileManager.default.fileExists(atPath: stagesURL.appendingPathComponent("meadow", withType: "txt").path())) 242 + } 243 + 244 + @Test func optionalDirectoryWriteDeletesNilDirectoryWhenEnabled() throws { 245 + let directory = try TupleWriteTemporaryDirectory() 246 + let stagesURL = directory.url.appending(component: "stages") 247 + try FileManager.default.createDirectory(at: stagesURL, withIntermediateDirectories: true) 248 + try Data("Existing".utf8) 249 + .write(to: stagesURL.appendingPathComponent("meadow", withType: "txt")) 250 + 251 + let result = try $deletingNilOptionalWrites.withValue(true) { 252 + try Directory.Optional("stages") { 253 + File.Many(withExtension: "txt") 254 + } 255 + .write(nil, to: directory.url) 256 + } 257 + 258 + let output = try result.output.requiredValue 259 + #expect(output == nil) 260 + #expect(!FileManager.default.fileExists(atPath: stagesURL.path())) 187 261 } 188 262 189 263 @Test func optionalDirectoryWritePrefixesGraphAndWritesPresentContent() throws {