Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cli-plugins/hooks/hook_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ package hooks
type ResponseType int

const (
// NextSteps renders suggestions under a shared "What's next:" header.
NextSteps ResponseType = 0
// GenericMessage renders before NextSteps, without a header or indentation.
// CLI versions that do not recognize this type ignore the response.
GenericMessage ResponseType = 1
)

// Request is the type representing the information
Expand Down
49 changes: 39 additions & 10 deletions cli-plugins/hooks/printer.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,46 @@ const (
indent = " "
)

// PrintNextSteps renders list of [NextSteps] messages and writes them
// to out. It is a no-op if messages is empty.
func PrintNextSteps(out io.Writer, messages []string) {
if len(messages) == 0 {
return
// EvaluatedMessage contains the lines of a hook response after template evaluation.
type EvaluatedMessage struct {
Type ResponseType
Lines []string
}

// PrintMessages groups messages by type and writes them to out. Generic messages
// appear first, without a header or indentation, followed by next steps under a
// shared "What's next:" header. Order within each type is preserved.
// Messages with no lines or an unknown type produce no output.
func PrintMessages(out io.Writer, messages []EvaluatedMessage) {
grouped := make(map[ResponseType][]string)
for _, message := range messages {
grouped[message.Type] = append(grouped[message.Type], message.Lines...)
}

_, _ = io.WriteString(out, whatsNext)
for _, msg := range messages {
_, _ = io.WriteString(out, indent)
_, _ = io.WriteString(out, msg)
_, _ = io.WriteString(out, "\n")
for _, responseType := range []ResponseType{GenericMessage, NextSteps} {
lines := grouped[responseType]
if len(lines) == 0 {
continue
}

header, prefix := "\n", ""
if responseType == NextSteps {
header, prefix = whatsNext, indent
}

_, _ = io.WriteString(out, header)
for _, line := range lines {
_, _ = io.WriteString(out, prefix)
_, _ = io.WriteString(out, line)
_, _ = io.WriteString(out, "\n")
}
}
}

// PrintNextSteps renders list of [NextSteps] messages and writes them
// to out. It is a no-op if messages is empty.
//
// Deprecated: use [PrintMessages] instead.
func PrintNextSteps(out io.Writer, messages []string) {
PrintMessages(out, []EvaluatedMessage{{Type: NextSteps, Lines: messages}})
}
78 changes: 77 additions & 1 deletion cli-plugins/hooks/printer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,78 @@ import (
"gotest.tools/v3/assert"
)

func TestPrintHookMessages(t *testing.T) {
func TestPrintMessages(t *testing.T) {
const header = "\n\x1b[1mWhat's next:\x1b[0m\n"

for _, tc := range []struct {
doc string
messages []hooks.EvaluatedMessage
expected string
}{
{doc: "no messages"},
{
doc: "no lines",
messages: []hooks.EvaluatedMessage{
{Type: hooks.GenericMessage},
{Type: hooks.NextSteps, Lines: []string{}},
},
},
{
doc: "generic message",
messages: []hooks.EvaluatedMessage{
{Type: hooks.GenericMessage, Lines: []string{"Session active"}},
},
expected: "\nSession active\n",
},
{
doc: "preserve message formatting",
messages: []hooks.EvaluatedMessage{
{Type: hooks.GenericMessage, Lines: []string{"Session active", "", " Details"}},
},
expected: "\nSession active\n\n Details\n",
},
{
doc: "next steps",
messages: []hooks.EvaluatedMessage{
{Type: hooks.NextSteps, Lines: []string{"Try another command"}},
},
expected: header + " Try another command\n",
},
{
doc: "group interleaved types and preserve order within each type",
messages: []hooks.EvaluatedMessage{
{Type: hooks.NextSteps, Lines: []string{"First suggestion"}},
{Type: hooks.GenericMessage, Lines: []string{"Session active"}},
{Type: hooks.NextSteps, Lines: []string{"Second suggestion", "Third suggestion"}},
{Type: hooks.GenericMessage, Lines: []string{"Another status"}},
},
expected: "\nSession active\nAnother status\n" + header + " First suggestion\n Second suggestion\n Third suggestion\n",
},
{
doc: "unknown type",
messages: []hooks.EvaluatedMessage{
{Type: hooks.ResponseType(99), Lines: []string{"Ignored"}},
},
},
{
doc: "unknown type does not affect known messages",
messages: []hooks.EvaluatedMessage{
{Type: hooks.ResponseType(99), Lines: []string{"Ignored"}},
{Type: hooks.GenericMessage, Lines: []string{"Session active"}},
{Type: hooks.NextSteps, Lines: []string{"Try another command"}},
},
expected: "\nSession active\n" + header + " Try another command\n",
},
} {
t.Run(tc.doc, func(t *testing.T) {
var w strings.Builder
hooks.PrintMessages(&w, tc.messages)
assert.Equal(t, w.String(), tc.expected)
})
}
}

func TestPrintNextSteps(t *testing.T) {
const header = "\n\x1b[1mWhat's next:\x1b[0m\n"

tests := []struct {
Expand All @@ -34,6 +105,11 @@ func TestPrintHookMessages(t *testing.T) {
" Foo\n" +
" bar\n",
},
{
doc: "preserve empty and indented lines",
messages: []string{"", " Details", ""},
expectedOutput: header + " \n Details\n \n",
},
}
for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) {
Expand Down
49 changes: 25 additions & 24 deletions cli-plugins/manager/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ func RunPluginHooks(ctx context.Context, dockerCLI config.Provider, rootCmd, sub
}

func runHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCommand *cobra.Command, invokedCommand string, flags map[string]string, cmdErrorMessage string) {
nextSteps := invokeAndCollectHooks(ctx, cfg, rootCmd, subCommand, invokedCommand, flags, cmdErrorMessage)
hooks.PrintNextSteps(subCommand.ErrOrStderr(), nextSteps)
messages := invokeAndCollectHooks(ctx, cfg, rootCmd, subCommand, invokedCommand, flags, cmdErrorMessage)
hooks.PrintMessages(subCommand.ErrOrStderr(), messages)
}

func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []string {
func invokeAndCollectHooks(
ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string,
) []hooks.EvaluatedMessage {
if ctx.Err() != nil {
return nil
}
Expand All @@ -64,17 +66,17 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root
}

pluginDirs := getPluginDirs(cfg)
nextSteps := make([]string, 0, len(pluginsCfg))
var collected []hooks.EvaluatedMessage

tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (messages []string, ok bool, err error) {
tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (hooks.EvaluatedMessage, error) {
match, matched := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage)
if !matched {
return nil, false, nil
return hooks.EvaluatedMessage{}, nil
}

p, err := getPlugin(pluginName, pluginDirs, rootCmd)
if err != nil {
return nil, false, err
return hooks.EvaluatedMessage{}, err
}

resp, err := p.RunHook(ctx, hooks.Request{
Expand All @@ -83,29 +85,28 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root
CommandError: cmdErrorMessage,
})
if err != nil {
return nil, false, err
return hooks.EvaluatedMessage{}, err
}

var message hooks.Response
if err := json.Unmarshal(resp, &message); err != nil {
return nil, false, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err)
return hooks.EvaluatedMessage{}, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err)
}

// currently the only hook type
if message.Type != hooks.NextSteps {
return nil, false, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type)))
if message.Type != hooks.NextSteps && message.Type != hooks.GenericMessage {
return hooks.EvaluatedMessage{}, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type)))
}

