diff --git a/README.md b/README.md index 54bed91..2f737c9 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,14 @@ Options: - `-json`: output compact JSON - `-pretty`: output indented JSON (implies `-json`) +- `-v`, `--version`: print the CLI version and exit Examples: ```sh unispeedtest -json unispeedtest -pretty +unispeedtest --version ``` ## JSON output shape diff --git a/cmd/unispeedtest/main.go b/cmd/unispeedtest/main.go index 29655f4..de1745b 100644 --- a/cmd/unispeedtest/main.go +++ b/cmd/unispeedtest/main.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/signal" + "runtime/debug" "github.com/hsblabs/universal-speedtest-cli/internal/cloudflare" "github.com/hsblabs/universal-speedtest-cli/internal/color" @@ -13,137 +14,187 @@ import ( "github.com/hsblabs/universal-speedtest-cli/internal/stats" ) -func main() { - os.Exit(run()) -} +var ( + version = "" + buildInfoReader = debug.ReadBuildInfo + benchmarkMain = func(jsonOut, prettyOut bool, stdout, stderr io.Writer) int { + if prettyOut { + jsonOut = true + } -func run() int { - jsonOut := flag.Bool("json", false, "Output results in JSON format") - prettyOut := flag.Bool("pretty", false, "Output pretty-printed JSON (implies -json)") - flag.Parse() + verbose := !jsonOut - if *prettyOut { - *jsonOut = true - } + var progress func(string, ...interface{}) + if verbose { + progress = func(format string, args ...interface{}) { + fmt.Fprintf(stdout, format, args...) + } + } else { + progress = func(string, ...interface{}) {} + } - verbose := !*jsonOut + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + defer signal.Stop(sigCh) + + go func() { + <-sigCh + fmt.Fprintln(stderr, "\nInterrupted.") + // Intentionally exit the whole process on SIGINT (130) instead of + // returning through run(). Signal handling is process-wide and an + // immediate exit avoids leaving background goroutines or partial state. + os.Exit(130) + }() + + progress("%sInitializing Cloudflare Speed Test...%s\n\n", color.Bold, color.Reset) + + var result reporter.Result + var warnings []string + + meta, err := cloudflare.FetchMeta() + if err != nil { + warnings = append(warnings, fmt.Sprintf("network metadata unavailable: %v", err)) + } else { + result.ServerColo = stringPtr(meta.Colo.City) + result.NetworkASN = stringPtr(fmt.Sprintf("AS%d", meta.ASN)) + result.NetworkASOrg = stringPtr(meta.ASOrganization) + result.IP = stringPtr(meta.ClientIP) + } - var progress func(string, ...interface{}) - if verbose { - progress = func(format string, args ...interface{}) { - fmt.Printf(format, args...) + latencyMeasurement := cloudflare.MeasureLatency() + warnings = append(warnings, latencyMeasurement.Warnings...) + if len(latencyMeasurement.Samples) == 0 { + fmt.Fprintln(stderr, "error: unloaded latency measurement produced no successful samples") + return 1 + } + result.UnloadedLatency = float64Ptr(stats.Median(latencyMeasurement.Samples)) + if len(latencyMeasurement.Samples) > 1 { + result.Jitter = float64Ptr(stats.Jitter(latencyMeasurement.Samples)) + } else { + warnings = append(warnings, "jitter unavailable: need at least 2 unloaded latency samples") } - } else { - progress = func(string, ...interface{}) {} - } - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt) - defer signal.Stop(sigCh) - go func() { - <-sigCh - fmt.Fprintln(os.Stderr, "\nInterrupted.") - os.Exit(130) - }() - - progress("%sInitializing Cloudflare Speed Test...%s\n\n", color.Bold, color.Reset) - - var result reporter.Result - var warnings []string - - meta, err := cloudflare.FetchMeta() - if err != nil { - warnings = append(warnings, fmt.Sprintf("network metadata unavailable: %v", err)) - } else { - result.ServerColo = stringPtr(meta.Colo.City) - result.NetworkASN = stringPtr(fmt.Sprintf("AS%d", meta.ASN)) - result.NetworkASOrg = stringPtr(meta.ASOrganization) - result.IP = stringPtr(meta.ClientIP) - } + progress("%s[ Download Measurements ]%s\n", color.Bold, color.Reset) + var progressWriter io.Writer + if verbose { + progressWriter = stdout + } + downloadSpecs := []cloudflare.PhaseSpec{ + {SizeBytes: 101000, Count: 10}, + {SizeBytes: 1001000, Count: 8}, + {SizeBytes: 10001000, Count: 6}, + {SizeBytes: 25001000, Count: 4}, + } + downloadMeasurement, err := cloudflare.MeasurePhase("download", downloadSpecs, progressWriter) + if err != nil { + fmt.Fprintf(stderr, "error: download measurement failed: %v\n", err) + return 1 + } + warnings = append(warnings, downloadMeasurement.Warnings...) + if len(downloadMeasurement.Speeds) == 0 { + fmt.Fprintln(stderr, "error: download measurement produced no successful samples") + return 1 + } + result.DownloadMbps = float64Ptr(stats.Quartile(downloadMeasurement.Speeds, 0.90)) + if len(downloadMeasurement.LoadedLatencies) > 0 { + result.LoadedDownLatency = float64Ptr(stats.Median(downloadMeasurement.LoadedLatencies)) + } else { + warnings = append(warnings, "download loaded latency unavailable: no samples collected") + } - latencyMeasurement := cloudflare.MeasureLatency() - warnings = append(warnings, latencyMeasurement.Warnings...) - if len(latencyMeasurement.Samples) == 0 { - fmt.Fprintln(os.Stderr, "error: unloaded latency measurement produced no successful samples") - return 1 - } - result.UnloadedLatency = float64Ptr(stats.Median(latencyMeasurement.Samples)) - if len(latencyMeasurement.Samples) > 1 { - result.Jitter = float64Ptr(stats.Jitter(latencyMeasurement.Samples)) - } else { - warnings = append(warnings, "jitter unavailable: need at least 2 unloaded latency samples") - } + progress("\n%s[ Upload Measurements ]%s\n", color.Bold, color.Reset) + uploadSpecs := []cloudflare.PhaseSpec{ + {SizeBytes: 101000, Count: 8}, + {SizeBytes: 1001000, Count: 6}, + {SizeBytes: 10001000, Count: 4}, + {SizeBytes: 25001000, Count: 4}, + } + uploadMeasurement, err := cloudflare.MeasurePhase("upload", uploadSpecs, progressWriter) + if err != nil { + fmt.Fprintf(stderr, "error: upload measurement failed: %v\n", err) + return 1 + } + warnings = append(warnings, uploadMeasurement.Warnings...) + if len(uploadMeasurement.Speeds) == 0 { + fmt.Fprintln(stderr, "error: upload measurement produced no successful samples") + return 1 + } + result.UploadMbps = float64Ptr(stats.Quartile(uploadMeasurement.Speeds, 0.90)) + if len(uploadMeasurement.LoadedLatencies) > 0 { + result.LoadedUpLatency = float64Ptr(stats.Median(uploadMeasurement.LoadedLatencies)) + } else { + warnings = append(warnings, "upload loaded latency unavailable: no samples collected") + } - progress("%s[ Download Measurements ]%s\n", color.Bold, color.Reset) - var progressWriter io.Writer - if verbose { - progressWriter = os.Stdout - } - downloadSpecs := []cloudflare.PhaseSpec{ - {SizeBytes: 101000, Count: 10}, - {SizeBytes: 1001000, Count: 8}, - {SizeBytes: 10001000, Count: 6}, - {SizeBytes: 25001000, Count: 4}, - } - downloadMeasurement, err := cloudflare.MeasurePhase("download", downloadSpecs, progressWriter) - if err != nil { - fmt.Fprintf(os.Stderr, "error: download measurement failed: %v\n", err) - return 1 - } - warnings = append(warnings, downloadMeasurement.Warnings...) - if len(downloadMeasurement.Speeds) == 0 { - fmt.Fprintln(os.Stderr, "error: download measurement produced no successful samples") - return 1 - } - result.DownloadMbps = float64Ptr(stats.Quartile(downloadMeasurement.Speeds, 0.90)) - if len(downloadMeasurement.LoadedLatencies) > 0 { - result.LoadedDownLatency = float64Ptr(stats.Median(downloadMeasurement.LoadedLatencies)) - } else { - warnings = append(warnings, "download loaded latency unavailable: no samples collected") + progress("\n%s[ Packet Loss Test ]%s Running 1000 requests...\n", color.Bold, color.Reset) + lossPercent, received, total := cloudflare.MeasurePacketLoss() + + result.PacketLoss = float64Ptr(lossPercent) + result.Received = received + result.Total = total + result.Warnings = warnings + + if verbose { + reporter.PrintHuman(stdout, result) + return 0 + } + + if err := reporter.PrintJSON(stdout, result, prettyOut); err != nil { + fmt.Fprintf(stderr, "error encoding JSON: %v\n", err) + return 1 + } + + return 0 } +) - progress("\n%s[ Upload Measurements ]%s\n", color.Bold, color.Reset) - uploadSpecs := []cloudflare.PhaseSpec{ - {SizeBytes: 101000, Count: 8}, - {SizeBytes: 1001000, Count: 6}, - {SizeBytes: 10001000, Count: 4}, - {SizeBytes: 25001000, Count: 4}, +func hasVersionFlag(args []string) bool { + for _, arg := range args { + if arg == "-v" || arg == "--version" { + return true + } } - uploadMeasurement, err := cloudflare.MeasurePhase("upload", uploadSpecs, progressWriter) - if err != nil { - fmt.Fprintf(os.Stderr, "error: upload measurement failed: %v\n", err) - return 1 + + return false +} + +func resolvedVersion() string { + if version != "" { + return version } - warnings = append(warnings, uploadMeasurement.Warnings...) - if len(uploadMeasurement.Speeds) == 0 { - fmt.Fprintln(os.Stderr, "error: upload measurement produced no successful samples") - return 1 + + info, ok := buildInfoReader() + if ok && info != nil && info.Main.Version != "" && info.Main.Version != "(devel)" { + return info.Main.Version } - result.UploadMbps = float64Ptr(stats.Quartile(uploadMeasurement.Speeds, 0.90)) - if len(uploadMeasurement.LoadedLatencies) > 0 { - result.LoadedUpLatency = float64Ptr(stats.Median(uploadMeasurement.LoadedLatencies)) - } else { - warnings = append(warnings, "upload loaded latency unavailable: no samples collected") + + return "dev" +} + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + if hasVersionFlag(args) { + fmt.Fprintln(stdout, resolvedVersion()) + return 0 } - progress("\n%s[ Packet Loss Test ]%s Running 1000 requests...\n", color.Bold, color.Reset) - lossPercent, received, total := cloudflare.MeasurePacketLoss() + fs := flag.NewFlagSet("unispeedtest", flag.ContinueOnError) + fs.SetOutput(stderr) - result.PacketLoss = float64Ptr(lossPercent) - result.Received = received - result.Total = total - result.Warnings = warnings + jsonOut := fs.Bool("json", false, "Output results in JSON format") + prettyOut := fs.Bool("pretty", false, "Output pretty-printed JSON (implies -json)") - if verbose { - reporter.PrintHuman(os.Stdout, result) - } else { - if err := reporter.PrintJSON(os.Stdout, result, *prettyOut); err != nil { - fmt.Fprintf(os.Stderr, "error encoding JSON: %v\n", err) - return 1 + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return 0 } + return 2 } - return 0 + return benchmarkMain(*jsonOut, *prettyOut, stdout, stderr) } func float64Ptr(value float64) *float64 { diff --git a/cmd/unispeedtest/main_test.go b/cmd/unispeedtest/main_test.go new file mode 100644 index 0000000..73e7f02 --- /dev/null +++ b/cmd/unispeedtest/main_test.go @@ -0,0 +1,167 @@ +package main + +import ( + "bytes" + "io" + "runtime/debug" + "strings" + "testing" +) + +func TestHasVersionFlag(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + {name: "no flags", args: []string{}, want: false}, + {name: "json only", args: []string{"-json"}, want: false}, + {name: "short version", args: []string{"-v"}, want: true}, + {name: "long version", args: []string{"--version"}, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasVersionFlag(tt.args); got != tt.want { + t.Fatalf("hasVersionFlag(%v) = %v, want %v", tt.args, got, tt.want) + } + }) + } +} + +func TestResolvedVersion(t *testing.T) { + oldVersion, oldReader := version, buildInfoReader + t.Cleanup(func() { + version = oldVersion + buildInfoReader = oldReader + }) + + t.Run("prefers injected version", func(t *testing.T) { + version = "v1.2.3" + buildInfoReader = func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{Main: debug.Module{Version: "v0.9.0"}}, true + } + + if got := resolvedVersion(); got != "v1.2.3" { + t.Fatalf("resolvedVersion() = %q, want %q", got, "v1.2.3") + } + }) + + t.Run("uses build info version", func(t *testing.T) { + version = "" + buildInfoReader = func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{Main: debug.Module{Version: "v0.9.0"}}, true + } + + if got := resolvedVersion(); got != "v0.9.0" { + t.Fatalf("resolvedVersion() = %q, want %q", got, "v0.9.0") + } + }) + + t.Run("falls back to dev", func(t *testing.T) { + version = "" + buildInfoReader = func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}, true + } + + if got := resolvedVersion(); got != "dev" { + t.Fatalf("resolvedVersion() = %q, want %q", got, "dev") + } + }) + + t.Run("nil build info falls back to dev", func(t *testing.T) { + version = "" + buildInfoReader = func() (*debug.BuildInfo, bool) { + return nil, true + } + + if got := resolvedVersion(); got != "dev" { + t.Fatalf("resolvedVersion() = %q, want %q", got, "dev") + } + }) +} + +func TestRunVersionFlagPrintsAndExits(t *testing.T) { + oldVersion, oldBenchmark := version, benchmarkMain + t.Cleanup(func() { + version = oldVersion + benchmarkMain = oldBenchmark + }) + + version = "v1.2.3" + benchmarkMain = func(jsonOut, prettyOut bool, stdout, stderr io.Writer) int { + t.Fatal("benchmark path should not run for version flags") + return 1 + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + if exitCode := run([]string{"--version"}, &stdout, &stderr); exitCode != 0 { + t.Fatalf("run(--version) exit code = %d, want 0", exitCode) + } + + if got := stdout.String(); got != "v1.2.3\n" { + t.Fatalf("stdout = %q, want %q", got, "v1.2.3\n") + } + + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRunShortVersionFlagPrintsAndExits(t *testing.T) { + oldVersion, oldBenchmark := version, benchmarkMain + t.Cleanup(func() { + version = oldVersion + benchmarkMain = oldBenchmark + }) + + version = "v9.9.9" + benchmarkMain = func(jsonOut, prettyOut bool, stdout, stderr io.Writer) int { + t.Fatal("benchmark path should not run for version flags") + return 1 + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + if exitCode := run([]string{"-v"}, &stdout, &stderr); exitCode != 0 { + t.Fatalf("run(-v) exit code = %d, want 0", exitCode) + } + + if got := stdout.String(); got != "v9.9.9\n" { + t.Fatalf("stdout = %q, want %q", got, "v9.9.9\n") + } + + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRunHelpFlagPrintsUsageAndExitsZero(t *testing.T) { + oldBenchmark := benchmarkMain + t.Cleanup(func() { + benchmarkMain = oldBenchmark + }) + + benchmarkMain = func(jsonOut, prettyOut bool, stdout, stderr io.Writer) int { + t.Fatal("benchmark path should not run for help flags") + return 1 + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + if exitCode := run([]string{"-h"}, &stdout, &stderr); exitCode != 0 { + t.Fatalf("run(-h) exit code = %d, want 0", exitCode) + } + + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + + if got := stderr.String(); !strings.Contains(got, "Usage of unispeedtest:") { + t.Fatalf("stderr = %q, want usage output", got) + } +} diff --git a/docs/README/ja.md b/docs/README/ja.md index bbafbb9..debe6c1 100644 --- a/docs/README/ja.md +++ b/docs/README/ja.md @@ -47,12 +47,14 @@ unispeedtest - `-json`: JSON(1行)で出力 - `-pretty`: 整形済み JSON で出力(`-json` を含む) +- `-v`, `--version`: CLI バージョンを表示して終了 例: ```sh unispeedtest -json unispeedtest -pretty +unispeedtest --version ``` ## JSON 出力形式 diff --git a/docs/superpowers/specs/2026-04-23-version-flag-design.md b/docs/superpowers/specs/2026-04-23-version-flag-design.md new file mode 100644 index 0000000..8703bf0 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-version-flag-design.md @@ -0,0 +1,85 @@ +# Design: CLI version flag support + +## Problem + +`unispeedtest` currently supports `-json` and `-pretty`, but it does not provide a standard way to print the CLI version. Users installing from GitHub Releases or via `go install` should be able to run `-v` or `--version` and get a meaningful version string without triggering the network benchmark flow. + +## Goals + +- Support `-v` and `--version` as aliases for version output. +- Print the version to standard output and exit successfully. +- Avoid any benchmark setup, progress output, or network access when version flags are used. +- Prefer version data that works for both release artifacts and `go install ...@latest`. + +## Non-goals + +- Adding a `version` subcommand. +- Changing JSON output shape in this task. +- Introducing a new CLI parsing framework. + +## Recommended approach + +Keep the existing `flag`-package-based CLI and add early handling for version flags in `cmd/unispeedtest/main.go`. Resolve the version string in the following order: + +1. A build-time override variable set through `-ldflags -X`. +2. Go build info module version from `runtime/debug.ReadBuildInfo()`. +3. A development fallback such as `dev`. + +This keeps the implementation small, preserves the existing CLI structure, and supports both release/distribution builds and module-based installs. + +## CLI behavior + +- `unispeedtest -v` prints the resolved version and exits with status 0. +- `unispeedtest --version` prints the resolved version and exits with status 0. +- Version output should happen before any progress banner, signal setup with measurement side effects, metadata fetch, or benchmark execution. +- Existing flags (`-json`, `-pretty`) continue to behave as they do today when version flags are not present. + +## Implementation sketch + +### Entry point + +Extend `cmd/unispeedtest/main.go` to recognize both `-v` and `--version`. + +Because Go's standard `flag` package does not accept long GNU-style flags in the same way users expect, the implementation should account for `--version` explicitly before or alongside normal flag parsing. The rest of the CLI can continue to use `flag`. + +### Version resolution + +Add a small helper local to `main` (or adjacent in the same package) that: + +- returns the build-time override when present, +- otherwise reads `debug.ReadBuildInfo()` and uses the main module version when available and not `(devel)`, +- otherwise returns the fallback development string. + +### Output and exit + +- Print the version with a trailing newline to standard output. +- Return immediately from `main` after printing. +- Do not emit warnings or extra prose around the version string. + +## Error handling + +- Failure to read build info is not fatal. +- Missing or development-only build metadata falls back to the development string. +- No additional error path or exit code is required for version printing. + +## Documentation changes + +Update both `README.md` and `docs/README/ja.md` to document: + +- `-v, --version`: print the CLI version and exit + +Examples should include a simple version command invocation. + +## Testing plan + +- Add or refactor tests so version-path behavior can be exercised without running the benchmark flow. +- Verify both `-v` and `--version` produce version output and successful termination. +- Verify the normal benchmark path is bypassed when version flags are used. +- Keep documentation consistent with the implemented flags. + +## Risks and mitigations + +- **Risk:** `--version` is awkward with the standard `flag` package. + **Mitigation:** Handle the long flag explicitly while preserving the rest of the current parser. +- **Risk:** Version metadata differs between release builds and local installs. + **Mitigation:** Use layered resolution with build override first and build info second.