diff --git a/.claude/skills/swiftyshell.md b/.claude/skills/swiftyshell.md index f9eb7aa..3a84c8a 100644 --- a/.claude/skills/swiftyshell.md +++ b/.claude/skills/swiftyshell.md @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ea36f56..15d5e74 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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`. diff --git a/Sources/SwiftyShell/Core/Command.swift b/Sources/SwiftyShell/Core/Command.swift index fd22706..cee131f 100644 --- a/Sources/SwiftyShell/Core/Command.swift +++ b/Sources/SwiftyShell/Core/Command.swift @@ -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? @@ -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. diff --git a/Sources/SwiftyShell/Core/MockExecutor.swift b/Sources/SwiftyShell/Core/MockExecutor.swift index 0df5af5..4ec1d4e 100644 --- a/Sources/SwiftyShell/Core/MockExecutor.swift +++ b/Sources/SwiftyShell/Core/MockExecutor.swift @@ -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" ) } } diff --git a/Sources/SwiftyShell/Core/ShellContext.swift b/Sources/SwiftyShell/Core/ShellContext.swift index b713ef5..4908439 100644 --- a/Sources/SwiftyShell/Core/ShellContext.swift +++ b/Sources/SwiftyShell/Core/ShellContext.swift @@ -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. @@ -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 diff --git a/Sources/SwiftyShell/Internal/Execution/SubprocessExecutor.swift b/Sources/SwiftyShell/Internal/Execution/SubprocessExecutor.swift index a291ddc..4f33549 100644 --- a/Sources/SwiftyShell/Internal/Execution/SubprocessExecutor.swift +++ b/Sources/SwiftyShell/Internal/Execution/SubprocessExecutor.swift @@ -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) diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Articles/CoreConcepts.md b/Sources/SwiftyShell/SwiftyShell.docc/Articles/CoreConcepts.md index 40f71e3..a1aa7be 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/Articles/CoreConcepts.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/Articles/CoreConcepts.md @@ -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 diff --git a/Sources/SwiftyShell/SwiftyShell.docc/ShellContext.md b/Sources/SwiftyShell/SwiftyShell.docc/ShellContext.md index 05d9d53..d90949c 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/ShellContext.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/ShellContext.md @@ -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 @@ -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( diff --git a/Tests/SwiftyShellTests/Core/CommandTests.swift b/Tests/SwiftyShellTests/Core/CommandTests.swift index 39c9def..dcfddb4 100644 --- a/Tests/SwiftyShellTests/Core/CommandTests.swift +++ b/Tests/SwiftyShellTests/Core/CommandTests.swift @@ -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") } } @@ -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") } } diff --git a/Tests/SwiftyShellTests/Core/SpawnTests.swift b/Tests/SwiftyShellTests/Core/SpawnTests.swift index 84e7bc0..f4c9c79 100644 --- a/Tests/SwiftyShellTests/Core/SpawnTests.swift +++ b/Tests/SwiftyShellTests/Core/SpawnTests.swift @@ -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") } }