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
7 changes: 7 additions & 0 deletions Sources/ShellCommandKit/ParsableCommandBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import ShellKit
/// `run()`; otherwise calls the synchronous `run()`.
/// 4. Translates ArgumentParser's exit conventions to ``ExitStatus``:
/// - `ExitCode` thrown from `run()` → matching `ExitStatus`.
/// - A ``CommandFailure`` thrown from `run()` → its diagnostic on
/// `stderr` as is, and its exit status.
/// - `--help` / `--version` (clean exits with code 0) print the
/// message to ``Shell/current``'s `stdout` and return success.
/// - Usage errors (parse failures, validation) print to
Expand Down Expand Up @@ -66,6 +68,11 @@ struct ParsableCommandBridge<P: ParsableCommand>: Command {
throw CancellationError()
} catch let exit as ExitCode {
return ExitStatus(exit.rawValue)
} catch let failure as CommandFailure {
// The command's own words and status, as it would print them
// standalone.
Shell.current.stderr(failure.diagnostic)
return failure.exitStatus
} catch let denial as Sandbox.Denial {
// Render only the human-readable reason — never the sandbox root
// or the requested path that `Sandbox.Denial` also carries — so an
Expand Down
16 changes: 16 additions & 0 deletions Sources/ShellKit/Command/CommandFailure.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation

/// An error that says exactly what its command prints and how it exits.
///
/// A command that fails the way a real tool does (`fatal: …` on stderr,
/// exit 128) throws one of these from `run()`. A host shell prints
/// ``diagnostic`` to the command's stderr as it is and returns
/// ``exitStatus``, with no formatting of its own. ArgumentParser's
/// formatter, by contrast, would prefix the text with `Error: ` and exit
/// with 1.
public protocol CommandFailure: Error {
/// What the command writes to stderr, line endings included.
var diagnostic: String { get }
/// The status the command exits with.
var exitStatus: ExitStatus { get }
}
26 changes: 26 additions & 0 deletions Tests/ShellKitTests/ShellTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,32 @@ import ShellCommandKit // Shell.register(_:) bridge
}
}

@Test func commandFailurePrintsItsOwnDiagnosticAndStatus() async throws {
struct Fatal: CommandFailure {
var diagnostic: String { "fatal: something git-like\n" }
var exitStatus: ShellKit.ExitStatus { ShellKit.ExitStatus(128) }
}
struct Failer: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "shellkit-test-fatal")
func run() async throws {
throw Fatal()
}
}

let stderr = OutputSink()
let shell = Shell(stderr: stderr)
shell.register(Failer.self)

try await shell.withCurrent {
let status = try await shell.commands["shellkit-test-fatal"]!.run(["shellkit-test-fatal"])
#expect(status.code == 128)
}
stderr.finish()
// As written: no `Error: ` prefix, no extra newline.
#expect(await stderr.readAllString() == "fatal: something git-like\n")
}

@Test func parsableCommandVendsNamedInstanceWithoutRegistering() async throws {
// The producer-side API: obtain an installable Command instance
// (named from configuration.commandName) WITHOUT touching any shell's
Expand Down
Loading