Compare commits

...

1 Commits

Author SHA1 Message Date
Peter Steinberger
afabfc82a6 refactor: centralize stdout writing 2026-02-16 03:33:54 +01:00
11 changed files with 93 additions and 60 deletions

View File

@ -33,7 +33,7 @@ struct CommandRouter {
func run(argv: [String]) async -> Int32 {
let argv = normalizeArguments(argv)
if argv.contains("--version") || argv.contains("-V") {
Swift.print(version)
StdoutWriter.writeLine(version)
return 0
}
if argv.count <= 1 || argv.contains("--help") || argv.contains("-h") {
@ -46,7 +46,7 @@ struct CommandRouter {
guard let commandName = invocation.path.last,
let spec = specs.first(where: { $0.name == commandName })
else {
Swift.print("Unknown command")
StdoutWriter.writeLine("Unknown command")
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
return 1
}
@ -55,17 +55,17 @@ struct CommandRouter {
try await spec.run(invocation.parsedValues, runtime)
return 0
} catch {
Swift.print(error)
StdoutWriter.writeLine(String(describing: error))
return 1
}
} catch let error as CommanderProgramError {
Swift.print(error.description)
StdoutWriter.writeLine(error.description)
if case .missingSubcommand = error {
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
}
return 1
} catch {
Swift.print(error)
StdoutWriter.writeLine(String(describing: error))
return 1
}
}

View File

@ -26,14 +26,14 @@ enum ChatsCommand {
if runtime.jsonOutput {
for chat in chats {
try JSONLines.print(ChatPayload(chat: chat))
try StdoutWriter.writeJSONLine(ChatPayload(chat: chat))
}
return
}
for chat in chats {
let last = CLIISO8601.format(chat.lastMessageAt)
Swift.print("[\(chat.id)] \(chat.name) (\(chat.identifier)) last=\(last)")
StdoutWriter.writeLine("[\(chat.id)] \(chat.name) (\(chat.identifier)) last=\(last)")
}
}
}

View File

@ -57,7 +57,7 @@ enum HistoryCommand {
attachments: attachments,
reactions: reactions
)
try JSONLines.print(payload)
try StdoutWriter.writeJSONLine(payload)
}
return
}
@ -65,18 +65,18 @@ enum HistoryCommand {
for message in filtered {
let direction = message.isFromMe ? "sent" : "recv"
let timestamp = CLIISO8601.format(message.date)
Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
if message.attachmentsCount > 0 {
if showAttachments {
let metas = try store.attachments(for: message.rowID)
for meta in metas {
let name = displayName(for: meta)
Swift.print(
StdoutWriter.writeLine(
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
)
}
} else {
Swift.print(
StdoutWriter.writeLine(
" (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))"
)
}

View File

@ -91,9 +91,9 @@ enum SendCommand {
))
if runtime.jsonOutput {
try JSONLines.print(["status": "sent"])
try StdoutWriter.writeJSONLine(["status": "sent"])
} else {
Swift.print("sent")
StdoutWriter.writeLine("sent")
}
}
}

View File

@ -1,5 +1,4 @@
import Commander
import Darwin
import Foundation
import IMsgCore
@ -90,29 +89,27 @@ enum WatchCommand {
attachments: attachments,
reactions: reactions
)
try JSONLines.print(payload)
fflush(stdout)
try StdoutWriter.writeJSONLine(payload)
continue
}
let direction = message.isFromMe ? "sent" : "recv"
let timestamp = CLIISO8601.format(message.date)
Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
if message.attachmentsCount > 0 {
if showAttachments {
let metas = try store.attachments(for: message.rowID)
for meta in metas {
let name = displayName(for: meta)
Swift.print(
StdoutWriter.writeLine(
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
)
}
} else {
Swift.print(
StdoutWriter.writeLine(
" (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))"
)
}
}
fflush(stdout)
}
}
}

View File

