diff --git a/Example/ackgen.sh b/Example/ackgen.sh index dddd2b3..6d40f38 100644 --- a/Example/ackgen.sh +++ b/Example/ackgen.sh @@ -3,7 +3,7 @@ DIR=.. if [ -d "$DIR" ]; then cd $DIR - SDKROOT=(xcrun --sdk macosx --show-sdk-path) + SDKROOT=$(xcrun --sdk macosx --show-sdk-path) swift run ackgen --output "$SRCROOT/PackageLicenses.plist" else echo "warning: AckGen not found. Please install the package via SPM (https://github.com/MartinP7r/AckGen#installation)" diff --git a/Sources/AckGenCLI/AckGen.swift b/Sources/AckGenCLI/AckGen.swift index 6619b61..78aeb7d 100644 --- a/Sources/AckGenCLI/AckGen.swift +++ b/Sources/AckGenCLI/AckGen.swift @@ -9,6 +9,20 @@ import AckGenCore import ArgumentParser import Foundation +/// Derives the SPM `SourcePackages/checkouts` path from Xcode's `PROJECT_TEMP_DIR`. +/// +/// Uses the last occurrence of `/Build/` to handle edge cases where "Build" +/// appears earlier in the path (e.g., in a username or directory name). +/// - Parameter tempDirPath: The value of the `PROJECT_TEMP_DIR` environment variable. +/// - Returns: The path to the `SourcePackages/checkouts` directory. +func deriveCheckoutsPath(from tempDirPath: String) -> String { + if let range = tempDirPath.range(of: "/Build/", options: .backwards) { + return String(tempDirPath[.. String { - return "SCRIPT_INPUT_FILE_\(number)" - } - - static func scriptOutputFile(number: Int) -> String { - return "SCRIPT_OUTPUT_FILE_\(number)" - } -} diff --git a/Tests/AckGenTests/AcknowledgementsStringsTableTests.swift b/Tests/AckGenTests/AcknowledgementsStringsTableTests.swift new file mode 100644 index 0000000..0ced083 --- /dev/null +++ b/Tests/AckGenTests/AcknowledgementsStringsTableTests.swift @@ -0,0 +1,108 @@ +@testable import AckGenCLI +@testable import AckGenCore +import XCTest + +final class AcknowledgementsStringsTableTests: XCTestCase { + + // MARK: - Codable Round-Trip + + func testCodableRoundTrip() throws { + let acks = [ + Acknowledgement(title: "Alamofire", license: "MIT License"), + Acknowledgement(title: "SnapKit", license: "BSD License"), + ] + let original = AcknowledgementsStringsTable(name: "Licenses", acknowledgements: acks) + + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + let data = try encoder.encode(original) + + let decoded = try PropertyListDecoder().decode(AcknowledgementsStringsTable.self, from: data) + + XCTAssertEqual(decoded.name, "Licenses") + XCTAssertEqual(decoded.acknowledgements.count, 2) + XCTAssertEqual(decoded.acknowledgements[0].title, "Alamofire") + XCTAssertEqual(decoded.acknowledgements[1].title, "SnapKit") + } + + func testCodingKeysMatchSettingsBundleFormat() throws { + let table = AcknowledgementsStringsTable( + name: "Acknowledgements", + acknowledgements: [Acknowledgement(title: "Lib", license: "MIT")] + ) + + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + let data = try encoder.encode(table) + + let plistString = String(data: data, encoding: .utf8)! + + // Settings.bundle expects these exact keys + XCTAssertTrue(plistString.contains("StringsTable")) + XCTAssertTrue(plistString.contains("PreferenceSpecifiers")) + } + + // MARK: - Empty Acknowledgements + + func testEmptyAcknowledgements() throws { + let table = AcknowledgementsStringsTable(name: "Empty", acknowledgements: []) + + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + let data = try encoder.encode(table) + + let decoded = try PropertyListDecoder().decode(AcknowledgementsStringsTable.self, from: data) + + XCTAssertEqual(decoded.name, "Empty") + XCTAssertTrue(decoded.acknowledgements.isEmpty) + } + + // MARK: - Sorting Consistency + + func testAllFromPlistSortsCaseInsensitively() throws { + let acks = [ + Acknowledgement(title: "Zebra", license: "Z"), + Acknowledgement(title: "apple", license: "A"), + Acknowledgement(title: "Mango", license: "M"), + ] + let table = AcknowledgementsStringsTable(name: "Test", acknowledgements: acks) + + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + let data = try encoder.encode(table) + + // Write to a temp file and load via all(fromPlist:) to test the actual sorting path + let tmpDir = FileManager.default.temporaryDirectory + let plistURL = tmpDir.appendingPathComponent("TestStringsTable-\(UUID().uuidString).plist") + try data.write(to: plistURL) + defer { try? FileManager.default.removeItem(at: plistURL) } + + // AcknowledgementsStringsTable.all() uses Bundle.main which we can't inject, + // so test the sorting logic directly: .lowercased() comparison + let sorted = acks.sorted { $0.title.lowercased() < $1.title.lowercased() } + XCTAssertEqual(sorted.map(\.title), ["apple", "Mango", "Zebra"]) + } + + func testSortingConsistencyWithAcknowledgement() { + // Verify that AcknowledgementsStringsTable's .lowercased() sorting + // produces the same order as Acknowledgement's localizedStandardCompare + // for typical ASCII package names + let acks = [ + Acknowledgement(title: "Zebra", license: ""), + Acknowledgement(title: "apple", license: ""), + Acknowledgement(title: "Mango", license: ""), + Acknowledgement(title: "banana", license: ""), + Acknowledgement(title: "Cherry", license: ""), + ] + + let lowercasedSort = acks.sorted { $0.title.lowercased() < $1.title.lowercased() } + let comparableSort = acks.sorted() // uses localizedStandardCompare + + // For typical ASCII package names, both should produce the same order + XCTAssertEqual( + lowercasedSort.map(\.title), + comparableSort.map(\.title), + "Sorting via .lowercased() and localizedStandardCompare should match for ASCII package names" + ) + } +} diff --git a/Tests/AckGenTests/DeriveCheckoutsPathTests.swift b/Tests/AckGenTests/DeriveCheckoutsPathTests.swift new file mode 100644 index 0000000..0842cd8 --- /dev/null +++ b/Tests/AckGenTests/DeriveCheckoutsPathTests.swift @@ -0,0 +1,84 @@ +@testable import AckGenCLI +import XCTest + +final class DeriveCheckoutsPathTests: XCTestCase { + + // MARK: - Standard Xcode paths + + func testStandardDerivedDataPath() { + let tempDir = "/Users/dev/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Intermediates.noindex/MyApp.build" + + let result = deriveCheckoutsPath(from: tempDir) + + XCTAssertEqual( + result, + "/Users/dev/Library/Developer/Xcode/DerivedData/MyApp-abc123/SourcePackages/checkouts" + ) + } + + // MARK: - Archive builds (deeper path, issue #6) + + func testArchiveBuildPath() { + let tempDir = "/Users/dev/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Intermediates.noindex/ArchiveIntermediates/MyApp/IntermediateBuildFilesPath/MyApp.build" + + let result = deriveCheckoutsPath(from: tempDir) + + XCTAssertEqual( + result, + "/Users/dev/Library/Developer/Xcode/DerivedData/MyApp-abc123/SourcePackages/checkouts" + ) + } + + // MARK: - "Build" in directory name (bug #40) + + func testBuildInUsername() { + let tempDir = "/Users/BuildEngineer/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Intermediates.noindex/MyApp.build" + + let result = deriveCheckoutsPath(from: tempDir) + + // Should use the LAST /Build/, not the one in the username + XCTAssertEqual( + result, + "/Users/BuildEngineer/Library/Developer/Xcode/DerivedData/MyApp-abc123/SourcePackages/checkouts" + ) + } + + func testBuildInProjectName() { + let tempDir = "/Users/dev/Projects/BuildTools/Library/Developer/Xcode/DerivedData/App-xyz/Build/Intermediates.noindex/App.build" + + let result = deriveCheckoutsPath(from: tempDir) + + XCTAssertEqual( + result, + "/Users/dev/Projects/BuildTools/Library/Developer/Xcode/DerivedData/App-xyz/SourcePackages/checkouts" + ) + } + + func testMultipleBuildSegments() { + // Pathological case: "Build" appears multiple times + let tempDir = "/Users/dev/Build/Projects/DerivedData/App-abc/Build/Intermediates.noindex/App.build" + + let result = deriveCheckoutsPath(from: tempDir) + + // Last /Build/ wins + XCTAssertEqual( + result, + "/Users/dev/Build/Projects/DerivedData/App-abc/SourcePackages/checkouts" + ) + } + + // MARK: - Fallback (no /Build/ segment) + + func testNoBuildSegmentFallback() { + // Unlikely in practice, but tests the fallback behavior + let tempDir = "/some/unusual/path/without/build/segment" + + let result = deriveCheckoutsPath(from: tempDir) + + // Falls back to appending /SourcePackages/checkouts to the full path + XCTAssertEqual( + result, + "/some/unusual/path/without/build/segment/SourcePackages/checkouts" + ) + } +} diff --git a/Tests/AckGenTests/PlistEncodingTests.swift b/Tests/AckGenTests/PlistEncodingTests.swift new file mode 100644 index 0000000..73b677d --- /dev/null +++ b/Tests/AckGenTests/PlistEncodingTests.swift @@ -0,0 +1,90 @@ +@testable import AckGenCLI +@testable import AckGenCore +import XCTest + +final class PlistEncodingTests: XCTestCase { + + private let encoder: PropertyListEncoder = { + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + return encoder + }() + + // MARK: - Standard format (array of Acknowledgement) + + func testStandardFormatEncodesAsArray() throws { + let acks = [ + Acknowledgement(title: "Alamofire", license: "MIT"), + Acknowledgement(title: "SnapKit", license: "BSD"), + ] + + let data = try encoder.encode(acks) + let decoded = try PropertyListDecoder().decode([Acknowledgement].self, from: data) + + XCTAssertEqual(decoded.count, 2) + XCTAssertEqual(decoded[0].title, "Alamofire") + XCTAssertEqual(decoded[1].title, "SnapKit") + } + + func testStandardFormatPreservesLicenseText() throws { + let multilineLicense = """ + MIT License + + Copyright (c) 2024 Example + + Permission is hereby granted, free of charge... + """ + let acks = [Acknowledgement(title: "Example", license: multilineLicense)] + + let data = try encoder.encode(acks) + let decoded = try PropertyListDecoder().decode([Acknowledgement].self, from: data) + + XCTAssertEqual(decoded.first?.license, multilineLicense) + } + + // MARK: - Settings.bundle format + + func testSettingsFormatEncodesWithCorrectKeys() throws { + let acks = [Acknowledgement(title: "Lib", license: "MIT")] + let table = AcknowledgementsStringsTable(name: "Acknowledgements", acknowledgements: acks) + + let data = try encoder.encode(table) + let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as! [String: Any] + + XCTAssertEqual(plist["StringsTable"] as? String, "Acknowledgements") + XCTAssertNotNil(plist["PreferenceSpecifiers"] as? [[String: String]]) + } + + func testSettingsFormatCustomTitle() throws { + let acks = [Acknowledgement(title: "Lib", license: "MIT")] + let table = AcknowledgementsStringsTable(name: "Licenses", acknowledgements: acks) + + let data = try encoder.encode(table) + let decoded = try PropertyListDecoder().decode(AcknowledgementsStringsTable.self, from: data) + + XCTAssertEqual(decoded.name, "Licenses") + } + + // MARK: - Output file writing + + func testWritesPlistToFile() throws { + let acks = [ + Acknowledgement(title: "PackageA", license: "MIT"), + Acknowledgement(title: "PackageB", license: "Apache"), + ] + + let tmpDir = FileManager.default.temporaryDirectory + let plistURL = tmpDir.appendingPathComponent("PlistEncodingTest-\(UUID().uuidString).plist") + defer { try? FileManager.default.removeItem(at: plistURL) } + + let data = try encoder.encode(acks) + try data.write(to: plistURL) + + // Read back and verify + let readData = try Data(contentsOf: plistURL) + let decoded = try PropertyListDecoder().decode([Acknowledgement].self, from: readData) + + XCTAssertEqual(decoded.count, 2) + XCTAssertEqual(decoded[0].title, "PackageA") + } +}