messages, err = hooks.ParseTemplate(message.Template, subCmd)
lines, err := hooks.ParseTemplate(message.Template, subCmd)
if err != nil {
return nil, false, err
return hooks.EvaluatedMessage{}, err
}

return messages, true, nil
return hooks.EvaluatedMessage{Type: message.Type, Lines: lines}, nil
}

for pluginName, pluginCfg := range pluginsCfg {
messages, ok, err := tryInvokeHook(pluginName, pluginCfg)
message, err := tryInvokeHook(pluginName, pluginCfg)
if err != nil {
// skip misbehaving plugins, but don't halt execution
logrus.WithFields(logrus.Fields{
Expand All @@ -114,38 +115,38 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root
}).Debug("Plugin hook invocation failed")
continue
}
if !ok {
if message.Lines == nil {
continue
}

var appended bool
nextSteps, appended = appendNextSteps(nextSteps, messages)
collected, appended = appendMessages(collected, message)
if !appended {
logrus.WithFields(logrus.Fields{
"plugin": pluginName,
}).Debug("Plugin responded with an empty hook message; ignoring")
}
}
return nextSteps
return collected
}

// appendNextSteps appends the processed hook output to the nextSteps slice.
// appendMessages appends the processed hook output to the messages slice.
// If the processed hook output is empty, it is not appended.
// Empty lines are not stripped if there's at least one non-empty line.
func appendNextSteps(nextSteps []string, processed []string) ([]string, bool) {
func appendMessages(messages []hooks.EvaluatedMessage, processed hooks.EvaluatedMessage) ([]hooks.EvaluatedMessage, bool) {
empty := true
for _, l := range processed {
for _, l := range processed.Lines {
if strings.TrimSpace(l) != "" {
empty = false
break
}
}

if empty {
return nextSteps, false
return messages, false
}

return append(nextSteps, processed...), true
return append(messages, processed), true
}

// pluginMatch takes a plugin configuration and a string representing the
Expand Down
Loading
Loading