Skip to content

Commit f98be84

Browse files
committed
Raise an error if the merge output directory already exists
Raise an error if the archives to merge has overlapping data Use a temporary directory while merging the archives
1 parent 270122b commit f98be84

File tree

6 files changed

+418
-88
lines changed

6 files changed

+418
-88
lines changed

Sources/SwiftDocC/Utility/FileManagerProtocol.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ public protocol FileManagerProtocol {
5151
func contentsOfDirectory(atPath path: String) throws -> [String]
5252
func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options mask: FileManager.DirectoryEnumerationOptions) throws -> [URL]
5353

54+
/// The temporary directory for the current user.
55+
var temporaryDirectory: URL { get }
56+
5457
/// Creates a file with the specified `contents` at the specified location.
5558
///
5659
/// - Parameters:

Sources/SwiftDocCTestUtilities/TestFileSystem.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import XCTest
4242
/// - Warning: Use this type for unit testing.
4343
@_spi(FileManagerProtocol) // This needs to be SPI because it conforms to an SPI protocol
4444
public class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataProvider {
45+
public let temporaryDirectory = URL(fileURLWithPath: "/tmp")
4546
public let currentDirectoryPath = "/"
4647

4748
public var identifier: String = UUID().uuidString
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
@_spi(FileManagerProtocol) import SwiftDocC
13+
14+
extension Action {
15+
16+
/// Creates a new unique directory, with an optional template, inside of specified container.
17+
/// - Parameters:
18+
/// - container: The container directory to create a new directory within.
19+
/// - template: An optional template for the new directory.
20+
/// - fileManager: The file manager to create the new directory.
21+
/// - Returns: The URL of the new unique directory.
22+
static func createUniqueDirectory(inside container: URL, template: URL?, fileManager: FileManagerProtocol) throws -> URL {
23+
let targetURL = container.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString)
24+
25+
if let template = template {
26+
// If a template directory has been provided, create the temporary build folder with its contents
27+
try fileManager.copyItem(at: template, to: targetURL)
28+
} else {
29+
// Otherwise, create an empty directory
30+
try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true, attributes: nil)
31+
}
32+
return targetURL
33+
}
34+
35+
/// Moves a file or directory from the specified location to a new location.
36+
/// - Parameters:
37+
/// - source: The file or directory to move.
38+
/// - destination: The new location for the file or directory.
39+
/// - fileManager: The file manager to move the file or directory.
40+
static func moveOutput(from source: URL, to destination: URL, fileManager: FileManagerProtocol) throws {
41+
// We only need to move output if it exists
42+
guard fileManager.fileExists(atPath: source.path) else { return }
43+
44+
if fileManager.fileExists(atPath: destination.path) {
45+
try fileManager.removeItem(at: destination)
46+
}
47+
48+
try ensureThatParentFolderExist(for: destination, fileManager: fileManager)
49+
try fileManager.moveItem(at: source, to: destination)
50+
}
51+
52+
private static func ensureThatParentFolderExist(for location: URL, fileManager: FileManagerProtocol) throws {
53+
let parentFolder = location.deletingLastPathComponent()
54+
if !fileManager.directoryExists(atPath: parentFolder.path) {
55+
try fileManager.createDirectory(at: parentFolder, withIntermediateDirectories: false, attributes: nil)
56+
}
57+
}
58+
}

Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -570,38 +570,10 @@ public struct ConvertAction: Action, RecreatingContext {
570570
}
571571

572572
func createTempFolder(with templateURL: URL?) throws -> URL {
573-
let targetURL = temporaryDirectory.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString)
574-
575-
if let templateURL = templateURL {
576-
// If a template directory has been provided, create the temporary build folder with
577-
// its contents
578-
try fileManager.copyItem(at: templateURL, to: targetURL)
579-
} else {
580-
// Otherwise, just create the temporary build folder
581-
try fileManager.createDirectory(
582-
at: targetURL,
583-
withIntermediateDirectories: true,
584-
attributes: nil)
585-
}
586-
return targetURL
573+
return try Self.createUniqueDirectory(inside: temporaryDirectory, template: templateURL, fileManager: fileManager)
587574
}
588575

