Luigit
repositories / termux-janitor

termux-janitor

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

owned by admin

tests/fixtures/cli_usage.zig

Raw
const std = @import("std");
const spec_data = @import("spec_data");

pub const StandardInput = enum { ignored };
pub const WorkingDirectory = enum { empty };

/// Rejected invocations observed by one execution of this fixture.
const cases_max = 32;
/// Arguments one listed invocation may carry, excluding the program name.
const case_arguments_max = 4;
/// Bytes one startup diagnostic may occupy.
const diagnostic_bytes_max = 512;
/// The registered bound on one echoed argument.
const echo_bytes_max: usize = @intCast(spec_data.limit_value.diagnostic_argument_bytes);
/// The marker that discloses a truncated echo.
const truncation_marker = "...";

const quote_open = ": '";
const quote_close = "'\n";
const usage_line = "usage: " ++ spec_data.cli_executable ++ " [OPTIONS]; run '" ++
    spec_data.cli_executable ++ " " ++ spec_data.cliOption(.help).spelling ++
    "' for the option list.\n";

const Case = struct {
    arguments: []const []const u8,
    expected_echo: []const u8,
    result: ?std.process.RunResult = null,
};

pub const Fixture = struct {
    temporary: std.testing.TmpDir,
    executable: ?[:0]u8 = null,
    cases: [cases_max]Case = undefined,
    case_count: usize = 0,
    stdin_ignored: bool = false,
    executed: bool = false,

    pub fn init() !Fixture {
        return .{ .temporary = std.testing.tmpDir(.{ .iterate = true }) };
    }

    pub fn deinit(fixture: *Fixture) void {
        for (fixture.cases[0..fixture.case_count]) |case| {
            if (case.result) |result| {
                std.testing.allocator.free(result.stdout);
                std.testing.allocator.free(result.stderr);
            }
        }
        if (fixture.executable) |executable| std.testing.allocator.free(executable);
        fixture.temporary.cleanup();
        fixture.* = undefined;
    }

    pub fn given_program(fixture: *Fixture, executable_name: []const u8) !void {
        std.debug.assert(fixture.executable == null);
        try std.testing.expectEqualStrings(spec_data.cli_executable, executable_name);
        fixture.executable = try std.Io.Dir.cwd().realPathFileAlloc(
            std.testing.io,
            "zig-out/bin/termux-janitor",
            std.testing.allocator,
        );
    }

    pub fn given_standard_input(fixture: *Fixture, mode: StandardInput) !void {
        std.debug.assert(!fixture.stdin_ignored);
        try std.testing.expectEqual(StandardInput.ignored, mode);
        fixture.stdin_ignored = true;
    }

    pub fn given_working_directory(fixture: *Fixture, state: WorkingDirectory) !void {
        try std.testing.expectEqual(WorkingDirectory.empty, state);
        var entries = fixture.temporary.dir.iterate();
        try std.testing.expect(try entries.next(std.testing.io) == null);
    }

    pub fn given_rejected_invocation(
        fixture: *Fixture,
        arguments: []const []const u8,
        expected_echo: []const u8,
    ) !void {
        std.debug.assert(!fixture.executed);
        try std.testing.expect(fixture.case_count < cases_max);
        try std.testing.expect(arguments.len > 0);
        try std.testing.expect(arguments.len <= case_arguments_max);
        try std.testing.expect(expected_echo.len > 0);
        fixture.cases[fixture.case_count] = .{
            .arguments = arguments,
            .expected_echo = expected_echo,
        };
        fixture.case_count += 1;
    }

    pub fn when_run_each(fixture: *Fixture) !void {
        const executable = fixture.executable orelse return error.ExecutableNotConfigured;
        if (fixture.case_count == 0) return error.CasesNotConfigured;
        if (!fixture.stdin_ignored) return error.StandardInputNotConfigured;
        for (fixture.cases[0..fixture.case_count]) |*case| {
            var argv: [1 + case_arguments_max][]const u8 = undefined;
            argv[0] = executable;
            for (case.arguments, 0..) |argument, index| argv[index + 1] = argument;
            case.result = try fixture.run(argv[0 .. case.arguments.len + 1]);
        }
        fixture.executed = true;
    }

    pub fn then_inv_usage_status(fixture: *Fixture) !void {
        for (fixture.cases[0..fixture.case_count]) |case| {
            switch (resultRef(&case).term) {
                .exited => |status| try std.testing.expectEqual(
                    spec_data.cliExitCode(.usage),
                    status,
                ),
                else => return error.UnexpectedTermination,
            }
        }
    }

    pub fn then_inv_usage_diagnostic(fixture: *Fixture) !void {
        for (fixture.cases[0..fixture.case_count]) |case| {
            const stderr = resultRef(&case).stderr;
            try std.testing.expect(stderr.len <= diagnostic_bytes_max);
            try std.testing.expect(std.mem.startsWith(
                u8,
                stderr,
                spec_data.cli_executable ++ ": ",
            ));
            try std.testing.expect(std.mem.endsWith(u8, stderr, usage_line));
            const quoted = quotedEcho(stderr) orelse return error.MissingQuotedArgument;
            try std.testing.expect(quoted.len > 0);
            try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, stderr, "\n"));
        }
    }

    pub fn then_inv_usage_echo_bound(fixture: *Fixture) !void {
        for (fixture.cases[0..fixture.case_count]) |case| {
            const quoted = quotedEcho(resultRef(&case).stderr) orelse {
                return error.MissingQuotedArgument;
            };
            const truncated = std.mem.endsWith(u8, quoted, truncation_marker);
            const echo = if (truncated)
                quoted[0 .. quoted.len - truncation_marker.len]
            else
                quoted;
            try std.testing.expect(echo.len <= echo_bytes_max);
            try std.testing.expectEqual(exceedsBound(case.arguments), truncated);
        }
    }

    pub fn then_inv_usage_echo_sanitized(fixture: *Fixture) !void {
        for (fixture.cases[0..fixture.case_count]) |case| {
            const stderr = resultRef(&case).stderr;
            const quoted = quotedEcho(stderr) orelse return error.MissingQuotedArgument;
            try std.testing.expectEqualStrings(case.expected_echo, quoted);
            try std.testing.expect(std.unicode.utf8ValidateSlice(stderr));
            for (stderr) |byte| {
                if (byte == '\n') continue;
                try std.testing.expect(byte >= 0x20);
                try std.testing.expect(byte != 0x7f);
            }
        }
    }

    pub fn forbid_no_usage_stdout(fixture: *Fixture) !void {
        for (fixture.cases[0..fixture.case_count]) |case| {
            try std.testing.expectEqualStrings("", resultRef(&case).stdout);
        }
    }

    pub fn forbid_no_usage_fixture_mutation(fixture: *Fixture) !void {
        var entries = fixture.temporary.dir.iterate();
        try std.testing.expect(try entries.next(std.testing.io) == null);
    }

    fn run(fixture: *Fixture, argv: []const []const u8) !std.process.RunResult {
        std.debug.assert(argv.len > 1);
        std.debug.assert(fixture.stdin_ignored);
        return std.process.run(std.testing.allocator, std.testing.io, .{
            .argv = argv,
            .stderr_limit = .limited(@intCast(spec_data.limit_value.captured_stderr_bytes)),
            .stdout_limit = .limited(@intCast(spec_data.limit_value.captured_stdout_bytes)),
            .reserve_amount = diagnostic_bytes_max,
            .cwd = .{ .dir = fixture.temporary.dir },
            .environ_map = null,
            .expand_arg0 = .no_expand,
            .progress_node = .none,
            .create_no_window = true,
            .disable_aslr = false,
            .timeout = .{ .duration = .{
                .raw = .fromSeconds(@intCast(spec_data.limit_value.test_process_timeout)),
                .clock = .awake,
            } },
        });
    }
};

/// The argument text quoted by the first diagnostic line, without its quotes.
fn quotedEcho(stderr: []const u8) ?[]const u8 {
    const start = std.mem.indexOf(u8, stderr, quote_open) orelse return null;
    const content = stderr[start + quote_open.len ..];
    const end = std.mem.indexOf(u8, content, quote_close) orelse return null;
    return content[0..end];
}

/// Whether any listed argument is longer than the registered echo bound.
fn exceedsBound(arguments: []const []const u8) bool {
    std.debug.assert(arguments.len > 0);
    std.debug.assert(echo_bytes_max > 0);
    for (arguments) |argument| {
        if (argument.len > echo_bytes_max) return true;
    }
    return false;
}

fn resultRef(case: *const Case) *const std.process.RunResult {
    if (case.result) |*result| return result;
    @panic("process has not run");
}