const std = @import("std"); const diagnostics = @import("diagnostics.zig"); // Explicit local build/test job execution for the shared panel model. // req: repo/002, ui/002, testing/001, testing/002, testing/003, testing/004 test { _ = runRowsAlloc; } pub const Error = error{ InvalidCwd, InvalidCommand, InvalidJobRow, InvalidPath, MissingCurrentFile, }; pub const Profile = enum { lint_file, lint_project, build, tests, check, pub fn name(self: Profile) []const u8 { return switch (self) { .lint_file => "lint_file", .lint_project => "lint_project", .build => "build", .tests => "test", .check => "check", }; } pub fn provider(self: Profile) []const u8 { return switch (self) { .lint_file, .lint_project => "zig-fmt", .build, .tests, .check => "zig-build", }; } pub fn scope(self: Profile) []const u8 { return switch (self) { .lint_file => "file", .lint_project, .build, .tests, .check => "project", }; } }; pub fn argvForProfileAlloc(allocator: std.mem.Allocator, profile: Profile, current_file: ?[]const u8) ![][]const u8 { var argv = std.ArrayList([]const u8).empty; errdefer { for (argv.items) |arg| allocator.free(arg); argv.deinit(allocator); } switch (profile) { .lint_file => { const path = current_file orelse return Error.MissingCurrentFile; try validatePath(path); try argv.append(allocator, try allocator.dupe(u8, "zig")); try argv.append(allocator, try allocator.dupe(u8, "fmt")); try argv.append(allocator, try allocator.dupe(u8, "--check")); try argv.append(allocator, try allocator.dupe(u8, path)); }, .lint_project => { try argv.append(allocator, try allocator.dupe(u8, "zig")); try argv.append(allocator, try allocator.dupe(u8, "fmt")); try argv.append(allocator, try allocator.dupe(u8, "--check")); try argv.append(allocator, try allocator.dupe(u8, "src")); }, .build => { try argv.append(allocator, try allocator.dupe(u8, "zig")); try argv.append(allocator, try allocator.dupe(u8, "build")); }, .tests => { try argv.append(allocator, try allocator.dupe(u8, "zig")); try argv.append(allocator, try allocator.dupe(u8, "build")); try argv.append(allocator, try allocator.dupe(u8, "test")); }, .check => { try argv.append(allocator, try allocator.dupe(u8, "zig")); try argv.append(allocator, try allocator.dupe(u8, "build")); try argv.append(allocator, try allocator.dupe(u8, "v1-smoke")); }, } return argv.toOwnedSlice(allocator); } pub fn freeArgv(allocator: std.mem.Allocator, argv: []const []const u8) void { for (argv) |arg| allocator.free(arg); allocator.free(argv); } pub fn profileRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, profile: Profile, current_file: ?[]const u8) ![][]const u8 { const argv = argvForProfileAlloc(allocator, profile, current_file) catch |err| { if (err == Error.MissingCurrentFile) return missingCurrentFileRowsAlloc(allocator, profile); return err; }; defer freeArgv(allocator, argv); return runRowsWithPreviewAlloc(allocator, io, cwd, argv, "job", profilePreview(profile)); } pub fn missingToolRowsAlloc(allocator: std.mem.Allocator, profile: Profile, tool: []const u8) ![][]const u8 { var rows = std.ArrayList([]const u8).empty; errdefer freeOwnedRows(allocator, rows.items); try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:profile:{s}:scope:{s}:provider:{s}", .{ profile.name(), profile.scope(), profile.provider() })); try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:status:missing_tool:{s}:no_install_attempted", .{sanitizeToken(tool)})); return rows.toOwnedSlice(allocator); } pub fn cancelRowsAlloc(allocator: std.mem.Allocator, profile: Profile) ![][]const u8 { var rows = std.ArrayList([]const u8).empty; errdefer freeOwnedRows(allocator, rows.items); try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:profile:{s}:scope:{s}:provider:{s}", .{ profile.name(), profile.scope(), profile.provider() })); try rows.append(allocator, try allocator.dupe(u8, "job:status:cancelled:user")); return rows.toOwnedSlice(allocator); } pub fn timeoutRowsAlloc(allocator: std.mem.Allocator, profile: Profile) ![][]const u8 { var rows = std.ArrayList([]const u8).empty; errdefer freeOwnedRows(allocator, rows.items); try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:profile:{s}:scope:{s}:provider:{s}", .{ profile.name(), profile.scope(), profile.provider() })); try rows.append(allocator, try allocator.dupe(u8, "job:status:timeout:recoverable")); return rows.toOwnedSlice(allocator); } fn missingCurrentFileRowsAlloc(allocator: std.mem.Allocator, profile: Profile) ![][]const u8 { var rows = std.ArrayList([]const u8).empty; errdefer freeOwnedRows(allocator, rows.items); try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:profile:{s}:scope:{s}:provider:{s}", .{ profile.name(), profile.scope(), profile.provider() })); try rows.append(allocator, try allocator.dupe(u8, "job:status:missing_current_file")); return rows.toOwnedSlice(allocator); } fn profilePreview(profile: Profile) []const u8 { return switch (profile) { .lint_file => "profile:lint_file:scope:file:provider:zig-fmt", .lint_project => "profile:lint_project:scope:project:provider:zig-fmt", .build => "profile:build:scope:project:provider:zig-build", .tests => "profile:test:scope:project:provider:zig-build", .check => "profile:check:scope:project:provider:zig-build", }; } fn sanitizeToken(text: []const u8) []const u8 { if (text.len == 0) return "unknown"; for (text) |byte| { if (byte <= 0x20 or byte == ':' or byte == '|') return "invalid"; } return text; } pub const Location = struct { path: []const u8, line: usize, column: usize, }; pub fn runRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, argv: []const []const u8) ![][]const u8 { try validateArgv(argv); const command_preview = try commandPreviewAlloc(allocator, argv); defer allocator.free(command_preview); return runRowsWithPreviewAlloc(allocator, io, cwd, argv, "job", command_preview); } pub fn shellRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, command: []const u8) ![][]const u8 { try validateShellCommand(command); const argv = [_][]const u8{ "sh", "-c", command }; const command_preview = try sanitizeLineAlloc(allocator, command); defer allocator.free(command_preview); return runRowsWithPreviewAlloc(allocator, io, cwd, &argv, "terminal", command_preview); } fn runRowsWithPreviewAlloc( allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, argv: []const []const u8, kind: []const u8, command_preview: []const u8, ) ![][]const u8 { try validateCwd(cwd); try validateArgv(argv); var rows = std.ArrayList([]const u8).empty; errdefer { for (rows.items) |row| allocator.free(row); rows.deinit(allocator); } try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:spawned:{s}", .{ kind, command_preview })); const result = std.process.run(allocator, io, .{ .argv = argv, .cwd = .{ .path = cwd }, .stdout_limit = .limited(diagnostics.process_stdout_limit), .stderr_limit = .limited(diagnostics.process_stderr_limit), }) catch |err| { try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:status:spawn_error_{s}", .{ kind, @errorName(err) })); try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, kind, diagnostics.process_stdout_limit, diagnostics.process_stderr_limit)); return rows.toOwnedSlice(allocator); }; defer allocator.free(result.stdout); defer allocator.free(result.stderr); try appendTermRow(allocator, &rows, kind, result.term); try appendOutputRows(allocator, &rows, "stdout", result.stdout); try appendOutputRows(allocator, &rows, "stderr", result.stderr); if (rows.items.len == 2) try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:output_empty", .{kind})); try rows.append(allocator, try diagnostics.processLimitRowAlloc(allocator, kind, diagnostics.process_stdout_limit, diagnostics.process_stderr_limit)); return rows.toOwnedSlice(allocator); } pub fn locationFromRow(row: []const u8) !Location { const prefix_end = std.mem.indexOfScalar(u8, row, ':') orelse return Error.InvalidJobRow; const body = row[prefix_end + 1 ..]; if (!std.mem.eql(u8, row[0..prefix_end], "stdout") and !std.mem.eql(u8, row[0..prefix_end], "stderr")) return Error.InvalidJobRow; const first = std.mem.indexOfScalar(u8, body, ':') orelse return Error.InvalidJobRow; const path = body[0..first]; try validatePath(path); const rest = body[first + 1 ..]; const second = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidJobRow; const line = std.fmt.parseUnsigned(usize, rest[0..second], 10) catch return Error.InvalidJobRow; if (line == 0) return Error.InvalidJobRow; const after_line = rest[second + 1 ..]; const maybe_column_token = if (std.mem.indexOfScalar(u8, after_line, ':')) |third| after_line[0..third] else after_line; const column = std.fmt.parseUnsigned(usize, maybe_column_token, 10) catch 1; return .{ .path = path, .line = line, .column = if (column == 0) 1 else column }; } pub fn byteOffsetForLineColumn(content: []const u8, line: usize, column: usize) !usize { if (line == 0 or column == 0) return Error.InvalidJobRow; var current_line: usize = 1; var line_start: usize = 0; var i: usize = 0; while (i <= content.len) : (i += 1) { if (i == content.len or content[i] == '\n') { if (current_line == line) { const line_bytes = content[line_start..i]; if (column - 1 > line_bytes.len) return Error.InvalidJobRow; return line_start + column - 1; } current_line += 1; line_start = i + 1; } } return Error.InvalidJobRow; } fn validateCwd(cwd: []const u8) !void { if (cwd.len == 0 or !std.unicode.utf8ValidateSlice(cwd)) return Error.InvalidCwd; for (cwd) |byte| { if (byte == 0 or byte == '\n' or byte == '\r' or byte == '|') return Error.InvalidCwd; } } fn validateArgv(argv: []const []const u8) !void { if (argv.len == 0) return Error.InvalidCommand; for (argv) |arg| { if (arg.len == 0 or !std.unicode.utf8ValidateSlice(arg)) return Error.InvalidCommand; for (arg) |byte| { if (byte == 0 or byte == '\n' or byte == '\r') return Error.InvalidCommand; } } } fn validateShellCommand(command: []const u8) !void { if (command.len == 0 or !std.unicode.utf8ValidateSlice(command)) return Error.InvalidCommand; for (command) |byte| { if (byte == 0 or byte == '\n' or byte == '\r') return Error.InvalidCommand; } } fn validatePath(path: []const u8) !void { if (path.len == 0 or !std.unicode.utf8ValidateSlice(path)) return Error.InvalidPath; if (std.mem.startsWith(u8, path, "/") or std.mem.indexOf(u8, path, "..") != null) return Error.InvalidPath; for (path) |byte| { if (byte <= 0x20 or byte == '|') return Error.InvalidPath; } } fn appendTermRow(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), kind: []const u8, term: std.process.Child.Term) !void { const row = switch (term) { .exited => |code| try std.fmt.allocPrint(allocator, "{s}:status:exit_{d}", .{ kind, code }), .signal => |sig| try std.fmt.allocPrint(allocator, "{s}:status:signal_{d}", .{ kind, @intFromEnum(sig) }), .stopped => |sig| try std.fmt.allocPrint(allocator, "{s}:status:stopped_{d}", .{ kind, @intFromEnum(sig) }), .unknown => |code| try std.fmt.allocPrint(allocator, "{s}:status:unknown_{d}", .{ kind, code }), }; try rows.append(allocator, row); } fn appendOutputRows(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), prefix: []const u8, output: []const u8) !void { var lines = std.mem.splitScalar(u8, output, '\n'); while (lines.next()) |raw_line| { const trimmed = std.mem.trim(u8, raw_line, "\r"); if (trimmed.len == 0) continue; const sanitized = try sanitizeLineAlloc(allocator, trimmed); defer allocator.free(sanitized); try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:{s}", .{ prefix, sanitized })); } } fn sanitizeLineAlloc(allocator: std.mem.Allocator, line: []const u8) ![]u8 { var out = std.ArrayList(u8).empty; errdefer out.deinit(allocator); var i: usize = 0; while (i < line.len and out.items.len < 160) { const len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1; const end = @min(line.len, i + len); const slice = line[i..end]; if (slice.len == 1 and (slice[0] <= 0x20 or slice[0] == '|')) { try out.append(allocator, '_'); } else { try out.appendSlice(allocator, slice); } i = end; } if (out.items.len == 0) try out.append(allocator, '_'); return out.toOwnedSlice(allocator); } fn commandPreviewAlloc(allocator: std.mem.Allocator, argv: []const []const u8) ![]u8 { var out = std.ArrayList(u8).empty; errdefer out.deinit(allocator); for (argv, 0..) |arg, index| { if (index != 0) try out.append(allocator, '_'); const sanitized = try sanitizeLineAlloc(allocator, arg); defer allocator.free(sanitized); try out.appendSlice(allocator, sanitized); if (out.items.len >= 120) break; } return out.toOwnedSlice(allocator); } fn freeOwnedRows(allocator: std.mem.Allocator, rows: []const []const u8) void { for (rows) |row| allocator.free(row); allocator.free(rows); } test "regular: job rows capture failing command output and status" { const rows = try runRowsAlloc(std.testing.allocator, std.testing.io, ".", &.{ "sh", "-c", "printf 'src/main.zig:2:5:error:bad\\n'; exit 1" }); defer freeOwnedRows(std.testing.allocator, rows); try std.testing.expect(rows.len >= 3); try std.testing.expectEqualStrings("job:status:exit_1", rows[1]); try std.testing.expectEqualStrings("stdout:src/main.zig:2:5:error:bad", rows[2]); const loc = try locationFromRow(rows[2]); try std.testing.expectEqualStrings("src/main.zig", loc.path); try std.testing.expectEqual(@as(usize, 2), loc.line); try std.testing.expectEqual(@as(usize, 5), loc.column); } test "regular: job row location converts to byte offset" { try std.testing.expectEqual(@as(usize, 6), try byteOffsetForLineColumn("one\ntwo needle\n", 2, 3)); } test "adversarial: invalid commands rows and unparseable output are rejected" { try std.testing.expectError(Error.InvalidCommand, runRowsAlloc(std.testing.allocator, std.testing.io, ".", &.{})); try std.testing.expectError(Error.InvalidCwd, runRowsAlloc(std.testing.allocator, std.testing.io, "bad\nwd", &.{"true"})); try std.testing.expectError(Error.InvalidJobRow, locationFromRow("job:status:exit_1")); try std.testing.expectError(Error.InvalidPath, locationFromRow("stderr:../secret:1:1:nope")); } test "regular: shell terminal rows capture command output and exit status" { const rows = try shellRowsAlloc(std.testing.allocator, std.testing.io, ".", "printf terminal_ok"); defer freeOwnedRows(std.testing.allocator, rows); try std.testing.expect(rows.len >= 3); try std.testing.expectEqualStrings("terminal:spawned:printf_terminal_ok", rows[0]); try std.testing.expectEqualStrings("terminal:status:exit_0", rows[1]); try std.testing.expectEqualStrings("stdout:terminal_ok", rows[2]); } test "adversarial: shell terminal rejects invalid command and reports shell failures" { try std.testing.expectError(Error.InvalidCommand, shellRowsAlloc(std.testing.allocator, std.testing.io, ".", "")); try std.testing.expectError(Error.InvalidCommand, shellRowsAlloc(std.testing.allocator, std.testing.io, ".", "echo bad\n")); const rows = try shellRowsAlloc(std.testing.allocator, std.testing.io, ".", "definitely-not-a-mim-command"); defer freeOwnedRows(std.testing.allocator, rows); try std.testing.expectEqualStrings("terminal:status:exit_127", rows[1]); try std.testing.expect(std.mem.startsWith(u8, rows[2], "stderr:")); }