Skip to content

Commit b81c9cc

Browse files
authored
Add a modeled command-line for better checking (#330)
Swiftly has many different commands that it invokes in different parts of its code base, supporting tooling, and tests. This list includes tar, git, pkgutil, and dscl among oseveral others. For the most part these tools are called using arrays that are manually assembled at the call site. In order to exercise that callsite in tests the test must necessarily become "medium" size because it involves integration testing with that tool, instead of "small", which is where most tests should be. A small test of that array would verify that the length has the correct arguments, number, spelling, and order. Also, there are various types of string interpolation in these arrays, which limit the type-safety. Introduce a new way of modeling command-line invocations using Swift types. Commands and subcommands are modeled using structs. Use functions to assemble the structs, and methods to assemble structs for subcommands. Arguments become function arguments, with default values if they are optional. Flags, and options will be described using enum cases with carrying values for options. Some commands are runnable, while others are not. Introduce a Runnable protocol that marks runnable commands, and provides a default implementation of a run() method to run the assembled command. Other commands can produce output. Introduce an Output protocol and default implementation that runs the command and receives the output as a string that the callsite can use. Some callsites make decisions based on whether a tool is present or not. Provide a convenient way to verify whether a command exists on the system. Use this mechanism to skip tests when they are run in an environment that doesn't have the tool. Pick one command, dscl, to use with the new modeled command-line. Write small tests that don't invoke the tool. Instead they verify the configuration that is produced with different typical inputs from swiftly. Add a single medium size test that runs dscl from a modeled command, enabled only if dscl exists on the system. Add tags for medium, and large tests. Mark existing large tests, which is the HTTPClientTests. Leave all of the remaining tests as untagged, (i.e. small). Mark the CommandLineTest that invokes the dscl tool as medium.
1 parent 4fca65e commit b81c9cc

File tree

8 files changed

+352
-47
lines changed

8 files changed

+352
-47
lines changed

Package.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ let package = Package(
117117
.executableTarget(
118118
name: "build-swiftly-release",
119119
dependencies: [
120+
.target(name: "SwiftlyCore"),
121+
.target(name: "LinuxPlatform", condition: .when(platforms: [.linux])),
122+
.target(name: "MacOSPlatform", condition: .when(platforms: [.macOS])),
120123
.product(name: "ArgumentParser", package: "swift-argument-parser"),
121124
],
122125
path: "Tools/build-swiftly-release"

Sources/MacOSPlatform/MacOS.swift

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Foundation
22
import SwiftlyCore
33
import SystemPackage
44

5+
typealias sys = SwiftlyCore.SystemCommand
56
typealias fs = SwiftlyCore.FileSystem
67

78
public struct SwiftPkgInfo: Codable {
@@ -191,14 +192,8 @@ public struct MacOS: Platform {
191192
}
192193

193194
public func getShell() async throws -> String {
194-
if let directoryInfo = try await runProgramOutput("dscl", ".", "-read", "\(fs.home)") {
195-
for line in directoryInfo.components(separatedBy: "\n") {
196-
if line.hasPrefix("UserShell: ") {
197-
if case let comps = line.components(separatedBy: ": "), comps.count == 2 {
198-
return comps[1]
199-
}
200-
}
201-
}
195+
for (key, value) in try await sys.dscl(datasource: ".").read(path: fs.home, keys: "UserShell").properties(self) {
196+
return value
202197
}
203198

204199
// Fall back to zsh on macOS

Sources/SwiftlyCore/Commands.swift

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import Foundation
2+
import SystemPackage
3+
4+
public enum SystemCommand {}
5+
6+
// This file contains a set of system commands that's used by Swiftly and its related tests and tooling
7+
8+
// Directory Service command line utility for macOS
9+
// See dscl(1) for details
10+
extension SystemCommand {
11+
public static func dscl(executable: Executable = DsclCommand.defaultExecutable, datasource: String? = nil) -> DsclCommand {
12+
DsclCommand(executable: executable, datasource: datasource)
13+
}
14+
15+
public struct DsclCommand {
16+
public static var defaultExecutable: Executable { .name("dscl") }
17+
18+
var executable: Executable
19+
var datasource: String?
20+
21+
internal init(
22+
executable: Executable,
23+
datasource: String?
24+
) {
25+
self.executable = executable
26+
self.datasource = datasource
27+
}
28+
29+
func config() -> Configuration {
30+
var args: [String] = []
31+
32+
if let datasource = self.datasource {
33+
args += [datasource]
34+
}
35+
36+
return Configuration(
37+
executable: self.executable,
38+
arguments: Arguments(args),
39+
environment: .inherit
40+
)
41+
}
42+
43+
public func read(path: FilePath? = nil, keys: [String]) -> ReadCommand {
44+
ReadCommand(dscl: self, path: path, keys: keys)
45+
}
46+
47+
public func read(path: FilePath? = nil, keys: String...) -> ReadCommand {
48+
self.read(path: path, keys: keys)
49+
}
50+
51+
public struct ReadCommand: Output {
52+
var dscl: DsclCommand
53+
var path: FilePath?
54+
var keys: [String]
55+
56+
internal init(dscl: DsclCommand, path: FilePath?, keys: [String]) {
57+
self.dscl = dscl
58+
self.path = path
59+
self.keys = keys
60+
}
61+
62+
public func config() -> Configuration {
63+
var c = self.dscl.config()
64+
65+
var args = c.arguments.storage.map(\.description) + ["-read"]
66+
67+
if let path = self.path {
68+
args += [path.string] + self.keys
69+
}
70+
71+
c.arguments = .init(args)
72+
73+
return c
74+
}
75+
}
76+
}
77+
}
78+
79+
extension SystemCommand.DsclCommand.ReadCommand {
80+
public func properties(_ p: Platform) async throws -> [(key: String, value: String)] {
81+
let output = try await self.output(p)
82+
guard let output else { return [] }
83+
84+
var props: [(key: String, value: String)] = []
85+
for line in output.components(separatedBy: "\n") {
86+
if case let comps = line.components(separatedBy: ": "), comps.count == 2 {
87+
props.append((key: comps[0], value: comps[1]))
88+
}
89+
}
90+
return props
91+
}
92+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import Foundation
2+
import SystemPackage
3+
4+
public enum CommandLineError: Error {
5+
case invalidArgs
6+
case errorExit(exitCode: Int32, program: String)
7+
case unknownVersion
8+
}
9+
10+
// This section is a clone of the Configuration type from the new Subprocess package, until we can depend on that package.
11+
public struct Configuration: Sendable {
12+
/// The executable to run.
13+
public var executable: Executable
14+
/// The arguments to pass to the executable.
15+
public var arguments: Arguments
16+
/// The environment to use when running the executable.
17+
public var environment: Environment
18+
}
19+
20+
public struct Executable: Sendable, Hashable {
21+
internal enum Storage: Sendable, Hashable {
22+
case executable(String)
23+
case path(FilePath)
24+
}
25+
26+
internal let storage: Storage
27+
28+
private init(_config: Storage) {
29+
self.storage = _config
30+
}
31+
32+
/// Locate the executable by its name.
33+
/// `Subprocess` will use `PATH` value to
34+
/// determine the full path to the executable.
35+
public static func name(_ executableName: String) -> Self {
36+
.init(_config: .executable(executableName))
37+
}
38+
39+
/// Locate the executable by its full path.
40+
/// `Subprocess` will use this path directly.
41+
public static func path(_ filePath: FilePath) -> Self {
42+
.init(_config: .path(filePath))
43+
}
44+
}
45+
46+
public struct Environment: Sendable, Hashable {
47+
internal enum Configuration: Sendable, Hashable {
48+
case inherit([String: String])
49+
case custom([String: String])
50+
}
51+
52+
internal let config: Configuration
53+
54+
init(config: Configuration) {
55+
self.config = config
56+
}
57+
58+
/// Child process should inherit the same environment
59+
/// values from its parent process.
60+
public static var inherit: Self {
61+
.init(config: .inherit([:]))
62+
}
63+
64+
/// Override the provided `newValue` in the existing `Environment`
65+
public func updating(_ newValue: [String: String]) -> Self {
66+
.init(config: .inherit(newValue))
67+
}
68+
69+
/// Use custom environment variables
70+
public static func custom(_ newValue: [String: String]) -> Self {
71+
.init(config: .custom(newValue))
72+
}
73+
}
74+
75+
internal enum StringOrRawBytes: Sendable, Hashable {
76+
case string(String)
77+
78+
var stringValue: String? {
79+
switch self {
80+
case let .string(string):
81+
return string
82+
}
83+
}
84+
85+
var description: String {
86+
switch self {
87+
case let .string(string):
88+
return string
89+
}
90+
}
91+
92+
var count: Int {
93+
switch self {
94+
case let .string(string):
95+
return string.count
96+
}
97+
}
98+
99+
func hash(into hasher: inout Hasher) {
100+
// If Raw bytes is valid UTF8, hash it as so
101+
switch self {
102+
case let .string(string):
103+
hasher.combine(string)
104+
}
105+
}
106+
}
107+
108+
public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable {
109+
public typealias ArrayLiteralElement = String
110+
111+
internal let storage: [StringOrRawBytes]
112+
113+
/// Create an Arguments object using the given literal values
114+
public init(arrayLiteral elements: String...) {
115+
self.storage = elements.map { .string($0) }
116+
}
117+
118+
/// Create an Arguments object using the given array
119+
public init(_ array: [String]) {
120+
self.storage = array.map { .string($0) }
121+
}
122+
}
123+
124+
public protocol Runnable {
125+
func config() -> Configuration
126+
}
127+
128+
extension Runnable {
129+
public func run(_ p: Platform, quiet: Bool) async throws {
130+
let c = self.config()
131+
let executable = switch c.executable.storage {
132+
case let .executable(name):
133+
name
134+
case let .path(p):
135+
p.string
136+
}
137+
let args = c.arguments.storage.map(\.description)
138+
var env: [String: String] = ProcessInfo.processInfo.environment
139+
switch c.environment.config {
140+
case let .inherit(newValue):
141+
for (key, value) in newValue {
142+
env[key] = value
143+
}
144+
case let .custom(newValue):
145+
env = newValue
146+
}
147+
try await p.runProgram([executable] + args, quiet: quiet, env: env)
148+
}
149+
}
150+
151+
public protocol Output {
152+
func config() -> Configuration
153+
}
154+
155+
// TODO: look into making this something that can be Decodable (i.e. streamable)
156+
extension Output {
157+
public func output(_ p: Platform) async throws -> String? {
158+
let c = self.config()
159+
let executable = switch c.executable.storage {
160+
case let .executable(name):
161+
name
162+
case let .path(p):
163+
p.string
164+
}
165+
let args = c.arguments.storage.map(\.description)
166+
var env: [String: String] = ProcessInfo.processInfo.environment
167+
switch c.environment.config {
168+
case let .inherit(newValue):
169+
for (key, value) in newValue {
170+
env[key] = value
171+
}
172+
case let .custom(newValue):
173+
env = newValue
174+
}
175+
return try await p.runProgramOutput(executable, args, env: env)
176+
}
177+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
@testable import Swiftly
2+
@testable import SwiftlyCore
3+
import SystemPackage
4+
import Testing
5+
6+
public typealias sys = SystemCommand
7+
8+
@Suite
9+
public struct CommandLineTests {
10+
@Test func testDsclModel() {
11+
var config = sys.dscl(datasource: ".").read(path: .init("/Users/swiftly"), keys: "UserShell").config()
12+
#expect(config.executable == .name("dscl"))
13+
#expect(config.arguments.storage.map(\.description) == [".", "-read", "/Users/swiftly", "UserShell"])
14+
15+
config = sys.dscl(datasource: ".").read(path: .init("/Users/swiftly"), keys: "UserShell", "Picture").config()
16+
#expect(config.executable == .name("dscl"))
17+
#expect(config.arguments.storage.map(\.description) == [".", "-read", "/Users/swiftly", "UserShell", "Picture"])
18+
}
19+
20+
@Test(
21+
.tags(.medium),
22+
.enabled {
23+
try await sys.DsclCommand.defaultExecutable.exists()
24+
}
25+
)
26+
func testDscl() async throws {
27+
let properties = try await sys.dscl(datasource: ".").read(path: fs.home, keys: "UserShell").properties(Swiftly.currentPlatform)
28+
#expect(properties.count == 1) // Only one shell for the current user
29+
#expect(properties[0].key == "UserShell") // The one property key should be the one that is requested
30+
}
31+
}

Tests/SwiftlyTests/HTTPClientTests.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import SystemPackage
77
import Testing
88

99
@Suite(.serialized) struct HTTPClientTests {
10-
@Test func getSwiftOrgGPGKeys() async throws {
10+
@Test(.tags(.large)) func getSwiftOrgGPGKeys() async throws {
1111
let tmpFile = fs.mktemp()
1212
try await fs.create(file: tmpFile, contents: nil)
1313

@@ -24,7 +24,7 @@ import Testing
2424
}
2525
}
2626

27-
@Test func getSwiftToolchain() async throws {
27+
@Test(.tags(.large)) func getSwiftToolchain() async throws {
2828
let tmpFile = fs.mktemp()
2929
try await fs.create(file: tmpFile, contents: nil)
3030
let tmpFileSignature = fs.mktemp(ext: ".sig")
@@ -53,7 +53,7 @@ import Testing
5353
}
5454
}
5555

56-
@Test func getSwiftlyRelease() async throws {
56+
@Test(.tags(.large)) func getSwiftlyRelease() async throws {
5757
let tmpFile = fs.mktemp()
5858
try await fs.create(file: tmpFile, contents: nil)
5959
let tmpFileSignature = fs.mktemp(ext: ".sig")
@@ -82,7 +82,7 @@ import Testing
8282
}
8383
}
8484

85-
@Test func getSwiftlyReleaseMetadataFromSwiftOrg() async throws {
85+
@Test(.tags(.large)) func getSwiftlyReleaseMetadataFromSwiftOrg() async throws {
8686
let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl())
8787
do {
8888
let currentRelease = try await httpClient.getCurrentSwiftlyRelease()
@@ -94,6 +94,7 @@ import Testing
9494
}
9595

9696
@Test(
97+
.tags(.large),
9798
arguments:
9899
[PlatformDefinition.macOS, .ubuntu2404, .ubuntu2204, .rhel9, .fedora39, .amazonlinux2, .debian12],
99100
[SwiftlyWebsiteAPI.Components.Schemas.Architecture.x8664, .aarch64]

0 commit comments

Comments
 (0)