Issue #522

Sometimes ago I created Puma, which is a thin wrapper around Xcode commandline tools, for example xcodebuild

There’s lots of arguments to pass in xcodebuild, and there are many tasks like build, test and archive that all uses this command.

Use Options struct to encapsulate parameters

To avoid passing many parameters into a class, I tend to make an Options struct to encapsulate all passing parameters. I also use composition, where Build.Options and Test.Options contains Xcodebuild.Options

This ensures that the caller must provide all needed parameters, when you can compile you are ensured that all required parameters are provided.

This is OK, but a bit rigid in a way that there are many more parameters we can pass into xcodebuild command, so we must provide a way for user to alter or add more parameters.

let xcodebuildOptions = Xcodebuild.Options(
    workspace: nil,
    project: "TestApp",
    scheme: "TestApp",
    configuration: Configuration.release,
    sdk: Sdk.iPhone,
    signing: .auto(automaticSigning),
    usesModernBuildSystem: true
)

run {
    SetVersionNumber(options: .init(buildNumber: "1.1"))
    SetBuildNumber(options: .init(buildNumber: "2"))
    Build(options: .init(
        buildOptions: xcodebuildOptions,
        buildsForTesting: true
    ))
    
    Test(options: .init(
        buildOptions: xcodebuildOptions,
        destination: Destination(
            platform: Destination.Platform.iOSSimulator,
            name: Destination.Name.iPhoneXr,
            os: Destination.OS.os12_2
        )
    ))
}

Here is how to convert from Options to arguments to pass to our command. Because each parameter has different specifiers, like with double hyphens --flag=true, single hyphen -flag=true or just hyphen with a space between parameter key and value -flag true, we need to manually specify that, and concat them with string. Luckily, the order of parameters is not important

public struct Xcodebuild {
    public struct Options {
        ///  build the workspace NAME
        public let workspace: String?
        /// build the project NAME
        public let project: String
        /// build the scheme NAME
        public let scheme: String
        /// use the build configuration NAME for building each target
        public let configuration: String
        /// use SDK as the name or path of the base SDK when building the project
        public let sdk: String?
        public let signing: Signing?
        public let usesModernBuildSystem: Bool
        
        public init(
            workspace: String? = nil,
            project: String,
            scheme: String,
            configuration: String = Configuration.debug,
            sdk: String? = Sdk.iPhoneSimulator,
            signing: Signing? = nil,
            usesModernBuildSystem: Bool = true) {
            
            self.workspace = workspace
            self.project = project
            self.scheme = scheme
            self.configuration = configuration
            self.sdk = sdk
            self.signing = signing
            self.usesModernBuildSystem = usesModernBuildSystem
        }
    }
}

extension Xcodebuild.Options {
    func toArguments() -> [String?] {
        return [
            workspace.map{ "-workspace \($0.addingFileExtension("xcworkspace"))" },
            "-project \(project.addingFileExtension("xcodeproj"))",
            "-scheme \(scheme)",
            "-configuration \(configuration)",
            sdk.map { "-sdk \($0)" },
            "-UseModernBuildSystem=\(usesModernBuildSystem ? "YES": "NO")"

        ]
    }
}

Use convenient methods

Another way is to have a Set<String> as a container of parameters, and provide common method via protocol extension

/// Any task that uses command line
public protocol UsesCommandLine: AnyObject {
    var program: String { get }
    var arguments: Set<String> { get set }
}

public extension UsesCommandLine {
    func run() throws {
        let command = "\(program) \(arguments.joined(separator: " "))"
        Log.command(command)
        _ = try Process().run(command: command)
    }
}

/// Any task that uses xcodebuild
public protocol UsesXcodeBuild: UsesCommandLine {}

public extension UsesXcodeBuild {
    var program: String { "xcodebuild" }

    func `default`(project: String, scheme: String) {
        self.project(project)
        self.scheme(scheme)
        self.configuration(Configuration.debug)
        self.sdk(Sdk.iPhoneSimulator)
        self.usesModernBuildSystem(enabled: true)
    }

    func project(_ name: String) {
        arguments.insert("-project \(name.addingFileExtension("xcodeproj"))")
    }

    func workspace(_ name: String) {
        arguments.insert("-workspace \(name.addingFileExtension("xcworkspace"))")
    }

    func scheme(_ name: String) {
        arguments.insert("-scheme \(name)")
    }

    func configuration(_ configuration: String) {
        arguments.insert("-configuration \(configuration)")
    }

    func sdk(_ sdk: String) {
        arguments.insert("-sdk \(sdk)")
    }

    func usesModernBuildSystem(enabled: Bool) {
        arguments.insert("-UseModernBuildSystem=\(enabled ? "YES": "NO")")
    }
}

class Build: Task, UsesXcodeBuild {}
class Test: Task, UsesXcodeBuild {}

Now the call site looks like this

run {
    SetVersionNumber {
        $0.versionNumberForAllTargets("1.1")
    }
    
    SetBuildNumber {
        $0.buildNumberForAllTargets("2")
    }
    
    Build {
        $0.default(project: "TestApp", scheme: "TestApp")
        $0.buildsForTesting(enabled: true)
    }
    
    Test {
        $0.default(project: "TestApp", scheme: "TestApp")
        $0.testsWithoutBuilding(enabled: true)
        $0.destination(Destination(
            platform: Destination.Platform.iOSSimulator,
            name: Destination.Name.iPhoneXr,
            os: Destination.OS.os12_2
        ))
    }
}