From 5c04a25caeb9d74ab15632ffcc586beb0c3a6956 Mon Sep 17 00:00:00 2001 From: Bart Van Bos Date: Sat, 7 Nov 2026 23:01:13 +0100 Subject: [PATCH 1/3] feat: aligned version output and full shell completion version prints one line (name, version without v, tag, full commit, build date, the go-profinet version, Go version, platform), or one JSON object with --json or --format/-o json. Missing values come from runtime/debug build info (commit with -dirty; pseudo-versions are dev) or are dev/unknown; internal/buildinfo replaces versionOf. The release workflow's -X main.* ldflags are unchanged; the Makefile injects the full commit, and a version and tag only when HEAD is on a tag. completion covers bash, zsh, fish and PowerShell. --mrp-role and im read --record complete their values next to --format, --mode and --interface; GSDML, GSDX, model, expected-link and pcap files complete by type, and every other value flag and argument completes nothing. Co-Authored-By: Claude Opus 5.5 --- Makefile | 5 +- README.md | 4 +- RELEASE.md | 38 +++++ cmd/completion.go | 43 +++++- cmd/completion_test.go | 86 ++++++++++++ cmd/dcp_test.go | 44 +++++- cmd/root.go | 88 +++--------- cmd/unit_test.go | 20 +-- docs/EXAMPLES.md | 6 +- docs/json-output.md | 1 + e2e/e2e_test.go | 16 ++- internal/buildinfo/buildinfo.go | 184 ++++++++++++++++++++++++ internal/buildinfo/buildinfo_test.go | 200 +++++++++++++++++++++++++++ internal/cli/complete_tree.go | 83 +++++++++++ main.go | 8 +- main_test.go | 2 +- 16 files changed, 714 insertions(+), 114 deletions(-) create mode 100644 cmd/completion_test.go create mode 100644 internal/buildinfo/buildinfo.go create mode 100644 internal/buildinfo/buildinfo_test.go create mode 100644 internal/cli/complete_tree.go diff --git a/Makefile b/Makefile index d8a45e0..37d1baa 100644 --- a/Makefile +++ b/Makefile @@ -22,9 +22,10 @@ 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") +# VERSION and TAG only when HEAD is exactly on a tag; otherwise the binary reports dev. +VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null) TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo "") -COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null) 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 6c496bb..8d67f26 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ From source (Go 1.26+): `go install github.com/otfabric/profinetctl@latest` ### Shell completion +Commands, flags, the values of `--format`, `--mode`, `--mrp-role` and `im read --record`, the Ethernet interfaces of `--interface`, and GSDML, GSDX, model, expected-link and pcap files complete. + ```bash # bash (needs the bash-completion package) mkdir -p ~/.local/share/bash-completion/completions @@ -133,7 +135,7 @@ A missing privilege exits with code 4 and the error code `permission_denied`. `p | `io status [--settle D]`, `io monitor [--interval D] [--changes]`, `io statistics [--duration D]` | a snapshot of the inputs and output statuses; the inputs at an interval (only changes with `--changes`); the cyclic counters over 5 s | | `alarms` | establish an AR and stream the device's alarms | | `disconnect --ar UUID --session-key N` | release an AR another controller left behind | -| `version`, `completion SHELL` | version; shell completion for bash, zsh, fish and PowerShell | +| `version [--json]`, `completion SHELL` | the version on one line (or JSON), with the go-profinet version; shell completion for bash, zsh, fish and PowerShell | Every command has `--help`. A device is selected by `--station NAME` (resolved with DCP Identify, which waits `--wait`, default 1s; several answers are an error) or `--mac`; `--ip` overrides the RPC address it reports. diff --git a/RELEASE.md b/RELEASE.md index 76d8436..96fd677 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -23,6 +23,44 @@ ending with a `---` line and no `---` line inside a section --- +## v0.9.0 + +**Date:** 2026-11-07 +**Previous release:** v0.8.0 + +### Summary + +`version` prints one line, as every OT Fabric CLI does, or JSON with +`--json` (or `-o json`). Shell completion completes `--mrp-role`, the I&M +records and files by type, and no longer offers file names for addresses, +names and numbers. + +### Added + +- `version --json`, and `version` with `--format json`/`-o json` or + `PROFINETCTL_FORMAT=json`: `{"name","version","tag","commit","built","libraries","go","platform"}`, + every key present; `libraries` lists go-profinet with its version. +- Completion of `--mrp-role` (client, manager, off) and `im read --record` + (0-4), next to `--format`, `--mode` and `--interface`. GSDML arguments + and `--gsdml` complete `.xml` files, `gsdml gsdx` `.gsdx`, `--model` and + `--expect` `.json`, `--pcap` `.pcap`; `--data-file` and `--output-file` + any file. Every other value flag and argument completes nothing instead + of file names. + +### Changed + +- `version` prints one line: `profinetctl 0.9.0 (tag v0.9.0, commit , + built , go-profinet 0.7.0, go1.26.10, linux/amd64)`, instead of one + line per field. No leading `v`; the full commit (from the build, or from + Go's build information with `-dirty` for a modified tree); the tag is left + out when there is none; a development build (a pseudo-version too) is + `dev`, built `unknown`. Scripts that read `profinetctl version: …` should + use `version --json`. +- `make build` injects the full commit SHA, and a version and tag only when + HEAD is exactly on a tag (otherwise the binary reports `dev`). + +--- + ## v0.8.0 **Date:** 2026-11-05 diff --git a/cmd/completion.go b/cmd/completion.go index bb5943f..9913bef 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -2,7 +2,11 @@ package cmd -import "github.com/spf13/cobra" +import ( + "github.com/spf13/cobra" + + clihelp "github.com/otfabric/profinetctl/internal/cli" +) const ( completionBash = "bash" @@ -24,8 +28,9 @@ func newCompletionCmd() *cobra.Command { Fish: profinetctl completion fish | source PowerShell: profinetctl completion powershell | Out-String | Invoke-Expression -Flags with a fixed set of values (--format, --mode, …) complete to those -values.`, +Flags with a fixed set of values (--format, --mode, --mrp-role, --record) complete +to those values, --interface to the Ethernet interfaces; GSDML, GSDX, model, expected +link and pcap files complete by type.`, ValidArgs: completionShells, Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), RunE: func(cmd *cobra.Command, args []string) error { @@ -43,3 +48,35 @@ values.`, }, } } + +// registerCompletions completes what no command completes itself: --mrp-role and the +// I&M records, the flags and arguments that take a file (files of its type), and every +// other flag that takes a value (an address, a name, a number) and every other argument +// nothing. +func registerCompletions(root *cobra.Command) { + mrpRoles, imRecords := []string{"client", "manager", "off"}, []string{"0", "1", "2", "3", "4"} + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + if c.Flags().Lookup("mrp-role") != nil { + _ = clihelp.RegisterEnumFlagCompletion(c, "mrp-role", mrpRoles) + } + if c.Name() == "read" && c.Parent() != nil && c.Parent().Name() == "im" { + _ = clihelp.RegisterEnumFlagCompletion(c, "record", imRecords) + } + for _, sub := range c.Commands() { + walk(sub) + } + } + walk(root) + gsdml, model := []string{"xml"}, []string{"json"} + clihelp.CompleteTree(root, clihelp.Completion{ + Files: map[string][]string{ + "pcap": {"pcap"}, "gsdml": gsdml, "model": model, "expect": model, + "data-file": {}, "output-file": {}, + }, + Args: map[string][]string{ + "gsdml inspect": gsdml, "gsdml validate": gsdml, "gsdml check": gsdml, + "gsdml modules": gsdml, "gsdml submodules": gsdml, "gsdml gsdx": {"gsdx"}, + }, + }) +} diff --git a/cmd/completion_test.go b/cmd/completion_test.go new file mode 100644 index 0000000..346d447 --- /dev/null +++ b/cmd/completion_test.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "regexp" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// completeLine runs the hidden __complete command and returns the candidates (descriptions +// cut off) followed by the directive line (":4"). +func completeLine(n *testNet, args ...string) string { + n.t.Helper() + out := n.ok(append([]string{cobra.ShellCompRequestCmd}, args...)...) + var words []string + for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") { + words = append(words, strings.SplitN(line, "\t", 2)[0]) + } + return strings.Join(words, " ") +} + +func TestCompletionCandidates(t *testing.T) { + n := newTestNet(t) + for _, c := range []struct { + args []string + want string + }{ + {[]string{"completion", ""}, "bash zsh fish powershell :4"}, + {[]string{"discover", "--format", ""}, "json table text :4"}, + {[]string{"version", "-o", "j"}, "json :4"}, + {[]string{"connect", "--mrp-role", ""}, "client manager off :4"}, + {[]string{"im", "read", "--record", ""}, "0 1 2 3 4 :4"}, + {[]string{"reset", "--mode", "fac"}, "factory :4"}, + {[]string{"gsdml", "inspect", ""}, "xml :8"}, + {[]string{"gsdml", "gsdx", ""}, "gsdx :8"}, + {[]string{"connect", "--gsdml", ""}, "xml :8"}, + {[]string{"connect", "--model", ""}, "json :8"}, + {[]string{"topology", "check", "--expect", ""}, "json :8"}, + {[]string{"discover", "--pcap", ""}, "pcap :8"}, + {[]string{"record", "write", "--data-file", ""}, ":0"}, + {[]string{"identify", "--station", ""}, ":4"}, + {[]string{"ip", "set", ""}, ":4"}, + {[]string{"connect", "--send-clock", ""}, ":4"}, + } { + if got := completeLine(n, c.args...); got != c.want { + t.Errorf("completion of %v: %q, want %q", c.args, got, c.want) + } + } +} + +// valueSet matches a usage that lists the values of a flag: "text, json or table", +// "a role: client, manager or off", "one of …", "a|b". +var valueSet = regexp.MustCompile(`(^|: )[a-z0-9-]+( \([^)]*\))?(, [a-z0-9-]+( \([^)]*\))?)*,? or [a-z0-9-]+|\bone of\b|\b[a-z0-9-]+\|[a-z0-9-]+\b`) + +// Every flag that takes a value completes, and one whose usage lists its values +// completes to them, not to file names. +func TestEveryFlagCompletes(t *testing.T) { + n := newTestNet(t) + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + c.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.NoOptDefVal != "" || f.Name == "help" { + return + } + fn, ok := c.GetFlagCompletionFunc(f.Name) + if !ok { + t.Errorf("%s --%s has no completion", c.CommandPath(), f.Name) + return + } + if valueSet.MatchString(f.Usage) || f.Name == "mode" { + values, dir := fn(c, nil, "") + if len(values) < 2 || dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("%s --%s (%q) completes %q, directive %d", c.CommandPath(), f.Name, f.Usage, values, dir) + } + } + }) + for _, s := range c.Commands() { + walk(s) + } + } + walk(NewRootCmd(n)) +} diff --git a/cmd/dcp_test.go b/cmd/dcp_test.go index 253de03..a85cff4 100644 --- a/cmd/dcp_test.go +++ b/cmd/dcp_test.go @@ -6,11 +6,14 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" "github.com/otfabric/go-profinet/dcp" "github.com/otfabric/go-profinet/pcap" + + "github.com/otfabric/profinetctl/internal/buildinfo" ) func TestInterfaces(t *testing.T) { @@ -318,14 +321,41 @@ func TestPcap(t *testing.T) { func TestVersionAndCompletion(t *testing.T) { n := newTestNet(t) - SetBuildMeta("v1.2.3", "v1.2.3", "abc1234", "2026-10-26") - defer SetBuildMeta("dev", "", "", "") - out := n.ok("version") - for _, want := range []string{"profinetctl version: v1.2.3", "tag: v1.2.3", "commit: abc1234", "build date: 2026-10-26", "go version:", "platform:"} { - if !strings.Contains(out, want) { - t.Fatalf("version lacks %q:\n%s", want, out) + const sha = "1802d0f9afe8642d1e278120b76fcdfac967cb9e" + SetBuildMeta("v1.2.3", "v1.2.3", sha, "2026-10-26T12:00:00Z") + defer SetBuildMeta("", "", "", "") + libText, libJSON := "", []string{} + for _, l := range buildinfo.Get("profinetctl").Libraries { + libText += l.Name + " " + l.Version + ", " + libJSON = append(libJSON, `{"name":"`+l.Name+`","version":"`+l.Version+`"}`) + } + if !strings.Contains(libText, "go-profinet ") { + t.Errorf("the libraries lack go-profinet: %q", libText) + } + platform := runtime.GOOS + "/" + runtime.GOARCH + text := "profinetctl 1.2.3 (tag v1.2.3, commit " + sha + ", built 2026-10-26T12:00:00Z, " + libText + runtime.Version() + ", " + platform + ")\n" + js := `{"name":"profinetctl","version":"1.2.3","tag":"v1.2.3","commit":"` + sha + `","built":"2026-10-26T12:00:00Z","libraries":[` + + strings.Join(libJSON, ",") + `],"go":"` + runtime.Version() + `","platform":"` + platform + "\"}\n" + if out := n.ok("version"); out != text { + t.Fatalf("version:\n got %q\nwant %q", out, text) + } + for _, args := range [][]string{{"version", "--json"}, {"version", "-o", "json"}, {"version", "--format", "table", "--json"}} { + if out := n.ok(args...); out != js { + t.Fatalf("%v:\n got %q\nwant %q", args, out, js) } } + if out := n.ok("version", "--format", "table"); out != text { + t.Fatalf("version --format table: %q", out) + } + t.Setenv(EnvFormat, "json") + if out := n.ok("version"); out != js { + t.Fatalf("%s=json version: %q", EnvFormat, out) + } + t.Setenv(EnvFormat, "") + SetBuildMeta("", "", "", "") + if out := n.ok("version"); strings.Contains(out, "tag ") || !strings.Contains(out, "built unknown, ") || strings.Count(out, "\n") != 1 { + t.Fatalf("version of a development build: %q", out) + } for _, sh := range []string{"bash", "zsh", "fish", "powershell"} { if out := n.ok("completion", sh); !strings.Contains(out, "profinetctl") { t.Fatalf("%s completion", sh) @@ -333,7 +363,7 @@ func TestVersionAndCompletion(t *testing.T) { } n.fails(2, "", "completion", "tcsh") // Completion of flags with fixed values. - out = n.ok("__complete", "reset", "--mode", "") + out := n.ok("__complete", "reset", "--mode", "") if !strings.Contains(out, "factory") || !strings.Contains(out, "communication") { t.Fatalf("mode completion %q", out) } diff --git a/cmd/root.go b/cmd/root.go index 07ec86a..b03e76f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,31 +9,22 @@ import ( "io" "os" "os/signal" - "runtime" - "runtime/debug" "strings" "syscall" "time" "github.com/spf13/cobra" + "github.com/otfabric/profinetctl/internal/buildinfo" clihelp "github.com/otfabric/profinetctl/internal/cli" "github.com/otfabric/profinetctl/internal/errs" "github.com/otfabric/profinetctl/internal/format" "github.com/otfabric/profinetctl/internal/runner" ) -// 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) } // Environment variables that set the defaults of the global flags. @@ -132,8 +123,9 @@ Raw Ethernet access needs privileges: root or CAP_NET_RAW on Linux, access to newHelloCmd(a), newMRPCmd(a), newCompletionCmd(), - newVersionCmd(), + newVersionCmd(a), ) + registerCompletions(root) return root } @@ -158,68 +150,28 @@ func envDuration(name string, def time.Duration) time.Duration { return d } -func newVersionCmd() *cobra.Command { - return &cobra.Command{ +func newVersionCmd(a *app) *cobra.Command { + var asJSON bool + c := &cobra.Command{ Use: "version", Short: "Print the version of profinetctl", - Args: cobra.NoArgs, + Long: `Print the version of profinetctl on one line: version, tag, commit, build date, the +OT Fabric libraries it is built with (go-profinet), Go version and platform. --json, or +--format json, prints it as one JSON object.`, + Example: ` profinetctl version + profinetctl version --json`, + Args: cobra.NoArgs, Run: func(cmd *cobra.Command, _ []string) { - out := cmd.OutOrStdout() - info, _ := debug.ReadBuildInfo() - ver, commit := versionOf(buildMeta.version, buildMeta.commit, info) - if ver == "" { - ver = "(unknown)" - } - _, _ = fmt.Fprintf(out, "profinetctl version: %s\n", ver) - if s := strings.TrimSpace(buildMeta.tag); s != "" { - _, _ = fmt.Fprintf(out, "tag: %s\n", s) - } - if commit != "" { - _, _ = fmt.Fprintf(out, "commit: %s\n", commit) + info := buildinfo.Get("profinetctl") + line := info.String() + if f, err := format.Parse(a.format); asJSON || (err == nil && f == format.FormatJSON) { + line = info.JSON() } - if s := strings.TrimSpace(buildMeta.buildDate); s != "" { - _, _ = fmt.Fprintf(out, "build date: %s\n", s) - } - if info != nil { - for _, d := range info.Deps { - if d.Path == "github.com/otfabric/go-profinet" { - _, _ = fmt.Fprintf(out, "go-profinet: %s\n", d.Version) - } - } - } - _, _ = fmt.Fprintf(out, "go version: %s\n", runtime.Version()) - _, _ = fmt.Fprintf(out, "platform: %s/%s\n", runtime.GOOS, runtime.GOARCH) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), line) }, } -} - -// devVersion is the version of a binary built without the ldflags of the Makefile. -const devVersion = "dev" - -// versionOf returns the version and commit to report: the ldflags of the Makefile and -// of the release set them; a binary built without them reports what the go toolchain -// records (the module version and the VCS revision). -func versionOf(version, commit string, info *debug.BuildInfo) (string, string) { - version, commit = strings.TrimSpace(version), strings.TrimSpace(commit) - if info == nil { - return version, commit - } - if version == devVersion || version == "" { - if v := info.Main.Version; v != "" && v != "(devel)" { - version = v - } - } - if commit == "" { - for _, s := range info.Settings { - if s.Key == "vcs.revision" { - commit = s.Value - if len(commit) > 7 { - commit = commit[:7] - } - } - } - } - return version, commit + c.Flags().BoolVar(&asJSON, "json", false, "Print the version as one JSON object") + return c } // Execute runs the root command and returns the process exit code (main should call os.Exit). diff --git a/cmd/unit_test.go b/cmd/unit_test.go index 708e88b..3b00b5b 100644 --- a/cmd/unit_test.go +++ b/cmd/unit_test.go @@ -10,7 +10,6 @@ import ( "net" "net/netip" "os" - "runtime/debug" "strings" "syscall" "testing" @@ -73,23 +72,6 @@ func TestClassify(t *testing.T) { } } -func TestVersionOf(t *testing.T) { - t.Parallel() - info := &debug.BuildInfo{Main: debug.Module{Version: "v0.3.1"}, Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: "0123456789abcdef"}}} - if v, c := versionOf("dev", "", info); v != "v0.3.1" || c != "0123456" { - t.Fatalf("from build info: %s %s", v, c) - } - if v, c := versionOf("v1.0.0", "abc", info); v != "v1.0.0" || c != "abc" { - t.Fatalf("from ldflags: %s %s", v, c) - } - if v, c := versionOf(" dev ", "", nil); v != "dev" || c != "" { - t.Fatalf("without build info: %s %s", v, c) - } - if v, _ := versionOf("", "", &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}); v != "" { - t.Fatalf("devel: %q", v) - } -} - func TestDifferences(t *testing.T) { t.Parallel() if differences(nil) != nil { @@ -146,7 +128,7 @@ func TestOSBackend(t *testing.T) { os.Stdout = stdout var out bytes.Buffer _, _ = out.ReadFrom(r) - if code != 0 || !strings.Contains(out.String(), "profinetctl version") { + if code != 0 || !strings.HasPrefix(out.String(), "profinetctl ") { t.Fatalf("execute %d %q", code, out.String()) } } diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 195d458..35c35f0 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -525,10 +525,6 @@ reset: e2:d7:d0:a5:23:0e otf-b → communication ```console $ profinetctl version -profinetctl version: v0.6.0 -commit: 2e52e76 -go-profinet: v0.6.0 -go version: go1.27.3 -platform: linux/arm64 +profinetctl 0.6.0 (commit 2e52e76efa8db321d043877ebd8049d1d0c1f595, built unknown, go-profinet 0.6.0, go1.27.3, linux/arm64) ``` diff --git a/docs/json-output.md b/docs/json-output.md index 1857e93..d4bfcc1 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -44,6 +44,7 @@ These conventions are a **stable contract** for scripting. Field names are snake | `iocr` | `{"ar_uuid", "entries": [{"iocr", "frame_id", "kind": "data" \| "iocs", "slot", "subslot", "offset", "length"}]}` | | `io status` | `{"station", "state", "inputs": [{"slot", "subslot", "data", "iops"}], "outputs": [{"slot", "subslot", "iocs"}]}`; a status is `"good"`, `"bad (0x..)"` or `"-"` (not received yet) | | `io statistics` | `{"station", "state", "duration", "cycle", "stats": {...}, "frames_per_second"}` | +| `version` (`--json`, `-o json`) | `name`, `version` (no `v`; `"dev"` for a development build), `tag` (`""` without one), `commit` (`"unknown"` when not known), `built` (RFC 3339, or `"unknown"`), `libraries` (the OT Fabric libraries of the build: `name`, `version`), `go`, `platform` (`"linux/amd64"`), in that order | ## Streams (JSON Lines) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 0810476..8b385e2 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -42,12 +42,20 @@ func TestVersionPrintsTheBuiltVersion(t *testing.T) { if code != 0 { t.Fatalf("exit code %d, want 0\n%s", code, output) } - want := os.Getenv("PROFINETCTL_E2E_VERSION") - if want == "" { + // make e2e sets the variable to the tag of HEAD, empty when HEAD has none: then dev. + want, ok := os.LookupEnv("PROFINETCTL_E2E_VERSION") + if !ok { t.Fatal("PROFINETCTL_E2E_VERSION is not set; run make e2e") } - if !strings.Contains(output, "profinetctl") || !strings.Contains(output, want) { - t.Fatalf("output does not name profinetctl %s:\n%s", want, output) + if want = strings.TrimPrefix(want, "v"); want == "" { + want = "dev" + } + if !strings.HasPrefix(output, "profinetctl "+want+" (") || strings.Count(output, "\n") != 1 { + t.Fatalf("output is not the line of profinetctl %s:\n%s", want, output) + } + output, code = run(t, "version", "--json") + if code != 0 || !strings.HasPrefix(output, `{"name":"profinetctl","version":"`+want+`","tag":`) { + t.Fatalf("version --json: exit %d\n%s", code, output) } } diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..40030da --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT + +// Package buildinfo is the build metadata of the binary, as the version command prints +// it: the values the release build injects with -ldflags -X main.*, completed from the +// module and VCS information Go embeds (runtime/debug.ReadBuildInfo). +package buildinfo + +import ( + "encoding/json" + "regexp" + "runtime" + "runtime/debug" + "sort" + "strings" +) + +// Info is the build metadata. Every field is set: an unknown value is "dev" (Version), +// "unknown" (Commit, Built) or "" (Tag only); Libraries is empty, not nil, without any. +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"` // go-canbus + Version string `json:"version"` // 0.8.0, without the leading v +} + +// libraryPrefix is the module path prefix of the OT Fabric libraries. +const libraryPrefix = "github.com/otfabric/" + +// injected holds the values main passes to Set (from -ldflags -X main.*). +var injected struct { + version, tag, commit, buildDate string +} + +// readBuildInfo is debug.ReadBuildInfo; a test replaces it. +var readBuildInfo = debug.ReadBuildInfo + +// Set records the values injected at build time. Empty values and the placeholders +// "dev", "none" and "unknown" count as not injected. +func Set(version, tag, commit, buildDate string) { + injected.version, injected.tag, injected.commit, injected.buildDate = version, tag, commit, buildDate +} + +// Get returns the build metadata of the binary called name. +func Get(name string) Info { + info := Info{ + Name: name, + Version: strings.TrimPrefix(clean(injected.version), "v"), + Tag: clean(injected.tag), + Commit: clean(injected.commit), + Built: clean(injected.buildDate), + Libraries: []Library{}, + Go: runtime.Version(), + Platform: runtime.GOOS + "/" + runtime.GOARCH, + } + if bi, ok := readBuildInfo(); ok && bi != nil { + if info.Version == "" && realVersion(bi.Main.Version) { + info.Version = strings.TrimPrefix(bi.Main.Version, "v") + } + if info.Commit == "" { + info.Commit = vcsCommit(bi.Settings) + } + info.Libraries = libraries(bi) + } + if info.Version == "" { + info.Version = "dev" + } + if info.Commit == "" { + info.Commit = "unknown" + } + if info.Built == "" { + info.Built = "unknown" + } + return info +} + +// String is the one-line text form: +// +// (tag , commit , built , , …, , /) +// +// The "tag , " part is left out when there is no tag, 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 form, keys in the order name, version, tag, commit, built, +// libraries, go, platform. +func (i Info) JSON() string { + if i.Libraries == nil { + i.Libraries = []Library{} + } + data, err := json.Marshal(i) + if err != nil { // a struct of strings always encodes + panic(err) + } + return string(data) +} + +// libraries returns the github.com/otfabric/go-* modules of bi other than the main +// module, by name: the module path after github.com/otfabric/ (go-canbus, or +// go-ieee20305/tlsccm for a nested module), at the version of its replacement if it +// has one. +func libraries(bi *debug.BuildInfo) []Library { + out := []Library{} + for _, d := range bi.Deps { + if d == nil || d.Path == bi.Main.Path || !strings.HasPrefix(d.Path, libraryPrefix+"go-") { + continue + } + version := d.Version + if d.Replace != nil { + version = d.Replace.Version + } + if version = strings.TrimPrefix(version, "v"); version == "" { + version = "unknown" + } + out = append(out, Library{Name: strings.TrimPrefix(d.Path, libraryPrefix), Version: version}) + } + sort.Slice(out, func(a, b int) bool { return out[a].Name < out[b].Name }) + return out +} + +// pseudoVersion matches the timestamp and commit of a pseudo-version +// (v0.4.1-0.20261107212139-415629327cbc). +var pseudoVersion = regexp.MustCompile(`[-.]\d{14}-[0-9a-f]{12}(\+[0-9A-Za-z.-]+)?$`) + +// realVersion reports whether v, the module version Go embeds, names a release: not +// "(devel)", not a pseudo-version and not the "+dirty" version Go stamps on a build of +// a modified checkout. go install …@vX.Y.Z embeds vX.Y.Z. +func realVersion(v string) bool { + v = clean(v) + return v != "" && v != "(devel)" && !strings.HasSuffix(v, "+dirty") && !pseudoVersion.MatchString(v) +} + +// vcsCommit is vcs.revision, with "-dirty" when vcs.modified is true, or "". +func vcsCommit(settings []debug.BuildSetting) string { + var rev string + var dirty bool + for _, s := range settings { + switch s.Key { + case "vcs.revision": + rev = s.Value + case "vcs.modified": + dirty = s.Value == "true" + } + } + if rev == "" { + return "" + } + if dirty { + rev += "-dirty" + } + return rev +} + +// clean trims s and maps the placeholders of an uninjected value to "". +func clean(s string) string { + s = strings.TrimSpace(s) + switch s { + case "dev", "none", "unknown": + return "" + } + return s +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..985c6ba --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MIT + +package buildinfo + +import ( + "runtime" + "runtime/debug" + "testing" +) + +const sha = "1802d0f9afe8642d1e278120b76fcdfac967cb9e" + +// with sets the injected values and the embedded build info for one test. +func with(t *testing.T, version, tag, commit, buildDate string, bi *debug.BuildInfo) { + t.Helper() + oldInjected, oldRead := injected, readBuildInfo + t.Cleanup(func() { injected, readBuildInfo = oldInjected, oldRead }) + Set(version, tag, commit, buildDate) + readBuildInfo = func() (*debug.BuildInfo, bool) { return bi, bi != nil } +} + +func platform() string { return runtime.Version() + ", " + runtime.GOOS + "/" + runtime.GOARCH } + +func TestStringAllFields(t *testing.T) { + with(t, "3.5.1", "v3.5.1", sha, "2026-11-05T15:41:49Z", nil) + want := "tool 3.5.1 (tag v3.5.1, commit " + sha + ", built 2026-11-05T15:41:49Z, " + platform() + ")" + if got := Get("tool").String(); got != want { + t.Fatalf("got %q\nwant %q", got, want) + } +} + +func TestStringWithoutTag(t *testing.T) { + for _, tag := range []string{"", "none"} { + with(t, "v3.5.1-2-gabcdef", tag, sha, "2026-11-05T15:41:49Z", nil) + want := "tool 3.5.1-2-gabcdef (commit " + sha + ", built 2026-11-05T15:41:49Z, " + platform() + ")" + if got := Get("tool").String(); got != want { + t.Fatalf("tag %q: got %q\nwant %q", tag, got, want) + } + } +} + +func TestDevFallbackWithoutBuildInfo(t *testing.T) { + with(t, "", "", "", "", nil) + want := "tool dev (commit unknown, built unknown, " + platform() + ")" + if got := Get("tool").String(); got != want { + t.Fatalf("got %q\nwant %q", got, want) + } +} + +func TestDevFallbackPlaceholders(t *testing.T) { + with(t, "dev", "none", "unknown", "unknown", &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}) + want := "tool dev (commit unknown, built unknown, " + platform() + ")" + if got := Get("tool").String(); got != want { + t.Fatalf("got %q\nwant %q", got, want) + } +} + +func TestDirtyCheckoutVersionIsDev(t *testing.T) { + bi := &debug.BuildInfo{ + Main: debug.Module{Version: "v0.4.1-0.20261107212139-415629327cbc+dirty"}, + Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "true"}}, + } + with(t, "", "", "", "", bi) + want := "tool dev (commit " + sha + "-dirty, built unknown, " + platform() + ")" + if got := Get("tool").String(); got != want { + t.Fatalf("got %q\nwant %q", got, want) + } +} + +func TestFallbackToEmbeddedBuildInfo(t *testing.T) { + bi := &debug.BuildInfo{ + Main: debug.Module{Version: "v3.5.1"}, + Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "true"}}, + } + with(t, "", "", "", "", bi) + want := "tool 3.5.1 (commit " + sha + "-dirty, built unknown, " + platform() + ")" + if got := Get("tool").String(); got != want { + t.Fatalf("got %q\nwant %q", got, want) + } + bi.Settings[1].Value = "false" + if got := Get("tool").Commit; got != sha { + t.Fatalf("clean tree: commit %q, want %q", got, sha) + } +} + +func TestInjectedWinsOverBuildInfo(t *testing.T) { + bi := &debug.BuildInfo{ + Main: debug.Module{Version: "v0.0.1"}, + Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: "0000"}}, + } + with(t, "v3.5.1", "v3.5.1", sha, "2026-11-05T15:41:49Z", bi) + i := Get("tool") + if i.Version != "3.5.1" || i.Commit != sha { + t.Fatalf("got %+v", i) + } +} + +func TestJSONKeyOrderAndValues(t *testing.T) { + with(t, "3.5.1", "v3.5.1", sha, "2026-11-05T15:41:49Z", nil) + want := `{"name":"tool","version":"3.5.1","tag":"v3.5.1","commit":"` + sha + + `","built":"2026-11-05T15:41:49Z","libraries":[],"go":"` + runtime.Version() + `","platform":"` + runtime.GOOS + "/" + runtime.GOARCH + `"}` + if got := Get("tool").JSON(); got != want { + t.Fatalf("got %s\nwant %s", got, want) + } +} + +func TestJSONDevBuild(t *testing.T) { + with(t, "", "", "", "", nil) + want := `{"name":"tool","version":"dev","tag":"","commit":"unknown","built":"unknown","libraries":[],"go":"` + + runtime.Version() + `","platform":"` + runtime.GOOS + "/" + runtime.GOARCH + `"}` + if got := Get("tool").JSON(); got != want { + t.Fatalf("got %s\nwant %s", got, want) + } +} + +func TestPseudoVersionIsDev(t *testing.T) { + for _, v := range []string{ + "v0.4.1-0.20261107212139-415629327cbc", + "v0.0.0-20261107212139-415629327cbc", + "v1.2.0-rc.1.0.20261107212139-415629327cbc", + "v0.4.1-0.20261107212139-415629327cbc+dirty", + "v0.4.0+dirty", + } { + with(t, "", "", "", "", &debug.BuildInfo{Main: debug.Module{Version: v}}) + if got := Get("tool").Version; got != "dev" { + t.Errorf("%s: version %q, want dev", v, got) + } + } + with(t, "", "", "", "", &debug.BuildInfo{Main: debug.Module{Version: "v1.2.0-rc.1"}}) + if got := Get("tool").Version; got != "1.2.0-rc.1" { + t.Errorf("v1.2.0-rc.1: version %q", got) + } +} + +// info is a release build with the libraries libs, independent of the real build info. +func info(libs ...Library) Info { + return Info{Name: "tool", Version: "0.6.0", Tag: "v0.6.0", Commit: sha, Built: "2026-11-05T15:41:49Z", + Libraries: libs, Go: "go1.27.3", Platform: "darwin/arm64"} +} + +func TestLibrariesText(t *testing.T) { + head := "tool 0.6.0 (tag v0.6.0, commit " + sha + ", built 2026-11-05T15:41:49Z, " + for _, c := range []struct { + libs []Library + want string + }{ + {nil, head + "go1.27.3, darwin/arm64)"}, + {[]Library{{"go-canbus", "0.8.0"}}, head + "go-canbus 0.8.0, go1.27.3, darwin/arm64)"}, + {[]Library{{"go-canbus", "0.8.0"}, {"go-canopen", "0.5.1"}}, head + "go-canbus 0.8.0, go-canopen 0.5.1, go1.27.3, darwin/arm64)"}, + } { + if got := info(c.libs...).String(); got != c.want { + t.Errorf("got %q\nwant %q", got, c.want) + } + } +} + +func TestLibrariesJSON(t *testing.T) { + head := `{"name":"tool","version":"0.6.0","tag":"v0.6.0","commit":"` + sha + `","built":"2026-11-05T15:41:49Z","libraries":` + tail := `,"go":"go1.27.3","platform":"darwin/arm64"}` + for _, c := range []struct { + libs []Library + want string + }{ + {nil, head + "[]" + tail}, + {[]Library{{"go-canbus", "0.8.0"}}, head + `[{"name":"go-canbus","version":"0.8.0"}]` + tail}, + {[]Library{{"go-canbus", "0.8.0"}, {"go-canopen", "0.5.1"}}, + head + `[{"name":"go-canbus","version":"0.8.0"},{"name":"go-canopen","version":"0.5.1"}]` + tail}, + } { + if got := info(c.libs...).JSON(); got != c.want { + t.Errorf("got %s\nwant %s", got, c.want) + } + } +} + +func TestLibrariesFromBuildInfo(t *testing.T) { + bi := &debug.BuildInfo{ + Main: debug.Module{Path: "github.com/otfabric/go-canopen/cmd/canopenctl"}, + Deps: []*debug.Module{ + {Path: "github.com/otfabric/go-canopen", Version: "v0.5.1"}, + {Path: "github.com/otfabric/go-canopen/cmd/canopenctl", Version: "v9.9.9"}, + {Path: "github.com/spf13/cobra", Version: "v1.10.2"}, + {Path: "github.com/otfabric/docsync", Version: "v0.3.1"}, + {Path: "github.com/otfabric/go-canbus", Version: "v0.8.0", Replace: &debug.Module{Path: "../go-canbus"}}, + {Path: "github.com/otfabric/go-ieee20305/tlsccm", Version: "v0.3.0"}, + {Path: "github.com/otfabric/go-ieee20305", Version: "v0.6.1-0.20261107212139-415629327cbc", + Replace: &debug.Module{Path: "github.com/otfabric/go-ieee20305", Version: "v0.6.2"}}, + }, + } + with(t, "", "", "", "", bi) + got := Get("tool").Libraries + want := []Library{{"go-canbus", "unknown"}, {"go-canopen", "0.5.1"}, {"go-ieee20305", "0.6.2"}, {"go-ieee20305/tlsccm", "0.3.0"}} + 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) + } + } +} diff --git a/internal/cli/complete_tree.go b/internal/cli/complete_tree.go new file mode 100644 index 0000000..ea97d6b --- /dev/null +++ b/internal/cli/complete_tree.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT + +package cli + +import ( + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Completion says what the flags and arguments without a completion of their own complete +// to; see [CompleteTree]. +type Completion struct { + // Files maps a flag name ("config"), or a command path and a flag name for the flag + // of one command ("adapter --config"), to the extensions (without the dot) of the + // files it takes. An empty list completes any file. + Files map[string][]string + // Dirs are the names of the flags that take a directory. + Dirs []string + // Args maps a command path without the root name ("cert inspect") to the extensions + // of the files its arguments are. An empty list completes any file. + Args map[string][]string +} + +// CompleteTree registers completion for every command of the tree under root that has +// none yet: a flag of c.Files completes files of its extensions, a flag of c.Dirs +// directories, and every other flag that takes a value (an address, a number, a name) +// nothing, not file names. The arguments of a command of c.Args complete files; those of +// any other command without ValidArgs or ValidArgsFunction complete nothing. +// Call it once the tree is built, after the enum completions. +func CompleteTree(root *cobra.Command, c Completion) { + dirs := map[string]bool{} + for _, d := range c.Dirs { + dirs[d] = true + } + var walk func(cmd *cobra.Command) + walk = func(cmd *cobra.Command) { + path := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), root.Name())) + register := func(f *pflag.Flag) { + if f.NoOptDefVal != "" { // a bool (or a flag with an optional value): nothing to complete + return + } + if _, ok := cmd.GetFlagCompletionFunc(f.Name); ok { + return + } + fn := cobra.NoFileCompletions + if exts, ok := c.Files[strings.TrimSpace(path+" --"+f.Name)]; ok { + fn = fileCompletion(exts) + } else if exts, ok := c.Files[f.Name]; ok { + fn = fileCompletion(exts) + } else if dirs[f.Name] { + fn = func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveFilterDirs + } + } + _ = cmd.RegisterFlagCompletionFunc(f.Name, fn) + } + cmd.LocalNonPersistentFlags().VisitAll(register) + cmd.PersistentFlags().VisitAll(register) + if len(cmd.ValidArgs) == 0 && cmd.ValidArgsFunction == nil { + if exts, ok := c.Args[path]; ok { + cmd.ValidArgsFunction = fileCompletion(exts) + } else if cmd.Runnable() || !cmd.HasSubCommands() { // cobra completes the subcommands of a group + cmd.ValidArgsFunction = cobra.NoFileCompletions + } + } + for _, sub := range cmd.Commands() { + walk(sub) + } + } + walk(root) +} + +// fileCompletion completes files of the extensions exts, or any file when exts is empty. +func fileCompletion(exts []string) cobra.CompletionFunc { + return func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + if len(exts) == 0 { + return nil, cobra.ShellCompDirectiveDefault + } + return exts, cobra.ShellCompDirectiveFilterFileExt + } +} diff --git a/main.go b/main.go index 6817c43..a5cabc5 100644 --- a/main.go +++ b/main.go @@ -12,10 +12,10 @@ import ( // Injected at link time (see Makefile): -X main.version=... etc. var ( - version = "dev" - tag = "" - commit = "" - buildDate = "" + version string + tag string + commit string + buildDate string ) // exit ends the process; a test replaces it. diff --git a/main_test.go b/main_test.go index 3ea2a6c..26aa292 100644 --- a/main_test.go +++ b/main_test.go @@ -37,7 +37,7 @@ func TestMainPassesBuildMetaAndExitCode(t *testing.T) { defer func() { version = old }() version = "v9.8.7-test" code, out := runMain(t, "version") - if code != 0 || !strings.Contains(out, "v9.8.7-test") { + if code != 0 || !strings.HasPrefix(out, "profinetctl 9.8.7-test (") { t.Fatalf("version: exit %d, output %q", code, out) } if code, _ := runMain(t, "no-such-command"); code == 0 { From 6ab6d1ebc795472fcf1b18c9411d2a1e14d64323 Mon Sep 17 00:00:00 2001 From: Bart Van Bos Date: Sat, 7 Nov 2026 23:08:29 +0100 Subject: [PATCH 2/3] test: CompleteTree unit test; dev in artifact names off a tag Co-Authored-By: Claude Opus 5.5 --- Makefile | 22 ++++---- internal/cli/complete_tree_test.go | 83 ++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 internal/cli/complete_tree_test.go diff --git a/Makefile b/Makefile index 37d1baa..d15b3e3 100644 --- a/Makefile +++ b/Makefile @@ -136,14 +136,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 @@ -155,15 +155,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/internal/cli/complete_tree_test.go b/internal/cli/complete_tree_test.go new file mode 100644 index 0000000..1597598 --- /dev/null +++ b/internal/cli/complete_tree_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT + +package cli + +import ( + "strconv" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestCompleteTree(t *testing.T) { + root := &cobra.Command{Use: "tool"} + root.PersistentFlags().String("pcap", "", "capture file") + root.PersistentFlags().Bool("debug", false, "") + run := func(*cobra.Command, []string) {} + cert := &cobra.Command{Use: "cert"} + inspect := &cobra.Command{Use: "inspect FILE", Run: run} + gen := &cobra.Command{Use: "generate", Run: run} + gen.Flags().String("out", "", "directory") + gen.Flags().String("config", "", "a number here") + gen.Flags().String("format", "", "text or json") + _ = gen.RegisterFlagCompletionFunc("format", cobra.FixedCompletions([]string{"text", "json"}, cobra.ShellCompDirectiveNoFileComp)) + cert.AddCommand(inspect, gen) + srv := &cobra.Command{Use: "server", Run: run} + srv.Flags().String("config", "", "seed file") + srv.Flags().String("log", "", "any file") + srv.Flags().String("listen", "", "address") + shells := &cobra.Command{Use: "completion", ValidArgs: []string{"bash"}, Run: run} + root.AddCommand(cert, srv, shells) + + CompleteTree(root, Completion{ + Files: map[string][]string{"pcap": {"pcap"}, "server --config": {"yaml"}, "log": {}}, + Dirs: []string{"out"}, + Args: map[string][]string{"cert inspect": {"pem"}}, + }) + + flag := func(c *cobra.Command, name string) string { + t.Helper() + fn, ok := c.GetFlagCompletionFunc(name) + if !ok { + return "none" + } + values, dir := fn(c, nil, "") + return strings.Join(append(values, ":"+strconv.Itoa(int(dir))), " ") + } + for _, c := range []struct { + cmd *cobra.Command + flag, want string + }{ + {root, "pcap", "pcap :8"}, + {root, "debug", "none"}, + {gen, "out", ":16"}, + {gen, "config", ":4"}, + {gen, "format", "text json :4"}, + {srv, "config", "yaml :8"}, + {srv, "log", ":0"}, + {srv, "listen", ":4"}, + } { + if got := flag(c.cmd, c.flag); got != c.want { + t.Errorf("%s --%s: %q, want %q", c.cmd.Name(), c.flag, got, c.want) + } + } + args := func(c *cobra.Command) string { + if c.ValidArgsFunction == nil { + return "none" + } + values, dir := c.ValidArgsFunction(c, nil, "") + return strings.Join(append(values, ":"+strconv.Itoa(int(dir))), " ") + } + for c, want := range map[*cobra.Command]string{ + inspect: "pem :8", + gen: ":4", + srv: ":4", + cert: "none", // a group: cobra completes its subcommands + shells: "none", // ValidArgs + } { + if got := args(c); got != want { + t.Errorf("%s arguments: %q, want %q", c.Name(), got, want) + } + } +} From 4771285180141f79e64c5ccced2ed80f9244bf03 Mon Sep 17 00:00:00 2001 From: Bart Van Bos Date: Sat, 7 Nov 2026 23:11:50 +0100 Subject: [PATCH 3/3] test: version libraries checked on the real binary Test binaries of Go 1.26 carry no module dependencies in their build info, so the unit test no longer requires the library there; the e2e suite checks it on the built binary. Co-Authored-By: Claude Opus 5.5 --- cmd/dcp_test.go | 5 ++--- e2e/e2e_test.go | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/dcp_test.go b/cmd/dcp_test.go index a85cff4..6e72fce 100644 --- a/cmd/dcp_test.go +++ b/cmd/dcp_test.go @@ -329,9 +329,8 @@ func TestVersionAndCompletion(t *testing.T) { libText += l.Name + " " + l.Version + ", " libJSON = append(libJSON, `{"name":"`+l.Name+`","version":"`+l.Version+`"}`) } - if !strings.Contains(libText, "go-profinet ") { - t.Errorf("the libraries lack go-profinet: %q", libText) - } + // The libraries come from the build info, which test binaries of Go 1.26 lack: + // the e2e suite checks them on the real binary. platform := runtime.GOOS + "/" + runtime.GOARCH text := "profinetctl 1.2.3 (tag v1.2.3, commit " + sha + ", built 2026-10-26T12:00:00Z, " + libText + runtime.Version() + ", " + platform + ")\n" js := `{"name":"profinetctl","version":"1.2.3","tag":"v1.2.3","commit":"` + sha + `","built":"2026-10-26T12:00:00Z","libraries":[` + diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 8b385e2..9bab41f 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -50,7 +50,8 @@ func TestVersionPrintsTheBuiltVersion(t *testing.T) { if want = strings.TrimPrefix(want, "v"); want == "" { want = "dev" } - if !strings.HasPrefix(output, "profinetctl "+want+" (") || strings.Count(output, "\n") != 1 { + if !strings.HasPrefix(output, "profinetctl "+want+" (") || strings.Count(output, "\n") != 1 || + !strings.Contains(output, ", go-profinet ") { t.Fatalf("output is not the line of profinetctl %s:\n%s", want, output) } output, code = run(t, "version", "--json")