diff --git a/src/ui/color.zig b/src/ui/color.zig index eb6c7e3..6826ed5 100644 --- a/src/ui/color.zig +++ b/src/ui/color.zig @@ -259,7 +259,8 @@ fn queryOsc11Background() ?Background { if (ready == 0) return null; var buf: [64]u8 = undefined; - const n = std.posix.read(stdin_fd, &buf) catch return null; + const stdin_file: std.Io.File = .{ .handle = stdin_fd, .flags = .{ .nonblocking = false } }; + const n = stdin_file.readStreaming(pkg_io, &.{buf[0..]}) catch return null; if (n == 0) return null; const rgb = parseOsc11Response(buf[0..n]) orelse return null; return classifyLuminance(rgb); diff --git a/src/ui/output.zig b/src/ui/output.zig index 63e02e1..7fb419a 100644 --- a/src/ui/output.zig +++ b/src/ui/output.zig @@ -10,6 +10,10 @@ pub const OutputMode = enum { json, }; +/// Seeded by argv parsing in `main` before any worker thread spawns and +/// not flipped afterwards, so plain reads/writes stay race-free. Mutators +/// that need a runtime flip must migrate the flag (and every reader) to +/// `@atomicLoad`/`@atomicStore`, matching `post_install_emit` below. var quiet: bool = false; var verbose: bool = false; var debug: bool = false; @@ -416,9 +420,15 @@ pub fn confirmTyped(expected: []const u8, prompt: []const u8) bool { if (!is_tty) return false; question("{s}", .{prompt}); + return readMatchingLine(stdin_file, pkg_io, expected); +} +/// Helper kept separate so tests can drive the read path against a pipe +/// without needing a real TTY; routing the read through `io` matches the +/// `isTty` probe and lets `AppCtx.io` redirection observe stdin. +fn readMatchingLine(stdin_file: std.Io.File, io: std.Io, expected: []const u8) bool { var buf: [128]u8 = undefined; - const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch return false; + const n = stdin_file.readStreaming(io, &.{buf[0..]}) catch return false; if (n == 0) return false; const input = std.mem.trim(u8, buf[0..n], " \t\r\n"); return std.mem.eql(u8, input, expected); @@ -830,3 +840,46 @@ test "postInstallEmit reflects the most recent setPostInstallEmit value" { resetPostInstallEvents(); try std.testing.expectEqual(PostInstallEmit.stream, postInstallEmit()); } + +// Drives the read path through the seeded io against a pipe so the +// `confirmTyped` accept/reject contract — including EOF — is pinned +// without relying on a real TTY. +fn pipeForTest() ![2]std.c.fd_t { + var fds: [2]std.c.fd_t = undefined; + if (std.c.pipe(&fds) != 0) return error.PipeFailed; + return fds; +} + +test "readMatchingLine accepts trimmed input that matches expected" { + const fds = try pipeForTest(); + defer _ = std.c.close(fds[0]); + + const msg = "yes\n"; + const written = std.c.write(fds[1], msg.ptr, msg.len); + try std.testing.expectEqual(@as(isize, msg.len), written); + _ = std.c.close(fds[1]); + + const stdin_file: std.Io.File = .{ .handle = fds[0], .flags = .{ .nonblocking = false } }; + try std.testing.expect(readMatchingLine(stdin_file, std.Options.debug_io, "yes")); +} + +test "readMatchingLine rejects mismatched input" { + const fds = try pipeForTest(); + defer _ = std.c.close(fds[0]); + + const msg = "no\n"; + _ = std.c.write(fds[1], msg.ptr, msg.len); + _ = std.c.close(fds[1]); + + const stdin_file: std.Io.File = .{ .handle = fds[0], .flags = .{ .nonblocking = false } }; + try std.testing.expect(!readMatchingLine(stdin_file, std.Options.debug_io, "yes")); +} + +test "readMatchingLine returns false when stdin is at EOF" { + const fds = try pipeForTest(); + defer _ = std.c.close(fds[0]); + _ = std.c.close(fds[1]); + + const stdin_file: std.Io.File = .{ .handle = fds[0], .flags = .{ .nonblocking = false } }; + try std.testing.expect(!readMatchingLine(stdin_file, std.Options.debug_io, "yes")); +}