Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Example/ackgen.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
22 changes: 15 additions & 7 deletions Sources/AckGenCLI/AckGen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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[..<range.lowerBound]) + "/SourcePackages/checkouts"
} else {
return tempDirPath.components(separatedBy: "/Build/")[0] + "/SourcePackages/checkouts"
}
}

struct LicenseScanner {
struct ScanResult {
var acknowledgements: [Acknowledgement]
Expand Down Expand Up @@ -64,13 +78,7 @@ struct AckGen: ParsableCommand {
}

let plistPath: String = output ?? "\(srcRoot)/Acknowledgements.plist"
// Use last occurrence of "/Build/" to handle edge cases like "Build" in username
let packageCachePath: String
if let range = tempDirPath.range(of: "/Build/", options: .backwards) {
packageCachePath = String(tempDirPath[..<range.lowerBound]) + "/SourcePackages/checkouts"
} else {
packageCachePath = tempDirPath.components(separatedBy: "/Build/")[0] + "/SourcePackages/checkouts"
}
let packageCachePath = deriveCheckoutsPath(from: tempDirPath)

let result = try LicenseScanner.scan(checkoutsPath: packageCachePath)

Expand Down
28 changes: 0 additions & 28 deletions Sources/AckGenCLI/EnvironmentKey.swift

This file was deleted.

108 changes: 108 additions & 0 deletions Tests/AckGenTests/AcknowledgementsStringsTableTests.swift
Original file line number Diff line number Diff line change
@@ -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("<key>StringsTable</key>"))
XCTAssertTrue(plistString.contains("<key>PreferenceSpecifiers</key>"))
}

// 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"
)
}
}
84 changes: 84 additions & 0 deletions Tests/AckGenTests/DeriveCheckoutsPathTests.swift
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
90 changes: 90 additions & 0 deletions Tests/AckGenTests/PlistEncodingTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading