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
16 changes: 14 additions & 2 deletions Sources/LucaCLI/Commands/RunCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,10 @@ struct RunCommand: AsyncParsableCommand {
)

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

Expand Down Expand Up @@ -165,7 +167,8 @@ struct RunCommand: AsyncParsableCommand {
validator: PipelineValidating,
printer: Printing,
resolvedParams: [String: String],
providedParams: [String: String]
providedParams: [String: String],
conditionEvaluator: TaskConditionEvaluating
) {
let displayName = name ?? pipelinePath.lastPathComponent
printer.printFormatted("\(.accent("[DRY RUN] Pipeline: \(displayName)"))")
Expand Down Expand Up @@ -207,6 +210,15 @@ struct RunCommand: AsyncParsableCommand {
}
}

if let condition = task.when {
var context: [String: String] = [:]
if let pipelineEnv = pipeline.env { context.merge(pipelineEnv) { _, new in new } }
if let taskEnv = task.env { context.merge(taskEnv) { _, new in new } }
context.merge(resolvedParams) { _, new in new }
let outcome = conditionEvaluator.evaluate(condition: condition, context: context) ? "run" : "skip"
printer.printFormatted("\(.raw(" When: \(condition) → \(outcome)"))")
}

if let workDir = task.workingDirectory ?? pipeline.workingDirectory {
printer.printFormatted("\(.raw(" WorkDir: \(workDir)"))")
}
Expand Down
32 changes: 28 additions & 4 deletions Sources/LucaCore/Core/PipelineRunner/PipelineRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Noora
/// Each task runs via `/usr/bin/env bash -c "set -eo pipefail && <command>"`.
/// Environment variables are merged in order: inherited process env ← pipeline-level env ← task-level env.
/// Working directory is resolved as: task-level → pipeline-level → invocation directory.
/// Tasks with a `when:` field are skipped when the condition evaluates to false.
public struct PipelineRunner: PipelineRunning {

public enum PipelineRunnerError: Error, LocalizedError, Equatable {
Expand All @@ -22,17 +23,20 @@ public struct PipelineRunner: PipelineRunning {
}

private let subprocessRunner: SubprocessRunning
private let conditionEvaluator: TaskConditionEvaluating
private let printer: Printing

/// Creates a runner using the default subprocess executor.
/// Creates a runner using the default subprocess executor and condition evaluator.
public init(printer: Printing) {
self.subprocessRunner = SubprocessRunner()
self.conditionEvaluator = TaskConditionEvaluator()
self.printer = printer
}

/// Creates a runner with a custom subprocess executor (used in tests).
init(subprocessRunner: SubprocessRunning, printer: Printing) {
/// Creates a runner with custom subprocess executor and condition evaluator (used in tests).
init(subprocessRunner: SubprocessRunning, conditionEvaluator: TaskConditionEvaluating, printer: Printing) {
self.subprocessRunner = subprocessRunner
self.conditionEvaluator = conditionEvaluator
self.printer = printer
}

Expand All @@ -41,10 +45,21 @@ public struct PipelineRunner: PipelineRunning {
public func run(_ pipeline: Pipeline, currentDirectoryURL: URL, parameters: [String: String]) async throws {
let start = Date()
let tasks = pipeline.tasks
var executedCount = 0

for (index, task) in tasks.enumerated() {
printTaskHeader(index: index + 1, total: tasks.count, name: task.name)

if let condition = task.when {
let context = buildContext(parameters: parameters, pipelineEnv: pipeline.env, taskEnv: task.env)
let shouldRun = conditionEvaluator.evaluate(condition: condition, context: context)
if !shouldRun {
printer.printFormatted("⊘ \(.muted("Skipped (when: \(condition) → false)"))")
printer.printFormatted("\(.raw(""))")
continue
}
}

let env = mergedEnvironment(pipelineEnv: pipeline.env, taskEnv: task.env)
let workingDirectory = resolveWorkingDirectory(task: task, pipeline: pipeline, invocationDirectory: currentDirectoryURL)

Expand All @@ -66,11 +81,12 @@ public struct PipelineRunner: PipelineRunning {
}
}

executedCount += 1
printer.printFormatted("\(.raw(""))")
}

let elapsed = Date().timeIntervalSince(start)
let summary = "── Pipeline complete (\(tasks.count) task\(tasks.count == 1 ? "" : "s"), \(String(format: "%.1f", elapsed))s) "
let summary = "── Pipeline complete (\(executedCount) task\(executedCount == 1 ? "" : "s"), \(String(format: "%.1f", elapsed))s) "
printer.printFormatted("\(.success(summary + String(repeating: "─", count: max(0, 60 - summary.count))))")
}

Expand All @@ -82,6 +98,14 @@ public struct PipelineRunner: PipelineRunning {
printer.printFormatted("\(.muted(prefix))\(.accent(name))\(.muted(" " + padding))")
}

private func buildContext(parameters: [String: String], pipelineEnv: [String: String]?, taskEnv: [String: String]?) -> [String: String] {
var context: [String: String] = [:]
if let pipelineEnv { context.merge(pipelineEnv) { _, new in new } }
if let taskEnv { context.merge(taskEnv) { _, new in new } }
context.merge(parameters) { _, new in new }
return context
}

private func mergedEnvironment(pipelineEnv: [String: String]?, taskEnv: [String: String]?) -> [String: String] {
var merged: [String: String] = [:]
if let pipelineEnv { merged.merge(pipelineEnv) { _, new in new } }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// TaskConditionEvaluating.swift

/// Evaluates a `when:` condition expression against a resolved key-value context.
public protocol TaskConditionEvaluating {
/// Returns `true` if `condition` evaluates to a truthy result using the provided context.
///
/// `${name}` tokens in `condition` are substituted from `context` before evaluation.
/// Unknown tokens resolve to an empty string.
func evaluate(condition: String, context: [String: String]) -> Bool
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// TaskConditionEvaluator.swift

/// Evaluates `when:` condition expressions for pipeline tasks.
///
/// Three expression forms are supported:
/// - `LHS == RHS` — true when both sides are equal after trimming.
/// - `LHS != RHS` — true when both sides differ after trimming.
/// - plain string — true when non-empty and not in `["false", "0", "no"]`.
///
/// `${name}` tokens are substituted from the provided context before evaluation.
/// Unknown tokens resolve to an empty string.
public struct TaskConditionEvaluator: TaskConditionEvaluating {

public init() {}

// MARK: - TaskConditionEvaluating

public func evaluate(condition: String, context: [String: String]) -> Bool {
let substituted = substitute(condition, context: context)

if let (lhs, rhs) = split(substituted, op: "==") {
return lhs == rhs
}
if let (lhs, rhs) = split(substituted, op: "!=") {
return lhs != rhs
}
return isTruthy(substituted.trimmingCharacters(in: .whitespaces))
}

// MARK: - Private

private func substitute(_ expression: String, context: [String: String]) -> String {
let substituted = context.reduce(expression) { result, pair in
result.replacingOccurrences(of: "${\(pair.key)}", with: pair.value)
}
// Replace any remaining unresolved ${...} tokens with empty string
return substituted.replacingOccurrences(of: #"\$\{[^}]*\}"#, with: "", options: .regularExpression)
}

private func split(_ expression: String, op: String) -> (String, String)? {
let parts = expression.components(separatedBy: op)
guard parts.count == 2 else { return nil }
return (parts[0].trimmingCharacters(in: .whitespaces),
parts[1].trimmingCharacters(in: .whitespaces))
}

private func isTruthy(_ value: String) -> Bool {
!value.isEmpty && !["false", "0", "no"].contains(value)
}
}
10 changes: 8 additions & 2 deletions Sources/LucaCore/Models/PipelineTask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Foundation
/// env:
/// DEVELOPER_DIR: /Applications/Xcode.app
/// working-directory: ios/
/// when: ${flavor} == release
/// ```
///
/// ## Topics
Expand All @@ -23,6 +24,7 @@ import Foundation
/// - ``env``
/// - ``continueOnError``
/// - ``workingDirectory``
/// - ``when``
public struct PipelineTask: Codable {
/// Human-readable label displayed in the terminal before the task executes.
public let name: String
Expand All @@ -39,18 +41,22 @@ public struct PipelineTask: Codable {
/// Relative paths are resolved against the directory where `luca run` was invoked.
/// Overrides the pipeline-level ``Pipeline/workingDirectory``.
public let workingDirectory: String?
/// Optional condition expression. The task is skipped when this evaluates to false.
/// Supports `${name} == value`, `${name} != value`, and plain truthy `${name}` forms.
public let when: String?

public init(name: String, command: String, tools: [String]?, env: [String: String]?, continueOnError: Bool?, workingDirectory: String?) {
public init(name: String, command: String, tools: [String]?, env: [String: String]?, continueOnError: Bool?, workingDirectory: String?, when: String? = nil) {
self.name = name
self.command = command
self.tools = tools
self.env = env
self.continueOnError = continueOnError
self.workingDirectory = workingDirectory
self.when = when
}

private enum CodingKeys: String, CodingKey {
case name, command, tools, env
case name, command, tools, env, when
case continueOnError = "continue-on-error"
case workingDirectory = "working-directory"
}
Expand Down
5 changes: 4 additions & 1 deletion Tests/Core/PipelineLoaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ struct PipelineLoaderTests {
#expect(pipeline.parameters?.last?.name == "upload")
#expect(pipeline.env?["PIPELINE_VAR"] == "pipeline-value")
#expect(pipeline.workingDirectory == "ios/")
#expect(pipeline.tasks.count == 5)
#expect(pipeline.tasks.count == 6)

let taskWithEnv = pipeline.tasks[1]
#expect(taskWithEnv.env?["MY_VAR"] == "task-value")
Expand All @@ -43,6 +43,9 @@ struct PipelineLoaderTests {

let optionalTask = pipeline.tasks[4]
#expect(optionalTask.continueOnError == true)

let taskWithWhen = pipeline.tasks[5]
#expect(taskWithWhen.when == "${upload} == true")
}

@Test
Expand Down
Loading