diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..056a0e7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +AckGen is an **Ack**nowledgements **Gen**erator that automatically extracts license information from Swift Package Manager dependencies and generates plist files for iOS/macOS apps. + +## Build Commands + +```bash +# Build the CLI executable +swift build + +# Build in release mode +swift build -c release + +# Run the CLI (requires Xcode environment variables SRCROOT, PROJECT_TEMP_DIR) +swift run ackgen --output --settings --title + +# Run tests +swift test + +# Bootstrap example project (requires mint) +make bootstrap + +# Open example project in Xcode +make open +``` + +## Architecture + +Three-module Swift Package with one external dependency (`swift-argument-parser` for the CLI): + +``` +AckGenCore (library) +├── Acknowledgement.swift # Model + plist decoding for runtime use +│ +AckGenCLI (executable: ackgen) +├── AckGen.swift # Main logic: scans SourcePackages/checkouts for LICENSE files +├── AcknowledgementsStringsTable.swift # Settings.bundle plist format +│ +AckGenUI (library) +├── AcknowledgementsList.swift # SwiftUI list view for displaying licenses +``` + +**Data Flow:** +1. CLI runs as Xcode build phase, reads `PROJECT_TEMP_DIR` to find SPM checkouts +2. Scans for LICENSE, LICENSE.txt, LICENSE.md in each package directory +3. Encodes to plist (standard array or Settings.bundle format) +4. App uses `Acknowledgement.all()` to decode plist at runtime + +**Two Output Formats:** +- Standard: Array of `Acknowledgement` (default) +- Settings Bundle: `AcknowledgementsStringsTable` format with `StringsTable` and `PreferenceSpecifiers` keys + +## Example App + +Located in `Example/`. Uses XcodeGen (`project.yml`) for project generation. Contains pre-build scripts demonstrating both plist formats: +- `ackgen.sh` - Standard format +- `ackgen_settings.sh` - Settings.bundle format diff --git a/Example/ackgen.sh b/Example/ackgen.sh index 2530de5..dddd2b3 100644 --- a/Example/ackgen.sh +++ b/Example/ackgen.sh @@ -4,7 +4,7 @@ DIR=.. if [ -d "$DIR" ]; then cd $DIR SDKROOT=(xcrun --sdk macosx --show-sdk-path) - swift run ackgen $SRCROOT/PackageLicenses.plist + 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)" fi diff --git a/Example/ackgen_settings.sh b/Example/ackgen_settings.sh index 10f2360..d43f36c 100644 --- a/Example/ackgen_settings.sh +++ b/Example/ackgen_settings.sh @@ -8,7 +8,7 @@ if [ -d "$DIR" ]; then if [ -d "$SETTINGS_BUNDLE" ]; then PLIST_PATH="$SETTINGS_BUNDLE/Acknowledgements.plist" PROJECT_NAME=$(basename "$SRCROOT") - swift run ackgen "$PLIST_PATH" 1 "$PROJECT_NAME" + swift run ackgen --output "$PLIST_PATH" --settings --title "$PROJECT_NAME" else echo "warning: Settings.bundle not found in the project." fi diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..6c245b1 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index ca03d63..9f29bf8 100644 --- a/Package.swift +++ b/Package.swift @@ -16,13 +16,15 @@ let package = Package( .library(name: "AckGenUI", targets: ["AckGenUI"]), ], dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), ], targets: [ .executableTarget( name: "AckGenCLI", - dependencies: ["AckGenCore"]), + dependencies: [ + "AckGenCore", + .product(name: "ArgumentParser", package: "swift-argument-parser") + ]), .target( name: "AckGenUI", dependencies: ["AckGenCore"], @@ -35,6 +37,7 @@ let package = Package( ), .testTarget( name: "AckGenTests", - dependencies: ["AckGenCore"]), + dependencies: ["AckGenCore"], + resources: [.copy("Fixtures")]), ] ) diff --git a/README.md b/README.md index e865481..789a849 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,20 @@ fi Make sure to set `ENABLE_USER_SCRIPT_SANDBOXING` to `NO` in your build settings so the build phase above can write to the desired destination. -If you want the plist file to be saved somewhere other than `Acknowledgements.plist` at the root of your project (`$SRCROOT/Acknowledgements.plist`), you can provide a custom path as the first command line argument to `ackgen` above. +If you want the plist file to be saved somewhere other than `Acknowledgements.plist` at the root of your project (`$SRCROOT/Acknowledgements.plist`), you can provide a custom output path: ```sh - swift run ackgen $SRCROOT/PackageLicenses.plist + swift run ackgen --output "$SRCROOT/PackageLicenses.plist" ``` +To generate a Settings.bundle-compatible plist instead, use the `--settings` flag: + +```sh + swift run ackgen --output "$PLIST_PATH" --settings --title "MyApp" +``` + +Run `swift run ackgen --help` for all available options. + 3. Add the generated `plist` file to your project if you haven't already. Make sure to remove the check for **Copy items if needed** @@ -96,5 +104,5 @@ make open - [x] Add UI components (SwiftUI List with NavigationLink to license info?) - [ ] Allow Run Script Output Files as alternative to command line argument - [ ] Allow to specify excluded packages -- [ ] Add tests +- [x] Add tests - [ ] Add other platforms diff --git a/Sources/AckGenCLI/AckGen.swift b/Sources/AckGenCLI/AckGen.swift index ad75fd6..0268d0c 100644 --- a/Sources/AckGenCLI/AckGen.swift +++ b/Sources/AckGenCLI/AckGen.swift @@ -1,66 +1,82 @@ // // AckGen.swift -// +// // // Created by Martin Pfundmair on 2021-08-09. // import AckGenCore +import ArgumentParser import Foundation -struct AckGenCLI { +@main +struct AckGen: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Generate acknowledgements plist from Swift Package Manager dependencies", + version: "0.8.0" + ) + + @Option(name: .shortAndLong, help: "Output path for the generated plist file") + var output: String? + + @Flag(name: .long, help: "Generate Settings.bundle format") + var settings: Bool = false - static func main() { + @Option(name: .long, help: "Title for Settings.bundle (only used with --settings)") + var title: String = "Acknowledgements" + + func run() throws { print("Generating Acknowledgements file") let licenseFiles: [String] = ["LICENSE", "LICENSE.txt", "LICENSE.md"] - - let arguments: [String] = Array(CommandLine.arguments.dropFirst()) guard let srcRoot = ProcessInfo.processInfo.environment["SRCROOT"] else { - print("error: could not detect the source root directory.") - return + throw ValidationError("Could not detect the source root directory (SRCROOT environment variable not set)") } guard let tempDirPath = ProcessInfo.processInfo.environment["PROJECT_TEMP_DIR"] else { - print("error: could not detect the project's temp directory.") - return + throw ValidationError("Could not detect the project's temp directory (PROJECT_TEMP_DIR environment variable not set)") } - let plistPath: String = arguments.first ?? "\(srcRoot)/Acknowledgements.plist" - - let forSettings: Bool = arguments.count > 1 - - let settingsTitle: String = arguments.count > 2 ? arguments[2] : "Acknowledgements" + let plistPath: String = output ?? "\(srcRoot)/Acknowledgements.plist" let packageCachePath = tempDirPath.components(separatedBy: "/Build/")[0] + "/SourcePackages/checkouts" let fman = FileManager.default - do { + let packageDirectories = try fman.contentsOfDirectory(atPath: packageCachePath) + var acknowledgements = [Acknowledgement]() - let packageDirectories = try fman.contentsOfDirectory(atPath: packageCachePath) - var acknowledgements = [Acknowledgement]() - - for pkgDir in packageDirectories where pkgDir.prefix(1) != "." { - for file in licenseFiles { - guard let data = fman.contents(atPath: "\(packageCachePath)/\(pkgDir)/\(file)") else { continue } - let new = Acknowledgement(title: pkgDir, license: String(data: data, encoding: .utf8)!) - acknowledgements.append(new) + for pkgDir in packageDirectories where pkgDir.prefix(1) != "." { + for file in licenseFiles { + guard let data = fman.contents(atPath: "\(packageCachePath)/\(pkgDir)/\(file)") else { continue } + guard let license = String(data: data, encoding: .utf8) else { + print("warning: Skipping \(pkgDir)/\(file) - invalid UTF-8 encoding") + continue } + let new = Acknowledgement(title: pkgDir, license: license) + acknowledgements.append(new) + break } + } + + if acknowledgements.isEmpty { + throw ValidationError( + "No license files found in \(packageCachePath). " + + "Ensure SPM packages are resolved (Xcode → File → Packages → Resolve)." + ) + } - let encoder = PropertyListEncoder() - encoder.outputFormat = .xml + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml - if forSettings { - let acknowledgementsSettings = AcknowledgementsStringsTable(name: settingsTitle, acknowledgements: acknowledgements) - let data = try encoder.encode(acknowledgementsSettings) - try data.write(to: URL(fileURLWithPath: plistPath)) - } else { - let data = try encoder.encode(acknowledgements) - try data.write(to: URL(fileURLWithPath: plistPath)) - } - } catch { - print(error) + if settings { + let acknowledgementsSettings = AcknowledgementsStringsTable(name: title, acknowledgements: acknowledgements) + let data = try encoder.encode(acknowledgementsSettings) + try data.write(to: URL(fileURLWithPath: plistPath)) + } else { + let data = try encoder.encode(acknowledgements) + try data.write(to: URL(fileURLWithPath: plistPath)) } + + print("✓ Generated acknowledgements at: \(plistPath)") } } diff --git a/Sources/AckGenCLI/main.swift b/Sources/AckGenCLI/main.swift deleted file mode 100644 index 4c5749f..0000000 --- a/Sources/AckGenCLI/main.swift +++ /dev/null @@ -1,3 +0,0 @@ -import Foundation - -AckGenCLI.main() diff --git a/Tests/AckGenTests/AcknowledgementAllTests.swift b/Tests/AckGenTests/AcknowledgementAllTests.swift new file mode 100644 index 0000000..80cd620 --- /dev/null +++ b/Tests/AckGenTests/AcknowledgementAllTests.swift @@ -0,0 +1,66 @@ +// +// AcknowledgementAllTests.swift +// AckGenTests +// +// Created by Martin Pfundmair on 2026-01-13. +// + +import XCTest +@testable import AckGenCore + +final class AcknowledgementAllTests: XCTestCase { + + func testAllDecodesFixturePlist() { + // Given: A fixture plist in the test bundle's Fixtures directory + // When: Loading acknowledgements from the fixture + let acks = Acknowledgement.all(fromPlist: "Fixtures/Acknowledgements", in: Bundle.module) + + // Then: All entries should be decoded correctly + XCTAssertEqual(acks.count, 3) + + // Verify the content is decoded properly + let titles = acks.map(\.title) + XCTAssertTrue(titles.contains("Zebra")) + XCTAssertTrue(titles.contains("apple")) + XCTAssertTrue(titles.contains("Banana")) + + // Verify license content is present + let zebra = acks.first { $0.title == "Zebra" } + XCTAssertEqual(zebra?.license, "MIT License for Zebra package") + } + + func testAllSortsCaseInsensitively() { + // Given: A fixture plist with mixed case titles (Zebra, apple, Banana) + // When: Loading acknowledgements (which applies case-insensitive sorting) + let acks = Acknowledgement.all(fromPlist: "Fixtures/Acknowledgements", in: Bundle.module) + + // Then: Should be sorted case-insensitively: apple, Banana, Zebra + XCTAssertEqual(acks.count, 3) + XCTAssertEqual(acks[0].title, "apple") // 'a' comes first + XCTAssertEqual(acks[1].title, "Banana") // 'B' (as 'b') comes second + XCTAssertEqual(acks[2].title, "Zebra") // 'Z' (as 'z') comes last + } + + func testAllReturnsEmptyArrayForMissingPlist() { + // Given: A non-existent plist name + let bundle = Bundle.module + + // When: Loading from a non-existent plist + let acks = Acknowledgement.all(fromPlist: "NonExistent", in: bundle) + + // Then: Should return empty array instead of crashing + XCTAssertTrue(acks.isEmpty) + } + + func testAllReturnsEmptyArrayForInvalidPlist() { + // Given: A malformed/invalid plist file (fixture: invalid-utf8-license) + // Note: This test verifies graceful handling of decode failures for malformed plist data + // The all() method should return an empty array for any plist decode failure + + // When: Attempting to decode a malformed plist that cannot be parsed + let acks = Acknowledgement.all(fromPlist: "Fixtures/invalid-utf8-license", in: Bundle.module) + + // Then: Should return empty array instead of crashing + XCTAssertTrue(acks.isEmpty) + } +} diff --git a/Tests/AckGenTests/AcknowledgementTests.swift b/Tests/AckGenTests/AcknowledgementTests.swift new file mode 100644 index 0000000..2406078 --- /dev/null +++ b/Tests/AckGenTests/AcknowledgementTests.swift @@ -0,0 +1,101 @@ +// +// AcknowledgementTests.swift +// AckGenTests +// +// Created by Martin Pfundmair on 2026-01-04. +// + +import XCTest +@testable import AckGenCore + +final class AcknowledgementTests: XCTestCase { + + func testInitialization() { + let ack = Acknowledgement(title: "TestPackage", license: "MIT License") + XCTAssertEqual(ack.title, "TestPackage") + XCTAssertEqual(ack.license, "MIT License") + XCTAssertEqual(ack.type, "PSGroupSpecifier") + } + + func testComparable() { + let ackA = Acknowledgement(title: "A", license: "") + let ackB = Acknowledgement(title: "B", license: "") + let ackZ = Acknowledgement(title: "Z", license: "") + + XCTAssertTrue(ackA < ackB) + XCTAssertTrue(ackB < ackZ) + XCTAssertFalse(ackB < ackA) + } + + func testSortingIsCaseSensitive() { + // Note: Comparable implementation uses case-sensitive sorting + // This differs from Acknowledgement.all() which uses case-insensitive + let acks = [ + Acknowledgement(title: "Zebra", license: "License 1"), + Acknowledgement(title: "apple", license: "License 2"), + Acknowledgement(title: "Banana", license: "License 3") + ] + + let sorted = acks.sorted() + + // Case-sensitive: uppercase comes before lowercase in ASCII + XCTAssertEqual(sorted[0].title, "Banana") + XCTAssertEqual(sorted[1].title, "Zebra") + XCTAssertEqual(sorted[2].title, "apple") + } + + func testCodingKeys() { + let ack = Acknowledgement(title: "TestPackage", license: "MIT License") + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + + var data: Data? + XCTAssertNoThrow(data = try encoder.encode(ack)) + + guard let encodedData = data else { + XCTFail("Failed to encode Acknowledgement to Property List data.") + return + } + + guard let plistString = String(data: encodedData, encoding: .utf8) else { + XCTFail("Failed to convert Property List data to UTF-8 String.") + return + } + // Verify the coding keys are correct (Title, FooterText, Type) + XCTAssertTrue(plistString.contains("Title")) + XCTAssertTrue(plistString.contains("FooterText")) + XCTAssertTrue(plistString.contains("Type")) + XCTAssertTrue(plistString.contains("PSGroupSpecifier")) + } + + func testEncodingAndDecoding() throws { + let original = Acknowledgement(title: "SwiftUI", license: "Apache 2.0") + let encoder = PropertyListEncoder() + let data = try encoder.encode(original) + + let decoder = PropertyListDecoder() + let decoded = try decoder.decode(Acknowledgement.self, from: data) + + XCTAssertEqual(decoded.title, original.title) + XCTAssertEqual(decoded.license, original.license) + XCTAssertEqual(decoded.type, original.type) + } + + func testArrayEncoding() throws { + let acks = [ + Acknowledgement(title: "Package1", license: "MIT"), + Acknowledgement(title: "Package2", license: "Apache") + ] + + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + let data = try encoder.encode(acks) + + let decoder = PropertyListDecoder() + let decoded = try decoder.decode([Acknowledgement].self, from: data) + + XCTAssertEqual(decoded.count, 2) + XCTAssertEqual(decoded[0].title, "Package1") + XCTAssertEqual(decoded[1].title, "Package2") + } +} diff --git a/Tests/AckGenTests/Fixtures/Acknowledgements.plist b/Tests/AckGenTests/Fixtures/Acknowledgements.plist new file mode 100644 index 0000000..151e246 --- /dev/null +++ b/Tests/AckGenTests/Fixtures/Acknowledgements.plist @@ -0,0 +1,30 @@ + + + + + + Title + Zebra + FooterText + MIT License for Zebra package + Type + PSGroupSpecifier + + + Title + apple + FooterText + Apache 2.0 License for apple package + Type + PSGroupSpecifier + + + Title + Banana + FooterText + BSD License for Banana package + Type + PSGroupSpecifier + + + diff --git a/Tests/AckGenTests/Fixtures/invalid-utf8-license.plist b/Tests/AckGenTests/Fixtures/invalid-utf8-license.plist new file mode 100644 index 0000000..503ecb8 --- /dev/null +++ b/Tests/AckGenTests/Fixtures/invalid-utf8-license.plist @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md new file mode 100644 index 0000000..256eacb --- /dev/null +++ b/docs/improvement-plan.md @@ -0,0 +1,143 @@ +# AckGen Improvement Plan + +## Overview + +Fix bugs, improve CLI ergonomics, and establish a working test suite. + +## Changes + +### 1. Fix Force Unwrap Crash (Bug) + +**File:** `Sources/AckGenCLI/AckGen.swift:46` + +```swift +// Before +let new = Acknowledgement(title: pkgDir, license: String(data: data, encoding: .utf8)!) + +// After +guard let license = String(data: data, encoding: .utf8) else { continue } +let new = Acknowledgement(title: pkgDir, license: license) +``` + +--- + +### 2. Add Exit Codes (Bug) + +**File:** `Sources/AckGenCLI/AckGen.swift` + +Replace `return` after error prints with proper exit: +```swift +import Darwin + +// After each error print: +Darwin.exit(1) +``` + +Or better: refactor to throw errors and handle at top level. + +--- + +### 3. Migrate to SwiftArgumentParser + +**File:** `Package.swift` +- Add dependency: `swift-argument-parser` from 1.3.0 + +**File:** `Sources/AckGenCLI/AckGen.swift` +- Convert to `@main struct AckGen: ParsableCommand` +- Define arguments: + - `--output` / `-o`: Output plist path (default: `$SRCROOT/Acknowledgements.plist`) + - `--settings`: Flag for Settings.bundle format + - `--title`: Settings bundle title (default: "Acknowledgements") + - `--version`: Auto-generated + +**File:** `Sources/AckGenCLI/main.swift` +- Remove (SwiftArgumentParser uses `@main`) + +**Breaking change:** Positional args → named flags. Update README and Example scripts. + +--- + +### 4. Set Up Unit Tests + +**File:** `Tests/AckGenTests/AckGenTests.swift` +- Delete broken integration test + +**New file:** `Tests/AckGenTests/AcknowledgementTests.swift` +```swift +import XCTest +@testable import AckGenCore + +final class AcknowledgementTests: XCTestCase { + + func testDecodeFromPlist() throws { + // Test Acknowledgement.all() with fixture plist + } + + func testSortingIsCaseInsensitive() { + let acks = [ + Acknowledgement(title: "Zebra", license: ""), + Acknowledgement(title: "apple", license: ""), + Acknowledgement(title: "Banana", license: "") + ] + let sorted = acks.sorted() + XCTAssertEqual(sorted.map(\.title), ["apple", "Banana", "Zebra"]) + } + + func testComparable() { + let a = Acknowledgement(title: "A", license: "") + let b = Acknowledgement(title: "B", license: "") + XCTAssertTrue(a < b) + } +} +``` + +**New file:** `Tests/AckGenTests/Fixtures/TestAcknowledgements.plist` +- Sample plist for decode testing + +**Update:** `Package.swift` - add resources to test target if needed + +--- + +### 5. Remove Hardcoded `type` from Core Model (Optional) + +**File:** `Sources/AckGenCore/Acknowledgement.swift` +- Remove `type` property (only needed for Settings.bundle format) + +**File:** `Sources/AckGenCLI/AcknowledgementsStringsTable.swift` +- Add wrapper that includes `type` during encoding + +*Note: This is a breaking change for anyone manually encoding Acknowledgement. Consider for v1.0.* + +--- + +## Execution Order + +1. Fix force unwrap (2 min) - immediate safety +2. Add exit codes (5 min) - immediate correctness +3. Set up unit tests (15 min) - establish test infrastructure +4. Migrate to SwiftArgumentParser (20 min) - larger refactor, needs tests passing first +5. (Optional) Clean up type property - defer to v1.0 + +## Files to Modify + +| File | Change | +|------|--------| +| `Package.swift` | Add swift-argument-parser dependency | +| `Sources/AckGenCLI/AckGen.swift` | Fix crash, add exit codes, migrate to ArgumentParser | +| `Sources/AckGenCLI/main.swift` | Delete | +| `Tests/AckGenTests/AckGenTests.swift` | Replace with proper unit tests | +| `Example/ackgen.sh` | Update CLI invocation | +| `Example/ackgen_settings.sh` | Update CLI invocation | +| `README.md` | Update CLI usage examples | + +## Verification + +```bash +# After each change +swift build +swift test + +# After ArgumentParser migration +swift run ackgen --help +swift run ackgen --version +```