diff --git a/cli/command/context/completion.go b/cli/command/context/completion.go new file mode 100644 index 0000000000..c5c3843ee5 --- /dev/null +++ b/cli/command/context/completion.go @@ -0,0 +1,46 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.22 + +package context + +import ( + "slices" + + "github.com/docker/cli/cli/context/store" + "github.com/spf13/cobra" +) + +type contextProvider interface { + ContextStore() store.Store + CurrentContext() string +} + +// completeContextNames implements shell completion for context-names. +// +// FIXME(thaJeztah): export, and remove duplicate of this function in cmd/docker. +func completeContextNames(dockerCLI contextProvider, limit int, withFileComp bool) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if limit > 0 && len(args) >= limit { + if withFileComp { + // Provide file/path completion after context name (for "docker context export") + return nil, cobra.ShellCompDirectiveDefault + } + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // TODO(thaJeztah): implement function similar to [store.Names] to (also) include descriptions. + names, _ := store.Names(dockerCLI.ContextStore()) + out := make([]string, 0, len(names)) + for _, name := range names { + if slices.Contains(args, name) { + // Already completed + continue + } + if name == dockerCLI.CurrentContext() { + name += "\tcurrent" + } + out = append(out, name) + } + return out, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/cli/command/context/completion_test.go b/cli/command/context/completion_test.go new file mode 100644 index 0000000000..d464ebf1d6 --- /dev/null +++ b/cli/command/context/completion_test.go @@ -0,0 +1,80 @@ +package context + +import ( + "testing" + + "github.com/docker/cli/cli/context/store" + "github.com/spf13/cobra" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +type fakeContextProvider struct { + contextStore store.Store +} + +func (c *fakeContextProvider) ContextStore() store.Store { + return c.contextStore +} + +func (*fakeContextProvider) CurrentContext() string { + return "default" +} + +type fakeContextStore struct { + store.Store + names []string +} + +func (f fakeContextStore) List() (c []store.Metadata, _ error) { + for _, name := range f.names { + c = append(c, store.Metadata{Name: name}) + } + return c, nil +} + +func TestCompleteContextNames(t *testing.T) { + allNames := []string{"context-b", "context-c", "context-a"} + cli := &fakeContextProvider{ + contextStore: fakeContextStore{ + names: allNames, + }, + } + + t.Run("with limit", func(t *testing.T) { + compFunc := completeContextNames(cli, 1, false) + values, directives := compFunc(nil, nil, "") + assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveNoFileComp)) + assert.Check(t, is.DeepEqual(values, allNames)) + + values, directives = compFunc(nil, []string{"context-c"}, "") + assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveNoFileComp)) + assert.Check(t, is.Len(values, 0)) + }) + + t.Run("with limit and file completion", func(t *testing.T) { + compFunc := completeContextNames(cli, 1, true) + values, directives := compFunc(nil, nil, "") + assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveNoFileComp)) + assert.Check(t, is.DeepEqual(values, allNames)) + + values, directives = compFunc(nil, []string{"context-c"}, "") + assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveDefault), "should provide filenames completion after limit") + assert.Check(t, is.Len(values, 0)) + }) + + t.Run("without limits", func(t *testing.T) { + compFunc := completeContextNames(cli, -1, false) + values, directives := compFunc(nil, []string{"context-c"}, "") + assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveNoFileComp)) + assert.Check(t, is.DeepEqual(values, []string{"context-b", "context-a"}), "should not contain already completed") + + values, directives = compFunc(nil, []string{"context-c", "context-a"}, "") + assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveNoFileComp)) + assert.Check(t, is.DeepEqual(values, []string{"context-b"}), "should not contain already completed") + + values, directives = compFunc(nil, []string{"context-c", "context-a", "context-b"}, "") + assert.Check(t, is.Equal(directives, cobra.ShellCompDirectiveNoFileComp), "should provide filenames completion after limit") + assert.Check(t, is.Len(values, 0)) + }) +} diff --git a/cli/command/context/export.go b/cli/command/context/export.go index 4594028f97..96e35b6750 100644 --- a/cli/command/context/export.go +++ b/cli/command/context/export.go @@ -18,7 +18,7 @@ type ExportOptions struct { Dest string } -func newExportCommand(dockerCli command.Cli) *cobra.Command { +func newExportCommand(dockerCLI command.Cli) *cobra.Command { return &cobra.Command{ Use: "export [OPTIONS] CONTEXT [FILE|-]", Short: "Export a context to a tar archive FILE or a tar stream on STDOUT.", @@ -32,8 +32,9 @@ func newExportCommand(dockerCli command.Cli) *cobra.Command { } else { opts.Dest = opts.ContextName + ".dockercontext" } - return RunExport(dockerCli, opts) + return RunExport(dockerCLI, opts) }, + ValidArgsFunction: completeContextNames(dockerCLI, 1, true), } } diff --git a/cli/command/context/import.go b/cli/command/context/import.go index ff0179feb3..182defcf53 100644 --- a/cli/command/context/import.go +++ b/cli/command/context/import.go @@ -7,6 +7,7 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/completion" "github.com/docker/cli/cli/context/store" "github.com/spf13/cobra" ) @@ -19,6 +20,8 @@ func newImportCommand(dockerCli command.Cli) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { return RunImport(dockerCli, args[0], args[1]) }, + // TODO(thaJeztah): this should also include "-" + ValidArgsFunction: completion.FileNames, } return cmd } diff --git a/cli/command/context/inspect.go b/cli/command/context/inspect.go index 3794b417c4..be87335110 100644 --- a/cli/command/context/inspect.go +++ b/cli/command/context/inspect.go @@ -19,7 +19,7 @@ type inspectOptions struct { } // newInspectCommand creates a new cobra.Command for `docker context inspect` -func newInspectCommand(dockerCli command.Cli) *cobra.Command { +func newInspectCommand(dockerCLI command.Cli) *cobra.Command { var opts inspectOptions cmd := &cobra.Command{ @@ -28,13 +28,14 @@ func newInspectCommand(dockerCli command.Cli) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { opts.refs = args if len(opts.refs) == 0 { - if dockerCli.CurrentContext() == "" { + if dockerCLI.CurrentContext() == "" { return errors.New("no context specified") } - opts.refs = []string{dockerCli.CurrentContext()} + opts.refs = []string{dockerCLI.CurrentContext()} } - return runInspect(dockerCli, opts) + return runInspect(dockerCLI, opts) }, + ValidArgsFunction: completeContextNames(dockerCLI, -1, false), } flags := cmd.Flags() diff --git a/cli/command/context/remove.go b/cli/command/context/remove.go index bcf17a0537..2630dcd22f 100644 --- a/cli/command/context/remove.go +++ b/cli/command/context/remove.go @@ -16,7 +16,7 @@ type RemoveOptions struct { Force bool } -func newRemoveCommand(dockerCli command.Cli) *cobra.Command { +func newRemoveCommand(dockerCLI command.Cli) *cobra.Command { var opts RemoveOptions cmd := &cobra.Command{ Use: "rm CONTEXT [CONTEXT...]", @@ -24,8 +24,9 @@ func newRemoveCommand(dockerCli command.Cli) *cobra.Command { Short: "Remove one or more contexts", Args: cli.RequiresMinArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return RunRemove(dockerCli, opts, args) + return RunRemove(dockerCLI, opts, args) }, + ValidArgsFunction: completeContextNames(dockerCLI, -1, false), } cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Force the removal of a context in use") return cmd diff --git a/cli/command/context/update.go b/cli/command/context/update.go index 980937e0c1..16ace82f7c 100644 --- a/cli/command/context/update.go +++ b/cli/command/context/update.go @@ -33,7 +33,7 @@ func longUpdateDescription() string { return buf.String() } -func newUpdateCommand(dockerCli command.Cli) *cobra.Command { +func newUpdateCommand(dockerCLI command.Cli) *cobra.Command { opts := &UpdateOptions{} cmd := &cobra.Command{ Use: "update [OPTIONS] CONTEXT", @@ -41,9 +41,10 @@ func newUpdateCommand(dockerCli command.Cli) *cobra.Command { Args: cli.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { opts.Name = args[0] - return RunUpdate(dockerCli, opts) + return RunUpdate(dockerCLI, opts) }, - Long: longUpdateDescription(), + Long: longUpdateDescription(), + ValidArgsFunction: completeContextNames(dockerCLI, 1, false), } flags := cmd.Flags() flags.StringVar(&opts.Description, "description", "", "Description of the context") diff --git a/cli/command/context/use.go b/cli/command/context/use.go index 412df755e9..6b0d927a80 100644 --- a/cli/command/context/use.go +++ b/cli/command/context/use.go @@ -10,15 +10,16 @@ import ( "github.com/spf13/cobra" ) -func newUseCommand(dockerCli command.Cli) *cobra.Command { +func newUseCommand(dockerCLI command.Cli) *cobra.Command { cmd := &cobra.Command{ Use: "use CONTEXT", Short: "Set the current docker context", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] - return RunUse(dockerCli, name) + return RunUse(dockerCLI, name) }, + ValidArgsFunction: completeContextNames(dockerCLI, 1, false), } return cmd }