Skip to content
Merged
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
61 changes: 61 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <output_path> --settings --title <settingsTitle>

Comment on lines +18 to +20

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI invocation documented here still uses the old positional-argument interface (swift run ackgen [output_path] [forSettings] [settingsTitle]). Since the CLI was migrated to SwiftArgumentParser, this should be updated to the new flags (--output, --settings, --title) to avoid misleading contributors.

Copilot uses AI. Check for mistakes.
# 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
2 changes: 1 addition & 1 deletion Example/ackgen.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion Example/ackgen_settings.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 7 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -35,6 +37,7 @@ let package = Package(
),
.testTarget(
name: "AckGenTests",
dependencies: ["AckGenCore"]),
dependencies: ["AckGenCore"],
resources: [.copy("Fixtures")]),
]
)
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down Expand Up @@ -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
86 changes: 51 additions & 35 deletions Sources/AckGenCLI/AckGen.swift
Original file line number Diff line number Diff line change
@@ -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"

Comment on lines +12 to +27

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions updating README CLI usage examples, but README.md in this branch still shows swift run ackgen and positional arguments. Please update README.md to reflect the new flag-based interface (and note the breaking change) so end users don’t follow outdated instructions.

Copilot uses AI. Check for mistakes.
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"

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

packageCachePath is derived by splitting PROJECT_TEMP_DIR on the literal string "/Build/". This is brittle (e.g., paths containing "Build" elsewhere, or when the substring isn’t present) and can produce an incorrect checkout path. Prefer extracting the DerivedData root by finding the last "/Build/" path component (or using URL path components) and fail with a clear ValidationError if it can’t be determined.

Suggested change
let packageCachePath = tempDirPath.components(separatedBy: "/Build/")[0] + "/SourcePackages/checkouts"
guard let buildRange = tempDirPath.range(of: "/Build/", options: .backwards) else {
throw ValidationError(
"Could not determine DerivedData root from PROJECT_TEMP_DIR (\(tempDirPath)). " +
"Expected path to contain a '/Build/' component."
)
}
let derivedDataRoot = String(tempDirPath[..<buildRange.lowerBound])
let packageCachePath = derivedDataRoot + "/SourcePackages/checkouts"

Copilot uses AI. Check for mistakes.
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)
Comment thread
MartinP7r marked this conversation as resolved.
break
}
}

if acknowledgements.isEmpty {
throw ValidationError(
"No license files found in \(packageCachePath). " +
"Ensure SPM packages are resolved (Xcode β†’ File β†’ Packages β†’ Resolve)."
)
}
Comment on lines +45 to +66

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new CLI behaviors (skipping invalid UTF-8 license files with a warning, and failing with ValidationError when no licenses are found) aren’t covered by tests. Since the repo now has a working XCTest suite, consider adding at least a minimal CLI test (or extracting the scanning/encoding logic into AckGenCore for unit testing) to prevent regressions in build-phase behavior.

Copilot uses AI. Check for mistakes.

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)")
}
}
3 changes: 0 additions & 3 deletions Sources/AckGenCLI/main.swift

This file was deleted.

66 changes: 66 additions & 0 deletions Tests/AckGenTests/AcknowledgementAllTests.swift
Original file line number Diff line number Diff line change
@@ -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)

Comment on lines +55 to +62

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test (and the fixture name) describes "invalid UTF-8" behavior, but the code under test (Acknowledgement.all) is decoding a plist resource via PropertyListDecoder. The failure mode here is "invalid plist / decode failure", not UTF-8 license decoding. Consider renaming the fixture/test description to reflect what’s actually being tested (e.g., invalid plist) to avoid confusion with the CLI UTF-8 guard.

Copilot uses AI. Check for mistakes.
// Then: Should return empty array instead of crashing
XCTAssertTrue(acks.isEmpty)
}
}
Loading