From 399efb30be9507c164f044afea0c9905af998caa Mon Sep 17 00:00:00 2001 From: Bart Van Bos Date: Sat, 7 Nov 2026 22:41:30 +0100 Subject: [PATCH 1/3] feat: aligned version output and full shell completion version prints one line, name version (tag, commit, built, go, platform), and version --json (or SUNSPECCTL_FORMAT=json) one JSON line with the keys name, version, tag, commit, built, go, platform. A development build falls back on the Go module version and VCS stamp. Every flag that takes a value now has a completion function: serial enums, URL schemes, model IDs and point names from the go-sunspec registry, JSON and PEM files, no file names for free values. The bash script is cobra's V2. make build stamps the full SHA and a tag only on a tagged commit. Co-Authored-By: Claude Opus 5.5 --- Makefile | 4 +- README.md | 4 +- RELEASE.md | 16 ++++ completion.go | 118 ++++++++++++++++++++++++++ completion_test.go | 103 ++++++++++++++++++++++ docs/json-output.md | 6 +- e2e/e2e_test.go | 15 +++- internal/buildinfo/buildinfo.go | 122 +++++++++++++++++++++++++++ internal/buildinfo/buildinfo_test.go | 90 ++++++++++++++++++++ main.go | 60 ++++--------- main_test.go | 69 +++++++-------- 11 files changed, 516 insertions(+), 91 deletions(-) create mode 100644 completion.go create mode 100644 completion_test.go create mode 100644 internal/buildinfo/buildinfo.go create mode 100644 internal/buildinfo/buildinfo_test.go diff --git a/Makefile b/Makefile index 319599a..e74ca6e 100644 --- a/Makefile +++ b/Makefile @@ -21,8 +21,8 @@ ARCHS = linux/amd64 linux/arm64 linux/arm/v7 darwin/amd64 darwin/arm64 win RELEASE_DIR = release # Override on the command line or in CI (matches the ldflags of the release workflow). VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -TAG ?= $(shell git describe --tags --abbrev=0 2>/dev/null || echo "") -COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "") +TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo "") +COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo "") BUILD_DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') LDFLAGS = -ldflags "-s -w -X main.version=$(VERSION) -X main.tag=$(TAG) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)" diff --git a/README.md b/README.md index 916c768..3a20c0b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ The existing commands, their flags and their output are unchanged. ### Shell completion +Completion knows the commands and flags, the values of `--parity`, `--data-bits`, `--stop-bits` and `--baud`, the URL schemes of `--url`, the model IDs with a known schema for `--id` and `--model`, the point names of that model for `--point`, and JSON or PEM files for `--file` and `--tls-*`. + ```bash # bash (needs the bash-completion package) mkdir -p ~/.local/share/bash-completion/completions @@ -95,7 +97,7 @@ sunspecctl ├── capture Read a device and write it as a device file for serve ├── template Write a device file skeleton for the given models ├── completion Generate shell completion script (bash/zsh/fish/powershell) -└── version Print the sunspecctl version +└── version Print the version, tag, commit and build date on one line (--json: one object) ``` ## Global flags diff --git a/RELEASE.md b/RELEASE.md index 79dd284..c60a14b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,21 @@ # sunspecctl Releases +## v0.6.0 + +**Date:** 2026-11-07 +**Previous release:** v0.5.1 + +### Added + +- Shell completion completes flag values: `--parity`, `--data-bits`, `--stop-bits`, `--baud`, the URL schemes of `--url`, the model IDs with a known schema for `--id` and `--model` (with their names), the point names of the chosen model for `--point`, JSON files for `serve --file`, PEM files for `--tls-ca`, `--tls-cert` and `--tls-key`; numbers and durations no longer complete file names. The bash script is cobra's V2 script, with descriptions. + +### Changed + +- **Breaking for scripts that read `version`:** `version` prints one line, `sunspecctl 0.6.0 (tag v0.6.0, commit , built , go1.26.10, linux/amd64)`, instead of a `tag:`, `commit:` and `built:` block; the version has no leading `v`. `version --json` (and `--json version`, `SUNSPECCTL_FORMAT=json`) prints one line with the keys `name`, `version`, `tag`, `commit`, `built`, `go`, `platform`, always present and in this order; `buildDate` is now `built`. A development build reports `dev` and the commit from the Go VCS stamp (`-dirty` for a modified tree); `go install …@vX.Y.Z` reports that version. +- `make build` stamps the full commit SHA, and a tag only when the commit is tagged. + +--- + ## v0.5.1 **Date:** 2026-11-06 diff --git a/completion.go b/completion.go new file mode 100644 index 0000000..b573a70 --- /dev/null +++ b/completion.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "fmt" + "slices" + "strconv" + + "github.com/otfabric/go-sunspec/registry" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// urlSchemes are the Modbus URL schemes --url completes. +var urlSchemes = []string{"tcp://", "tcp+tls://", "udp://", "rtu://", "ascii://", "rtuovertcp://", "rtuoverudp://", "asciiovertcp://"} + +// fixedFlagValues are the value sets of the enum flags. +var fixedFlagValues = map[string][]string{ + "parity": {"none", "even", "odd"}, + "data-bits": {"0", "5", "6", "7", "8"}, + "stop-bits": {"0", "1", "2"}, + "baud": {"0", "1200", "2400", "4800", "9600", "19200", "38400", "57600", "115200"}, +} + +// fileFlagExtensions are the flags that name an input file of a known type. +var fileFlagExtensions = map[string][]string{ + "file": {"json"}, + "tls-ca": {"pem", "crt", "cer"}, + "tls-cert": {"pem", "crt", "cer"}, + "tls-key": {"pem", "key"}, +} + +// registerCompletions gives every value flag of c and its subcommands a +// completion function: the values of an enum flag, the model IDs with a known +// schema for --id and --model, the point names of that model for --point, +// files of the right type for a file flag, the URL schemes for --url, and no +// file names for a free value (a number, a duration). A command without +// positional arguments completes no file names either. +func registerCompletions(c *cobra.Command) { + if c.Runnable() && !c.HasSubCommands() && c.ValidArgs == nil && c.ValidArgsFunction == nil { + c.ValidArgsFunction = cobra.NoFileCompletions + } + c.LocalNonPersistentFlags().VisitAll(func(f *pflag.Flag) { registerFlagCompletion(c, f) }) + c.PersistentFlags().VisitAll(func(f *pflag.Flag) { registerFlagCompletion(c, f) }) + for _, sub := range c.Commands() { + registerCompletions(sub) + } +} + +func registerFlagCompletion(c *cobra.Command, f *pflag.Flag) { + if f.Value.Type() == "bool" { + return + } + if _, ok := c.GetFlagCompletionFunc(f.Name); ok { + return + } + var fn cobra.CompletionFunc + switch { + case fixedFlagValues[f.Name] != nil: + fn = cobra.FixedCompletions(fixedFlagValues[f.Name], cobra.ShellCompDirectiveNoFileComp) + case fileFlagExtensions[f.Name] != nil: + fn = cobra.FixedCompletions(fileFlagExtensions[f.Name], cobra.ShellCompDirectiveFilterFileExt) + case f.Name == "output": + fn = cobra.FixedCompletions(nil, cobra.ShellCompDirectiveDefault) + case f.Name == "url": + fn = cobra.FixedCompletions(urlSchemes, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace) + case f.Name == "id" || f.Name == "model": + fn = completeModelIDs + case f.Name == "point": + fn = completePointNames + default: + fn = cobra.NoFileCompletions + } + _ = c.RegisterFlagCompletionFunc(f.Name, fn) +} + +// completeModelIDs offers the model IDs go-sunspec has a schema for, with the +// model label as the description. +func completeModelIDs(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + all := registry.All() + ids := make([]int, 0, len(all)) + for id := range all { + ids = append(ids, int(id)) + } + slices.Sort(ids) + out := make([]string, 0, len(ids)) + for _, id := range ids { + m := all[uint16(id)] + out = append(out, fmt.Sprintf("%d\t%s", id, m.Label)) + } + return out, cobra.ShellCompDirectiveNoFileComp +} + +// completePointNames offers the point names of the model given with --model. +func completePointNames(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + f := cmd.Flags().Lookup("model") + if f == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + id, err := strconv.ParseUint(f.Value.String(), 10, 16) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + m := registry.ByID(uint16(id)) + if m == nil || m.Group == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + out := make([]string, 0, len(m.Group.Points)) + for _, p := range m.Group.Points { + if p.Label != "" { + out = append(out, p.Name+"\t"+p.Label) + } else { + out = append(out, p.Name) + } + } + return out, cobra.ShellCompDirectiveNoFileComp +} diff --git a/completion_test.go b/completion_test.go new file mode 100644 index 0000000..0db19f5 --- /dev/null +++ b/completion_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "fmt" + "regexp" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// complete runs the hidden __complete command and returns the candidates +// (without descriptions) and the directive line. +func complete(t *testing.T, args ...string) ([]string, string) { + t.Helper() + r := runCLI(t, append([]string{"__complete"}, args...)...) + if r.err != nil { + t.Fatalf("__complete %v: %v", args, r.err) + } + var values []string + var directive string + for _, line := range strings.Split(strings.TrimSpace(r.stdout), "\n") { + if strings.HasPrefix(line, ":") { + directive = line + continue + } + v, _, _ := strings.Cut(line, "\t") + values = append(values, v) + } + return values, directive +} + +func wantCandidates(t *testing.T, args []string, want ...string) { + t.Helper() + got, _ := complete(t, args...) + for _, w := range want { + if !slices.Contains(got, w) { + t.Errorf("__complete %v = %v, missing %q", args, got, w) + } + } +} + +func TestCompleteShells(t *testing.T) { + got, _ := complete(t, "completion", "") + if want := []string{"bash", "zsh", "fish", "powershell"}; !slices.Equal(got, want) { + t.Fatalf("completion shells %v, want %v", got, want) + } +} + +func TestCompleteFlagValues(t *testing.T) { + wantCandidates(t, []string{"detect", "--parity", ""}, "none", "even", "odd") + wantCandidates(t, []string{"read", "--stop-bits", ""}, "1", "2") + wantCandidates(t, []string{"read", "--url", ""}, "tcp://", "rtu://") + wantCandidates(t, []string{"read-model", "--id", ""}, "1", "103") + wantCandidates(t, []string{"template", "--model", ""}, "1", "160") + wantCandidates(t, []string{"read-point", "--model", "1", "--point", ""}, "Mn", "Md", "SN") + wantCandidates(t, []string{"poll-point", "--model", "1", "--point", ""}, "Mn") + if got, _ := complete(t, "read-point", "--point", ""); len(got) != 0 { + t.Errorf("--point without --model offers %v", got) + } + + got, directive := complete(t, "serve", "--file", "") + if !slices.Equal(got, []string{"json"}) || directive != fmt.Sprintf(":%d", cobra.ShellCompDirectiveFilterFileExt) { + t.Errorf("serve --file: %v %s", got, directive) + } + if _, directive := complete(t, "poll", "--interval", ""); directive != fmt.Sprintf(":%d", cobra.ShellCompDirectiveNoFileComp) { + t.Errorf("poll --interval directive %s, want no file completion", directive) + } +} + +// valueSetUsage matches a usage string that lists the values of a flag. +var valueSetUsage = regexp.MustCompile(`\w+\|\w+|one of|: \w+, \w+,? (or|and) \w+|\w+ or \w+ \(`) + +// TestEveryValueFlagCompletes walks every command: a flag that takes a value +// has a completion function, so a flag whose usage lists a value set cannot +// lack one. +func TestEveryValueFlagCompletes(t *testing.T) { + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + check := func(f *pflag.Flag) { + if f.Value.Type() == "bool" || f.Name == "help" { + return + } + if _, ok := c.GetFlagCompletionFunc(f.Name); !ok { + kind := "value" + if valueSetUsage.MatchString(f.Usage) { + kind = "value set" + } + t.Errorf("%s --%s (%s flag) has no completion: %s", c.CommandPath(), f.Name, kind, f.Usage) + } + } + c.LocalFlags().VisitAll(check) + c.InheritedFlags().VisitAll(check) + for _, sub := range c.Commands() { + walk(sub) + } + } + walk(newRootCmd()) +} diff --git a/docs/json-output.md b/docs/json-output.md index 3391787..b4960dd 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -25,11 +25,13 @@ With `read` and `poll`, a model that cannot be read gives `warning: partial read ## Version +One line, not indented, the keys always present and in this order: + ```json -{ "version": "v0.8.0", "tag": "v0.8.0", "commit": "abc1234", "buildDate": "2026-10-31T10:00:00Z" } +{"name":"sunspecctl","version":"0.6.0","tag":"v0.6.0","commit":"1802d0f9afe8642d1e278120b76fcdfac967cb9e","built":"2026-11-07T10:00:00Z","go":"go1.26.10","platform":"linux/amd64"} ``` -`tag`, `commit` and `buildDate` are left out when the build did not set them. +`version` has no leading `v` and is `dev` for a development build; `tag` is `""` when the build has no tag; `commit` is the full SHA (with `-dirty` for a modified tree) or `unknown`; `built` is the RFC 3339 UTC build date or `unknown`; `platform` is `goos/goarch`. ## Detection diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index e6bf9e3..4aa2bd7 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -6,6 +6,7 @@ package e2e import ( "bytes" + "encoding/json" "errors" "os" "os/exec" @@ -46,8 +47,18 @@ func TestVersionPrintsTheBuiltVersion(t *testing.T) { if want == "" { t.Fatal("SUNSPECCTL_E2E_VERSION is not set; run make e2e") } - if !strings.Contains(output, "sunspecctl") || !strings.Contains(output, want) { - t.Fatalf("output does not name sunspecctl %s:\n%s", want, output) + want = strings.TrimPrefix(want, "v") + if !strings.HasPrefix(output, "sunspecctl "+want+" (") || strings.Count(output, "\n") != 1 { + t.Fatalf("output is not one line naming sunspecctl %s:\n%s", want, output) + } + + output, code = run(t, "version", "--json") + if code != 0 { + t.Fatalf("version --json: exit code %d, want 0\n%s", code, output) + } + var info map[string]string + if err := json.Unmarshal([]byte(output), &info); err != nil || info["name"] != "sunspecctl" || info["version"] != want { + t.Fatalf("version --json is not the build information of sunspecctl %s (%v):\n%s", want, err, output) } } diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..788e309 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT + +// Package buildinfo reports the version, tag, commit and build date of the binary on one +// line or as one JSON object. The release workflow sets the variables at link time with +// -X; a development build (go run, go build, go install) falls back on the module version +// and the VCS stamp of runtime/debug. +package buildinfo + +import ( + "encoding/json" + "regexp" + "runtime" + "runtime/debug" + "strings" +) + +// Set at link time: -X /internal/buildinfo.version=${VERSION} and so on. +var ( + version string + tag string + commit string + buildDate string +) + +// readBuildInfo is runtime/debug.ReadBuildInfo; the tests replace it. +var readBuildInfo = debug.ReadBuildInfo + +// Info is what `version` prints. The field order is the order of the JSON keys. +type Info struct { + Name string `json:"name"` + Version string `json:"version"` + Tag string `json:"tag"` + Commit string `json:"commit"` + Built string `json:"built"` + Go string `json:"go"` + Platform string `json:"platform"` +} + +// pseudoVersion matches the timestamp and commit of a Go pseudo-version +// (v0.0.0-20261107212210-dc108a41d537), which go build stamps from the VCS. +var pseudoVersion = regexp.MustCompile(`\d{14}-[0-9a-f]{12}`) + +// isRelease reports whether the module version go stamped is a released +// version (go install …@vX.Y.Z), not (devel), a pseudo-version or a build +// of a modified tree (+dirty). +func isRelease(v string) bool { + return v != "" && v != "(devel)" && !strings.HasSuffix(v, "+dirty") && !pseudoVersion.MatchString(v) +} + +// Get resolves the build information of the binary called name. +func Get(name string) Info { + info := Info{ + Name: name, + Version: strings.TrimPrefix(strings.TrimSpace(version), "v"), + Tag: strings.TrimSpace(tag), + Commit: strings.TrimSpace(commit), + Built: strings.TrimSpace(buildDate), + Go: runtime.Version(), + Platform: runtime.GOOS + "/" + runtime.GOARCH, + } + bi, ok := readBuildInfo() + if info.Version == "" && ok { + if v := bi.Main.Version; isRelease(v) { + info.Version = strings.TrimPrefix(v, "v") + } + } + if info.Commit == "" && ok { + var revision string + var modified bool + for _, s := range bi.Settings { + switch s.Key { + case "vcs.revision": + revision = s.Value + case "vcs.modified": + modified = s.Value == "true" + } + } + if revision != "" { + info.Commit = revision + if modified { + info.Commit += "-dirty" + } + } + } + if info.Version == "" { + info.Version = "dev" + } + if info.Commit == "" { + info.Commit = "unknown" + } + if info.Built == "" { + info.Built = "unknown" + } + return info +} + +// String is the text line: name version (tag …, commit …, built …, go, os/arch). The tag +// part is left out when no tag was set. +func (i Info) String() string { + var b strings.Builder + b.WriteString(i.Name + " " + i.Version + " (") + if i.Tag != "" { + b.WriteString("tag " + i.Tag + ", ") + } + b.WriteString("commit " + i.Commit + ", built " + i.Built + ", " + i.Go + ", " + i.Platform + ")") + return b.String() +} + +// JSON is the one-line JSON object, keys in the order name, version, tag, commit, built, +// go, platform. +func (i Info) JSON() string { + out, err := json.Marshal(i) + if err != nil { // cannot happen: every field is a string + return "{}" + } + return string(out) +} + +// Set replaces the link-time values, for a main package that receives them with -X main.*. +func Set(v, t, c, d string) { + version, tag, commit, buildDate = v, t, c, d +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..5f3969b --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT + +package buildinfo + +import ( + "runtime" + "runtime/debug" + "testing" +) + +func setVars(t *testing.T, v, tg, c, d string, bi *debug.BuildInfo) { + t.Helper() + ov, ot, oc, od, orb := version, tag, commit, buildDate, readBuildInfo + t.Cleanup(func() { version, tag, commit, buildDate, readBuildInfo = ov, ot, oc, od, orb }) + version, tag, commit, buildDate = v, tg, c, d + readBuildInfo = func() (*debug.BuildInfo, bool) { return bi, bi != nil } +} + +const platform = runtime.GOOS + "/" + runtime.GOARCH + +func TestTextAllFields(t *testing.T) { + setVars(t, "v3.5.1", "v3.5.1", "1802d0f9afe8642d1e278120b76fcdfac967cb9e", "2026-11-05T15:41:49Z", nil) + want := "tool 3.5.1 (tag v3.5.1, commit 1802d0f9afe8642d1e278120b76fcdfac967cb9e, built 2026-11-05T15:41:49Z, " + runtime.Version() + ", " + platform + ")" + if got := Get("tool").String(); got != want { + t.Fatalf("got %q\nwant %q", got, want) + } +} + +func TestTextWithoutTag(t *testing.T) { + setVars(t, "3.6.0-rc1", "", "abc", "2026-11-05T15:41:49Z", nil) + want := "tool 3.6.0-rc1 (commit abc, built 2026-11-05T15:41:49Z, " + runtime.Version() + ", " + platform + ")" + if got := Get("tool").String(); got != want { + t.Fatalf("got %q\nwant %q", got, want) + } +} + +func TestDevFallbackNothingInjected(t *testing.T) { + setVars(t, "", "", "", "", nil) + want := "tool dev (commit unknown, built unknown, " + runtime.Version() + ", " + platform + ")" + if got := Get("tool").String(); got != want { + t.Fatalf("got %q\nwant %q", got, want) + } +} + +func TestDevFallbackFromBuildInfo(t *testing.T) { + setVars(t, "", "", "", "", &debug.BuildInfo{ + Main: debug.Module{Version: "(devel)"}, + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "1802d0f9afe8642d1e278120b76fcdfac967cb9e"}, + {Key: "vcs.modified", Value: "true"}, + }, + }) + want := "tool dev (commit 1802d0f9afe8642d1e278120b76fcdfac967cb9e-dirty, built unknown, " + runtime.Version() + ", " + platform + ")" + if got := Get("tool").String(); got != want { + t.Fatalf("got %q\nwant %q", got, want) + } +} + +func TestGoInstallModuleVersion(t *testing.T) { + setVars(t, "", "", "", "", &debug.BuildInfo{ + Main: debug.Module{Version: "v3.5.1"}, + Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: "abc"}, {Key: "vcs.modified", Value: "false"}}, + }) + info := Get("tool") + if info.Version != "3.5.1" || info.Commit != "abc" || info.Tag != "" { + t.Fatalf("got %+v", info) + } +} + +func TestJSONKeyOrderAndValues(t *testing.T) { + setVars(t, "3.5.1", "v3.5.1", "abc", "2026-11-05T15:41:49Z", nil) + want := `{"name":"tool","version":"3.5.1","tag":"v3.5.1","commit":"abc","built":"2026-11-05T15:41:49Z","go":"` + runtime.Version() + `","platform":"` + platform + `"}` + if got := Get("tool").JSON(); got != want { + t.Fatalf("got %s\nwant %s", got, want) + } + setVars(t, "", "", "", "", nil) + want = `{"name":"tool","version":"dev","tag":"","commit":"unknown","built":"unknown","go":"` + runtime.Version() + `","platform":"` + platform + `"}` + if got := Get("tool").JSON(); got != want { + t.Fatalf("got %s\nwant %s", got, want) + } +} + +func TestPseudoAndDirtyModuleVersionsAreDev(t *testing.T) { + for _, v := range []string{"(devel)", "v0.0.0-20261107212210-dc108a41d537+dirty", "v3.5.2-0.20261107212210-dc108a41d537", "v3.5.1+dirty"} { + setVars(t, "", "", "", "", &debug.BuildInfo{Main: debug.Module{Version: v}}) + if got := Get("tool").Version; got != "dev" { + t.Errorf("module version %q: got %q, want dev", v, got) + } + } +} diff --git a/main.go b/main.go index ba2675f..bfdca1b 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( "github.com/otfabric/go-modbus" "github.com/otfabric/go-sunspec" + "github.com/otfabric/sunspecctl/internal/buildinfo" "github.com/spf13/cobra" ) @@ -39,8 +40,8 @@ var ( flagParity string flagStopBits uint - // Build metadata set at build time via -ldflags. - version = "dev" + // Build metadata set at build time via -ldflags; see internal/buildinfo. + version = "" tag = "" commit = "" buildDate = "" @@ -89,12 +90,10 @@ func newRootCmd() *cobra.Command { root.PersistentFlags().StringVar(&flagTLSCert, "tls-cert", "", "Certificate (PEM) this side presents, for tcp+tls:// URLs") root.PersistentFlags().StringVar(&flagTLSKey, "tls-key", "", "Private key (PEM) of --tls-cert") root.PersistentFlags().StringVar(&flagTLSCA, "tls-ca", "", "CA certificates (PEM) the other side's certificate must chain to") - _ = root.RegisterFlagCompletionFunc("parity", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { - return []string{"none", "even", "odd"}, cobra.ShellCompDirectiveNoFileComp - }) root.AddCommand(detectCmd(), modelsCmd(), readCmd(), readModelCmd(), readPointCmd(), pollCmd(), pollModelCmd(), pollPointCmd(), serveCmd(), captureCmd(), templateCmd(), completionCmd(root), versionCmd()) + registerCompletions(root) // Errors can quote what a device or a file holds: escape what a terminal // would act on before cobra prints them. @@ -112,28 +111,15 @@ func completionCmd(root *cobra.Command) *cobra.Command { return &cobra.Command{ Use: "completion [bash|zsh|fish|powershell]", Short: "Generate shell completion script", - Long: `Generate a shell completion script for sunspecctl. - -To load completions: - - # Bash (add to ~/.bashrc for persistence) - source <(sunspecctl completion bash) - - # Zsh (add to ~/.zshrc for persistence) - source <(sunspecctl completion zsh) - - # Fish - sunspecctl completion fish | source - - # PowerShell - sunspecctl completion powershell | Out-String | Invoke-Expression`, + Long: `Generate a shell completion script for sunspecctl: bash, zsh, fish or +PowerShell. Installation: see "Shell completion" in the README.`, DisableFlagsInUseLine: true, ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), RunE: func(cmd *cobra.Command, args []string) error { switch args[0] { case "bash": - return root.GenBashCompletion(cmd.OutOrStdout()) + return root.GenBashCompletionV2(cmd.OutOrStdout(), true) case "zsh": return root.GenZshCompletion(cmd.OutOrStdout()) case "fish": @@ -220,36 +206,22 @@ func discoverOpts() *sunspec.DiscoverOptions { // --- version --- -// versionCmd returns the command that prints the build metadata. +// versionCmd returns the command that prints the build metadata: one line, +// or one JSON object with --json or SUNSPECCTL_FORMAT=json. func versionCmd() *cobra.Command { return &cobra.Command{ Use: "version", Short: "Print the sunspecctl version", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - out := newOutputWriter(cmd.OutOrStdout()) + buildinfo.Set(version, tag, commit, buildDate) + info := buildinfo.Get("sunspecctl") + line := info.String() if flagJSON { - info := map[string]string{"version": version} - if tag != "" { - info["tag"] = tag - } - if commit != "" { - info["commit"] = commit - } - if buildDate != "" { - info["buildDate"] = buildDate - } - return printJSON(out, info) - } - _, _ = fmt.Fprintf(out, "sunspecctl %s\n", version) - if tag != "" { - _, _ = fmt.Fprintf(out, "tag: %s\n", tag) - } - if commit != "" { - _, _ = fmt.Fprintf(out, "commit: %s\n", commit) - } - if buildDate != "" { - _, _ = fmt.Fprintf(out, "built: %s\n", buildDate) + line = info.JSON() } + out := newOutputWriter(cmd.OutOrStdout()) + _, _ = fmt.Fprintln(out, line) return out.result() }, } diff --git a/main_test.go b/main_test.go index d49ff30..d86b3ca 100644 --- a/main_test.go +++ b/main_test.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "regexp" + "runtime" "strconv" "strings" "testing" @@ -1177,7 +1178,7 @@ func TestHelp(t *testing.T) { t.Errorf("read-model help:\n%s", r.stdout) } r = runCLI(t, "completion", "--help") - if !strings.Contains(r.stdout, " source <(sunspecctl completion bash)\n") || + if !strings.Contains(r.stdout, "PowerShell. Installation: see \"Shell completion\" in the README.\n") || !strings.Contains(r.stdout, " sunspecctl completion [bash|zsh|fish|powershell]\n") { t.Errorf("completion help:\n%s", r.stdout) } @@ -1185,7 +1186,7 @@ func TestHelp(t *testing.T) { func TestCompletion(t *testing.T) { headers := map[string]string{ - "bash": "# bash completion for sunspecctl", + "bash": "# bash completion V2 for sunspecctl", "zsh": "#compdef sunspecctl", "fish": "# fish completion for sunspecctl", "powershell": "# powershell completion for sunspecctl", @@ -1204,13 +1205,13 @@ func TestCompletion(t *testing.T) { } }) } - // The bash script knows the commands and flags of this tool. - bash := runCLI(t, "completion", "bash").stdout - for _, want := range []string{"poll-point", "read-model", "--unit-id", "--interval"} { - if !strings.Contains(bash, want) { - t.Errorf("bash completion does not mention %s", want) - } + // The bash script asks the binary (__complete), which knows the commands + // and flags of this tool. + if bash := runCLI(t, "completion", "bash").stdout; !strings.Contains(bash, "__complete") { + t.Error("bash completion does not call __complete") } + wantCandidates(t, []string{""}, "poll-point", "read-model") + wantCandidates(t, []string{"poll", "--"}, "--unit-id", "--interval") } // setBuildInfo overrides the link-time build metadata for one test. @@ -1222,41 +1223,29 @@ func setBuildInfo(t *testing.T, v, tg, c, d string) { } func TestVersion(t *testing.T) { - t.Run("defaults", func(t *testing.T) { - setBuildInfo(t, "dev", "", "", "") - wantOK(t, runCLI(t, "version"), "sunspecctl dev\n") - wantOK(t, runCLI(t, "version", "--json"), "{\n \"version\": \"dev\"\n}\n") - }) + tail := runtime.Version() + ", " + runtime.GOOS + "/" + runtime.GOARCH + jsonTail := `"go":"` + runtime.Version() + `","platform":"` + runtime.GOOS + "/" + runtime.GOARCH + `"}` + "\n" t.Run("full build metadata", func(t *testing.T) { - setBuildInfo(t, "v1.2.3-4-gabc1234", "v1.2.3", "abc1234", "2026-01-02T03:04:05Z") - wantOK(t, runCLI(t, "version"), lines( - "sunspecctl v1.2.3-4-gabc1234", - "tag: v1.2.3", - "commit: abc1234", - "built: 2026-01-02T03:04:05Z", - )) - var info map[string]string - decodeJSON(t, runCLI(t, "--json", "version"), &info) - want := map[string]string{ - "version": "v1.2.3-4-gabc1234", "tag": "v1.2.3", "commit": "abc1234", "buildDate": "2026-01-02T03:04:05Z", - } - if len(info) != len(want) { - t.Errorf("version --json = %v, want %v", info, want) - } - for k, v := range want { - if info[k] != v { - t.Errorf("version --json: %s = %q, want %q", k, info[k], v) - } - } + setBuildInfo(t, "0.6.0", "v0.6.0", "1802d0f9afe8642d1e278120b76fcdfac967cb9e", "2026-01-02T03:04:05Z") + text := "sunspecctl 0.6.0 (tag v0.6.0, commit 1802d0f9afe8642d1e278120b76fcdfac967cb9e, built 2026-01-02T03:04:05Z, " + tail + ")\n" + wantOK(t, runCLI(t, "version"), text) + js := `{"name":"sunspecctl","version":"0.6.0","tag":"v0.6.0","commit":"1802d0f9afe8642d1e278120b76fcdfac967cb9e","built":"2026-01-02T03:04:05Z",` + jsonTail + wantOK(t, runCLI(t, "version", "--json"), js) + wantOK(t, runCLI(t, "--json", "version"), js) + t.Setenv("SUNSPECCTL_FORMAT", "json") + wantOK(t, runCLI(t, "version"), js) + t.Setenv("SUNSPECCTL_FORMAT", "text") + wantOK(t, runCLI(t, "version"), text) }) - t.Run("partial build metadata", func(t *testing.T) { - setBuildInfo(t, "v9", "", "deadbee", "") - wantOK(t, runCLI(t, "version"), "sunspecctl v9\ncommit: deadbee\n") - wantOK(t, runCLI(t, "version", "--json"), "{\n \"commit\": \"deadbee\",\n \"version\": \"v9\"\n}\n") + t.Run("without tag", func(t *testing.T) { + setBuildInfo(t, "v0.6.0-4-gabc1234", "", "abc1234", "2026-01-02T03:04:05Z") + wantOK(t, runCLI(t, "version"), "sunspecctl 0.6.0-4-gabc1234 (commit abc1234, built 2026-01-02T03:04:05Z, "+tail+")\n") + wantOK(t, runCLI(t, "version", "--json"), + `{"name":"sunspecctl","version":"0.6.0-4-gabc1234","tag":"","commit":"abc1234","built":"2026-01-02T03:04:05Z",`+jsonTail) }) t.Run("needs no device", func(t *testing.T) { - setBuildInfo(t, "dev", "", "", "") - wantOK(t, runCLI(t, "version", "--url", "tcp://127.0.0.1:1"), "sunspecctl dev\n") + setBuildInfo(t, "0.6.0", "", "abc", "x") + wantOK(t, runCLI(t, "version", "--url", "tcp://127.0.0.1:1"), "sunspecctl 0.6.0 (commit abc, built x, "+tail+")\n") }) } @@ -1396,7 +1385,7 @@ func TestMainInProcess(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.HasPrefix(string(got), "sunspecctl "+version+"\n") { + if !strings.HasPrefix(string(got), "sunspecctl dev (commit ") || strings.Count(string(got), "\n") != 1 { t.Errorf("main printed %q", got) } } From 48de6a31b3a9c25dee761756a43be67ad2052abc Mon Sep 17 00:00:00 2001 From: Bart Van Bos Date: Sat, 7 Nov 2026 23:02:31 +0100 Subject: [PATCH 2/3] feat: OT Fabric libraries in version output; dev off a tag version names the github.com/otfabric/go-* modules the binary is built with after the build date, and version --json has a libraries array between built and go. Pseudo-versions and +dirty versions from the build info report dev. make build stamps VERSION and TAG only when HEAD is exactly on a tag; otherwise the binary reports dev. Co-Authored-By: Claude Opus 5.5 --- Makefile | 4 +- RELEASE.md | 4 +- docs/json-output.md | 4 +- e2e/e2e_test.go | 10 +++-- internal/buildinfo/buildinfo.go | 61 +++++++++++++++++++++++----- internal/buildinfo/buildinfo_test.go | 55 ++++++++++++++++++++++++- main_test.go | 20 ++++++++- 7 files changed, 134 insertions(+), 24 deletions(-) diff --git a/Makefile b/Makefile index e74ca6e..dffcaf4 100644 --- a/Makefile +++ b/Makefile @@ -20,8 +20,8 @@ APP_SRC = . ARCHS = linux/amd64 linux/arm64 linux/arm/v7 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 RELEASE_DIR = release # Override on the command line or in CI (matches the ldflags of the release workflow). -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo "") +VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null) +TAG ?= $(shell git describe --tags --exact-match 2>/dev/null) COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo "") BUILD_DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') LDFLAGS = -ldflags "-s -w -X main.version=$(VERSION) -X main.tag=$(TAG) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)" diff --git a/RELEASE.md b/RELEASE.md index c60a14b..3df33f3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -11,8 +11,8 @@ ### Changed -- **Breaking for scripts that read `version`:** `version` prints one line, `sunspecctl 0.6.0 (tag v0.6.0, commit , built , go1.26.10, linux/amd64)`, instead of a `tag:`, `commit:` and `built:` block; the version has no leading `v`. `version --json` (and `--json version`, `SUNSPECCTL_FORMAT=json`) prints one line with the keys `name`, `version`, `tag`, `commit`, `built`, `go`, `platform`, always present and in this order; `buildDate` is now `built`. A development build reports `dev` and the commit from the Go VCS stamp (`-dirty` for a modified tree); `go install …@vX.Y.Z` reports that version. -- `make build` stamps the full commit SHA, and a tag only when the commit is tagged. +- **Breaking for scripts that read `version`:** `version` prints one line, `sunspecctl 0.6.0 (tag v0.6.0, commit , built , go-modbus 1.11.1, go-serial 0.2.1, go-sunspec 0.10.0, go1.26.10, linux/amd64)`, with the OT Fabric libraries it is built with,, instead of a `tag:`, `commit:` and `built:` block; the version has no leading `v`. `version --json` (and `--json version`, `SUNSPECCTL_FORMAT=json`) prints one line with the keys `name`, `version`, `tag`, `commit`, `built`, `libraries`, `go`, `platform`, always present and in this order; `buildDate` is now `built`. A development build reports `dev` and the commit from the Go VCS stamp (`-dirty` for a modified tree); `go install …@vX.Y.Z` reports that version. +- `make build` stamps a version and tag only when HEAD is a tag (a build off a tag reports `dev`), and the full commit SHA. --- diff --git a/docs/json-output.md b/docs/json-output.md index b4960dd..3c1bbb3 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -28,10 +28,10 @@ With `read` and `poll`, a model that cannot be read gives `warning: partial read One line, not indented, the keys always present and in this order: ```json -{"name":"sunspecctl","version":"0.6.0","tag":"v0.6.0","commit":"1802d0f9afe8642d1e278120b76fcdfac967cb9e","built":"2026-11-07T10:00:00Z","go":"go1.26.10","platform":"linux/amd64"} +{"name":"sunspecctl","version":"0.6.0","tag":"v0.6.0","commit":"1802d0f9afe8642d1e278120b76fcdfac967cb9e","built":"2026-11-07T10:00:00Z","libraries":[{"name":"go-modbus","version":"1.11.1"},{"name":"go-serial","version":"0.2.1"},{"name":"go-sunspec","version":"0.10.0"}],"go":"go1.26.10","platform":"linux/amd64"} ``` -`version` has no leading `v` and is `dev` for a development build; `tag` is `""` when the build has no tag; `commit` is the full SHA (with `-dirty` for a modified tree) or `unknown`; `built` is the RFC 3339 UTC build date or `unknown`; `platform` is `goos/goarch`. +`version` has no leading `v` and is `dev` for a development build; `tag` is `""` when the build has no tag; `commit` is the full SHA (with `-dirty` for a modified tree) or `unknown`; `built` is the RFC 3339 UTC build date or `unknown`; `libraries` are the OT Fabric libraries the binary is built with (`[]` when none); `platform` is `goos/goarch`. ## Detection diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 4aa2bd7..c9e05f8 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -43,10 +43,14 @@ func TestVersionPrintsTheBuiltVersion(t *testing.T) { if code != 0 { t.Fatalf("exit code %d, want 0\n%s", code, output) } - want := os.Getenv("SUNSPECCTL_E2E_VERSION") - if want == "" { + // make e2e sets it to the tag of HEAD, empty off a tag: then the binary is a dev build. + want, ok := os.LookupEnv("SUNSPECCTL_E2E_VERSION") + if !ok { t.Fatal("SUNSPECCTL_E2E_VERSION is not set; run make e2e") } + if want == "" { + want = "dev" + } want = strings.TrimPrefix(want, "v") if !strings.HasPrefix(output, "sunspecctl "+want+" (") || strings.Count(output, "\n") != 1 { t.Fatalf("output is not one line naming sunspecctl %s:\n%s", want, output) @@ -56,7 +60,7 @@ func TestVersionPrintsTheBuiltVersion(t *testing.T) { if code != 0 { t.Fatalf("version --json: exit code %d, want 0\n%s", code, output) } - var info map[string]string + var info map[string]any if err := json.Unmarshal([]byte(output), &info); err != nil || info["name"] != "sunspecctl" || info["version"] != want { t.Fatalf("version --json is not the build information of sunspecctl %s (%v):\n%s", want, err, output) } diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index 788e309..43038a5 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -11,6 +11,7 @@ import ( "regexp" "runtime" "runtime/debug" + "sort" "strings" ) @@ -27,13 +28,43 @@ var readBuildInfo = debug.ReadBuildInfo // Info is what `version` prints. The field order is the order of the JSON keys. type Info struct { - Name string `json:"name"` - Version string `json:"version"` - Tag string `json:"tag"` - Commit string `json:"commit"` - Built string `json:"built"` - Go string `json:"go"` - Platform string `json:"platform"` + Name string `json:"name"` + Version string `json:"version"` + Tag string `json:"tag"` + Commit string `json:"commit"` + Built string `json:"built"` + Libraries []Library `json:"libraries"` + Go string `json:"go"` + Platform string `json:"platform"` +} + +// Library is an OT Fabric library (a github.com/otfabric/go-* module) the +// binary is built with. +type Library struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// libraryPrefix selects the OT Fabric libraries among the dependencies. +const libraryPrefix = "github.com/otfabric/go-" + +// libraries returns the OT Fabric libraries of the build, sorted by name: the +// replacement when a module is replaced, never the main module itself. +func libraries(bi *debug.BuildInfo) []Library { + libs := []Library{} + for _, d := range bi.Deps { + m := d + if d.Replace != nil && d.Replace.Version != "" { + m = d.Replace // a replacement by a local directory has no version + } + if !strings.HasPrefix(d.Path, libraryPrefix) || d.Path == bi.Main.Path { + continue + } + name := d.Path[strings.LastIndex(d.Path, "/")+1:] + libs = append(libs, Library{Name: name, Version: strings.TrimPrefix(m.Version, "v")}) + } + sort.Slice(libs, func(i, j int) bool { return libs[i].Name < libs[j].Name }) + return libs } // pseudoVersion matches the timestamp and commit of a Go pseudo-version @@ -59,6 +90,10 @@ func Get(name string) Info { Platform: runtime.GOOS + "/" + runtime.GOARCH, } bi, ok := readBuildInfo() + info.Libraries = []Library{} + if ok { + info.Libraries = libraries(bi) + } if info.Version == "" && ok { if v := bi.Main.Version; isRelease(v) { info.Version = strings.TrimPrefix(v, "v") @@ -94,20 +129,24 @@ func Get(name string) Info { return info } -// String is the text line: name version (tag …, commit …, built …, go, os/arch). The tag -// part is left out when no tag was set. +// String is the text line: name version (tag …, commit …, built …, libraries…, go, +// os/arch). The tag part is left out when no tag was set, the libraries when there are none. func (i Info) String() string { var b strings.Builder b.WriteString(i.Name + " " + i.Version + " (") if i.Tag != "" { b.WriteString("tag " + i.Tag + ", ") } - b.WriteString("commit " + i.Commit + ", built " + i.Built + ", " + i.Go + ", " + i.Platform + ")") + b.WriteString("commit " + i.Commit + ", built " + i.Built + ", ") + for _, l := range i.Libraries { + b.WriteString(l.Name + " " + l.Version + ", ") + } + b.WriteString(i.Go + ", " + i.Platform + ")") return b.String() } // JSON is the one-line JSON object, keys in the order name, version, tag, commit, built, -// go, platform. +// libraries, go, platform. func (i Info) JSON() string { out, err := json.Marshal(i) if err != nil { // cannot happen: every field is a string diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go index 5f3969b..f72fa58 100644 --- a/internal/buildinfo/buildinfo_test.go +++ b/internal/buildinfo/buildinfo_test.go @@ -69,12 +69,12 @@ func TestGoInstallModuleVersion(t *testing.T) { func TestJSONKeyOrderAndValues(t *testing.T) { setVars(t, "3.5.1", "v3.5.1", "abc", "2026-11-05T15:41:49Z", nil) - want := `{"name":"tool","version":"3.5.1","tag":"v3.5.1","commit":"abc","built":"2026-11-05T15:41:49Z","go":"` + runtime.Version() + `","platform":"` + platform + `"}` + want := `{"name":"tool","version":"3.5.1","tag":"v3.5.1","commit":"abc","built":"2026-11-05T15:41:49Z","libraries":[],"go":"` + runtime.Version() + `","platform":"` + platform + `"}` if got := Get("tool").JSON(); got != want { t.Fatalf("got %s\nwant %s", got, want) } setVars(t, "", "", "", "", nil) - want = `{"name":"tool","version":"dev","tag":"","commit":"unknown","built":"unknown","go":"` + runtime.Version() + `","platform":"` + platform + `"}` + want = `{"name":"tool","version":"dev","tag":"","commit":"unknown","built":"unknown","libraries":[],"go":"` + runtime.Version() + `","platform":"` + platform + `"}` if got := Get("tool").JSON(); got != want { t.Fatalf("got %s\nwant %s", got, want) } @@ -88,3 +88,54 @@ func TestPseudoAndDirtyModuleVersionsAreDev(t *testing.T) { } } } + +func TestLibrariesFromBuildInfo(t *testing.T) { + setVars(t, "1.0.0", "", "abc", "x", &debug.BuildInfo{ + Main: debug.Module{Path: "github.com/otfabric/go-self", Version: "(devel)"}, + Deps: []*debug.Module{ + {Path: "github.com/otfabric/go-modbus", Version: "v1.11.1"}, + {Path: "github.com/spf13/cobra", Version: "v1.10.2"}, + {Path: "github.com/otfabric/go-canbus", Version: "v0.8.0", Replace: &debug.Module{Path: "../go-canbus", Version: "v0.8.1"}}, + {Path: "github.com/otfabric/go-self", Version: "v9.9.9"}, + {Path: "github.com/otfabric/go-x", Version: "v0.0.0-20261107212210-dc108a41d537"}, + }, + }) + got := Get("tool").Libraries + want := []Library{{"go-canbus", "0.8.1"}, {"go-modbus", "1.11.1"}, {"go-x", "0.0.0-20261107212210-dc108a41d537"}} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestLibrariesFormatting(t *testing.T) { + base := Info{Name: "tool", Version: "1.0.0", Tag: "v1.0.0", Commit: "abc", Built: "2026-11-05T15:41:49Z", Go: "go1.27.3", Platform: "darwin/arm64"} + cases := []struct { + libs []Library + text, json string + }{ + {[]Library{}, + "tool 1.0.0 (tag v1.0.0, commit abc, built 2026-11-05T15:41:49Z, go1.27.3, darwin/arm64)", + `{"name":"tool","version":"1.0.0","tag":"v1.0.0","commit":"abc","built":"2026-11-05T15:41:49Z","libraries":[],"go":"go1.27.3","platform":"darwin/arm64"}`}, + {[]Library{{"go-canbus", "0.8.0"}}, + "tool 1.0.0 (tag v1.0.0, commit abc, built 2026-11-05T15:41:49Z, go-canbus 0.8.0, go1.27.3, darwin/arm64)", + `{"name":"tool","version":"1.0.0","tag":"v1.0.0","commit":"abc","built":"2026-11-05T15:41:49Z","libraries":[{"name":"go-canbus","version":"0.8.0"}],"go":"go1.27.3","platform":"darwin/arm64"}`}, + {[]Library{{"go-canbus", "0.8.0"}, {"go-canopen", "0.5.1"}}, + "tool 1.0.0 (tag v1.0.0, commit abc, built 2026-11-05T15:41:49Z, go-canbus 0.8.0, go-canopen 0.5.1, go1.27.3, darwin/arm64)", + `{"name":"tool","version":"1.0.0","tag":"v1.0.0","commit":"abc","built":"2026-11-05T15:41:49Z","libraries":[{"name":"go-canbus","version":"0.8.0"},{"name":"go-canopen","version":"0.5.1"}],"go":"go1.27.3","platform":"darwin/arm64"}`}, + } + for _, c := range cases { + i := base + i.Libraries = c.libs + if got := i.String(); got != c.text { + t.Errorf("text\n got %q\n want %q", got, c.text) + } + if got := i.JSON(); got != c.json { + t.Errorf("json\n got %s\n want %s", got, c.json) + } + } +} diff --git a/main_test.go b/main_test.go index d86b3ca..c5449b7 100644 --- a/main_test.go +++ b/main_test.go @@ -23,6 +23,7 @@ import ( "github.com/otfabric/go-sunspec/registry" "github.com/otfabric/go-sunspec/testutil" + "github.com/otfabric/sunspecctl/internal/buildinfo" ) // freePort returns a TCP port on 127.0.0.1 that was free a moment ago. @@ -1223,8 +1224,8 @@ func setBuildInfo(t *testing.T, v, tg, c, d string) { } func TestVersion(t *testing.T) { - tail := runtime.Version() + ", " + runtime.GOOS + "/" + runtime.GOARCH - jsonTail := `"go":"` + runtime.Version() + `","platform":"` + runtime.GOOS + "/" + runtime.GOARCH + `"}` + "\n" + tail := libsText("sunspecctl") + runtime.Version() + ", " + runtime.GOOS + "/" + runtime.GOARCH + jsonTail := libsJSON("sunspecctl") + `"go":"` + runtime.Version() + `","platform":"` + runtime.GOOS + "/" + runtime.GOARCH + `"}` + "\n" t.Run("full build metadata", func(t *testing.T) { setBuildInfo(t, "0.6.0", "v0.6.0", "1802d0f9afe8642d1e278120b76fcdfac967cb9e", "2026-01-02T03:04:05Z") text := "sunspecctl 0.6.0 (tag v0.6.0, commit 1802d0f9afe8642d1e278120b76fcdfac967cb9e, built 2026-01-02T03:04:05Z, " + tail + ")\n" @@ -1389,3 +1390,18 @@ func TestMainInProcess(t *testing.T) { t.Errorf("main printed %q", got) } } + +// libsText and libsJSON are how the OT Fabric libraries of this test binary +// appear in the version output (internal/buildinfo tests the formatting). +func libsText(name string) string { + var s string + for _, l := range buildinfo.Get(name).Libraries { + s += l.Name + " " + l.Version + ", " + } + return s +} + +func libsJSON(name string) string { + out, _ := json.Marshal(buildinfo.Get(name).Libraries) + return `"libraries":` + string(out) + `,` +} From f915248596eba4ca9e235f13f7d2e83f1f398515 Mon Sep 17 00:00:00 2001 From: Bart Van Bos Date: Sat, 7 Nov 2026 23:11:51 +0100 Subject: [PATCH 3/3] refactor: buildinfo JSON without an unreachable error branch Co-Authored-By: Claude Opus 5.5 --- internal/buildinfo/buildinfo.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index 43038a5..2b35e3e 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -148,10 +148,7 @@ func (i Info) String() string { // JSON is the one-line JSON object, keys in the order name, version, tag, commit, built, // libraries, go, platform. func (i Info) JSON() string { - out, err := json.Marshal(i) - if err != nil { // cannot happen: every field is a string - return "{}" - } + out, _ := json.Marshal(i) // cannot fail: strings and a slice of strings return string(out) }