589576
func moveOutput(from: URL, to: URL) throws {
590-
// We only need to move output if it exists
591-
guard fileManager.fileExists(atPath: from.path) else { return }
592-
593-
if fileManager.fileExists(atPath: to.path) {
594-
try fileManager.removeItem(at: to)
595-
}
596-
597-
try ensureThatParentFolderExist(for: to)
598-
try fileManager.moveItem(at: from, to: to)
599-
}
600-
601-
private func ensureThatParentFolderExist(for location: URL) throws {
602-
let parentFolder = location.deletingLastPathComponent()
603-
if !fileManager.directoryExists(atPath: parentFolder.path) {
604-
try fileManager.createDirectory(at: parentFolder, withIntermediateDirectories: false, attributes: nil)
605-
}
577+
return try Self.moveOutput(from: from, to: to, fileManager: fileManager)
606578
}
607579
}

Sources/SwiftDocCUtilities/Action/Actions/MergeAction.swift

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,37 +17,40 @@ struct MergeAction: Action {
1717
var landingPageCatalog: URL?
1818
var outputURL: URL
1919
var fileManager: FileManagerProtocol
20-
20+
2121
mutating func perform(logHandle: LogHandle) throws -> ActionResult {
2222
guard let firstArchive = archives.first else {
2323
// A validation warning should have already been raised in `Docc/Merge/InputAndOutputOptions/validate()`.
2424
return ActionResult(didEncounterError: true, outputs: [])
2525
}
2626

27-
try? fileManager.removeItem(at: outputURL)
28-
try fileManager.copyItem(at: firstArchive, to: outputURL)
27+
try validateThatOutputIsEmpty()
28+
try validateThatArchivesHaveDisjointData()
2929

30+
let targetURL = try Self.createUniqueDirectory(inside: fileManager.temporaryDirectory, template: firstArchive, fileManager: fileManager)
31+
defer {
32+
try? fileManager.removeItem(at: targetURL)
33+
}
34+
3035
// TODO: Merge the LMDB navigator index
3136

32-
let jsonIndexURL = outputURL.appendingPathComponent("index/index.json")
37+
let jsonIndexURL = targetURL.appendingPathComponent("index/index.json")
3338
guard let jsonIndexData = fileManager.contents(atPath: jsonIndexURL.path) else {
34-
// TODO: Error
35-
return ActionResult(didEncounterError: true, outputs: [])
39+
throw CocoaError.error(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: jsonIndexURL.path])
3640
}
3741
var combinedJSONIndex = try JSONDecoder().decode(RenderIndex.self, from: jsonIndexData)
3842

3943
for archive in archives.dropFirst() {
4044
for directoryToCopy in ["data/documentation", "data/tutorials", "documentation", "tutorials", "images", "videos", "downloads"] {
4145
let fromDirectory = archive.appendingPathComponent(directoryToCopy, isDirectory: true)
42-
let toDirectory = outputURL.appendingPathComponent(directoryToCopy, isDirectory: true)
46+
let toDirectory = targetURL.appendingPathComponent(directoryToCopy, isDirectory: true)
4347

4448
for from in (try? fileManager.contentsOfDirectory(at: fromDirectory, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? [] {
4549
try fileManager.copyItem(at: from, to: toDirectory.appendingPathComponent(from.lastPathComponent))
4650
}
4751
}
4852
guard let jsonIndexData = fileManager.contents(atPath: archive.appendingPathComponent("index/index.json").path) else {
49-
// TODO: Error
50-
return ActionResult(didEncounterError: true, outputs: [])
53+
throw CocoaError.error(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: archive.appendingPathComponent("index/index.json").path])
5154
}
5255
let renderIndex = try JSONDecoder().decode(RenderIndex.self, from: jsonIndexData)
5356

@@ -60,6 +63,102 @@ struct MergeAction: Action {
6063

6164
// TODO: Inactivate external links outside the merged archives
6265

66+
try Self.moveOutput(from: targetURL, to: outputURL, fileManager: fileManager)
67+
6368
return ActionResult(didEncounterError: false, outputs: [outputURL])
6469
}
70+
71+
private func validateThatArchivesHaveDisjointData() throws {
72+
// Check that the archives don't have overlapping data
73+
typealias ArchivesByDirectoryName = [String: Set<String>]
74+
75+
var archivesByTopLevelDocumentationDirectory = ArchivesByDirectoryName()
76+
var archivesByTopLevelTutorialDirectory = ArchivesByDirectoryName()
77+
78+
// Gather all the top level /data/documentation and /data/tutorials directories to ensure that the different archives don't have overlapping data
79+
for archive in archives {
80+
for topLevelDocumentation in (try? fileManager.contentsOfDirectory(at: archive.appendingPathComponent("data/documentation", isDirectory: true), includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? [] {
81+
archivesByTopLevelDocumentationDirectory[topLevelDocumentation.deletingPathExtension().lastPathComponent, default: []].insert(archive.lastPathComponent)
82+
}
83+
for topLevelDocumentation in (try? fileManager.contentsOfDirectory(at: archive.appendingPathComponent("data/tutorials", isDirectory: true), includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? [] {
84+
archivesByTopLevelTutorialDirectory[topLevelDocumentation.deletingPathExtension().lastPathComponent, default: []].insert(archive.lastPathComponent)
85+
}
86+
}
87+
88+
// Only data directories found in a multiple archives is a problem
89+
archivesByTopLevelDocumentationDirectory = archivesByTopLevelDocumentationDirectory.filter({ $0.value.count > 1 })
90+
archivesByTopLevelTutorialDirectory = archivesByTopLevelTutorialDirectory.filter({ $0.value.count > 1 })
91+
92+
guard archivesByTopLevelDocumentationDirectory.isEmpty, archivesByTopLevelTutorialDirectory.isEmpty else {
93+
struct OverlappingDataError: DescribedError {
94+
var archivesByDocumentationData: ArchivesByDirectoryName
95+
var archivesByTutorialData: ArchivesByDirectoryName
96+
97+
var errorDescription: String {
98+
var message = "Input archives contain overlapping data"
99+
if let overlappingDocumentationDescription = overlapDescription(archivesByData: archivesByDocumentationData, pathComponentName: "documentation") {
100+
message.append(overlappingDocumentationDescription)
101+
}
102+
if let overlappingDocumentationDescription = overlapDescription(archivesByData: archivesByTutorialData, pathComponentName: "tutorials") {
103+
message.append(overlappingDocumentationDescription)
104+
}
105+
return message
106+
}
107+
108+
private func overlapDescription(archivesByData: ArchivesByDirectoryName, pathComponentName: String) -> String? {
109+
guard !archivesByData.isEmpty else {
110+
return nil
111+
}
112+
113+
var description = "\n"
114+
for (topLevelDirectory, archives) in archivesByData.mapValues({ $0.sorted() }) {
115+
if archives.count == 2 {
116+
description.append("\n'\(archives.first!)' and '\(archives.last!)' both ")
117+
} else {
118+
description.append("\n\(archives.dropLast().map({ "'\($0)'" }).joined(separator: ", ")), and '\(archives.last!)' all ")
119+
}
120+
description.append("contain '/data/\(pathComponentName)/\(topLevelDirectory)/'")
121+
}
122+
return description
123+
}
124+
}
125+
126+
throw OverlappingDataError(
127+
archivesByDocumentationData: archivesByTopLevelDocumentationDirectory,
128+
archivesByTutorialData: archivesByTopLevelTutorialDirectory
129+
)
130+
}
131+
}
132+
133+
private func validateThatOutputIsEmpty() throws {
134+
guard fileManager.directoryExists(atPath: outputURL.path) else {
135+
return
136+
}
137+
138+
let existingContents = (try? fileManager.contentsOfDirectory(at: outputURL, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? []
139+
guard existingContents.isEmpty else {
140+
struct NonEmptyOutputError: DescribedError {
141+
var existingContents: [URL]
142+
var fileManager: FileManagerProtocol
143+
144+
var errorDescription: String {
145+
var contentDescriptions = existingContents
146+
.sorted(by: { $0.lastPathComponent < $1.lastPathComponent })
147+
.prefix(6)
148+
.map { " - \($0.lastPathComponent)\(fileManager.directoryExists(atPath: $0.path) ? "/" : "")" }
149+
150+
if existingContents.count > 6 {
151+
contentDescriptions[5] = "and \(existingContents.count - 5) more files and directories"
152+
}
153+
154+
return """
155+
Output directory is not empty. It contains:
156+
\(contentDescriptions.joined(separator: "\n"))
157+
"""
158+
}
159+
}
160+
161+
throw NonEmptyOutputError(existingContents: existingContents, fileManager: fileManager)
162+
}
163+
}
65164
}

0 commit comments

Comments
 (0)