repositories / termux-janitor
termux-janitor
Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.
owned by admin
src/main.zig
Rawconst 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));
}