@ -4,13 +4,13 @@ import Foundation
struct HelpPrinter {
static func printRoot(version: String, rootName: String, commands: [CommandSpec]) {
for line in renderRoot(version: version, rootName: rootName, commands: commands) {
Swift.print(line)
StdoutWriter.writeLine(line)
}
}
static func printCommand(rootName: String, spec: CommandSpec) {
for line in renderCommand(rootName: rootName, spec: spec) {
Swift.print(line)
StdoutWriter.writeLine(line)
}
}

View File

@ -15,7 +15,7 @@ enum JSONLines {
static func print<T: Encodable>(_ value: T) throws {
let line = try encode(value)
if !line.isEmpty {
Swift.print(line)
StdoutWriter.writeLine(line)
}
}
}

View File

@ -286,8 +286,6 @@ private func buildMessagePayload(
}
private final class RPCWriter: RPCOutput, @unchecked Sendable {
private let queue = DispatchQueue(label: "imsg.rpc.writer")
func sendResponse(id: Any, result: Any) {
send(["jsonrpc": "2.0", "id": id, "result": result])
}
@ -306,21 +304,15 @@ private final class RPCWriter: RPCOutput, @unchecked Sendable {
}
private func send(_ object: Any) {
queue.sync {
do {
let data = try JSONSerialization.data(withJSONObject: object, options: [])
if let output = String(data: data, encoding: .utf8) {
FileHandle.standardOutput.write(Data(output.utf8))
FileHandle.standardOutput.write(Data("\n".utf8))
}
} catch {
if let fallback =
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}\n"
.data(using: .utf8)
{
FileHandle.standardOutput.write(fallback)
}
do {
let data = try JSONSerialization.data(withJSONObject: object, options: [])
if let output = String(data: data, encoding: .utf8) {
StdoutWriter.writeLine(output)
}
} catch {
StdoutWriter.writeLine(
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}"
)
}
}
}

View File

@ -0,0 +1,24 @@
import Dispatch
import Foundation
enum StdoutWriter {
private static let queue = DispatchQueue(label: "imsg.stdout.writer")
private static let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
return encoder
}()
static func writeLine(_ line: String) {
queue.sync {
FileHandle.standardOutput.write(Data((line + "\n").utf8))
}
}
static func writeJSONLine<T: Encodable>(_ value: T) throws {
let data = try jsonEncoder.encode(value)
guard let line = String(data: data, encoding: .utf8), !line.isEmpty else { return }
writeLine(line)
}
}

View File

@ -4,25 +4,31 @@ import Testing
@testable import imsg
@Test
func commandRouterPrintsVersionFromEnv() async throws {
func commandRouterPrintsVersionFromEnv() async {
setenv("IMSG_VERSION", "9.9.9-test", 1)
defer { unsetenv("IMSG_VERSION") }
let router = CommandRouter()
#expect(router.version == "9.9.9-test")
let status = await router.run(argv: ["imsg", "--version"])
let (_, status) = await StdoutCapture.capture {
await router.run(argv: ["imsg", "--version"])
}
#expect(status == 0)
}
@Test
func commandRouterPrintsHelp() async {
let router = CommandRouter()
let status = await router.run(argv: ["imsg", "--help"])
let (_, status) = await StdoutCapture.capture {
await router.run(argv: ["imsg", "--help"])
}
#expect(status == 0)
}
@Test
func commandRouterUnknownCommand() async {
let router = CommandRouter()
let status = await router.run(argv: ["imsg", "nope"])
let (_, status) = await StdoutCapture.capture {
await router.run(argv: ["imsg", "nope"])
}
#expect(status == 1)
}

View File

@ -100,7 +100,9 @@ func chatsCommandRunsWithJsonOutput() async throws {
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
try await ChatsCommand.spec.run(values, runtime)
_ = try await StdoutCapture.capture {
try await ChatsCommand.spec.run(values, runtime)
}
}
@Test
@ -112,7 +114,9 @@ func historyCommandRunsWithChatID() async throws {
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
try await HistoryCommand.spec.run(values, runtime)
_ = try await StdoutCapture.capture {
try await HistoryCommand.spec.run(values, runtime)
}
}
@Test
@ -124,7 +128,9 @@ func historyCommandRunsWithAttachmentsNonJson() async throws {
flags: ["attachments"]
)
let runtime = RuntimeOptions(parsedValues: values)
try await HistoryCommand.spec.run(values, runtime)
_ = try await StdoutCapture.capture {
try await HistoryCommand.spec.run(values, runtime)
}
}
@Test
@ -136,7 +142,9 @@ func chatsCommandRunsWithPlainOutput() async throws {
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
try await ChatsCommand.spec.run(values, runtime)
_ = try await StdoutCapture.capture {
try await ChatsCommand.spec.run(values, runtime)
}
}
@Test
@ -204,7 +212,9 @@ func watchCommandRejectsInvalidDebounce() async {
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await WatchCommand.spec.run(values, runtime)
_ = try await StdoutCapture.capture {
try await WatchCommand.spec.run(values, runtime)
}
#expect(Bool(false))
} catch let error as ParsedValuesError {
#expect(error.description.contains("Invalid value"))
@ -251,12 +261,14 @@ func watchCommandRunsWithStubStream() async throws {
continuation.finish()
}
}
try await WatchCommand.run(
values: values,
runtime: runtime,
storeFactory: { _ in store },
streamProvider: streamProvider
)
_ = try await StdoutCapture.capture {
try await WatchCommand.run(
values: values,
runtime: runtime,
storeFactory: { _ in store },
streamProvider: streamProvider
)
}
}
@Test
@ -320,12 +332,14 @@ func watchCommandRunsWithJsonOutput() async throws {
continuation.finish()
}
}
try await WatchCommand.run(
values: values,
runtime: runtime,
storeFactory: { _ in store },
streamProvider: streamProvider
)
_ = try await StdoutCapture.capture {
try await WatchCommand.run(
values: values,
runtime: runtime,
storeFactory: { _ in store },
streamProvider: streamProvider
)
}
}
@Test