Luigit
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;
}