gitlink-cli/cmd/interactive/executor.go

238 lines
6.2 KiB
Go

package interactive
import (
"bytes"
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Executor runs shortcut commands and captures their stdout output.
type Executor struct{}
// Execute runs the given shortcut with the provided arguments, capturing
// anything written to os.Stdout during execution and returning it as a string.
//
// It builds the RuntimeContext via common.NewRuntimeContext so that Client,
// Format, Owner and Repo are properly initialized — a bare
// `&RuntimeContext{Args: args}` would leave Client nil and panic as soon as a
// command calls ctx.CallAPI. A deferred recover guards against panics raised
// inside the command so the REPL is never left with stdout pointing at a
// closed pipe.
func (e *Executor) Execute(s *common.Shortcut, args map[string]string) (output string, err error) {
// Guard against panics inside the command: always restore stdout even if
// s.Run panics, and surface the panic as a regular error so the REPL can
// display it instead of hanging with a corrupted stdout.
defer func() {
if r := recover(); r != nil {
output = ""
err = fmt.Errorf("command panicked: %v", r)
}
}()
// Save original stdout
oldStdout := os.Stdout
// Create a pipe: writes go to w, reads come from r
r, w, perr := os.Pipe()
if perr != nil {
return "", fmt.Errorf("failed to create pipe: %w", perr)
}
// Redirect stdout to the write end of the pipe
os.Stdout = w
// Channel to signal that the goroutine has finished reading
done := make(chan struct{})
var buf bytes.Buffer
// Read from the pipe in a goroutine so that writes don't block
go func() {
io.Copy(&buf, r)
close(done)
}()
// Build a fully-initialized context (Client/Format/Owner/Repo).
ctx, cerr := common.NewRuntimeContext(args)
if cerr != nil {
// Restore stdout and drain the pipe before returning.
os.Stdout = oldStdout
w.Close()
<-done
return "", fmt.Errorf("failed to initialize runtime context: %w", cerr)
}
// Execute the shortcut's Run function. Recover protects the path so that
// a panic still leaves the deferred cleanup below runnable.
runErr := safeRun(s, ctx)
// Close the writer to signal EOF to the reader goroutine, wait for it to
// finish copying into the buffer, then restore stdout.
w.Close()
<-done
os.Stdout = oldStdout
return buf.String(), runErr
}
// safeRun invokes a shortcut's Run function and converts any panic into an
// error, so a panicking command cannot crash the executor goroutine or leave
// os.Stdout redirected at a closed pipe.
func safeRun(s *common.Shortcut, ctx *common.RuntimeContext) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("command panicked: %v", r)
}
}()
return s.Run(ctx)
}
// parseDirectCommand parses a direct command string like "issue +list --state open"
// into its components: group, cmd (without +), flagStr, and whether parsing succeeded.
func parseDirectCommand(input string) (group, cmd, flagStr string, ok bool) {
input = strings.TrimSpace(input)
if input == "" {
return "", "", "", false
}
parts := strings.Fields(input)
if len(parts) < 2 {
return "", "", "", false
}
group = parts[0]
rawCmd := parts[1]
if !strings.HasPrefix(rawCmd, "+") {
return "", "", "", false
}
cmd = strings.TrimPrefix(rawCmd, "+")
if len(parts) > 2 {
flagStr = strings.Join(parts[2:], " ")
}
return group, cmd, flagStr, true
}
// parseFlagString parses a flag string like "--title hello -n 42" into a map.
// shortMap is used to expand short flag names to their long equivalents.
func parseFlagString(flagStr string, shortMap map[string]string) map[string]string {
result := make(map[string]string)
if flagStr == "" {
return result
}
parts := strings.Fields(flagStr)
for i := 0; i < len(parts); i++ {
part := parts[i]
if strings.HasPrefix(part, "--") {
// Long flag
flagPart := strings.TrimPrefix(part, "--")
if strings.Contains(flagPart, "=") {
// --flag=value format
kv := strings.SplitN(flagPart, "=", 2)
result[kv[0]] = kv[1]
} else if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
// --flag value format
result[flagPart] = parts[i+1]
i++
} else {
// --flag without value (boolean-like)
result[flagPart] = "true"
}
} else if strings.HasPrefix(part, "-") && len(part) > 1 {
// Short flag
shortName := strings.TrimPrefix(part, "-")
// Expand short name to long name if mapping exists
longName := shortName
if mapped, ok := shortMap[shortName]; ok {
longName = mapped
}
if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
result[longName] = parts[i+1]
i++
} else {
result[longName] = "true"
}
}
}
return result
}
// findShortcut looks up a shortcut by group and command name in the registry.
func findShortcut(all map[string][]*common.Shortcut, group, cmd string) (*common.Shortcut, bool) {
shortcuts, ok := all[group]
if !ok {
return nil, false
}
for _, s := range shortcuts {
if s.Name == cmd {
return s, true
}
}
return nil, false
}
// buildShortMap creates a mapping from short flag names to long flag names.
func buildShortMap(s *common.Shortcut) map[string]string {
m := make(map[string]string)
for _, f := range s.Flags {
if f.Short != "" {
m[f.Short] = f.Name
}
}
return m
}
// missingRequiredFlags returns the names (with -- prefix) of flags that are
// marked as required but not present in args.
func missingRequiredFlags(s *common.Shortcut, args map[string]string) []string {
var missing []string
for _, f := range s.Flags {
if f.Required {
if _, ok := args[f.Name]; !ok {
missing = append(missing, "--"+f.Name)
}
}
}
return missing
}
// formatCommandDisplay formats a command for display, e.g. "issue +create --title hello --body world".
func formatCommandDisplay(group string, s *common.Shortcut, args map[string]string) string {
var b strings.Builder
b.WriteString(group)
b.WriteString(" +")
b.WriteString(s.Name)
// Sort flag names for deterministic output
keys := make([]string, 0, len(args))
for k := range args {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b.WriteString(" --")
b.WriteString(k)
b.WriteString(" ")
b.WriteString(args[k])
}
return b.String()
}