repositories / termux-janitor
termux-janitor
Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.
owned by admin
src/cli.zig
Raw// Startup argument parsing for the command-line contract owned by
// `spec/model/cli.zon`. This module decides the invocation and every usage
// rejection. It performs no input or output, allocates nothing, and therefore
// cannot widen what the process later does.
const std = @import("std");
const spec_data = @import("spec_data");
/// Arguments accepted from one invocation, bounded by the registered capacity.
pub const arguments_max: u32 = @intCast(spec_data.limit_value.arguments_per_invocation);
/// Bytes of one argument that a startup diagnostic may echo. Only diagnostics
/// truncate, as required by the logging policy in `spec/LIMITS.md`.
pub const echo_bytes_max: u32 = @intCast(spec_data.limit_value.diagnostic_argument_bytes);
comptime {
std.debug.assert(spec_data.cli_long_options_only);
std.debug.assert(spec_data.cli_option_terminator);
std.debug.assert(!spec_data.cli_operands_allowed);
std.debug.assert(!spec_data.cli_duplicate_options_allowed);
std.debug.assert(!spec_data.cli_unattended_cleanup);
std.debug.assert(arguments_max > 0);
std.debug.assert(echo_bytes_max > "<U+XXXX>".len);
}
/// What the accepted invocation asks the process to do.
pub const Mode = enum { help, version, interactive };
/// One accepted invocation. Informational modes bypass every startup subsystem.
pub const Invocation = struct {
mode: Mode = .interactive,
dry_run: bool = false,
no_color: bool = false,
ascii: bool = false,
config_path: ?[]const u8 = null,
};
/// Why an invocation is rejected before any startup subsystem runs.
pub const Reason = enum {
unknown_option,
inline_value,
missing_value,
empty_value,
duplicate_option,
exclusive_option,
operand,
short_option,
too_many_arguments,
/// One concise reason, free of operator-supplied bytes.
pub fn message(reason: Reason) []const u8 {
return switch (reason) {
.unknown_option => "unknown option",
.inline_value => "option value must be a separate argument",
.missing_value => "option requires a value",
.empty_value => "option value must not be empty",
.duplicate_option => "duplicate option",
.exclusive_option => "option must appear alone",
.operand => "operands are not supported",
.short_option => "short options are not supported",
.too_many_arguments => "too many arguments",
};
}
};
/// A rejected invocation and the bounded, sanitized argument it names.
pub const Failure = struct {
reason: Reason,
echo: Echo,
};
/// The parse outcome. Exactly one of the two states is reachable per invocation.
pub const Result = union(enum) {
invocation: Invocation,
failure: Failure,
};
/// A sanitized, bounded copy of one operator-supplied argument. Diagnostics
/// never write raw argument bytes to a terminal.
pub const Echo = struct {
bytes: [echo_bytes_max]u8 = [_]u8{0} ** echo_bytes_max,
length: u32 = 0,
truncated: bool = false,
pub fn text(echo: *const Echo) []const u8 {
std.debug.assert(echo.length <= echo_bytes_max);
std.debug.assert(echo.bytes.len == echo_bytes_max);
return echo.bytes[0..echo.length];
}
};
/// Decide the invocation named by `arguments`, which excludes the program name.
pub fn parse(arguments: []const []const u8) Result {
std.debug.assert(arguments.len <= arguments_max);
var invocation: Invocation = .{};
var seen = [_]bool{false} ** spec_data.cli_options.len;
var index: u32 = 0;
while (index < arguments.len) {
const argument = arguments[index];
index += 1;
if (std.mem.eql(u8, argument, "--")) {
if (index == arguments.len) continue;
return reject(.operand, arguments[index]);
}
if (!std.mem.startsWith(u8, argument, "--")) {
if (argument.len > 1 and argument[0] == '-') return reject(.short_option, argument);
return reject(.operand, argument);
}
if (std.mem.indexOfScalar(u8, argument, '=')) |equals| {
const name = argument[0..equals];
if (optionOf(name) == null) return reject(.unknown_option, name);
return reject(.inline_value, name);
}
const option = optionOf(argument) orelse return reject(.unknown_option, argument);
const slot = @intFromEnum(option.id);
std.debug.assert(slot < seen.len);
if (option.must_appear_alone and arguments.len != 1) {
return reject(.exclusive_option, argument);
}
if (seen[slot]) return reject(.duplicate_option, argument);
seen[slot] = true;
if (option.argument == .none) {
apply(&invocation, option.id, null);
continue;
}
if (index == arguments.len) return reject(.missing_value, argument);
const value = arguments[index];
index += 1;
if (value.len == 0) return reject(.empty_value, argument);
apply(&invocation, option.id, value);
}
return .{ .invocation = invocation };
}
/// Record one accepted option. Every registered identifier is handled here, so
/// adding a registry option fails compilation rather than being ignored.
fn apply(invocation: *Invocation, id: spec_data.CliOptionId, value: ?[]const u8) void {
std.debug.assert(invocation.mode == .interactive);
switch (id) {
.dry_run => {
std.debug.assert(value == null);
invocation.dry_run = true;
},
.no_color => {
std.debug.assert(value == null);
invocation.no_color = true;
},
.ascii => {
std.debug.assert(value == null);
invocation.ascii = true;
},
.config => {
std.debug.assert(value.?.len > 0);
invocation.config_path = value;
},
.help => invocation.mode = .help,
.version => invocation.mode = .version,
}
}
/// Look up one registered long option by its exact spelling.
fn optionOf(spelling: []const u8) ?spec_data.CliOption {
std.debug.assert(spec_data.cli_options.len > 0);
if (!std.mem.startsWith(u8, spelling, "--")) return null;
for (spec_data.cli_options) |option| {
if (std.mem.eql(u8, option.spelling, spelling)) return option;
}
return null;
}
fn reject(reason: Reason, argument: []const u8) Result {
std.debug.assert(@intFromEnum(reason) <= @intFromEnum(Reason.too_many_arguments));
std.debug.assert(reason != .too_many_arguments);
return .{ .failure = .{ .reason = reason, .echo = echoOf(argument) } };
}
/// Sanitize one argument for display: strict UTF-8 decoding, bounded escapes for
/// C0, DEL, C1, bidi-control, and noncharacter code points, and `?` for every
/// maximal invalid sequence, as required by the ASCII display track.
pub fn echoOf(argument: []const u8) Echo {
var echo: Echo = .{};
var index: u32 = 0;
while (index < argument.len) {
const remaining = argument[index..];
const length = std.unicode.utf8ByteSequenceLength(remaining[0]) catch {
if (!append(&echo, "?")) return echo;
index += 1;
continue;
};
if (length > remaining.len) {
if (!append(&echo, "?")) return echo;
index += 1;
continue;
}
const sequence = remaining[0..length];
const code_point = std.unicode.utf8Decode(sequence) catch {
if (!append(&echo, "?")) return echo;
index += 1;
continue;
};
if (escapes(code_point)) {
if (!appendEscape(&echo, code_point)) return echo;
} else {
if (!append(&echo, sequence)) return echo;
}
index += length;
}
return echo;
}
/// Code points that never reach a terminal unescaped.
fn escapes(code_point: u21) bool {
std.debug.assert(code_point <= 0x10ffff);
if (code_point < 0x20) return true;
if (code_point == 0x7f) return true;
if (code_point >= 0x80 and code_point <= 0x9f) return true;
if (code_point == 0x61c) return true;
if (code_point >= 0x200e and code_point <= 0x200f) return true;
if (code_point >= 0x202a and code_point <= 0x202e) return true;
if (code_point >= 0x2066 and code_point <= 0x2069) return true;
if (code_point >= 0xfdd0 and code_point <= 0xfdef) return true;
if (code_point & 0xfffe == 0xfffe) return true;
return false;
}
/// Append one escape. Returns false when the registered bound is reached.
fn appendEscape(echo: *Echo, code_point: u21) bool {
std.debug.assert(escapes(code_point));
std.debug.assert(echo.length <= echo_bytes_max);
var buffer: ["<U+10FFFF>".len]u8 = undefined;
const escape = if (code_point < 0x100)
std.fmt.bufPrint(&buffer, "<{X:0>2}>", .{code_point}) catch unreachable
else
std.fmt.bufPrint(&buffer, "<U+{X:0>4}>", .{code_point}) catch unreachable;
return append(echo, escape);
}
/// Append sanitized bytes, or mark the echo truncated when they do not fit.
fn append(echo: *Echo, bytes: []const u8) bool {
std.debug.assert(bytes.len > 0);
std.debug.assert(echo.length <= echo_bytes_max);
if (echo.length + bytes.len > echo_bytes_max) {
echo.truncated = true;
return false;
}
@memcpy(echo.bytes[echo.length..][0..bytes.len], bytes);
echo.length += @intCast(bytes.len);
return true;
}