const std = @import("std"); const spec_data = @import("spec_data"); const cli = @import("cli.zig"); const config_mod = @import("config.zig"); const runlog_mod = @import("runlog.zig"); const scan_mod = @import("scan.zig"); const classify_mod = @import("classify.zig"); const plan_mod = @import("plan.zig"); const posix = std.posix; const tui_mod = @import("tui.zig"); /// The one canonical version value required by `spec/ARTIFACT.md`, reported /// without consulting Git, the network, or the filesystem. const version_line = spec_data.cli_executable ++ " " ++ spec_data.artifact_version ++ "\n"; /// Argument storage is reserved before parsing and never grows. var argument_storage: [cli.arguments_max][]const u8 = undefined; pub fn main(init: std.process.Init.Minimal) !void { const io = std.Io.Threaded.global_single_threaded.io(); var arguments = std.process.Args.Iterator.init(init.args); std.debug.assert(arguments.skip()); var count: u32 = 0; while (arguments.next()) |argument| { if (count == argument_storage.len) { exitUsage(io, .{ .reason = .too_many_arguments, .echo = .{} }); } argument_storage[count] = argument; count += 1; } std.debug.assert(count <= argument_storage.len); switch (cli.parse(argument_storage[0..count])) { .failure => |failure| exitUsage(io, failure), .invocation => |invocation| try run(io, invocation, init), } } fn run(io: std.Io, invocation: cli.Invocation, init: std.process.Init.Minimal) !void { std.debug.assert(invocation.config_path == null or invocation.config_path.?.len > 0); switch (invocation.mode) { .help => try writeInformation(io, spec_data.cli_help), .version => try writeInformation(io, version_line), .interactive => try runInteractive(io, invocation, init), } } var config_buffer: config_mod.Buffer = .{}; var scan_state: scan_mod.Scan = .{}; var path_pool: [path_pool_bytes]u8 = undefined; var path_pool_used: u64 = 0; var paths: [scan_mod.retained_findings_max][]const u8 = undefined; /// Root paths retained for execution: `mutate` reopens the selected root /// descriptor-relative and revalidates ancestry below it (`TJ-EXEC-02`). /// The paths point into `config_buffer`, which outlives the interactive loop. var root_paths: [config_mod.roots_max][]const u8 = undefined; var root_paths_len: u32 = 0; /// Per-finding reconstruction budget: half the retained count at the full /// registered path length; exhaustion leaves a finding reviewable but /// unavailable for mutation. const path_pool_bytes: u64 = @as(u64, scan_mod.retained_findings_max / 2) * scan_mod.raw_path_bytes_max; fn runInteractive(io: std.Io, invocation: cli.Invocation, init_in: std.process.Init.Minimal) !void { var init = init_in; try admitTerminal(io, init); const environment = config_mod.Environment.capture(&init); var config_path_buffer: config_mod.Normalized = .{}; const loaded = config_mod.load( io, &environment, invocation.config_path, &config_buffer, &config_path_buffer, ) catch exitConfigError(); var config = loaded.config; if (invocation.no_color) config.color = false; if (invocation.ascii) config.ascii = true; var state_path: [config_mod.path_bytes_max]u8 = undefined; var log: runlog_mod.RunLog = .{ .broken = true }; if (environment.stateBasePath(&state_path)) |base| { var log_path: [config_mod.path_bytes_max + 16]u8 = undefined; if (std.fmt.bufPrint(&log_path, "{s}/run.jsonl", .{base})) |joined| { std.Io.Dir.cwd().createDirPath(io, base) catch {}; log = runlog_mod.RunLog.open(joined); } else |_| {} } else |_| {} if (log.broken) { // Review-only mode: scanning and dry-run discovery remain // available and every mutation is unavailable (`TJ-LOG-01`). } var roots: [config_mod.roots_max]scan_mod.Root = undefined; var roots_len: u32 = 0; if (config.scan_roots.len > 0) { var index: u32 = 0; while (index < config.scan_roots.len) : (index += 1) { roots[roots_len] = .{ .path = config.scan_roots.paths[index] }; roots_len += 1; } } else { if (environment.home.len > 0) { roots[roots_len] = .{ .path = environment.home }; roots_len += 1; } if (environment.prefix.len > 0) { roots[roots_len] = .{ .path = environment.prefix }; roots_len += 1; } } root_paths_len = roots_len; var root_index: u32 = 0; while (root_index < roots_len) : (root_index += 1) { root_paths[root_index] = roots[root_index].path; } scan_state = .{}; scan_mod.run(&scan_state, &config, roots[0..roots_len]); rebuildPaths(); classifyAll(&config); var tui = tui_mod.Tui.init( io, posix.STDIN_FILENO, posix.STDOUT_FILENO, ) catch exitUnavailable(io); tui.ascii = config.ascii; tui.log_broken = log.broken; tui.termios = tui_mod.Termios.enterRaw(posix.STDIN_FILENO) catch exitUnavailable(io); tui.enterAlternateScreen(); const exit_code = interactiveLoop(&tui, &config, &log, invocation.dry_run, root_paths[0..root_paths_len]); tui.leaveAlternateScreen(); tui.termios.restore(posix.STDIN_FILENO); log.close(); std.process.exit(exit_code); } fn rebuildPaths() void { path_pool_used = 0; var index: u32 = 0; var scratch: [scan_mod.raw_path_bytes_max]u8 = undefined; while (index < scan_state.findings_len) : (index += 1) { const path = scan_state.pathOf(index, &scratch); if (path_pool_used + path.len + 1 > path_pool_bytes) { paths[index] = ""; continue; } const start: usize = @intCast(path_pool_used); @memcpy(path_pool[start..][0..path.len], path); path_pool_used += path.len; paths[index] = path_pool[start..][0..path.len]; } } fn classifyAll(config: *config_mod.Config) void { const context = classify_mod.Context{ .workspace_roots = &config.workspace_roots.paths, .document_roots = &config.document_roots.paths, .thresholds = &config.thresholds, .realtime_sec = scan_state.realtime_sec, }; var index: u32 = 0; while (index < scan_state.findings_len) : (index += 1) { const finding = scan_state.findings[index]; const result = classify_mod.classify(&finding, paths[index], &.{}, &context); scan_state.findings[index].category = @intFromEnum(result.category); scan_state.findings[index].manual_only = result.manual_only; } } fn interactiveLoop( tui: *tui_mod.Tui, config: *config_mod.Config, log: *runlog_mod.RunLog, dry_run: bool, roots: []const []const u8, ) u8 { _ = config; tui.render(&scan_state, paths[0..scan_state.findings_len]); var plan: plan_mod.Plan = .{}; while (true) { const key = tui.readKey() orelse break; // An open search prompt consumes every key: printable bytes and // Backspace edit the query, Enter accepts it, Escape clears and // closes it (`TJ-UI` interaction). if (tui.search_open) { switch (key) { .byte => |byte| { if (byte == 0x1B) { tui.search_open = false; tui.search_len = 0; tui.focus = 0; } else if (byte == '\r') { tui.search_open = false; } else if (byte == 0x7F or byte == 0x08) { if (tui.search_len > 0) tui.search_len -= 1; } else if (byte >= 0x20 and byte <= 0x7E) { if (tui.search_len < tui.search_query.len) { tui.search_query[tui.search_len] = byte; tui.search_len += 1; } } }, .up, .down, .left, .right => {}, } tui.render(&scan_state, paths[0..scan_state.findings_len]); continue; } tui.rebuildVisibility(&scan_state, paths[0..scan_state.findings_len]); if (tui.visible_len == 0) tui.focus = 0; if (tui.visible_len > 0 and tui.focus >= tui.visible_len) tui.focus = tui.visible_len - 1; switch (key) { .up => { if (tui.screen == .filters) { if (tui.filter_focus > 0) tui.filter_focus -= 1; } else if (tui.focus > 0) tui.focus -= 1; }, .down => { if (tui.screen == .filters) { const categories = @typeInfo(classify_mod.Category).@"enum".fields.len; const rows: u32 = @intCast(categories + 2); if (tui.filter_focus + 1 < rows) tui.filter_focus += 1; } else if (tui.focus + 1 < tui.visible_len) tui.focus += 1; }, .byte => |byte| { // Escape or Ctrl-C clears the confirmation phrase and returns // to review (`TJ-PLAN-10`); outside confirmation Ctrl-C // remains a quit request. if (tui.screen == .confirmation and (byte == 0x1B or byte == 0x03)) { tui.confirm = .{}; tui.screen = .checklist; tui.render(&scan_state, paths[0..scan_state.findings_len]); continue; } if (byte == 'q' or byte == 0x03) { return if (scan_state.cancelled) spec_data.cliExitCode(.cancelled) else spec_data.cliExitCode(.success); } if (tui.screen == .filters) { const categories = @typeInfo(classify_mod.Category).@"enum".fields.len; if (byte == 0x1B) { tui.screen = .checklist; } else if (byte == '\r') { tui.category_filter = tui.filters_draft; tui.selection_filter_selected = tui.selection_draft_selected; tui.selection_filter_unselected = tui.selection_draft_unselected; tui.screen = .checklist; tui.focus = 0; } else if (byte == 'j' or byte == 'k') { const rows: u32 = @intCast(categories + 2); if (byte == 'j' and tui.filter_focus + 1 < rows) tui.filter_focus += 1; if (byte == 'k' and tui.filter_focus > 0) tui.filter_focus -= 1; } else if (byte == ' ') { if (tui.filter_focus < categories) { tui.filters_draft ^= @as(u32, 1) << @intCast(tui.filter_focus); } else if (tui.filter_focus == categories) { tui.selection_draft_selected = !tui.selection_draft_selected; } else { tui.selection_draft_unselected = !tui.selection_draft_unselected; } } tui.render(&scan_state, paths[0..scan_state.findings_len]); continue; } if (byte == '?') { tui.screen = if (tui.screen == .help) .checklist else .help; } if (byte == 'f') { tui.screen = .filters; tui.filters_draft = tui.category_filter; tui.selection_draft_selected = tui.selection_filter_selected; tui.selection_draft_unselected = tui.selection_filter_unselected; tui.filter_focus = 0; } if (byte == 0x1B and tui.search_len > 0) { tui.search_len = 0; tui.focus = 0; } if (byte == 'k' or byte == 'j') { if (byte == 'k' and tui.focus > 0) tui.focus -= 1; if (byte == 'j' and tui.focus + 1 < tui.visible_len) tui.focus += 1; } if (byte == '/') { tui.search_open = true; } if (byte == '+' or byte == '-') { // Bulk scope: every visible non-manual-only candidate; // hidden and manual-only selections are preserved // (`TJ-UI-04`). var position: u32 = 0; while (position < tui.visible_len) : (position += 1) { const index = tui.visible[position]; if (scan_state.findings[index].manual_only) continue; scan_state.findings[index].selected = byte == '+'; } } if (byte == ' ' and tui.visible_len > 0) { scan_state.findings[tui.visible[tui.focus]].selected = !scan_state.findings[tui.visible[tui.focus]].selected; } if (byte == 'c' and !dry_run) { tui.screen = .confirmation; tui.confirm = .{}; plan = plan_mod.build(&scan_state); tui.plan = &plan; // The transition key must never contribute to the // confirmation phrase; input begins on the next turn. tui.render(&scan_state, paths[0..scan_state.findings_len]); continue; } if (byte == 'c' and dry_run) { plan = plan_mod.build(&scan_state); tui.message = "dry-run: mutation prohibited"; } if (tui.screen == .confirmation and plan.confirmable()) { switch (tui.confirm.feed(byte)) { .accepted => { var ok_count: u32 = 0; var failed_count: u32 = 0; var index: u32 = 0; while (index < plan.actions_len) : (index += 1) { const outcome = plan_mod.executeAction( paths[0..], roots, &scan_state, &plan.actions[index], log, ); if (outcome == .success) ok_count += 1 else failed_count += 1; } tui.screen = .report; tui.report_ok = ok_count; tui.report_failed = failed_count; tui.message = "execution complete"; }, .cleared, .more => {}, } } }, .left, .right => {}, } if (tui.screen != .confirmation) tui.confirm.len = 0; tui.render(&scan_state, paths[0..scan_state.findings_len]); } return spec_data.cliExitCode(.success); } fn exitConfigError() noreturn { var buffer: [256]u8 = undefined; var stderr = std.Io.File.stderr().writer(std.Io.Threaded.global_single_threaded.io(), &buffer); stderr.interface.print( "{s}: configuration error\n", .{spec_data.cli_executable}, ) catch {}; stderr.interface.flush() catch {}; std.process.exit(spec_data.cliExitCode(.failure)); } /// Interactive terminal admission (TJ-TERM-01). The TUI itself is not yet /// implemented, so after the terminal passes admission the invocation still /// fails closed with the unavailable diagnostic rather than degrading to /// unattended behavior. fn admitTerminal(io: std.Io, init: std.process.Init.Minimal) !void { var stdin = std.Io.File.stdin(); if (!try stdin.isTty(io)) exitStdinNotTty(io); var stdout = std.Io.File.stdout(); if (!try stdout.isTty(io)) exitStdoutNotTty(io); const term = init.environ.getPosix("TERM"); if (term == null or term.?.len == 0) exitTermDumb(io); if (std.mem.eql(u8, term.?, "dumb")) exitTermDumb(io); } fn exitStdinNotTty(io: std.Io) noreturn { var buffer: [256]u8 = undefined; var stderr = std.Io.File.stderr().writer(io, &buffer); stderr.interface.print( "{s}: interactive mode requires a terminal stdin\n", .{spec_data.cli_executable}, ) catch std.process.exit(spec_data.cliExitCode(.failure)); stderr.interface.flush() catch std.process.exit(spec_data.cliExitCode(.failure)); std.process.exit(spec_data.cliExitCode(.failure)); } fn exitStdoutNotTty(io: std.Io) noreturn { var buffer: [256]u8 = undefined; var stderr = std.Io.File.stderr().writer(io, &buffer); stderr.interface.print( "{s}: interactive mode requires a terminal stdout\n", .{spec_data.cli_executable}, ) catch std.process.exit(spec_data.cliExitCode(.failure)); stderr.interface.flush() catch std.process.exit(spec_data.cliExitCode(.failure)); std.process.exit(spec_data.cliExitCode(.failure)); } fn exitTermDumb(io: std.Io) noreturn { var buffer: [256]u8 = undefined; var stderr = std.Io.File.stderr().writer(io, &buffer); stderr.interface.print( "{s}: TERM must name a non-dumb terminal\n", .{spec_data.cli_executable}, ) catch std.process.exit(spec_data.cliExitCode(.failure)); stderr.interface.flush() catch std.process.exit(spec_data.cliExitCode(.failure)); std.process.exit(spec_data.cliExitCode(.failure)); } fn writeInformation(io: std.Io, text: []const u8) !void { std.debug.assert(text.len > 0); std.debug.assert(text[text.len - 1] == '\n'); try std.Io.File.stdout().writeStreamingAll(io, text); } fn exitUsage(io: std.Io, failure: cli.Failure) noreturn { var buffer: [512]u8 = undefined; var stderr = std.Io.File.stderr().writer(io, &buffer); writeUsage(&stderr.interface, failure) catch { std.process.exit(spec_data.cliExitCode(.failure)); }; std.process.exit(spec_data.cliExitCode(.usage)); } fn writeUsage(writer: *std.Io.Writer, failure: cli.Failure) !void { std.debug.assert(failure.echo.length <= cli.echo_bytes_max); const echo = failure.echo.text(); try writer.print("{s}: {s}", .{ spec_data.cli_executable, failure.reason.message() }); if (echo.len > 0) { const ellipsis = if (failure.echo.truncated) "..." else ""; try writer.print(": '{s}{s}'", .{ echo, ellipsis }); } try writer.print("\nusage: {s} [OPTIONS]; run '{s} {s}' for the option list.\n", .{ spec_data.cli_executable, spec_data.cli_executable, spec_data.cliOption(.help).spelling, }); try writer.flush(); } fn exitUnavailable(io: std.Io) noreturn { var buffer: [256]u8 = undefined; var stderr = std.Io.File.stderr().writer(io, &buffer); stderr.interface.print( "{s}: interactive mode is not implemented\n", .{spec_data.cli_executable}, ) catch std.process.exit(spec_data.cliExitCode(.failure)); stderr.interface.flush() catch std.process.exit(spec_data.cliExitCode(.failure)); std.process.exit(spec_data.cliExitCode(.failure)); }