commit ca38ee2b26db7caaae46c8293277af836e493786
parent 62d17e4ba1e58d38398a5c57859f8251e735e9c3
Author: mtmn <miro@haravara.org>
Date: Tue, 9 Jun 2026 22:24:41 +0200
speediness: add cli flags and justfile
Diffstat:
4 files changed, 149 insertions(+), 19 deletions(-)
diff --git a/speediness/.gitignore b/speediness/.gitignore
@@ -1,2 +1,3 @@
+build
zig-out
.zig-cache
diff --git a/speediness/README.md b/speediness/README.md
@@ -1,6 +1,6 @@
# speediness
-A network speed test. Downloads 100 MB from OVH and uploads 20 MB to Cloudflare, printing live throughput and a final summary for each.
+A network speed test. Downloads 100 MB from OVH and uploads 50 MB to Cloudflare, printing live throughput and a final summary for each.
## Building
@@ -10,16 +10,34 @@ zig build
## Usage
+Running without arguments prints the help:
+
```sh
speediness
```
-Output example:
+Pass one or more options to run the test. Each unspecified option falls back to
+its default, so the original behaviour is `--upload-mb 50` with the default URLs:
+```sh
+speediness --download-url <url> --upload-url <url> --upload-mb <n>
```
- 100.0 MB 11.23 MB/s 89.8 Mbps
-100.0 MB in 8.90s => 11.23 MB/s (89.8 Mbps)
- 20.0 MB 47.81 MB/s 382.5 Mbps
-20.0 MB in 0.42s => 47.81 MB/s (382.5 Mbps)
+| Option | Description | Default |
+| ---------------- | --------------------- | ---------------------------------------- |
+| `--download-url` | URL to download from | `https://proof.ovh.net/files/100Mb.dat` |
+| `--upload-url` | URL to upload to | `https://speed.cloudflare.com/__upload` |
+| `--upload-mb` | Megabytes to upload | `50` |
+| `--both` | Run both tests | |
+| `--download-only` | Run the download test only | |
+| `--upload-only` | Run the upload test only | |
+| `-h`, `--help` | Show help and exit | |
+
+Output example:
+
+```
+ 104.9 MB 4.18 MB/s 33.4 Mbps
+104.9 MB in 23.92s => 4.18 MB/s (33.4 Mbps)
+ 52.4 MB 2.22 MB/s 17.7 Mbps
+52.4 MB in 22.57s => 2.22 MB/s (17.7 Mbps)
```
diff --git a/speediness/justfile b/speediness/justfile
@@ -0,0 +1,12 @@
+run opt="Debug":
+ zig build run -Doptimize={{opt}}
+
+build opt="Debug":
+ zig build -Doptimize={{opt}} -p build/{{opt}}
+
+freebsd target="x86_64-freebsd":
+ zig build -Doptimize=ReleaseSafe -Dtarget={{target}} -p build/{{target}}
+
+openbsd target="x86_64-openbsd":
+ zig build -Doptimize=ReleaseSafe -Dtarget={{target}} -p build/{{target}}
+
diff --git a/speediness/src/main.zig b/speediness/src/main.zig
@@ -1,14 +1,42 @@
const std = @import("std");
+const default_download_url = "https://proof.ovh.net/files/100Mb.dat";
+const default_upload_url = "https://speed.cloudflare.com/__upload";
+const default_upload_mb: usize = 50;
+
+const Config = struct {
+ download_url: []const u8 = default_download_url,
+ upload_url: []const u8 = default_upload_url,
+ upload_bytes: usize = default_upload_mb * 1024 * 1024,
+ run_download: bool = true,
+ run_upload: bool = true,
+};
+
+const usage =
+ \\Usage: speediness [options]
+ \\
+ \\Options:
+ \\ --download-url <url> URL to download from (default: {s})
+ \\ --upload-url <url> URL to upload to (default: {s})
+ \\ --upload-mb <n> Megabytes to upload (default: {d})
+ \\ --both Run both tests
+ \\ --download-only Run the download test only
+ \\ --upload-only Run the upload test only
+ \\ -h, --help Show this help and exit
+ \\
+;
+
+fn printUsage() void {
+ std.debug.print(usage, .{ default_download_url, default_upload_url, default_upload_mb });
+}
+
fn mbps(bytes: usize, ns: u64) f64 {
const secs = @as(f64, @floatFromInt(ns)) / 1e9;
if (secs < 0.001) return 0;
return (@as(f64, @floatFromInt(bytes)) / secs) / (1024.0 * 1024.0);
}
-fn downloadTest(io: std.Io, allocator: std.mem.Allocator) !void {
- const url = "https://proof.ovh.net/files/100Mb.dat";
-
+fn downloadTest(io: std.Io, allocator: std.mem.Allocator, url: []const u8) !void {
var client: std.http.Client = .{
.allocator = allocator,
.io = io,
@@ -68,10 +96,7 @@ fn downloadTest(io: std.Io, allocator: std.mem.Allocator) !void {
});
}
-fn uploadTest(io: std.Io, allocator: std.mem.Allocator) !void {
- const url = "https://speed.cloudflare.com/__upload";
- const upload_bytes: usize = 20 * 1024 * 1024;
-
+fn uploadTest(io: std.Io, allocator: std.mem.Allocator, url: []const u8, upload_bytes: usize) !void {
var client: std.http.Client = .{
.allocator = allocator,
.io = io,
@@ -132,14 +157,88 @@ fn uploadTest(io: std.Io, allocator: std.mem.Allocator) !void {
});
}
+/// Returns the value for an option, taking it either from the rest of the
+/// current argument after '=' or from the next argument.
+fn optionValue(args: []const [:0]const u8, i: *usize, eq: ?usize) ![]const u8 {
+ if (eq) |pos| return args[i.*][pos + 1 ..];
+ if (i.* + 1 >= args.len) {
+ std.debug.print("missing value for '{s}'\n", .{args[i.*]});
+ return error.MissingValue;
+ }
+ i.* += 1;
+ return args[i.*];
+}
+
+fn parseArgs(args: []const [:0]const u8) !?Config {
+ // With no arguments, show usage instead of running the tests.
+ if (args.len <= 1) {
+ printUsage();
+ return null;
+ }
+
+ var cfg: Config = .{};
+
+ // Tracks which of the mutually exclusive mode flags has been seen.
+ var mode: ?[]const u8 = null;
+
+ var i: usize = 1;
+ while (i < args.len) : (i += 1) {
+ const arg = args[i];
+
+ // Split "--flag=value" into name and value position.
+ const eq = std.mem.indexOfScalar(u8, arg, '=');
+ const name = if (eq) |pos| arg[0..pos] else arg;
+
+ if (std.mem.eql(u8, name, "-h") or std.mem.eql(u8, name, "--help")) {
+ printUsage();
+ return null;
+ } else if (std.mem.eql(u8, name, "--download-url")) {
+ cfg.download_url = try optionValue(args, &i, eq);
+ } else if (std.mem.eql(u8, name, "--upload-url")) {
+ cfg.upload_url = try optionValue(args, &i, eq);
+ } else if (std.mem.eql(u8, name, "--upload-mb")) {
+ const value = try optionValue(args, &i, eq);
+ const mb = std.fmt.parseInt(usize, value, 10) catch {
+ std.debug.print("invalid --upload-mb value: '{s}'\n", .{value});
+ return error.InvalidArgument;
+ };
+ cfg.upload_bytes = mb * 1024 * 1024;
+ } else if (std.mem.eql(u8, name, "--both") or
+ std.mem.eql(u8, name, "--download-only") or
+ std.mem.eql(u8, name, "--upload-only"))
+ {
+ if (mode) |prev| {
+ std.debug.print("{s} and {s} are mutually exclusive\n", .{ prev, name });
+ return error.InvalidArgument;
+ }
+ mode = name;
+ cfg.run_download = !std.mem.eql(u8, name, "--upload-only");
+ cfg.run_upload = !std.mem.eql(u8, name, "--download-only");
+ } else {
+ std.debug.print("unknown argument: '{s}'\n", .{arg});
+ printUsage();
+ return error.UnknownArgument;
+ }
+ }
+
+ return cfg;
+}
+
pub fn main(init: std.process.Init) !void {
const allocator = init.gpa;
- downloadTest(init.io, allocator) catch |err| {
- std.debug.print("Download error: {}\n", .{err});
- };
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ const cfg = parseArgs(args) catch std.process.exit(2) orelse return;
- uploadTest(init.io, allocator) catch |err| {
- std.debug.print("Upload error: {}\n", .{err});
- };
+ if (cfg.run_download) {
+ downloadTest(init.io, allocator, cfg.download_url) catch |err| {
+ std.debug.print("Download error: {}\n", .{err});
+ };
+ }
+
+ if (cfg.run_upload) {
+ uploadTest(init.io, allocator, cfg.upload_url, cfg.upload_bytes) catch |err| {
+ std.debug.print("Upload error: {}\n", .{err});
+ };
+ }
}