cli: Simplify API by removing Config field

- Remove Config struct (.sorted/.default presets)
- partition() now handles multi-value flags correctly
- Add Iterator.partitionRemaining() for deferred partitioning
- parseStruct calls partitionRemaining() automatically
- Rework tests

Signed-off-by: Pablo Alessandro Santos Hugen <phugen@redhat.com>
This commit is contained in:
Pablo Alessandro Santos Hugen 2026-01-25 23:51:28 -03:00 committed by Alex Rønne Petersen
parent 2dd7956105
commit 2e53b865ea

View file

@ -1,23 +1,22 @@
//! Type-driven command-line argument parser.
//!
//! Structs map to named flags, tuples to positionals, unions to subcommands.
//! Use `.sorted` config to allow flags anywhere; `.default` requires flags first.
//!
//! ```
//! const Args = struct {
//! struct { verbose: bool = false, output: ?[]const u8 = null }, // flags
//! []const u8, // positional
//! struct { verbose: ?void = null, output: ?[]const u8 = null },
//! []const u8,
//! };
//! const flags, const input = try std.cli.parse(Args, .sorted, args, allocator);
//! const named, const positionals = try std.cli.parse(Args, args, allocator);
//! ```
const std = @import("std");
const heap = std.heap;
const mem = std.mem;
const meta = std.meta;
const testing = std.testing;
const fmt = std.fmt;
const Allocator = mem.Allocator;
const Args = std.process.Args;
const EnumSet = std.EnumSet;
/// Parsing errors returned by `parse`.
@ -30,40 +29,21 @@ pub const Error = error{
UnknownFlag,
};
/// Parser configuration presets.
pub const Config = struct {
/// Reorders args so flags come before positionals. Allows `<program> file.txt --verbose`.
pub const sorted: @This() = .{ .sort = true };
/// Expects flags before positionals. Requires strictly `<program> --verbose file.txt`.
pub const default: @This() = .{ .sort = false };
sort: bool = false,
};
/// Parse command-line arguments into type `T`. Skips first arg (program name).
///
/// - `T`: struct for flags, tuple for positionals, union for subcommands
/// - `config`: `.sorted` allows flags after positionals, `.default` requires flags first
/// - `args`: Argument slice. see `std.process.Args.toSlice`
/// - `allocator`: for string/slice allocations
///
/// Returns parsed `T` or `Error`.
pub fn parse(
comptime T: type,
comptime config: Config,
args: if (config.sort) [][:0]const u8 else []const [:0]const u8,
args: [][:0]const u8,
allocator: Allocator,
) (Error || Allocator.Error)!T {
if (config.sort and args.len > 1) partition(args[1..]);
var iter: Iterator = .{ .args = args };
return parseInner(T, &iter, allocator);
}
// This indirection is just to enable selective dropping of the first
// arg (program name, subcommand, etc.)
inline fn parseInner(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T {
var iter: Iterator = .init(args);
iter.skip() orelse return Error.MissingValue;
return parseValue(T, iter, allocator);
return parseValue(T, &iter, allocator);
}
fn parseValue(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T {
@ -85,44 +65,37 @@ fn parseValue(comptime T: type, iter: *Iterator, allocator: Allocator) (Error ||
};
}
// @"struct" rules:
// struct { foo: u32, bar: bool }, expect args like --foo 42 --bar 1
// field types can be void (presence-only flag), optional, or have default values
// fields without default values or optional types are required
fn parseStruct(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T {
const info = @typeInfo(T);
const fields = info.@"struct".fields;
const fields = @typeInfo(T).@"struct".fields;
var result: T = undefined;
// First pass for setting defaults
inline for (fields) |field| @field(result, field.name) = field.defaultValue() orelse
if (@typeInfo(field.type) == .optional) null else continue;
iter.partitionRemaining();
inline for (fields) |f| @field(result, f.name) = f.defaultValue() orelse
if (@typeInfo(f.type) == .optional) null else continue;
const FieldEnum = meta.FieldEnum(T);
var fieldsSeen = EnumSet(FieldEnum).initEmpty();
var seen = EnumSet(FieldEnum).initEmpty();
// Parse args - stop on first non-flag (positional)
while (iter.peek()) |arg| {
const field_name = flagName(arg) orelse break;
const name = flagName(arg) orelse break;
_ = iter.skip();
inline for (fields) |field| {
if (mem.eql(u8, field.name, field_name)) {
fieldsSeen.insert(@field(FieldEnum, field.name));
@field(result, field.name) = try parseValue(field.type, iter, allocator);
inline for (fields) |f| {
if (mem.eql(u8, f.name, name)) {
seen.insert(@field(FieldEnum, f.name));
@field(result, f.name) = try parseValue(f.type, iter, allocator);
break;
}
} else {
return Error.UnknownFlag;
}
} else return Error.UnknownFlag;
}
// Required fields were set?
inline for (fields) |field|
if (field.default_value_ptr == null and
!(@typeInfo(field.type) == .optional) and
!fieldsSeen.contains(@field(FieldEnum, field.name)))
return Error.MissingValue;
if (iter.peek()) |a| {
if (mem.eql(u8, a, "--")) _ = iter.skip();
}
inline for (fields) |f|
if (f.default_value_ptr == null and @typeInfo(f.type) != .optional and
!seen.contains(@field(FieldEnum, f.name))) return Error.MissingValue;
return result;
}
@ -130,22 +103,25 @@ fn parseStruct(comptime T: type, iter: *Iterator, allocator: Allocator) (Error |
fn parseTuple(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T {
const fields = @typeInfo(T).@"struct".fields;
var result: T = undefined;
inline for (fields) |field| {
inline for (fields) |field|
@field(result, field.name) = try parseValue(field.type, iter, allocator);
}
return result;
}
fn parseUnion(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T {
const info = @typeInfo(T).@"union";
const Tag = info.tag_type orelse @compileError("Non-tagged unions are not supported");
const arg = iter.peek() orelse return Error.MissingValue;
const tag = meta.stringToEnum(Tag, arg) orelse return Error.InvalidValue;
inline for (info.fields) |field| if (@field(Tag, field.name) == tag) {
const value = try parseInner(field.type, iter, allocator);
return @unionInit(T, field.name, value);
};
const arg = iter.next() orelse
return Error.MissingValue;
const tag = meta.stringToEnum(Tag, arg) orelse
return Error.InvalidValue;
inline for (info.fields) |field| if (@field(Tag, field.name) == tag)
return @unionInit(T, field.name, try parseValue(field.type, iter, allocator));
unreachable;
}
@ -153,7 +129,9 @@ fn parseUnion(comptime T: type, iter: *Iterator, allocator: Allocator) (Error ||
fn parseEnum(comptime T: type, iter: *Iterator) Error!T {
const arg = iter.peek() orelse return Error.MissingValue;
const val = meta.stringToEnum(T, arg) orelse return Error.InvalidValue;
_ = iter.skip();
return val;
}
@ -164,7 +142,9 @@ fn parseScalar(comptime T: type, iter: *Iterator) Error!T {
.float => fmt.parseFloat(T, arg) catch return Error.InvalidValue,
else => unreachable,
};
_ = iter.skip();
return val;
}
@ -197,58 +177,49 @@ fn parsePointer(comptime T: type, iter: *Iterator, allocator: Allocator) (Error
fn parseArray(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T {
const info = @typeInfo(T).array;
var result: T = undefined;
inline for (0..info.len) |i| result[i] =
try parseValue(info.child, iter, allocator);
return result;
}
fn parseVector(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T {
const info = @typeInfo(T).vector;
var result: T = undefined;
inline for (0..info.len) |i| result[i] =
try parseValue(info.child, iter, allocator);
return result;
}
// bool rules:
// maps {1/0, true/false, yes/no and on/off} to bool
// NOTE: bool expects a value. For presence-only flags, use ?void type in struct
fn parseBool(comptime T: type, iter: *Iterator) Error!T {
const arg = iter.peek() orelse return Error.MissingValue;
if (mem.eql(u8, arg, "1") or
mem.eql(u8, arg, "true") or
mem.eql(u8, arg, "yes") or
mem.eql(u8, arg, "on"))
{
inline for (.{ "1", "true", "yes", "on" }) |s| if (mem.eql(u8, arg, s)) {
_ = iter.skip();
return true;
}
};
if (mem.eql(u8, arg, "0") or
mem.eql(u8, arg, "false") or
mem.eql(u8, arg, "no") or
mem.eql(u8, arg, "off"))
{
inline for (.{ "0", "false", "no", "off" }) |s| if (mem.eql(u8, arg, s)) {
_ = iter.skip();
return false;
}
};
return Error.InvalidValue;
}
fn parseOptional(comptime T: type, iter: *Iterator, allocator: Allocator) (Error || Allocator.Error)!T {
const ChildT = @typeInfo(T).optional.child;
return parseValue(ChildT, iter, allocator) catch |err| switch (err) {
return parseValue(@typeInfo(T).optional.child, iter, allocator) catch |err| switch (err) {
Error.MissingValue => null,
else => err,
};
}
// If arg is a flag, return its name without the prefix, otherwise return null
inline fn flagName(arg: []const u8) ?[]const u8 {
if (mem.startsWith(u8, arg, "--")) return arg[2..];
if (mem.startsWith(u8, arg, "--")) return if (arg.len == 2) null else arg[2..];
if (mem.startsWith(u8, arg, "-") or
mem.startsWith(u8, arg, "+")) return arg[1..];
@ -257,19 +228,20 @@ inline fn flagName(arg: []const u8) ?[]const u8 {
}
// Stable in-place partition: moves all flag units before positionals
// Stops at "--" (everything after is positional)
fn partition(args: [][:0]const u8) void {
var write: usize = 0;
var read: usize = 0;
while (read < args.len) {
if (flagName(args[read]) != null) {
const has_value = mem.indexOfScalar(u8, args[read], '=') == null and
read + 1 < args.len and flagName(args[read + 1]) == null;
const unit_size = @as(usize, @intFromBool(has_value)) + 1;
mem.rotate([:0]const u8, args[write .. read + unit_size], read - write);
write += unit_size;
read += unit_size;
if (mem.eql(u8, args[read], "--")) break;
if (flagName(args[read])) |_| {
var end = read + 1;
if (mem.indexOfScalar(u8, args[read], '=') == null)
while (end < args.len and flagName(args[end]) == null) : (end += 1) {};
mem.rotate([:0]const u8, args[write..end], read - write);
write += end - read;
read = end;
} else read += 1;
}
}
@ -278,282 +250,254 @@ fn partition(args: [][:0]const u8) void {
// index position or in the same index position separated by '=', serving a rather
// different purpose than std.process.Args.Iterator
const Iterator = struct {
args: []const [:0]const u8,
args: [][:0]const u8,
index: usize = 0,
@"=": ?[:0]const u8 = null, // stores value after '=' to return on next call
@"=": ?[:0]const u8 = null,
pub fn init(args: []const [:0]const u8) Iterator {
pub fn init(args: [][:0]const u8) @This() {
return .{ .args = args };
}
pub fn next(self: *Iterator) ?[]const u8 {
if (self.@"=") |value| {
self.@"=" = null;
return value;
pub fn next(this: *@This()) ?[]const u8 {
if (this.@"=") |v| {
this.@"=" = null;
return v;
}
if (this.index >= this.args.len) return null;
if (self.index >= self.args.len) return null;
const arg = this.args[this.index];
this.index += 1;
const arg = self.args[self.index];
self.index += 1;
if (flagName(arg)) |_| if (mem.findScalar(u8, arg, '=')) |eq_pos| {
self.@"=" = arg[eq_pos + 1 .. :0];
return arg[0..eq_pos];
if (flagName(arg) != null) if (mem.indexOfScalar(u8, arg, '=')) |i| {
this.@"=" = arg[i + 1 .. :0];
return arg[0..i];
};
return arg;
}
pub fn peek(self: *Iterator) ?[]const u8 {
const prev = .{ self.index, self.@"=" };
pub fn peek(this: *@This()) ?[]const u8 {
const s = .{ this.index, this.@"=" };
defer {
self.index = prev[0];
self.@"=" = prev[1];
this.index = s[0];
this.@"=" = s[1];
}
return self.next();
return this.next();
}
pub fn skip(self: *Iterator) ?void {
_ = self.next() orelse return null;
pub fn skip(this: *@This()) ?void {
_ = this.next() orelse return null;
}
pub fn collect(this: *@This(), allocator: Allocator) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
while (this.next()) |arg| try list.append(allocator, arg);
return list.toOwnedSlice(allocator);
}
pub fn partitionRemaining(this: *@This()) void {
if (this.index < this.args.len) partition(this.args[this.index..]);
}
};
test "std.cli: Iterator" {
var iter1: Iterator = .{ .args = &.{ "--foo", "42", "--bar", "1" } };
try testing.expectEqualStrings("--foo", iter1.next().?);
try testing.expectEqualStrings("42", iter1.next().?);
try testing.expectEqualStrings("--bar", iter1.next().?);
try testing.expectEqualStrings("1", iter1.next().?);
try testing.expectEqual(null, iter1.next());
var args = [_][:0]const u8{ "--foo=42", "--bar", "1", "--foobar=💔", "🥀" };
const expected: []const []const u8 = &.{ "--foo", "42", "--bar", "1", "--foobar", "💔", "🥀" };
var iter2: Iterator = .{ .args = &.{ "--foo=42", "--bar=hello" } };
try testing.expectEqualStrings("--foo", iter2.next().?);
try testing.expectEqualStrings("42", iter2.next().?);
try testing.expectEqualStrings("--bar", iter2.next().?);
try testing.expectEqualStrings("hello", iter2.next().?);
try testing.expectEqual(null, iter2.next());
var it: Iterator = .init(&args);
const actual = try it.collect(testing.allocator);
defer testing.allocator.free(actual);
try testing.expectEqualDeep(expected, actual);
}
test "std.cli: partition" {
// Positionals appear BEFORE flags - partition reorders them
var args = [_][:0]const u8{ "file1.txt", "file2.txt", "--verbose", "true", "--output", "result.txt" };
partition(&args);
var actual = [_][:0]const u8{ "output.mp4", "--resolution", "1920", "1080", "--fps", "60", "input.mp4" };
const expected: []const []const u8 = &.{ "--resolution", "1920", "1080", "--fps", "60", "input.mp4", "output.mp4" };
try testing.expectEqualStrings("--verbose", args[0]);
try testing.expectEqualStrings("true", args[1]);
try testing.expectEqualStrings("--output", args[2]);
try testing.expectEqualStrings("result.txt", args[3]);
try testing.expectEqualStrings("file1.txt", args[4]);
try testing.expectEqualStrings("file2.txt", args[5]);
partition(&actual);
try testing.expectEqualDeep(expected, &actual);
}
// zig build -Doptimize=ReleaseFast -Dtarget=x86_64-linux -j8
test "std.cli: zig build" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
test "std.cli: simple" {
var arena: heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const Optimize = enum { Debug, ReleaseSafe, ReleaseFast, ReleaseSmall };
const BuildOptions = struct {
optimize: Optimize = .Debug,
target: []const u8 = "native",
j: u32 = 1,
const allocator = arena.allocator();
const types = .{
bool,
i32,
f64,
void,
?bool,
?i32,
?f64,
enum { red, green, blue },
?enum { one, many },
};
var arguments = [_][2][:0]const u8{
.{ "<arg0>", "true" },
.{ "<arg1>", "42" },
.{ "<arg2>", "3.14" },
.{ "<arg3>", "" },
.{ "<arg4>", "false" },
.{ "<arg5>", "-123" },
.{ "<arg6>", "2.718" },
.{ "<arg7>", "green" },
.{ "<arg8>", "one" },
};
const expected = .{
true,
42,
3.14,
{},
false,
@as(?i32, -123),
@as(?f64, 2.718),
.green,
.one,
};
inline for (types, &arguments, expected) |T, *a, e|
try testing.expectEqual(e, try parse(T, a, allocator));
}
test "std.cli: sequences" {
var arena: heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const types = .{
[3]i32,
@Vector(3, f64),
struct { i32, bool, f64 },
[]const []const u8,
};
var arguments = [_][4][:0]const u8{
.{ "<arg0>", "1", "2", "3" },
.{ "<arg0>", "1.5", "2.5", "3.5" },
.{ "<arg0>", "42", "true", "3.14" },
.{ "<arg0>", "hello", "🦎", "good" },
};
const expected = .{
[3]i32{ 1, 2, 3 },
@Vector(3, f64){ 1.5, 2.5, 3.5 },
.{ 42, true, 3.14 },
@as([]const []const u8, &.{ "hello", "🦎", "good" }),
};
inline for (types, &arguments, expected) |T, *a, e|
try testing.expectEqualDeep(e, try parse(T, a, allocator));
}
test "std.cli: struct" {
var arena: heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const Point = struct { x: i32 = 0, y: i32 = 0 };
const Config = struct { name: []const u8, verbose: bool = false };
const Color = struct { r: u8 = 0, g: u8 = 0, b: u8 = 0 };
const types = .{ Point, Config, Color };
var arguments = [_][5][:0]const u8{
.{ "<arg0>", "--x", "10", "--y", "20" },
.{ "<arg0>", "--verbose", "true", "--name", "alice" },
.{ "<arg0>", "--r", "255", "--g", "128" },
};
const expected = .{
Point{ .x = 10, .y = 20 },
Config{ .name = "alice", .verbose = true },
Color{ .r = 255, .g = 128 },
};
inline for (types, &arguments, expected) |T, *a, e|
try testing.expectEqualDeep(e, try parse(T, a, allocator));
}
test "std.cli: union" {
var arena: heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const Op = union(enum) { add: i32, sub: i32, nop: void };
const types = .{ Op, Op, Op };
var arguments = [_][3][:0]const u8{
.{ "<arg0>", "add", "5" },
.{ "<arg0>", "sub", "-3" },
.{ "<arg0>", "nop", "" },
};
const expected = .{
Op{ .add = 5 },
Op{ .sub = -3 },
Op{ .nop = {} },
};
inline for (types, &arguments, expected) |T, *a, e|
try testing.expectEqualDeep(e, try parse(T, a, allocator));
}
test "std.cli: combined" {
var arena: heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const EncodeOptions = struct {
input: []const u8,
output: []const u8,
format: enum { mp4, avi, mkv } = .mp4,
resolution: [2]u32,
quality: ?enum { low, medium, high } = null,
verbose: bool = false,
};
const parsed = try parse(BuildOptions, .default, &.{
"zig-build", "--optimize=ReleaseFast", "--target=x86_64-linux", "-j=8",
}, arena.allocator());
try testing.expectEqual(.ReleaseFast, parsed.optimize);
try testing.expectEqualStrings("x86_64-linux", parsed.target);
try testing.expectEqual(@as(u32, 8), parsed.j);
try testing.expectEqual(false, parsed.verbose);
}
// git clone --depth 1 --branch main https://github.com/ziglang/zig
// git commit --message "fix: resolve memory leak" --amend true
// git log --oneline true --count 10
test "std.cli: git" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const Command = union(enum) {
clone: struct {
depth: ?u32 = null,
branch: []const u8 = "master",
url: []const u8,
},
commit: struct {
message: []const u8,
amend: bool = false,
signoff: bool = false,
},
log: struct {
oneline: bool = false,
count: u32 = 20,
const GitCommand = union(enum) {
remote: union(enum) {
add: struct { name: []const u8, url: []const u8 },
remove: []const u8,
},
status: void,
};
const clone = try parse(Command, .default, &.{
"git", "clone", "--depth", "1", "--branch", "main", "--url", "https://github.com/ziglang/zig",
}, allocator);
try testing.expectEqual(@as(?u32, 1), clone.clone.depth);
try testing.expectEqualStrings("main", clone.clone.branch);
try testing.expectEqualStrings("https://github.com/ziglang/zig", clone.clone.url);
const commit = try parse(Command, .default, &.{
"git", "commit", "--message", "fix: resolve memory leak", "--amend", "true",
}, allocator);
try testing.expectEqualStrings("fix: resolve memory leak", commit.commit.message);
try testing.expectEqual(true, commit.commit.amend);
const log = try parse(Command, .default, &.{ "git", "log", "--oneline", "true", "--count", "10" }, allocator);
try testing.expectEqual(true, log.log.oneline);
try testing.expectEqual(@as(u32, 10), log.log.count);
}
// curl --request POST --header "Content-Type: application/json" --data '{"key":"value"}' https://api.example.com
test "std.cli: curl" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const Method = enum { GET, POST, PUT, DELETE, PATCH };
const CurlOptions = struct {
request: Method = .GET,
header: []const u8 = "",
data: []const u8 = "",
output: []const u8 = "-",
silent: bool = false,
url: []const u8,
};
const parsed = try parse(CurlOptions, .default, &.{
"curl", "--request", "POST",
"--header", "Content-Type: application/json", "--data",
"{\"key\":\"value\"}", "--url", "https://api.example.com/endpoint",
}, arena.allocator());
try testing.expectEqual(.POST, parsed.request);
try testing.expectEqualStrings("Content-Type: application/json", parsed.header);
try testing.expectEqualStrings("{\"key\":\"value\"}", parsed.data);
try testing.expectEqualStrings("https://api.example.com/endpoint", parsed.url);
}
// ffmpeg -i input.mp4 --codec h264 --bitrate 4500 --resolution 1920 1080 --fps 60 output.mp4
test "std.cli: ffmpeg" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const Codec = enum { h264, h265, vp9, av1 };
const FfmpegOptions = struct {
const GrepArgs = struct {
struct {
i: []const u8,
codec: Codec = .h264,
bitrate: u32 = 2500,
resolution: @Vector(2, u32) = .{ 1280, 720 },
fps: u8 = 30,
@"ignore-case": ?void = null,
@"line-number": ?void = null,
},
[]const u8, // output file
[]const []const u8,
};
const opts, const output = try parse(FfmpegOptions, .default, &.{
"ffmpeg", "-i", "input.mp4", "--codec", "h265",
"--bitrate", "4500", "--resolution", "1920", "1080",
"--fps", "60", "output.mp4",
}, arena.allocator());
const types = .{ EncodeOptions, GitCommand, GitCommand, GitCommand, GrepArgs };
try testing.expectEqualStrings("input.mp4", opts.i);
try testing.expectEqual(.h265, opts.codec);
try testing.expectEqual(@as(u32, 4500), opts.bitrate);
try testing.expectEqual(@Vector(2, u32){ 1920, 1080 }, opts.resolution);
try testing.expectEqual(@as(u8, 60), opts.fps);
try testing.expectEqualStrings("output.mp4", output);
}
// cc -O2 --target x86_64-linux --output program main.c utils.c lib.c
test "std.cli: cc" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const OptLevel = enum { O0, O1, O2, O3, Os, Oz };
const CompilerOptions = struct {
struct {
O: OptLevel = .O0,
target: []const u8 = "native",
output: []const u8 = "a.out",
g: bool = false,
},
[]const []const u8, // input files
var arguments = [_][10][:0]const u8{
.{ "<arg0>", "--input", "video.mp4", "--output", "result.mkv", "--format", "mkv", "--resolution", "1920", "1080" },
.{ "<arg0>", "remote", "add", "--name", "origin", "--url", "git@github.com:user/repo", "", "", "" },
.{ "<arg0>", "remote", "remove", "upstream", "", "", "", "", "", "" },
.{ "<arg0>", "status", "", "", "", "", "", "", "", "" },
.{ "<arg0>", "--ignore-case", "pattern", "file1.txt", "file2.txt", "", "", "", "", "" },
};
const opts, const files = try parse(CompilerOptions, .default, &.{
"cc", "-O", "O2", "--target", "x86_64-linux", "--output", "program", "main.c", "utils.c", "lib.c",
}, arena.allocator());
try testing.expectEqual(.O2, opts.O);
try testing.expectEqualStrings("x86_64-linux", opts.target);
try testing.expectEqualStrings("program", opts.output);
try testing.expectEqual(@as(usize, 3), files.len);
try testing.expectEqualStrings("main.c", files[0]);
try testing.expectEqualStrings("utils.c", files[1]);
try testing.expectEqualStrings("lib.c", files[2]);
}
// docker run --detach true --publish 8080 80 --env KEY=value --name myapp nginx:latest
test "std.cli: docker run" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const DockerRun = struct {
struct {
detach: bool = false,
publish: @Vector(2, u16) = .{ 0, 0 },
env: []const u8 = "",
name: []const u8 = "",
volume: []const u8 = "",
},
[]const u8, // image
const expected = .{
EncodeOptions{ .input = "video.mp4", .output = "result.mkv", .format = .mkv, .resolution = .{ 1920, 1080 }, .quality = null },
GitCommand{ .remote = .{ .add = .{ .name = "origin", .url = "git@github.com:user/repo" } } },
GitCommand{ .remote = .{ .remove = "upstream" } },
GitCommand{ .status = {} },
GrepArgs{ .{ .@"ignore-case" = {} }, @as([]const []const u8, &.{ "pattern", "file1.txt", "file2.txt", "", "", "", "", "" }) },
};
const opts, const image = try parse(DockerRun, .default, &.{
"docker-run", "--detach", "true", "--publish", "8080", "80", "--env", "KEY=value", "--name", "myapp", "nginx:latest",
}, arena.allocator());
try testing.expectEqual(true, opts.detach);
try testing.expectEqual(@Vector(2, u16){ 8080, 80 }, opts.publish);
try testing.expectEqualStrings("KEY=value", opts.env);
try testing.expectEqualStrings("myapp", opts.name);
try testing.expectEqualStrings("nginx:latest", image);
}
// tar --create true --gzip true --file archive.tar.gz src/ lib/ include/
test "std.cli: tar" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const TarOptions = struct {
struct {
create: bool = false,
extract: bool = false,
gzip: bool = false,
verbose: bool = false,
file: []const u8,
},
[]const []const u8, // paths
};
const opts, const paths = try parse(TarOptions, .default, &.{
"tar", "--create", "true", "--gzip", "true", "--file", "archive.tar.gz", "src/", "lib/", "include/",
}, arena.allocator());
try testing.expectEqual(true, opts.create);
try testing.expectEqual(true, opts.gzip);
try testing.expectEqualStrings("archive.tar.gz", opts.file);
try testing.expectEqual(@as(usize, 3), paths.len);
try testing.expectEqualStrings("src/", paths[0]);
try testing.expectEqualStrings("lib/", paths[1]);
try testing.expectEqualStrings("include/", paths[2]);
inline for (types, &arguments, expected) |T, *a, e|
try testing.expectEqualDeep(e, try parse(T, a, allocator));
}