diff --git a/.claude/skills/swiftyshell.md b/.claude/skills/swiftyshell.md index b4a3843..d1d9cfb 100644 --- a/.claude/skills/swiftyshell.md +++ b/.claude/skills/swiftyshell.md @@ -203,10 +203,11 @@ public extension RunnableCommandFamily { #### OutputDestination ```swift -public enum OutputDestination: Sendable { +public enum OutputDestination: Sendable, Equatable { case capture case discard case file(path: String, append: Bool) + case tee // streams live to the parent stdout/stderr AND captures into ShellOutput } ``` diff --git a/Sources/SwiftyShell/Core/OutputDestination.swift b/Sources/SwiftyShell/Core/OutputDestination.swift index 71b5342..8830d3f 100644 --- a/Sources/SwiftyShell/Core/OutputDestination.swift +++ b/Sources/SwiftyShell/Core/OutputDestination.swift @@ -35,6 +35,15 @@ import Foundation /// .stderr(.file(path: "/tmp/build.log", append: true)) /// .run(in: context) /// ``` +/// +/// Stream a long build live while still capturing it for parsing: +/// +/// ```swift +/// let output = try await Command("./gradlew", arguments: "bundleRelease") +/// .stdout(.tee) +/// .run(in: context) +/// // Console shows progress during the build; output.stdout still holds the full log. +/// ``` public enum OutputDestination: Sendable, Equatable { /// Captures the stream in memory and returns it via ``ShellOutput/stdout`` or /// ``ShellOutput/stderr``. @@ -63,4 +72,13 @@ public enum OutputDestination: Sendable, Equatable { /// - append: When `true`, output is appended to any existing file contents. When `false`, /// the file is truncated before writing. case file(path: String, append: Bool) + + /// Streams the stream to the parent process's stdout (for ``StreamKind/stdout``) or + /// stderr (for ``StreamKind/stderr``) as bytes arrive, while ALSO capturing them in + /// memory for ``ShellOutput``. Use for long-running commands where live progress matters + /// but the output is also needed for parsing. + /// + /// Captured bytes count toward the output limit exactly like ``capture``. Live bytes are + /// written to the inherited FD immediately and are not buffered until exit. + case tee } diff --git a/Sources/SwiftyShell/Internal/Execution/SubprocessExecutor.swift b/Sources/SwiftyShell/Internal/Execution/SubprocessExecutor.swift index 4f33549..94d6246 100644 --- a/Sources/SwiftyShell/Internal/Execution/SubprocessExecutor.swift +++ b/Sources/SwiftyShell/Internal/Execution/SubprocessExecutor.swift @@ -872,6 +872,32 @@ private func routeData( break case .file: try fileHandle?.write(contentsOf: data) + case .tee: + // Capture for ShellOutput… + try store.append(data, to: stream) + // …and echo live to the parent process stream. + try TeeSink.shared.write(data, to: stream) + } +} + +/// Serializes live `.tee` writes to the parent process's standard output and standard error. +/// +/// Routing tasks for stdout and stderr run concurrently, so without coordination their writes to +/// ``FileHandle/standardOutput``/``FileHandle/standardError`` could interleave mid-buffer and +/// corrupt each other. A single shared lock guards every live write, mirroring the locking pattern +/// used by ``FileDescriptorClosure``. +private final class TeeSink: @unchecked Sendable { + static let shared = TeeSink() + + private let lock = NSLock() + + private init() {} + + func write(_ data: Data, to stream: StreamKind) throws { + let handle: FileHandle = (stream == .stdout) ? .standardOutput : .standardError + lock.lock() + defer { lock.unlock() } + try handle.write(contentsOf: data) } } diff --git a/Tests/SwiftyShellTests/Core/OutputDestinationTeeTests.swift b/Tests/SwiftyShellTests/Core/OutputDestinationTeeTests.swift new file mode 100644 index 0000000..dbbe0c5 --- /dev/null +++ b/Tests/SwiftyShellTests/Core/OutputDestinationTeeTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing + +@testable import SwiftyShell + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +/// Redirects the given process file descriptors (e.g. `STDOUT_FILENO`) to temporary files for the +/// duration of `body`, then restores them and returns the captured contents keyed by descriptor. +/// +/// Used to observe the live writes that ``OutputDestination/tee`` makes to the parent process's +/// standard output and standard error without polluting the test runner's own output. +private func capturingStandardStreams( + _ fileDescriptors: [Int32], + during body: () async -> Void +) async throws -> [Int32: String] { + var entries: [(fileDescriptor: Int32, url: URL, handle: FileHandle, saved: Int32)] = [] + + for fileDescriptor in fileDescriptors { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("tee-\(UUID().uuidString).log") + _ = FileManager.default.createFile(atPath: url.path, contents: nil) + let handle = try FileHandle(forWritingTo: url) + entries.append((fileDescriptor, url, handle, saved: -1)) + } + + fflush(nil) + for index in entries.indices { + entries[index].saved = dup(entries[index].fileDescriptor) + dup2(entries[index].handle.fileDescriptor, entries[index].fileDescriptor) + } + + await body() + + fflush(nil) + var results: [Int32: String] = [:] + for entry in entries { + dup2(entry.saved, entry.fileDescriptor) + close(entry.saved) + try? entry.handle.close() + results[entry.fileDescriptor] = (try? String(contentsOf: entry.url, encoding: .utf8)) ?? "" + try? FileManager.default.removeItem(at: entry.url) + } + return results +} + +@Suite(.serialized) +struct OutputDestinationTeeTests { + @Test func teeStillCapturesStreamForShellOutput() async throws { + let context = ShellContext() + var captured: ShellOutput? + _ = try await capturingStandardStreams([STDOUT_FILENO]) { + captured = try? await Command("/bin/sh", arguments: "-c", "printf 'tee-capture-text'") + .stdout(.tee) + .run(in: context) + } + + let output = try #require(captured) + #expect(output.stdout == "tee-capture-text") + } + + @Test func teeWritesLiveToParentStandardOutput() async throws { + let context = ShellContext() + let streams = try await capturingStandardStreams([STDOUT_FILENO]) { + _ = try? await Command("/bin/sh", arguments: "-c", "printf 'tee-live-text'") + .stdout(.tee) + .run(in: context) + } + + #expect(streams[STDOUT_FILENO]?.contains("tee-live-text") == true) + } + + @Test func teeInterleavesStdoutAndStderrWithoutCorruption() async throws { + let context = ShellContext() + var captured: ShellOutput? + let streams = try await capturingStandardStreams([STDOUT_FILENO, STDERR_FILENO]) { + captured = try? await Command( + "/bin/sh", + arguments: "-c", + "printf 'out-stream'; printf 'err-stream' >&2" + ) + .stdout(.tee) + .stderr(.tee) + .run(in: context) + } + + let output = try #require(captured) + #expect(output.stdout == "out-stream") + #expect(output.stderr == "err-stream") + #expect(streams[STDOUT_FILENO]?.contains("out-stream") == true) + #expect(streams[STDERR_FILENO]?.contains("err-stream") == true) + } + + @Test func teeRespectsOutputLimit() async throws { + let context = ShellContext(defaultOutputLimit: 4) + var thrown: Error? + _ = try await capturingStandardStreams([STDOUT_FILENO]) { + do { + _ = try await Command("/bin/sh", arguments: "-c", "printf 'abcdef'") + .stdout(.tee) + .run(in: context) + Issue.record("Expected outputLimitExceeded") + } catch { + thrown = error + } + } + + let error = try #require(thrown as? ShellError) + guard case let .outputLimitExceeded(_, limit, partialOutput) = error else { + Issue.record("Expected outputLimitExceeded, got \(error)") + return + } + #expect(limit == 4) + #expect(partialOutput.stdout == "abcd") + } +}