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
64 changes: 61 additions & 3 deletions Sources/LucaCLI/Commands/RunCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,30 @@ struct RunCommand: AsyncParsableCommand {
))
var dryRun: Bool = false

@Option(name: .customLong("param"), help: ArgumentHelp(
"Set a pipeline parameter value.",
discussion: """
Provide KEY=VALUE pairs to satisfy parameters declared in the pipeline's `parameters:` block.
May be repeated for multiple parameters.
Example: --param flavor=release --param upload=true
""",
valueName: "KEY=VALUE"
))
var params: [String] = []

func validate() throws {
guard name != nil || file != nil else {
throw ValidationError("Missing required argument. Provide <name> or --file <path>.")
}
if name != nil && file != nil {
throw ValidationError("<name> and --file are mutually exclusive.")
}
for param in params {
let parts = param.split(separator: "=", maxSplits: 1)
guard parts.count == 2, !parts[0].isEmpty else {
throw ValidationError("Invalid --param '\(param)': expected KEY=VALUE format.")
}
}
}

func run() async throws {
Expand All @@ -98,16 +115,22 @@ struct RunCommand: AsyncParsableCommand {
let loader = PipelineLoader()
let pipeline = try loader.loadPipeline(at: pipelinePath)
let validator = PipelineValidator(fileManager: fileManager)
let resolver = ParameterResolver()
let resolvedParams = try resolver.resolve(
declared: pipeline.parameters ?? [],
provided: parsedParams()
)

if dryRun {
printDryRun(pipeline: pipeline, pipelinePath: pipelinePath, validator: validator, printer: printer)
printDryRun(pipeline: pipeline, pipelinePath: pipelinePath, validator: validator,
printer: printer, resolvedParams: resolvedParams, providedParams: parsedParams())
return
}

try validator.validate(pipeline)

let runner = PipelineRunner(printer: printer)
try await runner.run(pipeline, currentDirectoryURL: invocationDirectory)
try await runner.run(pipeline, currentDirectoryURL: invocationDirectory, parameters: resolvedParams)

}

Expand All @@ -128,11 +151,46 @@ struct RunCommand: AsyncParsableCommand {
throw RunCommandError.pipelineNotFound(name)
}

private func printDryRun(pipeline: Pipeline, pipelinePath: URL, validator: PipelineValidating, printer: Printing) {
private func parsedParams() -> [String: String] {
params.reduce(into: [:]) { dict, entry in
let parts = entry.split(separator: "=", maxSplits: 1)
guard parts.count == 2 else { return }
dict[String(parts[0])] = String(parts[1])
}
}

private func printDryRun(
pipeline: Pipeline,
pipelinePath: URL,
validator: PipelineValidating,
printer: Printing,
resolvedParams: [String: String],
providedParams: [String: String]
) {
let displayName = name ?? pipelinePath.lastPathComponent
printer.printFormatted("\(.accent("[DRY RUN] Pipeline: \(displayName)"))")
printer.printFormatted("\(.raw(""))")

if let declared = pipeline.parameters, !declared.isEmpty {
printer.printFormatted("\(.primary("Parameters:"))")
for param in declared {
let value = resolvedParams[param.name] ?? "(not set)"
let source: String
if providedParams.keys.contains(param.name) {
source = " (override)"
} else if param.defaultValue != nil {
source = " (default)"
} else {
source = ""
}
printer.printFormatted("\(.raw(" \(param.name) = \(value)\(source)"))")
if let desc = param.description {
printer.printFormatted("\(.muted(" \(desc)"))")
}
}
printer.printFormatted("\(.raw(""))")
}

let allResults = validator.toolCheckResults(for: pipeline)

for (index, task) in pipeline.tasks.enumerated() {
Expand Down
47 changes: 47 additions & 0 deletions Sources/LucaCore/Core/ParameterResolver/ParameterResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// ParameterResolver.swift

import Foundation

/// Validates declared pipeline parameters and merges them with CLI-provided values.
///
/// Precedence: CLI `--param` value → YAML `default:` → error (required, not provided).
public struct ParameterResolver: ParameterResolving {

public enum ParameterResolverError: Error, LocalizedError, Equatable {
case unknownParameter(String)
case missingRequiredParameter(String)

public var errorDescription: String? {
switch self {
case .unknownParameter(let name):
return "Unknown parameter '\(name)'. It is not declared in the pipeline's parameters block."
case .missingRequiredParameter(let name):
return "Required parameter '\(name)' was not provided. Pass it with --param \(name)=<value>."
}
}
}

public init() {}

// MARK: - ParameterResolving

public func resolve(declared: [PipelineParameter], provided: [String: String]) throws -> [String: String] {
let declaredNames = Set(declared.map(\.name))
for key in provided.keys {
guard declaredNames.contains(key) else {
throw ParameterResolverError.unknownParameter(key)
}
}
var resolved: [String: String] = [:]
for param in declared {
if let value = provided[param.name] {
resolved[param.name] = value
} else if let defaultValue = param.defaultValue {
resolved[param.name] = defaultValue
} else {
throw ParameterResolverError.missingRequiredParameter(param.name)
}
}
return resolved
}
}
13 changes: 13 additions & 0 deletions Sources/LucaCore/Core/ParameterResolver/ParameterResolving.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// ParameterResolving.swift

/// Validates and resolves pipeline parameter declarations against caller-supplied values.
public protocol ParameterResolving {
/// Merges declared parameter definitions with caller-supplied values.
///
/// - Parameters:
/// - declared: Parameter definitions from the pipeline YAML.
/// - provided: Key-value pairs supplied via `--param` on the CLI.
/// - Returns: Resolved `[name: value]` map ready for command substitution.
/// - Throws: If a provided key is not declared, or if a required parameter has no value.
func resolve(declared: [PipelineParameter], provided: [String: String]) throws -> [String: String]
}
11 changes: 9 additions & 2 deletions Sources/LucaCore/Core/PipelineRunner/PipelineRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public struct PipelineRunner: PipelineRunning {

// MARK: - PipelineRunning

public func run(_ pipeline: Pipeline, currentDirectoryURL: URL) async throws {
public func run(_ pipeline: Pipeline, currentDirectoryURL: URL, parameters: [String: String]) async throws {
let start = Date()
let tasks = pipeline.tasks

Expand All @@ -48,9 +48,10 @@ public struct PipelineRunner: PipelineRunning {
let env = mergedEnvironment(pipelineEnv: pipeline.env, taskEnv: task.env)
let workingDirectory = resolveWorkingDirectory(task: task, pipeline: pipeline, invocationDirectory: currentDirectoryURL)

let command = renderCommand(task.command, parameters: parameters)
let exitCode = try await subprocessRunner.run(
executableURL: URL(fileURLWithPath: "/usr/bin/env"),
arguments: ["bash", "-c", "set -eo pipefail && \(task.command)"],
arguments: ["bash", "-c", "set -eo pipefail && \(command)"],
environment: env,
workingDirectory: workingDirectory,
inheritStdin: true
Expand Down Expand Up @@ -95,4 +96,10 @@ public struct PipelineRunner: PipelineRunning {
}
return invocationDirectory.appending(path: workDir)
}

private func renderCommand(_ command: String, parameters: [String: String]) -> String {
parameters.reduce(command) { result, pair in
result.replacingOccurrences(of: "${\(pair.key)}", with: pair.value)
}
}
}
12 changes: 10 additions & 2 deletions Sources/LucaCore/Core/PipelineRunner/PipelineRunning.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@ import Foundation

/// Executes a ``Pipeline`` sequentially and reports progress.
public protocol PipelineRunning {
/// Runs all tasks in the pipeline in order, stopping on the first failure unless `continue-on-error` is set.
/// Runs all tasks in the pipeline in order with parameter substitution applied to commands.
///
/// - Parameters:
/// - pipeline: The pipeline to execute.
/// - currentDirectoryURL: The directory from which `luca run` was invoked; used to resolve relative working-directory paths.
func run(_ pipeline: Pipeline, currentDirectoryURL: URL) async throws
/// - parameters: Resolved parameter values used to substitute `${name}` tokens in task commands.
func run(_ pipeline: Pipeline, currentDirectoryURL: URL, parameters: [String: String]) async throws
}

public extension PipelineRunning {
/// Convenience overload with no parameter substitution.
func run(_ pipeline: Pipeline, currentDirectoryURL: URL) async throws {
try await run(pipeline, currentDirectoryURL: currentDirectoryURL, parameters: [:])
}
}
15 changes: 12 additions & 3 deletions Sources/LucaCore/Models/Pipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import Foundation
///
/// ```yaml
/// ---
/// parameters:
/// - name: flavor
/// default: debug
/// env:
/// CI: "true"
/// working-directory: ios/
///
/// tasks:
/// - name: Generate project
/// command: tuist generate
/// command: tuist generate --platform ${flavor}
/// - name: Run backend tests
/// command: swift test
/// working-directory: backend/
Expand All @@ -26,31 +29,37 @@ import Foundation
///
/// ### Properties
/// - ``tasks``
/// - ``parameters``
/// - ``env``
/// - ``workingDirectory``
///
/// ### Related Types
/// - ``PipelineTask``
/// - ``PipelineParameter``
/// - ``PipelineLoader``
/// - ``PipelineRunner``
public struct Pipeline: Codable {
/// Ordered list of tasks to execute.
public let tasks: [PipelineTask]
/// Declared input parameters for this pipeline.
/// Values are supplied at runtime via `--param name=value` and substituted into commands as `${name}`.
public let parameters: [PipelineParameter]?
/// Environment variables applied to every task unless overridden at the task level.
public let env: [String: String]?
/// Default working directory for all tasks.
/// Relative paths are resolved against the directory where `luca run` was invoked.
/// Task-level ``PipelineTask/workingDirectory`` overrides this value.
public let workingDirectory: String?

public init(tasks: [PipelineTask], env: [String: String]?, workingDirectory: String?) {
public init(tasks: [PipelineTask], env: [String: String]?, workingDirectory: String?, parameters: [PipelineParameter]? = nil) {
self.tasks = tasks
self.parameters = parameters
self.env = env
self.workingDirectory = workingDirectory
}

private enum CodingKeys: String, CodingKey {
case tasks, env
case tasks, parameters, env
case workingDirectory = "working-directory"
}
}
46 changes: 46 additions & 0 deletions Sources/LucaCore/Models/PipelineParameter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// PipelineParameter.swift

import Foundation

/// A single declared input parameter for a ``Pipeline``.
///
/// Parameters are declared in the pipeline YAML and resolved against values
/// supplied via `--param` at invocation time. Use `${name}` in task commands
/// to reference the resolved value.
///
/// ## Example
///
/// ```yaml
/// parameters:
/// - name: flavor
/// description: Build flavor (debug or release)
/// default: debug
/// - name: upload
/// description: Whether to upload the artifact
/// ```
///
/// ## Topics
///
/// ### Properties
/// - ``name``
/// - ``description``
/// - ``defaultValue``
public struct PipelineParameter: Codable, Equatable {
/// Key used in `${name}` substitutions and `--param name=value` on the CLI.
public let name: String
/// Optional human-readable description shown in `--dry-run` output.
public let description: String?
/// Value used when no `--param` override is supplied. A `nil` default means the parameter is required.
public let defaultValue: String?

public init(name: String, description: String? = nil, defaultValue: String? = nil) {
self.name = name
self.description = description
self.defaultValue = defaultValue
}

private enum CodingKeys: String, CodingKey {
case name, description
case defaultValue = "default"
}
}
Loading