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
135 changes: 135 additions & 0 deletions cmd/run.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"

"github.com/jongio/grut/internal/config"
Expand All @@ -18,6 +20,7 @@
describeFlag string
dryRunFlag bool
noConfirm bool
jsonFlag bool
)

runCmd := &cobra.Command{
Expand Down Expand Up @@ -59,6 +62,9 @@
// Handle --list.
if listFlag {
all := engine.List()
if jsonFlag {
return printShortcutListJSON(cmd, all)
}
if len(all) == 0 {
_, _ = fmt.Fprintln(w, "No shortcuts available.")
return nil
Expand All @@ -66,9 +72,9 @@
_, _ = fmt.Fprintln(w, "Available shortcuts:")
_, _ = fmt.Fprintln(w)
for _, s := range all {
src := "builtin"

Check failure on line 75 in cmd/run.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

string `builtin` has 3 occurrences, make it a constant (goconst)
if !s.Builtin {
src = "custom"

Check failure on line 77 in cmd/run.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

string `custom` has 3 occurrences, make it a constant (goconst)
}
_, _ = fmt.Fprintf(w, " %-12s %-8s %s\n", s.Name, "["+src+"]", s.Description)
}
Expand All @@ -81,6 +87,9 @@
if !ok {
return fmt.Errorf("unknown shortcut %q", describeFlag)
}
if jsonFlag {
return printShortcutJSON(cmd, s)
}
printShortcutDetails(cmd, s)
return nil
}
Expand All @@ -89,6 +98,9 @@
if len(args) == 0 {
return fmt.Errorf("shortcut name required (use --list to see available shortcuts)")
}
if jsonFlag && !dryRunFlag {
return fmt.Errorf("--json can only be used with --list, --describe, or --dry-run")
}

name := args[0]
scArgs := parseShortcutArgs(args[1:])
Expand All @@ -99,6 +111,9 @@
if err != nil {
return fmt.Errorf("plan shortcut %q: %w", name, err)
}
if jsonFlag {
return printShortcutPlanJSON(cmd, name, steps)
}
fmt.Fprintf(w, "Dry run for %q:\n\n", name)
for i, step := range steps {
params := formatParams(step.Params)
Expand Down Expand Up @@ -165,10 +180,130 @@
runCmd.Flags().StringVar(&describeFlag, "describe", "", "Show details for a shortcut")
runCmd.Flags().BoolVar(&dryRunFlag, "dry-run", false, "Show execution plan without running")
runCmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip confirmation prompts")
runCmd.Flags().BoolVar(&jsonFlag, "json", false, "Print machine-readable JSON for list, describe, or dry-run")

return runCmd
}

type shortcutSummaryJSON struct {
Name string `json:"name"`
Description string `json:"description"`
Source string `json:"source"`
Builtin bool `json:"builtin"`
Confirm bool `json:"confirm"`
}

type shortcutDetailJSON struct {
Name string `json:"name"`
Description string `json:"description"`
Source string `json:"source"`
Args []shortcutArgJSON `json:"args"`
Steps []shortcutStepJSON `json:"steps"`
Builtin bool `json:"builtin"`
Confirm bool `json:"confirm"`
}

type shortcutArgJSON struct {
Name string `json:"name"`
Prompt string `json:"prompt"`
Default string `json:"default"`
Required bool `json:"required"`
}

type shortcutStepJSON struct {
Op string `json:"op"`
Params map[string]string `json:"params,omitempty"`
OnFail string `json:"on_fail"`
AIAssist bool `json:"ai_assist"`
}

type shortcutPlanJSON struct {
Name string `json:"name"`
Steps []shortcutStepJSON `json:"steps"`
}

func printShortcutListJSON(cmd *cobra.Command, shortcuts []shortcuts.Shortcut) error {
summaries := make([]shortcutSummaryJSON, 0, len(shortcuts))
for _, s := range shortcuts {
summaries = append(summaries, shortcutSummaryJSON{
Name: s.Name,
Description: s.Description,
Source: shortcutSource(s),
Builtin: s.Builtin,
Confirm: s.Confirm,
})
}
sort.Slice(summaries, func(i, j int) bool {
return summaries[i].Name < summaries[j].Name
})
return writeJSON(cmd, summaries)
}

func printShortcutJSON(cmd *cobra.Command, s shortcuts.Shortcut) error {
return writeJSON(cmd, shortcutDetail(s))
}

func printShortcutPlanJSON(cmd *cobra.Command, name string, steps []shortcuts.Step) error {
out := shortcutPlanJSON{
Name: name,
Steps: make([]shortcutStepJSON, 0, len(steps)),
}
for _, step := range steps {
out.Steps = append(out.Steps, shortcutStep(step))
}
return writeJSON(cmd, out)
}

func shortcutDetail(s shortcuts.Shortcut) shortcutDetailJSON {
detail := shortcutDetailJSON{
Name: s.Name,
Description: s.Description,
Source: shortcutSource(s),
Builtin: s.Builtin,
Confirm: s.Confirm,
Args: make([]shortcutArgJSON, 0, len(s.Args)),
Steps: make([]shortcutStepJSON, 0, len(s.Steps)),
}
for _, arg := range s.Args {
detail.Args = append(detail.Args, shortcutArgJSON{
Name: arg.Name,
Prompt: arg.Prompt,
Default: arg.Default,
Required: arg.Required,
})
}
for _, step := range s.Steps {
detail.Steps = append(detail.Steps, shortcutStep(step))
}
return detail
}

func shortcutStep(step shortcuts.Step) shortcutStepJSON {
params := make(map[string]string, len(step.Params))
for key, value := range step.Params {
params[key] = value
}
return shortcutStepJSON{
Op: step.Op,
Params: params,
OnFail: stepOnFail(step),
AIAssist: step.AIAssist,
}
}

func shortcutSource(s shortcuts.Shortcut) string {
if s.Builtin {
return "builtin"
}
return "custom"
}

func writeJSON(cmd *cobra.Command, value any) error {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(value)
}

// registerCustomShortcuts converts config CustomShortcut definitions into
// engine-registered Shortcut values.
func registerCustomShortcuts(engine *shortcuts.Engine, customs []config.CustomShortcut) {
Expand Down
72 changes: 72 additions & 0 deletions cmd/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"bytes"
"encoding/json"
"sort"
"strings"
"testing"
Expand All @@ -10,6 +11,7 @@ import (
"github.com/jongio/grut/internal/shortcuts"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -259,6 +261,7 @@ func TestNewRunCmd_FlagsRegistered(t *testing.T) {
{"describe", "string"},
{"dry-run", "bool"},
{"no-confirm", "bool"},
{"json", "bool"},
}

for _, tt := range tests {
Expand All @@ -269,6 +272,75 @@ func TestNewRunCmd_FlagsRegistered(t *testing.T) {
}
}

func TestPrintShortcutListJSON_SortedSummaries(t *testing.T) {
cmd := &cobra.Command{}
var buf bytes.Buffer
cmd.SetOut(&buf)

err := printShortcutListJSON(cmd, []shortcuts.Shortcut{
{Name: "zeta", Description: "last", Builtin: false, Confirm: true},
{Name: "alpha", Description: "first", Builtin: true, Confirm: false},
})
require.NoError(t, err)

var out []shortcutSummaryJSON
require.NoError(t, json.Unmarshal(buf.Bytes(), &out))
require.Len(t, out, 2)
assert.Equal(t, "alpha", out[0].Name)
assert.Equal(t, "builtin", out[0].Source)
assert.Equal(t, "zeta", out[1].Name)
assert.Equal(t, "custom", out[1].Source)
}

func TestPrintShortcutJSON_IncludesArgsAndSteps(t *testing.T) {
cmd := &cobra.Command{}
var buf bytes.Buffer
cmd.SetOut(&buf)

err := printShortcutJSON(cmd, shortcuts.Shortcut{
Name: "release",
Description: "Create a release",
Builtin: true,
Confirm: true,
Args: []shortcuts.Arg{
{Name: "version", Prompt: "Release version", Required: true, Default: "v1.0.0"},
},
Steps: []shortcuts.Step{
{Op: "tag", Params: map[string]string{"name": "{{version}}"}, OnFail: ""},
},
})
require.NoError(t, err)

var out shortcutDetailJSON
require.NoError(t, json.Unmarshal(buf.Bytes(), &out))
assert.Equal(t, "release", out.Name)
require.Len(t, out.Args, 1)
assert.Equal(t, "version", out.Args[0].Name)
require.Len(t, out.Steps, 1)
assert.Equal(t, "tag", out.Steps[0].Op)
assert.Equal(t, shortcuts.OnFailStop, out.Steps[0].OnFail)
assert.Equal(t, "{{version}}", out.Steps[0].Params["name"])
}

func TestPrintShortcutPlanJSON_IncludesResolvedSteps(t *testing.T) {
cmd := &cobra.Command{}
var buf bytes.Buffer
cmd.SetOut(&buf)

err := printShortcutPlanJSON(cmd, "deploy", []shortcuts.Step{
{Op: "push", Params: map[string]string{"remote": "origin"}, OnFail: shortcuts.OnFailContinue},
})
require.NoError(t, err)

var out shortcutPlanJSON
require.NoError(t, json.Unmarshal(buf.Bytes(), &out))
assert.Equal(t, "deploy", out.Name)
require.Len(t, out.Steps, 1)
assert.Equal(t, "push", out.Steps[0].Op)
assert.Equal(t, "origin", out.Steps[0].Params["remote"])
assert.Equal(t, shortcuts.OnFailContinue, out.Steps[0].OnFail)
}

func TestNewRunCmd_UseAndShort(t *testing.T) {
cmd := newRunCmd()
assert.Equal(t, "run <shortcut> [args...]", cmd.Use)
Expand Down
Loading