From 825220bf04a5e355229ce7d7e29fd8acbf71066b Mon Sep 17 00:00:00 2001 From: mktbsh <59120037+mktbsh@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:00:37 +0900 Subject: [PATCH 1/6] docs: add version flag design spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../specs/2026-04-23-version-flag-design.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-23-version-flag-design.md 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. From 5915b0d93712d43067403b9a4f3b5e8e4aaed724 Mon Sep 17 00:00:00 2001 From: mktbsh <59120037+mktbsh@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:12:03 +0900 Subject: [PATCH 2/6] feat: add version resolution helpers Add version helper functions and tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/unispeedtest/main.go | 29 +++++++++++++++ cmd/unispeedtest/main_test.go | 68 +++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 cmd/unispeedtest/main_test.go diff --git a/cmd/unispeedtest/main.go b/cmd/unispeedtest/main.go index afe72d9..d0acba6 100644 --- a/cmd/unispeedtest/main.go +++ b/cmd/unispeedtest/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "runtime/debug" "github.com/hsblabs/universal-speedtest-cli/internal/cloudflare" "github.com/hsblabs/universal-speedtest-cli/internal/color" @@ -12,6 +13,34 @@ import ( "github.com/hsblabs/universal-speedtest-cli/internal/stats" ) +var ( + version = "" + buildInfoReader = debug.ReadBuildInfo +) + +func hasVersionFlag(args []string) bool { + for _, arg := range args { + if arg == "-v" || arg == "--version" { + return true + } + } + + return false +} + +func resolvedVersion() string { + if version != "" { + return version + } + + info, ok := buildInfoReader() + if ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + return info.Main.Version + } + + return "dev" +} + func main() { jsonOut := flag.Bool("json", false, "Output results in JSON format") prettyOut := flag.Bool("pretty", false, "Output pretty-printed JSON (implies -json)") diff --git a/cmd/unispeedtest/main_test.go b/cmd/unispeedtest/main_test.go new file mode 100644 index 0000000..5bc3f0f --- /dev/null +++ b/cmd/unispeedtest/main_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "runtime/debug" + "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") + } + }) +} From 4fbc37897bb514d72a62b53d250abbe3c08984aa Mon Sep 17 00:00:00 2001 From: mktbsh <59120037+mktbsh@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:22:45 +0900 Subject: [PATCH 3/6] feat: add version flags to the CLI Short-circuit version flags before benchmark execution by routing CLI handling through a testable run function. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/unispeedtest/main.go | 173 ++++++++++++++++------------ cmd/unispeedtest/main_test.go | 204 ++++++++++++++++++++++++---------- 2 files changed, 246 insertions(+), 131 deletions(-) diff --git a/cmd/unispeedtest/main.go b/cmd/unispeedtest/main.go index d0acba6..046e220 100644 --- a/cmd/unispeedtest/main.go +++ b/cmd/unispeedtest/main.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + "io" "os" "os/signal" "runtime/debug" @@ -16,6 +17,89 @@ import ( var ( version = "" buildInfoReader = debug.ReadBuildInfo + benchmarkMain = func(jsonOut, prettyOut bool, stdout, stderr io.Writer) int { + if prettyOut { + jsonOut = true + } + + verbose := !jsonOut + + var progress func(string, ...interface{}) + if verbose { + progress = func(format string, args ...interface{}) { + fmt.Fprintf(stdout, format, args...) + } + } 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(stderr, "\nInterrupted.") + os.Exit(130) + }() + + progress("%sInitializing Cloudflare Speed Test...%s\n\n", color.Bold, color.Reset) + + meta, err := cloudflare.FetchMeta() + if err != nil { + fmt.Fprintf(stderr, "Warning: could not fetch network metadata: %v\n", err) + } + + unloadedLatencies := cloudflare.MeasureLatency() + unloadedMed := stats.Median(unloadedLatencies) + unloadedJitter := stats.Jitter(unloadedLatencies) + + progress("%s[ Download Measurements ]%s\n", color.Bold, color.Reset) + var progressWriter io.Writer + if verbose { + progressWriter = stdout + } + testSizes := []int{101000, 1001000, 10001000, 25001000} + downCounts := []int{10, 8, 6, 4} + downSpeeds, downLatencies := cloudflare.MeasurePhase("download", testSizes, downCounts, progressWriter) + downOverall := stats.Quartile(downSpeeds, 0.90) + + progress("\n%s[ Upload Measurements ]%s\n", color.Bold, color.Reset) + upCounts := []int{8, 6, 4, 4} + upSpeeds, upLatencies := cloudflare.MeasurePhase("upload", testSizes, upCounts, progressWriter) + upOverall := stats.Quartile(upSpeeds, 0.90) + + progress("\n%s[ Packet Loss Test ]%s Running 1000 requests...\n", color.Bold, color.Reset) + lossPercent, received, total := cloudflare.MeasurePacketLoss() + + result := reporter.Result{ + DownloadMbps: downOverall, + UploadMbps: upOverall, + UnloadedLatency: unloadedMed, + LoadedDownLatency: stats.Median(downLatencies), + LoadedUpLatency: stats.Median(upLatencies), + Jitter: unloadedJitter, + PacketLoss: lossPercent, + Received: received, + Total: total, + ServerColo: meta.Colo.City, + NetworkASN: fmt.Sprintf("AS%d", meta.ASN), + NetworkASOrg: meta.ASOrganization, + IP: meta.ClientIP, + } + + 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 + } ) func hasVersionFlag(args []string) bool { @@ -42,84 +126,27 @@ func resolvedVersion() string { } func main() { - jsonOut := flag.Bool("json", false, "Output results in JSON format") - prettyOut := flag.Bool("pretty", false, "Output pretty-printed JSON (implies -json)") - flag.Parse() - - if *prettyOut { - *jsonOut = true - } - - verbose := !*jsonOut - - var progress func(string, ...interface{}) - if verbose { - progress = func(format string, args ...interface{}) { - fmt.Printf(format, args...) - } - } else { - progress = func(string, ...interface{}) {} - } - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt) - go func() { - <-sigCh - fmt.Fprintln(os.Stderr, "\nInterrupted.") - os.Exit(130) - }() - - progress("%sInitializing Cloudflare Speed Test...%s\n\n", color.Bold, color.Reset) + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} - meta, err := cloudflare.FetchMeta() - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not fetch network metadata: %v\n", err) +func run(args []string, stdout, stderr io.Writer) int { + if hasVersionFlag(args) { + fmt.Fprintln(stdout, resolvedVersion()) + return 0 } - unloadedLatencies := cloudflare.MeasureLatency() - unloadedMed := stats.Median(unloadedLatencies) - unloadedJitter := stats.Jitter(unloadedLatencies) + fs := flag.NewFlagSet("unispeedtest", flag.ContinueOnError) + fs.SetOutput(stderr) - progress("%s[ Download Measurements ]%s\n", color.Bold, color.Reset) - var progressWriter *os.File - if verbose { - progressWriter = os.Stdout - } - testSizes := []int{101000, 1001000, 10001000, 25001000} - downCounts := []int{10, 8, 6, 4} - downSpeeds, downLatencies := cloudflare.MeasurePhase("download", testSizes, downCounts, progressWriter) - downOverall := stats.Quartile(downSpeeds, 0.90) - - progress("\n%s[ Upload Measurements ]%s\n", color.Bold, color.Reset) - upCounts := []int{8, 6, 4, 4} - upSpeeds, upLatencies := cloudflare.MeasurePhase("upload", testSizes, upCounts, progressWriter) - upOverall := stats.Quartile(upSpeeds, 0.90) - - progress("\n%s[ Packet Loss Test ]%s Running 1000 requests...\n", color.Bold, color.Reset) - lossPercent, received, total := cloudflare.MeasurePacketLoss() - - result := reporter.Result{ - DownloadMbps: downOverall, - UploadMbps: upOverall, - UnloadedLatency: unloadedMed, - LoadedDownLatency: stats.Median(downLatencies), - LoadedUpLatency: stats.Median(upLatencies), - Jitter: unloadedJitter, - PacketLoss: lossPercent, - Received: received, - Total: total, - ServerColo: meta.Colo.City, - NetworkASN: fmt.Sprintf("AS%d", meta.ASN), - NetworkASOrg: meta.ASOrganization, - IP: meta.ClientIP, - } + 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) - os.Exit(1) + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return 0 } + return 2 } + + return benchmarkMain(*jsonOut, *prettyOut, stdout, stderr) } diff --git a/cmd/unispeedtest/main_test.go b/cmd/unispeedtest/main_test.go index 5bc3f0f..aebe9b2 100644 --- a/cmd/unispeedtest/main_test.go +++ b/cmd/unispeedtest/main_test.go @@ -1,68 +1,156 @@ package main import ( - "runtime/debug" - "testing" + "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) - } - }) - } + 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") - } - }) + 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") + } + }) +} + +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) + } } From 37a48886ef12c2e3b43bffcd4097808b5b9f45b1 Mon Sep 17 00:00:00 2001 From: mktbsh <59120037+mktbsh@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:33:40 +0900 Subject: [PATCH 4/6] chore: clarify interrupt exit behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/unispeedtest/main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/unispeedtest/main.go b/cmd/unispeedtest/main.go index 046e220..b6bfaca 100644 --- a/cmd/unispeedtest/main.go +++ b/cmd/unispeedtest/main.go @@ -40,6 +40,9 @@ var ( 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) }() From c3cbda483e57e90b653620642183718a985e7e2d Mon Sep 17 00:00:00 2001 From: mktbsh <59120037+mktbsh@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:39:18 +0900 Subject: [PATCH 5/6] docs: document version flags Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 ++ docs/README/ja.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index e177695..2225ade 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/docs/README/ja.md b/docs/README/ja.md index ba0384a..69af425 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 出力形式 From c6e482dccb33bf1a95e7aaf98126daa884433718 Mon Sep 17 00:00:00 2001 From: mktbsh <59120037+mktbsh@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:48:38 +0900 Subject: [PATCH 6/6] fix: guard nil build info Guard against nil BuildInfo when debug.ReadBuildInfo returns (nil,true). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/unispeedtest/main.go | 2 +- cmd/unispeedtest/main_test.go | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/unispeedtest/main.go b/cmd/unispeedtest/main.go index b6bfaca..5cde997 100644 --- a/cmd/unispeedtest/main.go +++ b/cmd/unispeedtest/main.go @@ -121,7 +121,7 @@ func resolvedVersion() string { } info, ok := buildInfoReader() - if ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + if ok && info != nil && info.Main.Version != "" && info.Main.Version != "(devel)" { return info.Main.Version } diff --git a/cmd/unispeedtest/main_test.go b/cmd/unispeedtest/main_test.go index aebe9b2..73e7f02 100644 --- a/cmd/unispeedtest/main_test.go +++ b/cmd/unispeedtest/main_test.go @@ -68,6 +68,17 @@ func TestResolvedVersion(t *testing.T) { 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) {