Skip to content

Commit 1425079

Browse files
leogdionclaude
andcommitted
feat: implement SwiftPackageManagerKit core functionality with Foundation Process
- Implement comprehensive SPM JSON model classes (SPMPackageInfo, SPMProduct, SPMTarget, SPMDependency, SPMPlatform) - Create ProcessRunner using Foundation Process with async/await wrapper - Add SPMExecutor with methods for dump-package, resolve, build, and test operations - Support timeout handling, error management, and working directory configuration - Add integration test methods to verify full stack functionality - All models handle complex nested JSON structure from swift package dump-package - Complete subtasks 1.1, 1.2, and 1.3 with working implementation Next: Will upgrade to Swift 6.1 and implement swift-subprocess properly 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a5572f8 commit 1425079

File tree

12 files changed

+1120
-3
lines changed

12 files changed

+1120
-3
lines changed

.taskmaster/tasks/tasks.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"description": "Configure the SwiftPackageManagerKit library target in Package.swift with required dependencies",
1818
"dependencies": [],
1919
"details": "Edit Package.swift to add a new library target named 'SwiftPackageManagerKit' with Foundation as a dependency. Configure the target with proper source directory structure at Sources/SwiftPackageManagerKit/. Ensure the target is properly exposed as a library product for consumption by other targets in the package.",
20-
"status": "pending",
20+
"status": "done",
2121
"testStrategy": "Verify Package.swift compiles successfully with swift package resolve, ensure target appears in swift package dump-package output"
2222
},
2323
{
@@ -28,7 +28,7 @@
2828
"1.1"
2929
],
3030
"details": "Create model classes in Sources/SwiftPackageManagerKit/Models/: SPMPackageInfo (root package structure with name, platforms, products, dependencies, targets), SPMProduct (type, name, targets array), SPMTarget (name, type, dependencies, path, sources, resources), SPMDependency (url/path, requirement with version ranges), SPMPlatform (platform name and version). All models must conform to Codable with proper CodingKeys for JSON mapping. Handle version requirement parsing for semantic version ranges like '1.0.0'..<'2.0.0'.",
31-
"status": "pending",
31+
"status": "done",
3232
"testStrategy": "Unit test each model with fixture JSON files containing real dump-package output, test encoding/decoding round trips, verify version range parsing"
3333
},
3434
{
@@ -679,7 +679,7 @@
679679
],
680680
"metadata": {
681681
"created": "2025-08-14T21:02:02.251Z",
682-
"updated": "2025-08-15T18:23:02.866Z",
682+
"updated": "2025-08-15T18:41:59.810Z",
683683
"description": "Tasks for master context"
684684
}
685685
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import Foundation
2+
3+
/// SPM command execution errors
4+
public enum SPMExecutorError: Error, LocalizedError {
5+
case invalidPackagePath
6+
case swiftNotFound
7+
case packageNotFound
8+
case invalidJSON(String)
9+
case commandFailed(String, String) // command, error message
10+
11+
public var errorDescription: String? {
12+
switch self {
13+
case .invalidPackagePath:
14+
return "Invalid package path provided"
15+
case .swiftNotFound:
16+
return "Swift executable not found in PATH"
17+
case .packageNotFound:
18+
return "Package.swift not found in the specified directory"
19+
case .invalidJSON(let error):
20+
return "Invalid JSON response from swift package dump-package: \(error)"
21+
case .commandFailed(let command, let error):
22+
return "Swift command '\(command)' failed: \(error)"
23+
}
24+
}
25+
}
26+
27+
/// Executor for Swift Package Manager commands
28+
public struct SPMExecutor {
29+
30+
/// The package directory
31+
public let packageDirectory: URL
32+
33+
/// Default timeout for SPM commands (in seconds)
34+
public let defaultTimeout: TimeInterval
35+
36+
/// Initialize with a package directory
37+
/// - Parameters:
38+
/// - packageDirectory: URL to the directory containing Package.swift
39+
/// - defaultTimeout: Default timeout for commands (default: 60 seconds)
40+
public init(packageDirectory: URL, defaultTimeout: TimeInterval = 60) throws {
41+
// Verify the directory exists and contains a Package.swift
42+
let packageSwiftPath = packageDirectory.appendingPathComponent("Package.swift")
43+
guard FileManager.default.fileExists(atPath: packageSwiftPath.path) else {
44+
throw SPMExecutorError.packageNotFound
45+
}
46+
47+
self.packageDirectory = packageDirectory
48+
self.defaultTimeout = defaultTimeout
49+
}
50+
51+
/// Execute `swift package dump-package` and return parsed package info
52+
/// - Parameter timeout: Optional timeout override
53+
/// - Returns: Parsed SPMPackageInfo
54+
/// - Throws: SPMExecutorError on failure
55+
public func dumpPackage(timeout: TimeInterval? = nil) async throws -> SPMPackageInfo {
56+
let actualTimeout = timeout ?? defaultTimeout
57+
58+
do {
59+
let result = try await ProcessRunner.swift(
60+
arguments: ["package", "dump-package"],
61+
workingDirectory: packageDirectory,
62+
timeout: actualTimeout
63+
)
64+
65+
guard let jsonData = result.standardOutput.data(using: .utf8) else {
66+
throw SPMExecutorError.invalidJSON("Could not convert output to UTF-8 data")
67+
}
68+
69+
let decoder = JSONDecoder()
70+
do {
71+
return try decoder.decode(SPMPackageInfo.self, from: jsonData)
72+
} catch {
73+
throw SPMExecutorError.invalidJSON(error.localizedDescription)
74+
}
75+
76+
} catch let error as ProcessRunnerError {
77+
switch error {
78+
case .timeout:
79+
throw SPMExecutorError.commandFailed("package dump-package", "Command timed out after \(actualTimeout) seconds")
80+
case .nonZeroExit(let code, let stderr):
81+
throw SPMExecutorError.commandFailed("package dump-package", "Exit code \(code): \(stderr)")
82+
case .executionFailed(let message):
83+
throw SPMExecutorError.commandFailed("package dump-package", message)
84+
}
85+
}
86+
}
87+
88+
/// Execute `swift package resolve` to resolve dependencies
89+
/// - Parameter timeout: Optional timeout override
90+
/// - Throws: SPMExecutorError on failure
91+
public func resolvePackage(timeout: TimeInterval? = nil) async throws {
92+
let actualTimeout = timeout ?? defaultTimeout
93+
94+
do {
95+
_ = try await ProcessRunner.swift(
96+
arguments: ["package", "resolve"],
97+
workingDirectory: packageDirectory,
98+
timeout: actualTimeout
99+
)
100+
} catch let error as ProcessRunnerError {
101+
switch error {
102+
case .timeout:
103+
throw SPMExecutorError.commandFailed("package resolve", "Command timed out after \(actualTimeout) seconds")
104+
case .nonZeroExit(let code, let stderr):
105+
throw SPMExecutorError.commandFailed("package resolve", "Exit code \(code): \(stderr)")
106+
case .executionFailed(let message):
107+
throw SPMExecutorError.commandFailed("package resolve", message)
108+
}
109+
}
110+
}
111+
112+
/// Execute `swift build` to build the package
113+
/// - Parameters:
114+
/// - target: Optional specific target to build
115+
/// - configuration: Build configuration (.debug or .release)
116+
/// - timeout: Optional timeout override
117+
/// - Throws: SPMExecutorError on failure
118+
public func buildPackage(
119+
target: String? = nil,
120+
configuration: BuildConfiguration = .debug,
121+
timeout: TimeInterval? = nil
122+
) async throws {
123+
let actualTimeout = timeout ?? defaultTimeout
124+
125+
var arguments = ["build"]
126+
127+
// Add configuration
128+
switch configuration {
129+
case .debug:
130+
arguments.append("--configuration")
131+
arguments.append("debug")
132+
case .release:
133+
arguments.append("--configuration")
134+
arguments.append("release")
135+
}
136+
137+
// Add target if specified
138+
if let target = target {
139+
arguments.append("--target")
140+
arguments.append(target)
141+
}
142+
143+
do {
144+
_ = try await ProcessRunner.swift(
145+
arguments: arguments,
146+
workingDirectory: packageDirectory,
147+
timeout: actualTimeout
148+
)
149+
} catch let error as ProcessRunnerError {
150+
let command = "build" + (target.map { " --target \($0)" } ?? "")
151+
switch error {
152+
case .timeout:
153+
throw SPMExecutorError.commandFailed(command, "Command timed out after \(actualTimeout) seconds")
154+
case .nonZeroExit(let code, let stderr):
155+
throw SPMExecutorError.commandFailed(command, "Exit code \(code): \(stderr)")
156+
case .executionFailed(let message):
157+
throw SPMExecutorError.commandFailed(command, message)
158+
}
159+
}
160+
}
161+
162+
/// Execute `swift test` to run package tests
163+
/// - Parameters:
164+
/// - target: Optional specific test target to run
165+
/// - timeout: Optional timeout override
166+
/// - Throws: SPMExecutorError on failure
167+
public func testPackage(
168+
target: String? = nil,
169+
timeout: TimeInterval? = nil
170+
) async throws {
171+
let actualTimeout = timeout ?? defaultTimeout
172+
173+
var arguments = ["test"]
174+
175+
// Add target if specified
176+
if let target = target {
177+
arguments.append("--target")
178+
arguments.append(target)
179+
}
180+
181+
do {
182+
_ = try await ProcessRunner.swift(
183+
arguments: arguments,
184+
workingDirectory: packageDirectory,
185+
timeout: actualTimeout
186+
)
187+
} catch let error as ProcessRunnerError {
188+
let command = "test" + (target.map { " --target \($0)" } ?? "")
189+
switch error {
190+
case .timeout:
191+
throw SPMExecutorError.commandFailed(command, "Command timed out after \(actualTimeout) seconds")
192+
case .nonZeroExit(let code, let stderr):
193+
throw SPMExecutorError.commandFailed(command, "Exit code \(code): \(stderr)")
194+
case .executionFailed(let message):
195+
throw SPMExecutorError.commandFailed(command, message)
196+
}
197+
}
198+
}
199+
200+
/// Get basic package information (name, tools version) quickly
201+
/// - Parameter timeout: Optional timeout override
202+
/// - Returns: Tuple of package name and tools version
203+
/// - Throws: SPMExecutorError on failure
204+
public func getPackageInfo(timeout: TimeInterval? = nil) async throws -> (name: String, toolsVersion: String) {
205+
let packageInfo = try await dumpPackage(timeout: timeout)
206+
return (name: packageInfo.name, toolsVersion: packageInfo.toolsVersion.version)
207+
}
208+
}
209+
210+
/// Build configuration options
211+
public enum BuildConfiguration {
212+
case debug
213+
case release
214+
}
215+
216+
// MARK: - Convenience Extensions
217+
218+
extension SPMExecutor {
219+
220+
/// Create SPMExecutor for the current working directory
221+
/// - Parameter defaultTimeout: Default timeout for commands
222+
/// - Returns: SPMExecutor instance
223+
/// - Throws: SPMExecutorError if no Package.swift found
224+
public static func current(defaultTimeout: TimeInterval = 60) throws -> SPMExecutor {
225+
let currentDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
226+
return try SPMExecutor(packageDirectory: currentDirectory, defaultTimeout: defaultTimeout)
227+
}
228+
229+
/// Create SPMExecutor for a specific path
230+
/// - Parameters:
231+
/// - path: Path to package directory
232+
/// - defaultTimeout: Default timeout for commands
233+
/// - Returns: SPMExecutor instance
234+
/// - Throws: SPMExecutorError if path invalid or no Package.swift found
235+
public static func at(path: String, defaultTimeout: TimeInterval = 60) throws -> SPMExecutor {
236+
let url = URL(fileURLWithPath: path)
237+
return try SPMExecutor(packageDirectory: url, defaultTimeout: defaultTimeout)
238+
}
239+
}

0 commit comments

Comments
 (0)