-
Notifications
You must be signed in to change notification settings - Fork 55
Add a modeled command-line for better checking #330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
import Foundation | ||
import SystemPackage | ||
|
||
public enum SystemCommand {} | ||
|
||
// This file contains a set of system commands that's used by Swiftly and its related tests and tooling | ||
|
||
// Directory Service command line utility for macOS | ||
// See dscl(1) for details | ||
extension SystemCommand { | ||
public static func dscl(executable: Executable = DsclCommand.defaultExecutable, datasource: String? = nil) -> DsclCommand { | ||
DsclCommand(executable: executable, datasource: datasource) | ||
} | ||
|
||
public struct DsclCommand { | ||
public static var defaultExecutable: Executable { .name("dscl") } | ||
|
||
var executable: Executable | ||
var datasource: String? | ||
|
||
internal init( | ||
executable: Executable, | ||
datasource: String? | ||
) { | ||
self.executable = executable | ||
self.datasource = datasource | ||
} | ||
|
||
func config() -> Configuration { | ||
var args: [String] = [] | ||
|
||
if let datasource = self.datasource { | ||
args += [datasource] | ||
} | ||
|
||
return Configuration( | ||
executable: self.executable, | ||
arguments: Arguments(args), | ||
environment: .inherit | ||
) | ||
} | ||
|
||
public func read(path: FilePath? = nil, keys: [String]) -> ReadCommand { | ||
ReadCommand(dscl: self, path: path, keys: keys) | ||
} | ||
|
||
public func read(path: FilePath? = nil, keys: String...) -> ReadCommand { | ||
self.read(path: path, keys: keys) | ||
} | ||
|
||
public struct ReadCommand: Output { | ||
var dscl: DsclCommand | ||
var path: FilePath? | ||
var keys: [String] | ||
|
||
internal init(dscl: DsclCommand, path: FilePath?, keys: [String]) { | ||
self.dscl = dscl | ||
self.path = path | ||
self.keys = keys | ||
} | ||
|
||
public func config() -> Configuration { | ||
var c = self.dscl.config() | ||
|
||
var args = c.arguments.storage.map(\.description) + ["-read"] | ||
|
||
if let path = self.path { | ||
args += [path.string] + self.keys | ||
} | ||
|
||
c.arguments = .init(args) | ||
|
||
return c | ||
} | ||
} | ||
} | ||
} | ||
|
||
extension SystemCommand.DsclCommand.ReadCommand { | ||
public func properties(_ p: Platform) async throws -> [(key: String, value: String)] { | ||
let output = try await self.output(p) | ||
guard let output else { return [] } | ||
|
||
var props: [(key: String, value: String)] = [] | ||
for line in output.components(separatedBy: "\n") { | ||
if case let comps = line.components(separatedBy: ": "), comps.count == 2 { | ||
props.append((key: comps[0], value: comps[1])) | ||
} | ||
} | ||
return props | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
import Foundation | ||
import SystemPackage | ||
|
||
public enum CommandLineError: Error { | ||
case invalidArgs | ||
case errorExit(exitCode: Int32, program: String) | ||
case unknownVersion | ||
} | ||
|
||
// This section is a clone of the Configuration type from the new Subprocess package, until we can depend on that package. | ||
public struct Configuration: Sendable { | ||
/// The executable to run. | ||
public var executable: Executable | ||
/// The arguments to pass to the executable. | ||
public var arguments: Arguments | ||
/// The environment to use when running the executable. | ||
public var environment: Environment | ||
} | ||
|
||
public struct Executable: Sendable, Hashable { | ||
internal enum Storage: Sendable, Hashable { | ||
case executable(String) | ||
case path(FilePath) | ||
} | ||
|
||
internal let storage: Storage | ||
|
||
private init(_config: Storage) { | ||
self.storage = _config | ||
} | ||
|
||
/// Locate the executable by its name. | ||
/// `Subprocess` will use `PATH` value to | ||
/// determine the full path to the executable. | ||
public static func name(_ executableName: String) -> Self { | ||
.init(_config: .executable(executableName)) | ||
} | ||
|
||
/// Locate the executable by its full path. | ||
/// `Subprocess` will use this path directly. | ||
public static func path(_ filePath: FilePath) -> Self { | ||
.init(_config: .path(filePath)) | ||
} | ||
} | ||
|
||
public struct Environment: Sendable, Hashable { | ||
internal enum Configuration: Sendable, Hashable { | ||
case inherit([String: String]) | ||
case custom([String: String]) | ||
} | ||
|
||
internal let config: Configuration | ||
|
||
init(config: Configuration) { | ||
self.config = config | ||
} | ||
|
||
/// Child process should inherit the same environment | ||
/// values from its parent process. | ||
public static var inherit: Self { | ||
.init(config: .inherit([:])) | ||
} | ||
|
||
/// Override the provided `newValue` in the existing `Environment` | ||
public func updating(_ newValue: [String: String]) -> Self { | ||
.init(config: .inherit(newValue)) | ||
} | ||
|
||
/// Use custom environment variables | ||
public static func custom(_ newValue: [String: String]) -> Self { | ||
.init(config: .custom(newValue)) | ||
} | ||
} | ||
|
||
internal enum StringOrRawBytes: Sendable, Hashable { | ||
case string(String) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This enum seems to be unnecessary w/ only one case? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code comes from the new subprocess package, which unfortunately is targeting a newer macOS requirement than swiftly at the moment (should be fixed soon). This is a duplicate of the relevant data structures from there so that it's aligned with it once it's usable here. |
||
|
||
var stringValue: String? { | ||
switch self { | ||
case let .string(string): | ||
return string | ||
} | ||
} | ||
|
||
var description: String { | ||
switch self { | ||
case let .string(string): | ||
return string | ||
} | ||
} | ||
|
||
var count: Int { | ||
switch self { | ||
case let .string(string): | ||
return string.count | ||
} | ||
} | ||
|
||
func hash(into hasher: inout Hasher) { | ||
// If Raw bytes is valid UTF8, hash it as so | ||
switch self { | ||
case let .string(string): | ||
hasher.combine(string) | ||
} | ||
} | ||
} | ||
|
||
public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable { | ||
public typealias ArrayLiteralElement = String | ||
|
||
internal let storage: [StringOrRawBytes] | ||
|
||
/// Create an Arguments object using the given literal values | ||
public init(arrayLiteral elements: String...) { | ||
self.storage = elements.map { .string($0) } | ||
} | ||
|
||
/// Create an Arguments object using the given array | ||
public init(_ array: [String]) { | ||
self.storage = array.map { .string($0) } | ||
} | ||
} | ||
|
||
public protocol Runnable { | ||
func config() -> Configuration | ||
} | ||
|
||
extension Runnable { | ||
public func run(_ p: Platform, quiet: Bool) async throws { | ||
let c = self.config() | ||
let executable = switch c.executable.storage { | ||
case let .executable(name): | ||
name | ||
case let .path(p): | ||
p.string | ||
} | ||
let args = c.arguments.storage.map(\.description) | ||
var env: [String: String] = ProcessInfo.processInfo.environment | ||
switch c.environment.config { | ||
case let .inherit(newValue): | ||
for (key, value) in newValue { | ||
env[key] = value | ||
} | ||
case let .custom(newValue): | ||
env = newValue | ||
} | ||
try await p.runProgram([executable] + args, quiet: quiet, env: env) | ||
} | ||
} | ||
|
||
public protocol Output { | ||
func config() -> Configuration | ||
} | ||
|
||
// TODO: look into making this something that can be Decodable (i.e. streamable) | ||
extension Output { | ||
public func output(_ p: Platform) async throws -> String? { | ||
let c = self.config() | ||
let executable = switch c.executable.storage { | ||
case let .executable(name): | ||
name | ||
case let .path(p): | ||
p.string | ||
} | ||
let args = c.arguments.storage.map(\.description) | ||
var env: [String: String] = ProcessInfo.processInfo.environment | ||
switch c.environment.config { | ||
case let .inherit(newValue): | ||
for (key, value) in newValue { | ||
env[key] = value | ||
} | ||
case let .custom(newValue): | ||
env = newValue | ||
} | ||
return try await p.runProgramOutput(executable, args, env: env) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
@testable import Swiftly | ||
@testable import SwiftlyCore | ||
import SystemPackage | ||
import Testing | ||
|
||
public typealias sys = SystemCommand | ||
|
||
@Suite | ||
public struct CommandLineTests { | ||
@Test func testDsclModel() { | ||
var config = sys.dscl(datasource: ".").read(path: .init("/Users/swiftly"), keys: "UserShell").config() | ||
#expect(config.executable == .name("dscl")) | ||
#expect(config.arguments.storage.map(\.description) == [".", "-read", "/Users/swiftly", "UserShell"]) | ||
|
||
config = sys.dscl(datasource: ".").read(path: .init("/Users/swiftly"), keys: "UserShell", "Picture").config() | ||
#expect(config.executable == .name("dscl")) | ||
#expect(config.arguments.storage.map(\.description) == [".", "-read", "/Users/swiftly", "UserShell", "Picture"]) | ||
} | ||
|
||
@Test( | ||
.tags(.medium), | ||
.enabled { | ||
try await sys.DsclCommand.defaultExecutable.exists() | ||
} | ||
) | ||
func testDscl() async throws { | ||
let properties = try await sys.dscl(datasource: ".").read(path: fs.home, keys: "UserShell").properties(Swiftly.currentPlatform) | ||
#expect(properties.count == 1) // Only one shell for the current user | ||
#expect(properties[0].key == "UserShell") // The one property key should be the one that is requested | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.