diff --git a/Makefile b/Makefile index 11a7416..b28270f 100644 --- a/Makefile +++ b/Makefile @@ -22,9 +22,9 @@ BINARY = $(BIN_DIR)/$(APP_NAME) ARCHS = linux/amd64 linux/arm64 linux/arm/v7 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 # Override on the command line or in CI (matches reusable release workflow ldflags). -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") -TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo "") -COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +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 "unknown") 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)'" @@ -117,14 +117,14 @@ build-all: ## Build the application for all architectures cpu=$${rest%%/*}; \ variant=$${rest#*/}; \ if [ "$$os" = "windows" ]; then \ - echo "Building $(APP_NAME)-$(VERSION)-$$os-$$cpu.exe..."; \ - GOOS=$$os GOARCH=$$cpu go build $(LDFLAGS) -o $(RELEASE_DIR)/$(APP_NAME)-$(VERSION)-$$os-$$cpu.exe $(APP_SRC); \ + echo "Building $(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu.exe..."; \ + GOOS=$$os GOARCH=$$cpu go build $(LDFLAGS) -o $(RELEASE_DIR)/$(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu.exe $(APP_SRC); \ elif [ "$$cpu" = "arm" ] && [ "$$variant" = "v7" ]; then \ - echo "Building $(APP_NAME)-$(VERSION)-$$os-armv7..."; \ - GOOS=$$os GOARCH=$$cpu GOARM=7 go build $(LDFLAGS) -o $(RELEASE_DIR)/$(APP_NAME)-$(VERSION)-$$os-armv7 $(APP_SRC); \ + echo "Building $(APP_NAME)-$(or $(VERSION),dev)-$$os-armv7..."; \ + GOOS=$$os GOARCH=$$cpu GOARM=7 go build $(LDFLAGS) -o $(RELEASE_DIR)/$(APP_NAME)-$(or $(VERSION),dev)-$$os-armv7 $(APP_SRC); \ else \ - echo "Building $(APP_NAME)-$(VERSION)-$$os-$$cpu..."; \ - GOOS=$$os GOARCH=$$cpu go build $(LDFLAGS) -o $(RELEASE_DIR)/$(APP_NAME)-$(VERSION)-$$os-$$cpu $(APP_SRC); \ + echo "Building $(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu..."; \ + GOOS=$$os GOARCH=$$cpu go build $(LDFLAGS) -o $(RELEASE_DIR)/$(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu $(APP_SRC); \ fi \ done @@ -136,15 +136,15 @@ release-all: build-all ## Package the build binaries into tar.gz (Unix) or zip ( cpu=$${rest%%/*}; \ variant=$${rest#*/}; \ if [ "$$os" = "windows" ]; then \ - bin=$(APP_NAME)-$(VERSION)-$$os-$$cpu.exe; \ - echo "Packaging $$bin into $(APP_NAME)-$(VERSION)-$$os-$$cpu.zip..."; \ - cd $(RELEASE_DIR) && zip -q $(APP_NAME)-$(VERSION)-$$os-$$cpu.zip $$bin && cd - > /dev/null; \ + bin=$(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu.exe; \ + echo "Packaging $$bin into $(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu.zip..."; \ + cd $(RELEASE_DIR) && zip -q $(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu.zip $$bin && cd - > /dev/null; \ elif [ "$$cpu" = "arm" ] && [ "$$variant" = "v7" ]; then \ - bin=$(APP_NAME)-$(VERSION)-$$os-armv7; \ + bin=$(APP_NAME)-$(or $(VERSION),dev)-$$os-armv7; \ echo "Packaging $$bin.tar.gz..."; \ tar czf $(RELEASE_DIR)/$$bin.tar.gz -C $(RELEASE_DIR) $$bin; \ else \ - bin=$(APP_NAME)-$(VERSION)-$$os-$$cpu; \ + bin=$(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu; \ echo "Packaging $$bin.tar.gz..."; \ tar czf $(RELEASE_DIR)/$$bin.tar.gz -C $(RELEASE_DIR) $$bin; \ fi; \ diff --git a/README.md b/README.md index 3830fe1..78cd1c4 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Linux (amd64, arm64, armv7), macOS (Intel, Apple silicon) and Windows (amd64, ar ### Shell completion -Completion knows the subcommands and flags, and the values of enum flags such as **`--format`** (text/json/table), **`--algo`**, **`--function`**, **`--codec`**, **`--parity`**, **`--regtype`** and **`--sub-function`**. +Completion knows the subcommands and flags, the values of enum flags such as **`--format`** (text/json/table), **`--algo`**, **`--function`**, **`--codec`**, **`--parity`**, **`--regtype`**, **`--sub-function`**, **`--missing`** and **`--family`**, the URL schemes of **`--url`**, and capture, profile and certificate files for **`--input`**, **`--profile`** and **`--tls-*`**. ```bash # bash (needs the bash-completion package) @@ -237,7 +237,7 @@ modbusctl │ └── frequencies Detect ~50 Hz frequency data in captured registers ├── discover Discover Modbus devices on subnets ├── completion Generate shell completion script (bash|zsh|fish|powershell) -└── version Print version +└── version Print version, tag, commit and build date (--json) ``` --- @@ -303,6 +303,14 @@ modbusctl discover modbusctl version ``` +`modbusctl version` prints one line; `--json` (or `MODBUSCTL_FORMAT=json`) prints the same as one JSON object ([json-output.md](docs/json-output.md)): + +```text +modbusctl 3.6.0 (tag v3.6.0, commit 1802d0f9afe8642d1e278120b76fcdfac967cb9e, built 2026-11-07T10:00:00Z, go-modbus 1.11.1, go-modbus-capture 0.1.0, go-serial 0.2.1, go1.26.10, linux/amd64) +``` + +After the build date come the OT Fabric libraries the binary is built with. A development build (`go build`, `go run`, `make build` off a tag) says `dev` and takes the commit from the Go VCS stamp, with `-dirty` for a modified tree; `go install …@vX.Y.Z` reports that version. + ### Transports and serial settings Every `modbusctl client` command talks to the device named by `--url`. The scheme selects the transport: diff --git a/RELEASE.md b/RELEASE.md index 96faf4c..ea7a4c3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,22 @@ # modbusctl Releases +## v3.6.0 + +**Date:** 2026-11-07 +**Previous release:** v3.5.1 + +### Added + +- `version --json` prints the build information as one JSON object (`name`, `version`, `tag`, `commit`, `built`, `libraries`, `go`, `platform`, always in this order); `MODBUSCTL_FORMAT=json` does the same. +- Shell completion completes the value of every flag that takes one: the enum flags that had no completion (`--function` of the `capture` commands, `--missing`, `--before-first`, `--retry-timeout`, `--data-bits`, `--stop-bits`, `--baud`, `--family`, `--value-kind`), the URL schemes of `--url`, network interfaces for `--interface`, `.mbcap`/`.jsonl`/`.csv` files for `--input`, JSON files for `--profile` and `--blocks-file`, PEM files for `--tls-ca`, `--tls-cert` and `--tls-key`; numbers and addresses no longer complete file names. + +### Changed + +- `version` prints one line, `modbusctl 3.6.0 (tag v3.6.0, commit , built , go-modbus 1.11.1, go-modbus-capture 0.1.0, go-serial 0.2.1, go1.26.10, linux/amd64)`, with the OT Fabric libraries it is built with,, instead of a block of `version:`, `tag:`, `commit:` and `build date:` lines; the version has no leading `v`. 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. Scripts that parsed the old lines should use `version --json`. +- `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. + +--- + ## v3.5.1 **Date:** 2026-11-05 diff --git a/cmd/completion.go b/cmd/completion.go index 6bdb975..076ace9 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -21,7 +21,7 @@ var completionShells = []string{completionBash, completionZsh, completionFish, c var completionCmd = &cobra.Command{ Use: "completion [bash|zsh|fish|powershell]", Short: "Generate shell completion script", - Long: "Output a completion script for the given shell. Install with: Bash: source <(modbusctl completion bash); Zsh: source <(modbusctl completion zsh); Fish: modbusctl completion fish | source.", + Long: "Output a completion script for bash, zsh, fish or PowerShell. Installation: see Shell completion in the README.", ValidArgs: completionShells, Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/flag_completion.go b/cmd/flag_completion.go new file mode 100644 index 0000000..afdef54 --- /dev/null +++ b/cmd/flag_completion.go @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "net" + "slices" + "strings" + + "github.com/otfabric/go-modbus/codec" + "github.com/otfabric/modbusctl/internal/cli" + "github.com/otfabric/modbusctl/internal/format" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// fixedFlagValues are the value sets of flags that the commands do not register a +// completion for themselves. A command that registered its own keeps it. +var fixedFlagValues = map[string][]string{ + "format": formatValues(), + "function": {"0", "1", "2", "3", "4"}, // capture commands: 0 = the only one + "before-first": {"hold", "absent"}, + "missing": {"exception", "zero", "silence"}, + "retry-timeout": {"0", "1"}, + "data-bits": {"0", "5", "6", "7", "8"}, + "stop-bits": {"0", "1", "2"}, + "baud": {"0", "1200", "2400", "4800", "9600", "19200", "38400", "57600", "115200"}, + "family": codecFamilies(), + "value-kind": codecValueKinds(), +} + +// fileFlagExtensions are the flags that name an input file of a known type. +var fileFlagExtensions = map[string][]string{ + "input": {"mbcap", "jsonl", "csv"}, + "profile": {"json"}, + "blocks-file": {"json"}, + "tls-ca": {"pem", "crt", "cer"}, + "tls-cert": {"pem", "crt", "cer"}, + "tls-key": {"pem", "key"}, +} + +// anyFileFlags name a file of any type (an output path). +var anyFileFlags = []string{"output"} + +// urlSchemes are the Modbus URL schemes --url completes. +var urlSchemes = []string{"tcp://", "tcp+tls://", "udp://", "rtu://", "ascii://", "rtuovertcp://", "rtuoverudp://", "asciiovertcp://"} + +// registerFlagCompletions gives every value flag of c and its subcommands a completion +// function: the values of an enum flag, files of the right type for a file flag, the +// network interfaces, the URL schemes, and no file names for a free value (a number, +// an address). A command without positional arguments completes no file names either. +func registerFlagCompletions(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() { + registerFlagCompletions(sub) + } +} + +func registerFlagCompletion(c *cobra.Command, f *pflag.Flag) { + if f.Value.Type() == "bool" { + return + } + if _, ok := c.GetFlagCompletionFunc(f.Name); ok { + return + } + switch { + case fixedFlagValues[f.Name] != nil: + _ = cli.RegisterEnumFlagCompletion(c, f.Name, fixedFlagValues[f.Name]) + case fileFlagExtensions[f.Name] != nil: + _ = c.RegisterFlagCompletionFunc(f.Name, cobra.FixedCompletions(fileFlagExtensions[f.Name], cobra.ShellCompDirectiveFilterFileExt)) + case slices.Contains(anyFileFlags, f.Name): + _ = c.RegisterFlagCompletionFunc(f.Name, cobra.FixedCompletions(nil, cobra.ShellCompDirectiveDefault)) + case f.Name == "url": + _ = c.RegisterFlagCompletionFunc(f.Name, cobra.FixedCompletions(urlSchemes, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace)) + case f.Name == "interface": + _ = c.RegisterFlagCompletionFunc(f.Name, completeInterfaces) + default: + _ = c.RegisterFlagCompletionFunc(f.Name, cobra.NoFileCompletions) + } +} + +func completeInterfaces(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + names := make([]string, 0, len(ifaces)) + for _, i := range ifaces { + names = append(names, i.Name) + } + return names, cobra.ShellCompDirectiveNoFileComp +} + +func formatValues() []string { + vals := make([]string, 0, 3) + for v := range format.ValueDescriptions() { + vals = append(vals, v) + } + slices.Sort(vals) + return vals +} + +func codecFamilies() []string { + return codecDescriptorValues(func(d codec.CodecDescriptor) string { return d.Family.String() }) +} + +func codecValueKinds() []string { + return codecDescriptorValues(func(d codec.CodecDescriptor) string { return d.ValueKind.String() }) +} + +func codecDescriptorValues(field func(codec.CodecDescriptor) string) []string { + var vals []string + for _, d := range codec.AvailableCodecDescriptors() { + if v := strings.TrimSpace(field(d)); v != "" && !slices.Contains(vals, v) { + vals = append(vals, v) + } + } + slices.Sort(vals) + return vals +} diff --git a/cmd/flag_completion_test.go b/cmd/flag_completion_test.go new file mode 100644 index 0000000..42d266b --- /dev/null +++ b/cmd/flag_completion_test.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT + +package cmd + +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 their +// descriptions) and the directive line. +func complete(t *testing.T, args ...string) ([]string, string) { + t.Helper() + code, stdout, stderr := execute(t, append([]string{"__complete"}, args...)...) + if code != 0 { + t.Fatalf("__complete %v: exit %d, stderr %q", args, code, stderr) + } + var values []string + var directive string + for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") { + if strings.HasPrefix(line, ":") { + directive = line + continue + } + if strings.HasPrefix(line, "Completion ended") { + 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", "") + want := []string{"bash", "zsh", "fish", "powershell"} + if !slices.Equal(got, want) { + t.Fatalf("completion shells %v, want %v", got, want) + } +} + +func TestCompleteEnumFlagPerGroup(t *testing.T) { + wantCandidates(t, []string{"client", "read", "--format", ""}, "text", "json", "table") + wantCandidates(t, []string{"client", "scan", "--algo", ""}, "safe", "smart", "deep", "sunspec") + wantCandidates(t, []string{"client", "read", "--parity", ""}, "none", "even", "odd") + wantCandidates(t, []string{"client", "sunspec", "map", "--regtype", ""}, "holding", "input") + wantCandidates(t, []string{"client", "read", "--stop-bits", ""}, "1", "2") + wantCandidates(t, []string{"client", "codecs", "--family", ""}, "float", "integer") + wantCandidates(t, []string{"client", "read", "--url", "rt"}, "rtu://", "rtuovertcp://") + wantCandidates(t, []string{"capture", "decode", "--function", ""}, "1", "2", "3", "4") + wantCandidates(t, []string{"server", "replay", "--missing", ""}, "exception", "zero", "silence") + wantCandidates(t, []string{"server", "replay", "--before-first", ""}, "hold", "absent") + wantCandidates(t, []string{"discover", "--format", ""}, "text", "json", "table") +} + +func TestCompleteFileFlags(t *testing.T) { + got, directive := complete(t, "capture", "decode", "--input", "") + if !slices.Equal(got, []string{"mbcap", "jsonl", "csv"}) { + t.Fatalf("--input extensions %v", got) + } + if want := fmt.Sprintf(":%d", cobra.ShellCompDirectiveFilterFileExt); directive != want { + t.Fatalf("--input directive %q, want %q", directive, want) + } + if _, directive := complete(t, "client", "read", "--timeout", ""); directive != fmt.Sprintf(":%d", cobra.ShellCompDirectiveNoFileComp) { + t.Fatalf("--timeout directive %q, 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(rootCmd) +} diff --git a/cmd/root.go b/cmd/root.go index 9a2d728..d257df8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -16,6 +16,7 @@ import ( "github.com/otfabric/modbusctl/cmd/client" "github.com/otfabric/modbusctl/cmd/discover" "github.com/otfabric/modbusctl/cmd/server" + "github.com/otfabric/modbusctl/internal/buildinfo" "github.com/otfabric/modbusctl/internal/capfile" "github.com/otfabric/modbusctl/internal/config" "github.com/otfabric/modbusctl/internal/errs" @@ -25,17 +26,9 @@ import ( "github.com/spf13/cobra" ) -// buildMeta holds link-time metadata set from main via SetBuildMeta. -var buildMeta struct { - version, tag, commit, buildDate string -} - // SetBuildMeta is called from main before Execute; values come from ldflags -X main.*. func SetBuildMeta(version, tag, commit, buildDate string) { - buildMeta.version = version - buildMeta.tag = tag - buildMeta.commit = commit - buildMeta.buildDate = buildDate + buildinfo.Set(version, tag, commit, buildDate) if v := strings.TrimSpace(version); v != "" { capfile.Producer = "modbusctl " + v } @@ -56,6 +49,7 @@ func init() { rootCmd.AddCommand(server.ServerCmd) rootCmd.AddCommand(versionCmd) refuseUnknownSubcommands(rootCmd) + registerFlagCompletions(rootCmd) } // refuseUnknownSubcommands makes the unknown subcommand of a command group a usage error. @@ -73,25 +67,30 @@ func refuseUnknownSubcommands(c *cobra.Command) { var versionCmd = &cobra.Command{ Use: "version", Short: "Print the version of modbusctl", - Run: func(cmd *cobra.Command, args []string) { - out := cmd.OutOrStdout() - ver := strings.TrimSpace(buildMeta.version) - if ver == "" { - ver = "(unknown)" - } - _, _ = fmt.Fprintf(out, "modbusctl version: %s\n", ver) - if s := strings.TrimSpace(buildMeta.tag); s != "" { - _, _ = fmt.Fprintf(out, "tag: %s\n", s) - } - if s := strings.TrimSpace(buildMeta.commit); s != "" { - _, _ = fmt.Fprintf(out, "commit: %s\n", s) + Long: "Print the version, tag, commit, build date, Go version and platform of modbusctl on one line, " + + "or as one JSON object with --json or MODBUSCTL_FORMAT=json.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + info := buildinfo.Get("modbusctl") + asJSON, _ := cmd.Flags().GetBool("json") + if !asJSON { + if f, err := format.Parse(config.Getenv("MODBUSCTL_FORMAT")); err == nil && f == format.FormatJSON { + asJSON = true + } } - if s := strings.TrimSpace(buildMeta.buildDate); s != "" { - _, _ = fmt.Fprintf(out, "build date: %s\n", s) + line := info.String() + if asJSON { + line = info.JSON() } + _, err := fmt.Fprintln(cmd.OutOrStdout(), line) + return err }, } +func init() { + versionCmd.Flags().Bool("json", false, "Print the build information as one JSON object") +} + var typeRunErrors sync.Once // typeCommandErrors makes every error a command returns while running typed, which leaves diff --git a/cmd/root_test.go b/cmd/root_test.go index 50cc2a4..3fc4cfd 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -11,9 +11,11 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "testing" + "github.com/otfabric/modbusctl/internal/buildinfo" "github.com/otfabric/modbusctl/internal/errs" "github.com/otfabric/modbusctl/internal/mbtest" "github.com/otfabric/modbusctl/internal/types" @@ -149,24 +151,36 @@ func TestRootCommandTree(t *testing.T) { } func TestVersion(t *testing.T) { - defer SetBuildMeta(buildMeta.version, buildMeta.tag, buildMeta.commit, buildMeta.buildDate) + defer SetBuildMeta("", "", "", "") + tail := libsText("modbusctl") + runtime.Version() + ", " + runtime.GOOS + "/" + runtime.GOARCH - SetBuildMeta(" v9.8.7 ", "v9.8.7", "abc1234", "2026-01-02T03:04:05Z") + SetBuildMeta(" v9.8.7 ", "v9.8.7", "1802d0f9afe8642d1e278120b76fcdfac967cb9e", "2026-01-02T03:04:05Z") code, stdout, stderr := execute(t, "version") if code != errs.ExitOK || stderr != "" { t.Fatalf("version: exit %d, stderr %q", code, stderr) } - want := "modbusctl version: v9.8.7\ntag: v9.8.7\ncommit: abc1234\nbuild date: 2026-01-02T03:04:05Z\n" + want := "modbusctl 9.8.7 (tag v9.8.7, commit 1802d0f9afe8642d1e278120b76fcdfac967cb9e, built 2026-01-02T03:04:05Z, " + tail + ")\n" if stdout != want { t.Fatalf("version output:\n%q\nwant:\n%q", stdout, want) } - // Without link-time metadata only the version line is printed. - SetBuildMeta("", "", " ", "") + wantJSON := `{"name":"modbusctl","version":"9.8.7","tag":"v9.8.7","commit":"1802d0f9afe8642d1e278120b76fcdfac967cb9e",` + + `"built":"2026-01-02T03:04:05Z",` + libsJSON("modbusctl") + `"go":"` + runtime.Version() + `","platform":"` + runtime.GOOS + "/" + runtime.GOARCH + `"}` + "\n" + code, stdout, _ = execute(t, "version", "--json") + if code != errs.ExitOK || stdout != wantJSON { + t.Fatalf("version --json: exit %d, output %q, want %q", code, stdout, wantJSON) + } + t.Setenv("MODBUSCTL_FORMAT", "json") code, stdout, _ = execute(t, "version") - if code != errs.ExitOK || stdout != "modbusctl version: (unknown)\n" { - t.Fatalf("version without metadata: exit %d, output %q", code, stdout) + if code != errs.ExitOK || stdout != wantJSON { + t.Fatalf("MODBUSCTL_FORMAT=json version: exit %d, output %q, want %q", code, stdout, wantJSON) } + t.Setenv("MODBUSCTL_FORMAT", "table") + code, stdout, _ = execute(t, "version") + if code != errs.ExitOK || stdout != want { + t.Fatalf("MODBUSCTL_FORMAT=table version: exit %d, output %q, want %q", code, stdout, want) + } + t.Setenv("MODBUSCTL_FORMAT", "") } func TestCompletion(t *testing.T) { @@ -507,3 +521,18 @@ func TestExecute_unknownSubcommandText(t *testing.T) { t.Fatalf("exit %d, stdout %q", code, stdout) } } + +// 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) + `,` +} diff --git a/docs/json-output.md b/docs/json-output.md index d3b19bf..2504eb2 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -40,6 +40,7 @@ The JSON output types live in `internal/types`. Fields marked *optional* are lef | `client sunspec map` | `SunSpecMapOutput` | `target`, `base`, `marker_regs` (the two marker registers, `base-base+1`), `models`, `not_detected` (optional) | | `client sunspec probe` | `SunSpecProbeOutput` | `target`, `unit_id`, `modbus` (`fc03`, `fc04`, `fc43`: function supported; `fc03_error`, `fc04_error`, `fc43_error`: optional, when the probe itself failed), `sunspec` (`detected`, `base_address`, `models_found`, `end_model`, and the optional `detection_error` and `model_headers_error`) | | `discover` | `DiscoverOutput` | `port`, `interface` (optional), `subnets` (optional), `devices`: per device `ip`, `port`, `mac`, `interface`. With `--output`, the file holds the `devices` array alone | +| `version` | `buildinfo.Info` | `version --json` or `MODBUSCTL_FORMAT=json`: one line, keys in this order, all present: `name`, `version` (no leading `v`; `dev` for a development build), `tag` (`""` when the build has no tag), `commit` (full SHA, `-dirty` for a modified tree, or `unknown`), `built` (RFC 3339 UTC or `unknown`), `libraries` (the OT Fabric libraries the binary is built with: `name`, `version`; `[]` when none), `go`, `platform` (`goos/goarch`) | | `capture extract` | `AddressBlock` | the output file is an array of `start_address`, `register_count` | A SunSpec model header (in `models`) has `id`, `length`, `start_address`, `end_address`, `next_address` and `is_end_model`. A `summary` (`ResultSummary`) has `requested`, `succeeded` and `failed`. diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index ed4e5ae..6c4ab6e 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" @@ -42,12 +43,26 @@ func TestVersionPrintsTheBuiltVersion(t *testing.T) { if code != 0 { t.Fatalf("exit code %d, want 0\n%s", code, output) } - want := os.Getenv("MODBUSCTL_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("MODBUSCTL_E2E_VERSION") + if !ok { t.Fatal("MODBUSCTL_E2E_VERSION is not set; run make e2e") } - if !strings.Contains(output, "modbusctl") || !strings.Contains(output, want) { - t.Fatalf("output does not name modbusctl %s:\n%s", want, output) + if want == "" { + want = "dev" + } + want = strings.TrimPrefix(want, "v") + if !strings.HasPrefix(output, "modbusctl "+want+" (") || strings.Count(output, "\n") != 1 { + t.Fatalf("output is not one line naming modbusctl %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]any + if err := json.Unmarshal([]byte(output), &info); err != nil || info["name"] != "modbusctl" || info["version"] != want { + t.Fatalf("version --json is not the build information of modbusctl %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..2b35e3e --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,158 @@ +// 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" + "sort" + "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"` + 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 +// (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() + 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") + } + } + 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 …, 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 + ", ") + 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, +// libraries, go, platform. +func (i Info) JSON() string { + out, _ := json.Marshal(i) // cannot fail: strings and a slice of strings + 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..f72fa58 --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,141 @@ +// 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","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","libraries":[],"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) + } + } +} + +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.go b/main.go index 70768bd..2211434 100644 --- a/main.go +++ b/main.go @@ -10,7 +10,7 @@ import ( // Injected at link time (see Makefile): -X main.version=... etc. var ( - version = "dev" + version = "" tag = "" commit = "" buildDate = "" diff --git a/main_test.go b/main_test.go index 711531b..a4b3af6 100644 --- a/main_test.go +++ b/main_test.go @@ -66,8 +66,8 @@ func TestMain_version(t *testing.T) { if code != 0 || stderr != "" { t.Fatalf("version: exit %d, stderr %q", code, stderr) } - if want := "modbusctl version: " + version + "\n"; stdout != want { - t.Fatalf("version output %q, want %q", stdout, want) + if !strings.HasPrefix(stdout, "modbusctl dev (commit ") || strings.Count(stdout, "\n") != 1 { + t.Fatalf("version output %q, want one line starting with %q", stdout, "modbusctl dev (commit ") } }