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
2 changes: 1 addition & 1 deletion .claude/skills/swiftyshell.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public struct ShellContext: Sendable {
environment: [String: String] = ProcessInfo.processInfo.environment,
workingDirectory: String? = nil,
defaultTimeout: TimeInterval? = nil,
defaultOutputLimit: Int = 10_485_760
defaultOutputLimit: Int = 0
)

public let executor: any CommandExecutor
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ This does **not** mean every executable on the system gets its own Swift type, o
### Output Handling

- Output is buffered in memory by default (stdout and stderr decoded as UTF-8).
- Default maximum captured output size is 10 MiB; configurable via `ShellContext.defaultOutputLimit` or per-command/client `.outputLimit(_:)`.
- Default output limit is unlimited (`0`); configurable via `ShellContext.defaultOutputLimit` or per-command/client `.outputLimit(_:)`. Pass a positive byte count to cap captured output.
- Exceeding the limit keeps draining the child process output, then throws `ShellError.outputLimitExceeded`.
- Invalid UTF-8 throws `ShellError.decodingError`.
- Negative timeout or output-limit values throw `ShellError.invalidConfiguration`.
Expand Down
7 changes: 4 additions & 3 deletions Sources/SwiftyShell/Core/Command.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,9 @@ public struct Command: Sendable {

/// An optional per-command output capture limit in bytes.
///
/// When non-`nil`, this value replaces ``ShellContext/defaultOutputLimit``. Exceeding the
/// limit raises ``ShellError/outputLimitExceeded(command:limit:partialOutput:)``. Set with
/// When non-`nil`, this value replaces ``ShellContext/defaultOutputLimit``. A value of `0`
/// means unlimited (no cap). A positive value enforces that byte limit — exceeding it
/// raises ``ShellError/outputLimitExceeded(command:limit:partialOutput:)``. Set with
/// ``outputLimit(_:)``.
public let outputLimitOverride: Int?

Expand Down Expand Up @@ -287,7 +288,7 @@ public struct Command: Sendable {
/// The limit applies to the combined size of captured stdout and stderr. When exceeded,
/// the executor terminates the process and throws
/// ``ShellError/outputLimitExceeded(command:limit:partialOutput:)`` containing the captured
/// portion. The value must be greater than or equal to zero.
/// portion. Pass `0` for unlimited (no cap), or a positive value for a byte limit.
///
/// Streams routed through ``OutputDestination/file(path:append:)`` or
/// ``OutputDestination/discard`` do not contribute to the captured size.
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftyShell/Core/MockExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ public struct MockExecutor: CommandExecutor {
let outputLimit = command.outputLimitOverride ?? context.defaultOutputLimit
if outputLimit < 0 {
throw ShellError.invalidConfiguration(
description: "Output limit must be greater than or equal to zero bytes"
description: "Output limit must be zero (unlimited) or a positive byte count"
)
}
}
Expand Down
8 changes: 4 additions & 4 deletions Sources/SwiftyShell/Core/ShellContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ public struct ShellContext: Sendable {
///
/// Applies to the combined captured stdout and stderr per command. When exceeded, the
/// executor raises ``ShellError/outputLimitExceeded(command:limit:partialOutput:)``.
/// Defaults to `10_485_760` (10 MB). Per-command overrides via ``Command/outputLimit(_:)``
/// take precedence.
/// Defaults to `0` (unlimited — no cap). Set to a positive value to enforce a byte limit.
/// Per-command overrides via ``Command/outputLimit(_:)`` take precedence.
public let defaultOutputLimit: Int

/// Creates a shell context with execution defaults.
Expand All @@ -136,14 +136,14 @@ public struct ShellContext: Sendable {
/// - defaultTimeout: The default timeout in seconds for commands that do not override it.
/// Must be `>= 0` when provided. Pass `nil` (the default) to leave commands unbounded.
/// - defaultOutputLimit: The maximum captured output size in bytes for commands that do
/// not override it. Must be `>= 0`. Defaults to `10_485_760` (10 MB).
/// not override it. `0` means unlimited (default). Must be `>= 0`.
public init(
executor: any CommandExecutor = SubprocessExecutor(),
searchPaths: [String] = ShellContext.defaultSearchPaths,
environment: [String: String] = ProcessInfo.processInfo.environment,
workingDirectory: String? = nil,
defaultTimeout: TimeInterval? = nil,
defaultOutputLimit: Int = 10_485_760
defaultOutputLimit: Int = 0
) {
self.executor = executor
self.searchPaths = searchPaths
Expand Down
14 changes: 8 additions & 6 deletions Sources/SwiftyShell/Internal/Execution/SubprocessExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,17 @@ private struct ResolvedCommand: Sendable {
self.environment = context.environment.merging(command.environmentOverrides) { _, new in new }
self.workingDirectory = command.workingDirectoryOverride ?? context.workingDirectory
self.timeout = command.timeoutOverride ?? context.defaultTimeout
self.outputLimit = command.outputLimitOverride ?? context.defaultOutputLimit
if let timeout, timeout < 0 || timeout.isFinite == false {
throw ShellError.invalidConfiguration(description: "Timeout must be greater than or equal to zero seconds")
}
guard outputLimit >= 0 else {
let rawLimit = command.outputLimitOverride ?? context.defaultOutputLimit
guard rawLimit >= 0 else {
throw ShellError.invalidConfiguration(
description: "Output limit must be greater than or equal to zero bytes"
description: "Output limit must be zero (unlimited) or a positive byte count"
)
}
// 0 means unlimited; normalize to Int.max so downstream comparisons work unchanged.
self.outputLimit = rawLimit == 0 ? Int.max : rawLimit
if let timeout, timeout < 0 || timeout.isFinite == false {
throw ShellError.invalidConfiguration(description: "Timeout must be greater than or equal to zero seconds")
}
self.stdoutDestination = command.stdoutDestination
self.stderrDestination = command.stderrDestination
self.displayCommand = command.displayString(using: executablePath)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ SwiftyShell has a small set of core primitives that every typed command family b
| `environment` | Inherited from the process | Base env vars for every command |
| `workingDirectory` | `nil` (inherits from the process) | Default directory for commands |
| `defaultTimeout` | `nil` (unlimited) | Seconds before ``ShellError/timeout(command:duration:partialOutput:)`` is thrown |
| `defaultOutputLimit` | `10_485_760` (10 MB) | Bytes before ``ShellError/outputLimitExceeded(command:limit:partialOutput:)`` is thrown |
| `defaultOutputLimit` | `0` (unlimited) | Bytes before ``ShellError/outputLimitExceeded(command:limit:partialOutput:)`` is thrown; `0` means no cap |

### Creating a Context

Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftyShell/SwiftyShell.docc/ShellContext.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ while individual calls tune themselves where needed.

A fresh context inherits sensible platform defaults — the current process's
environment, the platform `PATH`, no working-directory override, no timeout,
and a 10 MB output cap:
and no output cap (unlimited by default):

```swift
import SwiftyShell
Expand All @@ -28,7 +28,7 @@ process cleanup after that point is handled by ``SubprocessExecutor``. Timeout
values must be finite and non-negative.

For longer-running scripts, set program-wide defaults at construction time so
every call inherits them:
every call inherits them. You can opt into an output cap if needed:

```swift
let buildContext = ShellContext(
Expand Down
4 changes: 2 additions & 2 deletions Tests/SwiftyShellTests/Core/CommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ struct CommandTests {
Issue.record("Unexpected error: \(error)")
return
}
#expect(description == "Output limit must be greater than or equal to zero bytes")
#expect(description == "Output limit must be zero (unlimited) or a positive byte count")
}
}

Expand All @@ -215,7 +215,7 @@ struct CommandTests {
Issue.record("Unexpected error: \(error)")
return
}
#expect(description == "Output limit must be greater than or equal to zero bytes")
#expect(description == "Output limit must be zero (unlimited) or a positive byte count")
}
}

Expand Down
2 changes: 1 addition & 1 deletion Tests/SwiftyShellTests/Core/SpawnTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ struct SpawnTests {
Issue.record("Unexpected error: \(error)")
return
}
#expect(description == "Output limit must be greater than or equal to zero bytes")
#expect(description == "Output limit must be zero (unlimited) or a positive byte count")
}
}

Expand Down