Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});

const dep_zli = b.dependency("zli", .{ .target = target });
const dep_clap = b.dependency("clap", .{ .target = target, .optimize = optimize });
const dep_mvzr = b.dependency("mvzr", .{ .target = target, .optimize = optimize });

const build_options = b.addOptions();
Expand All @@ -26,7 +26,7 @@ pub fn build(b: *std.Build) void {
}),
});

exe.root_module.addImport("zli", dep_zli.module("zli"));
exe.root_module.addImport("clap", dep_clap.module("clap"));
exe.root_module.addImport("mvzr", dep_mvzr.module("mvzr"));
exe.root_module.addImport("build_options", build_options.createModule());

Expand Down
8 changes: 4 additions & 4 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@
.minimum_zig_version = "0.15.1",

.dependencies = .{
.zli = .{
.url = "https://github.com/xcaeser/zli/archive/v4.1.1.tar.gz",
.hash = "zli-4.1.1-LeUjpljfAAAak_E3L4NPowuzPs_FUF9-jYyxuTSNSthM",
},
.mvzr = .{
.url = "https://github.com/mnemnion/mvzr/archive/refs/tags/v0.3.7.tar.gz",
.hash = "mvzr-0.3.7-ZSOky5FtAQB2VrFQPNbXHQCFJxWTMAYEK7ljYEaMR6jt",
},
.clap = .{
.url = "https://github.com/Hejsil/zig-clap/archive/refs/tags/0.11.0.tar.gz",
.hash = "clap-0.11.0-oBajB-HnAQDPCKYzwF7rO3qDFwRcD39Q0DALlTSz5H7e",
},
},
.paths = .{
"build.zig",
Expand Down
76 changes: 76 additions & 0 deletions src/cli/args.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const std = @import("std");
const clap = @import("clap");
const Allocator = std.mem.Allocator;
const build_options = @import("build_options");

// ANSI formatting codes
const BOLD = "\x1b[1m";
const YELLOW = "\x1b[33m";
const RESET = "\x1b[0m";

pub const Args = struct {
https: bool,
upload: bool,
json: bool,
duration: u32,
help: bool,

allocator: Allocator,
clap_result: ?clap.Result(clap.Help, &params, parsers) = null,

pub fn deinit(self: *Args) void {
if (self.clap_result) |*res| {
res.deinit();
}
}
};

const params = clap.parseParamsComptime(
\\-h, --help Display this help and exit.
\\ --https Use HTTPS when connecting to fast.com (default)
\\ --no-https Use HTTP instead of HTTPS
\\-u, --upload Check upload speed as well
\\-j, --json Output results in JSON format
\\-d, --duration <usize> Maximum test duration in seconds (default: 30)
\\
);

const parsers = .{
.usize = clap.parsers.int(u32, 10),
};

pub fn parse(allocator: Allocator) !Args {
var diag = clap.Diagnostic{};
const res = clap.parse(clap.Help, &params, parsers, .{
.diagnostic = &diag,
.allocator = allocator,
}) catch |err| {
var stderr_buffer: [4096]u8 = undefined;
var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
const stderr = &stderr_writer.interface;
try diag.report(stderr, err);
return err;
};

return .{
.https = if (res.args.@"no-https" != 0) false else true,
.upload = res.args.upload != 0,
.json = res.args.json != 0,
.duration = res.args.duration orelse 30,
.help = res.args.help != 0,
.allocator = allocator,
.clap_result = res,
};
}

pub fn printHelp() !void {
var stderr_buffer: [4096]u8 = undefined;
var stderr_writer = std.fs.File.stderr().writerStreaming(&stderr_buffer);
const stderr = &stderr_writer.interface;
try stderr.print(BOLD ++ "fast-cli" ++ RESET ++ " v{s} - Estimate connection speed using fast.com\n\n", .{build_options.version});
try stderr.writeAll(YELLOW ++ "USAGE:\n" ++ RESET);
try stderr.writeAll(" fast-cli [OPTIONS]\n\n");
try stderr.writeAll(YELLOW ++ "OPTIONS:\n" ++ RESET);
try clap.help(stderr, clap.Help, &params, .{ .spacing_between_parameters = 0, .description_on_new_line = false });
try stderr.flush();
}
144 changes: 54 additions & 90 deletions src/cli/root.zig
Original file line number Diff line number Diff line change
@@ -1,173 +1,134 @@
const std = @import("std");
const zli = @import("zli");
const builtin = @import("builtin");
const Writer = std.Io.Writer;
const Args = @import("args.zig");
const Spinner = @import("../lib/spinner/spinner.zig");

const log = std.log.scoped(.cli);

const Fast = @import("../lib/fast.zig").Fast;
const HTTPSpeedTester = @import("../lib/http_speed_tester_v2.zig").HTTPSpeedTester;

const StabilityCriteria = @import("../lib/http_speed_tester_v2.zig").StabilityCriteria;
const SpeedTestResult = @import("../lib/http_speed_tester_v2.zig").SpeedTestResult;
const BandwidthMeter = @import("../lib/bandwidth.zig");
const SpeedMeasurement = @import("../lib/bandwidth.zig").SpeedMeasurement;
const progress = @import("../lib/progress.zig");
const HttpLatencyTester = @import("../lib/http_latency_tester.zig").HttpLatencyTester;

const https_flag = zli.Flag{
.name = "https",
.description = "Use https when connecting to fast.com",
.type = .Bool,
.default_value = .{ .Bool = true },
};

const check_upload_flag = zli.Flag{
.name = "upload",
.description = "Check upload speed as well",
.shortcut = "u",
.type = .Bool,
.default_value = .{ .Bool = false },
};

const json_output_flag = zli.Flag{
.name = "json",
.description = "Output results in JSON format",
.shortcut = "j",
.type = .Bool,
.default_value = .{ .Bool = false },
};

const max_duration_flag = zli.Flag{
.name = "duration",
.description = "Maximum test duration in seconds (uses CoV stability detection by default)",
.shortcut = "d",
.type = .Int,
.default_value = .{ .Int = 30 },
};

pub fn build(writer: *Writer, allocator: std.mem.Allocator) !*zli.Command {
const root = try zli.Command.init(writer, allocator, .{
.name = "fast-cli",
.description = "Estimate connection speed using fast.com",
.version = null,
}, run);

try root.addFlag(https_flag);
try root.addFlag(check_upload_flag);
try root.addFlag(json_output_flag);
try root.addFlag(max_duration_flag);

return root;
}

fn run(ctx: zli.CommandContext) !void {
const use_https = ctx.flag("https", bool);
const check_upload = ctx.flag("upload", bool);
const json_output = ctx.flag("json", bool);
const max_duration = ctx.flag("duration", i64);
pub fn run(allocator: std.mem.Allocator) !void {
var args = try Args.parse(allocator);
defer args.deinit();

const spinner = ctx.spinner;
if (args.help) {
try Args.printHelp();
return;
}

log.info("Config: https={}, upload={}, json={}, max_duration={}s", .{
use_https, check_upload, json_output, max_duration,
args.https,
args.upload,
args.json,
args.duration,
});

var fast = Fast.init(std.heap.smp_allocator, use_https);
var spinner = Spinner.init(allocator, .{});
defer spinner.deinit();

var fast = Fast.init(std.heap.smp_allocator, args.https);
defer fast.deinit();

const urls = fast.get_urls(5) catch |err| {
if (!json_output) {
if (!args.json) {
try spinner.fail("Failed to get URLs: {}", .{err});
} else {
const error_msg = switch (err) {
error.ConnectionTimeout => "Failed to contact fast.com servers",
else => "Failed to get URLs",
};
try outputJson(ctx.writer, null, null, null, error_msg);
try outputJson(null, null, null, error_msg);
}
return;
};

log.info("Got {} URLs\n", .{urls.len});
log.info("Got {} URLs", .{urls.len});
for (urls) |url| {
log.info("URL: {s}\n", .{url});
log.info("URL: {s}", .{url});
}

// Measure latency first
// Measure latency
var latency_tester = HttpLatencyTester.init(std.heap.smp_allocator);
defer latency_tester.deinit();

const latency_ms = if (!json_output) blk: {
const latency_ms = if (!args.json) blk: {
try spinner.start("Measuring latency...", .{});
const result = latency_tester.measureLatency(urls) catch |err| {
try spinner.fail("Latency test failed: {}", .{err});
break :blk null;
};
spinner.stop();
break :blk result;
} else blk: {
break :blk latency_tester.measureLatency(urls) catch null;
};

if (!json_output) {
if (!args.json) {
log.info("Measuring download speed...", .{});
try spinner.start("Measuring download speed...", .{});
}

// Initialize speed tester
var speed_tester = HTTPSpeedTester.init(std.heap.smp_allocator);
defer speed_tester.deinit();

// Use Fast.com-style stability detection by default
const criteria = StabilityCriteria{
.ramp_up_duration_seconds = 4,
.max_duration_seconds = @as(u32, @intCast(@max(25, max_duration))),
.max_duration_seconds = @as(u32, @intCast(@max(25, args.duration))),
.measurement_interval_ms = 750,
.sliding_window_size = 6,
.stability_threshold_cov = 0.15,
.stable_checks_required = 2,
};

const download_result = if (json_output) blk: {
// JSON mode: clean output only
const download_result = if (args.json) blk: {
break :blk speed_tester.measure_download_speed_stability(urls, criteria) catch |err| {
try spinner.fail("Download test failed: {}", .{err});
try outputJson(ctx.writer, null, null, null, "Download test failed");
try outputJson(null, null, null, "Download test failed");
return;
};
} else blk: {
// Interactive mode with spinner updates
const progressCallback = progress.createCallback(spinner, updateSpinnerText);
break :blk speed_tester.measureDownloadSpeedWithStabilityProgress(urls, criteria, progressCallback) catch |err| {
const progressCallback = progress.createCallback(&spinner, updateSpinnerText);
const result = speed_tester.measureDownloadSpeedWithStabilityProgress(urls, criteria, progressCallback) catch |err| {
try spinner.fail("Download test failed: {}", .{err});
return;
};
spinner.stop();
break :blk result;
};

var upload_result: ?SpeedTestResult = null;
if (check_upload) {
if (!json_output) {
if (args.upload) {
if (!args.json) {
spinner.stop();
log.info("Measuring upload speed...", .{});
try spinner.start("Measuring upload speed...", .{});
}

upload_result = if (json_output) blk: {
// JSON mode: clean output only
upload_result = if (args.json) blk: {
break :blk speed_tester.measure_upload_speed_stability(urls, criteria) catch |err| {
try spinner.fail("Upload test failed: {}", .{err});
try outputJson(ctx.writer, download_result.speed.value, latency_ms, null, "Upload test failed");
try outputJson(download_result.speed.value, latency_ms, null, "Upload test failed");
return;
};
} else blk: {
// Interactive mode with spinner updates
const uploadProgressCallback = progress.createCallback(spinner, updateUploadSpinnerText);
break :blk speed_tester.measureUploadSpeedWithStabilityProgress(urls, criteria, uploadProgressCallback) catch |err| {
const uploadProgressCallback = progress.createCallback(&spinner, updateUploadSpinnerText);
const result = speed_tester.measureUploadSpeedWithStabilityProgress(urls, criteria, uploadProgressCallback) catch |err| {
try spinner.fail("Upload test failed: {}", .{err});
return;
};
spinner.stop();
break :blk result;
};
}

// Output results
if (!json_output) {
if (!args.json) {
if (latency_ms) |ping| {
if (upload_result) |up| {
try spinner.succeed("🏓 {d:.0}ms | ⬇️ Download: {d:.1} {s} | ⬆️ Upload: {d:.1} {s}", .{ ping, download_result.speed.value, download_result.speed.unit.toString(), up.speed.value, up.speed.unit.toString() });
Expand All @@ -183,21 +144,23 @@ fn run(ctx: zli.CommandContext) !void {
}
} else {
const upload_speed = if (upload_result) |up| up.speed.value else null;
try outputJson(ctx.writer, download_result.speed.value, latency_ms, upload_speed, null);
try outputJson(download_result.speed.value, latency_ms, upload_speed, null);
}
}

/// Update spinner text with current speed measurement
fn updateSpinnerText(spinner: anytype, measurement: SpeedMeasurement) void {
fn updateSpinnerText(spinner: *Spinner, measurement: SpeedMeasurement) void {
spinner.updateMessage("⬇️ {d:.1} {s}", .{ measurement.value, measurement.unit.toString() }) catch {};
}

/// Update spinner text with current upload speed measurement
fn updateUploadSpinnerText(spinner: anytype, measurement: SpeedMeasurement) void {
fn updateUploadSpinnerText(spinner: *Spinner, measurement: SpeedMeasurement) void {
spinner.updateMessage("⬆️ {d:.1} {s}", .{ measurement.value, measurement.unit.toString() }) catch {};
}

fn outputJson(writer: *Writer, download_mbps: ?f64, ping_ms: ?f64, upload_mbps: ?f64, error_message: ?[]const u8) !void {
fn outputJson(download_mbps: ?f64, ping_ms: ?f64, upload_mbps: ?f64, error_message: ?[]const u8) !void {
var stdout_buffer: [4096]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writerStreaming(&stdout_buffer);
const stdout = &stdout_writer.interface;

var download_buf: [32]u8 = undefined;
var ping_buf: [32]u8 = undefined;
var upload_buf: [32]u8 = undefined;
Expand All @@ -208,5 +171,6 @@ fn outputJson(writer: *Writer, download_mbps: ?f64, ping_ms: ?f64, upload_mbps:
const upload_str = if (upload_mbps) |u| try std.fmt.bufPrint(&upload_buf, "{d:.1}", .{u}) else "null";
const error_str = if (error_message) |e| try std.fmt.bufPrint(&error_buf, "\"{s}\"", .{e}) else "null";

try writer.print("{{\"download_mbps\": {s}, \"ping_ms\": {s}, \"upload_mbps\": {s}, \"error\": {s}}}\n", .{ download_str, ping_str, upload_str, error_str });
try stdout.print("{{\"download_mbps\": {s}, \"ping_ms\": {s}, \"upload_mbps\": {s}, \"error\": {s}}}\n", .{ download_str, ping_str, upload_str, error_str });
try stdout.flush();
}
Loading