Luigit
repositories / termux-janitor

termux-janitor

Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.

owned by admin

src/main.zig

Raw
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));
}