fast-cli/src/cli/root.zig
mikkelam 3570f5d5b7 Initial commit: Fast CLI - Blazing fast internet speed tester
- Zig CLI tool for testing internet speed via Fast.com
- Cross-platform binaries for Linux, macOS, ARM64
- Real-time progress, latency measurement, upload testing
- Zero runtime dependencies, 1.3 MiB binary
2025-06-19 00:04:14 +02:00

262 lines
10 KiB
Zig

const std = @import("std");
const zli = @import("zli");
const builtin = @import("builtin");
const build_options = @import("build_options");
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/latency.zig").HttpLatencyTester;
const log = std.log.scoped(.cli);
/// Update spinner text with current speed measurement
fn updateSpinnerText(spinner: anytype, measurement: SpeedMeasurement) void {
spinner.updateText("⬇️ {d:.1} {s}", .{ measurement.value, measurement.unit.toString() }) catch {};
}
/// Update spinner text with current upload speed measurement
fn updateUploadSpinnerText(spinner: anytype, measurement: SpeedMeasurement) void {
spinner.updateText("⬆️ {d:.1} {s}", .{ measurement.value, measurement.unit.toString() }) catch {};
}
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 test_mode_flag = zli.Flag{
.name = "mode",
.description = "Test mode: 'duration' or 'stability'",
.shortcut = "m",
.type = .String,
.default_value = .{ .String = "duration" },
};
const test_duration_flag = zli.Flag{
.name = "duration",
.description = "Duration in seconds for each test phase - download, then upload if enabled (duration mode only)",
.shortcut = "d",
.type = .Int,
.default_value = .{ .Int = 5 },
};
const stability_min_samples_flag = zli.Flag{
.name = "stability-min-samples",
.description = "Minimum samples for stability test",
.type = .Int,
.default_value = .{ .Int = 5 },
};
const stability_max_variance_flag = zli.Flag{
.name = "stability-max-variance",
.description = "Maximum variance percentage for stability test",
.type = .String,
.default_value = .{ .String = "10.0" },
};
const stability_max_duration_flag = zli.Flag{
.name = "stability-max-duration",
.description = "Maximum duration in seconds for stability test",
.type = .Int,
.default_value = .{ .Int = 30 },
};
pub fn build(allocator: std.mem.Allocator) !*zli.Command {
const root = try zli.Command.init(allocator, .{
.name = "fast-cli",
.description = "Estimate connection speed using fast.com",
.version = std.SemanticVersion.parse(build_options.version) catch null,
}, run);
try root.addFlag(https_flag);
try root.addFlag(check_upload_flag);
try root.addFlag(json_output_flag);
try root.addFlag(test_mode_flag);
try root.addFlag(test_duration_flag);
try root.addFlag(stability_min_samples_flag);
try root.addFlag(stability_max_variance_flag);
try root.addFlag(stability_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 test_mode = ctx.flag("mode", []const u8);
const test_duration = ctx.flag("duration", i64);
const stability_min_samples = ctx.flag("stability-min-samples", i64);
const stability_max_variance_str = ctx.flag("stability-max-variance", []const u8);
const stability_max_duration = ctx.flag("stability-max-duration", i64);
const stability_max_variance = std.fmt.parseFloat(f64, stability_max_variance_str) catch 10.0;
log.info("Config: https={}, upload={}, json={}, mode={s}, duration={}s", .{
use_https, check_upload, json_output, test_mode, test_duration,
});
var fast = Fast.init(std.heap.page_allocator, use_https);
defer fast.deinit();
const urls = fast.get_urls(5) catch |err| {
if (!json_output) {
try ctx.spinner.fail("Failed to get URLs: {}", .{err});
} else {
std.debug.print("{{\"error\": \"{}\"}}\n", .{err});
}
return;
};
log.info("Got {} URLs", .{urls.len});
for (urls) |url| {
log.debug("URL: {s}", .{url});
}
// Measure latency first
var latency_tester = HttpLatencyTester.init(std.heap.page_allocator);
defer latency_tester.deinit();
const latency_ms = if (!json_output) blk: {
try ctx.spinner.start(.{}, "Measuring latency...", .{});
const result = latency_tester.measureLatency(urls) catch |err| {
log.err("Latency test failed: {}", .{err});
break :blk null;
};
break :blk result;
} else blk: {
break :blk latency_tester.measureLatency(urls) catch null;
};
if (!json_output) {
try ctx.spinner.start(.{}, "Measuring download speed...", .{});
}
// Initialize speed tester
var speed_tester = HTTPSpeedTester.init(std.heap.page_allocator);
defer speed_tester.deinit();
// Determine test mode
const use_stability = std.mem.eql(u8, test_mode, "stability");
// Measure download speed
const download_result = if (use_stability) blk: {
const criteria = StabilityCriteria{
.min_samples = @as(u32, @intCast(stability_min_samples)),
.max_variance_percent = stability_max_variance,
.max_duration_seconds = @as(u32, @intCast(stability_max_duration)),
};
break :blk speed_tester.measure_download_speed_stability(urls, criteria) catch |err| {
if (!json_output) {
try ctx.spinner.fail("Download test failed: {}", .{err});
} else {
log.err("Download test failed: {}", .{err});
std.debug.print("{{\"error\": \"{}\"}}\n", .{err});
}
return;
};
} else blk: {
if (json_output) {
// JSON mode: clean output only
break :blk speed_tester.measureDownloadSpeed(urls, @as(u32, @intCast(@max(0, test_duration)))) catch |err| {
log.err("Download test failed: {}", .{err});
std.debug.print("{{\"error\": \"{}\"}}\n", .{err});
return;
};
} else {
// Create progress callback with spinner context
const progressCallback = progress.createCallback(ctx.spinner, updateSpinnerText);
break :blk speed_tester.measureDownloadSpeedWithProgress(urls, @as(u32, @intCast(@max(0, test_duration))), progressCallback) catch |err| {
try ctx.spinner.fail("Download test failed: {}", .{err});
return;
};
}
};
var upload_result: ?SpeedTestResult = null;
if (check_upload) {
if (!json_output) {
const upload_mode_str = if (use_stability) "stability" else "duration";
try ctx.spinner.start(.{}, "Measuring upload speed ({s} mode)...", .{upload_mode_str});
}
upload_result = if (use_stability) blk: {
const criteria = StabilityCriteria{
.min_samples = @as(u32, @intCast(stability_min_samples)),
.max_variance_percent = stability_max_variance,
.max_duration_seconds = @as(u32, @intCast(stability_max_duration)),
};
break :blk speed_tester.measure_upload_speed_stability(urls, criteria) catch |err| {
if (!json_output) {
try ctx.spinner.fail("Upload test failed: {}", .{err});
}
return;
};
} else blk: {
if (json_output) {
// JSON mode: clean output only
break :blk speed_tester.measureUploadSpeed(urls, @as(u32, @intCast(@max(0, test_duration)))) catch |err| {
log.err("Upload test failed: {}", .{err});
std.debug.print("{{\"error\": \"{}\"}}\n", .{err});
return;
};
} else {
// Create progress callback with spinner context
const uploadProgressCallback = progress.createCallback(ctx.spinner, updateUploadSpinnerText);
break :blk speed_tester.measureUploadSpeedWithProgress(urls, @as(u32, @intCast(@max(0, test_duration))), uploadProgressCallback) catch |err| {
try ctx.spinner.fail("Upload test failed: {}", .{err});
return;
};
}
};
}
// Output results
if (!json_output) {
if (latency_ms) |ping| {
if (upload_result) |up| {
try ctx.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() });
} else {
try ctx.spinner.succeed("🏓 {d:.0}ms | ⬇️ Download: {d:.1} {s}", .{ ping, download_result.speed.value, download_result.speed.unit.toString() });
}
} else {
if (upload_result) |up| {
try ctx.spinner.succeed("⬇️ Download: {d:.1} {s} | ⬆️ Upload: {d:.1} {s}", .{ download_result.speed.value, download_result.speed.unit.toString(), up.speed.value, up.speed.unit.toString() });
} else {
try ctx.spinner.succeed("⬇️ Download: {d:.1} {s}", .{ download_result.speed.value, download_result.speed.unit.toString() });
}
}
} else {
std.debug.print("{{\"download_mbps\": {d:.1}", .{download_result.speed.value});
if (latency_ms) |ping| {
std.debug.print(", \"ping_ms\": {d:.1}", .{ping});
}
if (upload_result) |up| {
std.debug.print(", \"upload_mbps\": {d:.1}", .{up.speed.value});
}
std.debug.print("}}\n", .{});
}
}