diff --git a/README.md b/README.md index 5cc4d48..e5a1a08 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,14 @@ Export cheat metadata as JSON or CSV for indexing and tooling: cheatmd dump ~/cheats --json ``` +### Headless Mode + +Run CheatMD without a TUI to integrate with other tools (like VS Code or Obsidian) using a JSON-RPC interface over standard I/O: + +```bash +cheatmd --headless -q "docker exec" +``` + ## TUI Keys | Key | Action | @@ -127,6 +135,7 @@ Full documentation lives in the **[Wiki](../../wiki)**: - **[Shell Integration](../../wiki/Shell-Integration)** - widget, tmux, zellij - **[Linting](../../wiki/Linting)** - syntax and reference validation - **[Dump](../../wiki/Dump)** - metadata export +- **[Headless Mode](../../wiki/Headless-Mode)** - JSON-RPC programmatic interface - **[Recipes](../../wiki/Recipes)** - copy-pasteable patterns ## Contributing diff --git a/cmd/cheatmd/root.go b/cmd/cheatmd/root.go index f91c6a2..daf4d31 100644 --- a/cmd/cheatmd/root.go +++ b/cmd/cheatmd/root.go @@ -7,6 +7,7 @@ import ( "runtime" "time" + "github.com/gubarz/cheatmd/internal/headless" "github.com/gubarz/cheatmd/internal/ui" "github.com/gubarz/cheatmd/pkg/config" "github.com/gubarz/cheatmd/pkg/executor" @@ -36,6 +37,7 @@ func init() { chainCmd.AddCommand(chainResetCmd) rootCmd.PersistentFlags().StringP("query", "q", "", "Initial search query") + rootCmd.PersistentFlags().Bool("headless", false, "Run in headless interactive JSON-RPC mode") rootCmd.PersistentFlags().StringP("match", "m", "", "Match a full command pattern and auto-fill its variables") rootCmd.PersistentFlags().BoolP("print", "p", false, "Print command (default)") rootCmd.PersistentFlags().BoolP("copy", "c", false, "Copy command") @@ -140,6 +142,11 @@ func runCheats(cmd *cobra.Command, args []string) error { return nil } + headlessFlag, _ := cmd.Flags().GetBool("headless") + if headlessFlag { + return headless.Run(index, exec, query, match) + } + // Run the TUI (history view if --history was passed) var finalCmd string if historyFlag, _ := cmd.Flags().GetBool("history"); historyFlag { diff --git a/internal/headless/headless.go b/internal/headless/headless.go new file mode 100644 index 0000000..22793fa --- /dev/null +++ b/internal/headless/headless.go @@ -0,0 +1,651 @@ +package headless + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/gubarz/cheatmd/internal/resolver" + "github.com/gubarz/cheatmd/pkg/config" + "github.com/gubarz/cheatmd/pkg/executor" + "github.com/gubarz/cheatmd/pkg/parser" +) + +// Executor defines the interface required by the headless runner for shell command execution. +type Executor interface { + RunShell(command string) (string, error) + BuildFinalCommand(cheat *parser.Cheat) string + OutputWithMode(command string, mode executor.OutputMode) error +} + +// promptVar defines the JSON-RPC structure for prompting variables. +type promptVar struct { + Name string `json:"name"` + Header string `json:"header"` + Placeholder string `json:"placeholder"` + Options []string `json:"options"` + Multi bool `json:"multi"` + varIdx int `json:"-"` +} + +// RunnerSession encapsulates the state and lifecycle of a command execution process. +// It orchestrates variable resolution and final command execution. +type RunnerSession struct { + Index *parser.CheatIndex + Exec Executor + Cheat *parser.Cheat + Vars []resolver.VarState + Scanner *bufio.Scanner +} + +// Run acts as the primary entry point, constructing a session and starting the execution. +func Run(index *parser.CheatIndex, exec Executor, initialQuery, matchCmd string) error { + session := &RunnerSession{ + Index: index, + Exec: exec, + Scanner: bufio.NewScanner(os.Stdin), + } + return session.Execute(initialQuery, matchCmd) +} + +// Execute initiates the command execution lifecycle. It locates the target cheat, +// resolves required variables, and executes the finalized command. +func (s *RunnerSession) Execute(initialQuery, matchCmd string) error { + if err := s.findTargetCheat(initialQuery, matchCmd); err != nil { + return err + } + s.initializeVariables() + if err := s.resolveInteractively(); err != nil { + return err + } + return s.runCommand() +} + +// findTargetCheat scans the provided index for the optimal cheat block to run. +func (s *RunnerSession) findTargetCheat(initialQuery, matchCmd string) error { + cheats := s.Index.FilterByConfig(config.GetRequireCheatBlock()) + if len(cheats) == 0 { + return fmt.Errorf("no executable cheats found in index") + } + + if s.tryMatchByCommand(cheats, matchCmd) { + return nil + } + + query := s.resolveInitialQuery(initialQuery, matchCmd) + if s.tryMatchByQuery(cheats, query) { + return nil + } + + return fmt.Errorf("headless runner requires a precise query or match command to isolate a single cheat block") +} + +func (s *RunnerSession) resolveInitialQuery(initialQuery, matchCmd string) string { + if matchCmd != "" { + return matchCmd + } + return initialQuery +} + +func (s *RunnerSession) tryMatchByCommand(cheats []*parser.Cheat, matchCmd string) bool { + if matchCmd == "" { + return false + } + s.Cheat = resolver.FindMatchingCheat(cheats, matchCmd) + if s.Cheat == nil { + return false + } + resolver.PrefillScopeFromMatch(s.Cheat, matchCmd) + resolver.InferDependentVars(s.Cheat, s.Index) + return true +} + +func (s *RunnerSession) tryMatchByQuery(cheats []*parser.Cheat, query string) bool { + if query == "" { + return false + } + words := strings.Fields(strings.ToLower(query)) + matchedCheats := s.filterCheatsByWords(cheats, words) + s.Cheat = s.findExactHeaderMatch(matchedCheats, query) + if s.Cheat != nil { + return true + } + if len(matchedCheats) > 0 { + s.Cheat = matchedCheats[0] + return true + } + return false +} + +func (s *RunnerSession) filterCheatsByWords(cheats []*parser.Cheat, words []string) []*parser.Cheat { + var matched []*parser.Cheat + for _, c := range cheats { + if cheatMatchesQuery(c, words) { + matched = append(matched, c) + } + } + return matched +} + +func (s *RunnerSession) findExactHeaderMatch(cheats []*parser.Cheat, query string) *parser.Cheat { + for _, mc := range cheats { + if strings.EqualFold(mc.Header, query) { + return mc + } + } + return nil +} + +// initializeVariables extracts and pre-fills environmental and scoped variables, +// priming the parameter list for resolution. +func (s *RunnerSession) initializeVariables() { + if s.Cheat.Scope == nil { + s.Cheat.Scope = make(map[string]string) + } + + s.Vars = resolver.CollectVariables(s.Cheat, s.Index) + for i := range s.Vars { + s.primeVariable(&s.Vars[i]) + } +} + +func (s *RunnerSession) primeVariable(vs *resolver.VarState) { + varName := vs.Def.Name + vs.MultiSelectedSet = make(map[string]bool) + + if scopeVal, ok := s.Cheat.Scope[varName]; ok && scopeVal != "" { + vs.Prefill = scopeVal + vs.SkipAutoCont = true + return + } + if envVal := os.Getenv(varName); envVal != "" { + vs.Prefill = envVal + } +} + +// resolveInteractively loops through the state machine, attempting auto-resolution +// before falling back to prompting the user over JSON-RPC until all variables are resolved. +func (s *RunnerSession) resolveInteractively() error { + for !s.allVariablesResolved() { + s.autoResolveLoop() + if s.allVariablesResolved() { + break + } + + if err := s.promptNextBatch(); err != nil { + return err + } + } + + s.commitFinalizedVariables() + return nil +} + +func (s *RunnerSession) promptNextBatch() error { + promptVars, err := s.collectUnresolvedDependencies() + if err != nil { + return err + } + return s.promptClient(promptVars) +} + +func (s *RunnerSession) commitFinalizedVariables() { + for _, vs := range s.Vars { + if vs.Resolved { + s.Cheat.Scope[vs.Def.Name] = vs.Value + } + } +} + +// autoResolveLoop continuously evaluates conditions and literal dependencies, +// resolving variable states automatically whenever possible without user interaction. +func (s *RunnerSession) autoResolveLoop() { + for s.attemptAutoResolvePass() { + } +} + +func (s *RunnerSession) attemptAutoResolvePass() bool { + progress := false + scope := s.buildCurrentScope() + + for i := range s.Vars { + if s.tryResolveVariable(&s.Vars[i], scope) { + progress = true + } + } + + return progress +} + +func (s *RunnerSession) tryResolveVariable(vs *resolver.VarState, scope map[string]string) bool { + if vs.Resolved { + return false + } + if !s.areConditionDependenciesResolved(vs) { + return false + } + + s.updateVariableDefinition(vs, scope) + + if s.tryAutoContinue(vs) { + return true + } + + return s.tryLiteralResolution(vs, scope) +} + +func (s *RunnerSession) updateVariableDefinition(vs *resolver.VarState, scope map[string]string) { + selectedDef := resolver.SelectVariant(vs.Variants, scope) + if selectedDef != nil { + vs.Def = *selectedDef + return + } + + if s.allVariantsConditional(vs) { + vs.Resolved = true + vs.Value = "" + } +} + +func (s *RunnerSession) tryAutoContinue(vs *resolver.VarState) bool { + if !s.canAutoContinue(vs) { + return false + } + vs.Value = vs.Prefill + vs.Resolved = true + return true +} + +func (s *RunnerSession) tryLiteralResolution(vs *resolver.VarState, scope map[string]string) bool { + if vs.Def.Literal == "" { + return false + } + if !s.areLiteralDependenciesResolved(vs.Def.Literal) { + return false + } + vs.Value = executor.SubstituteVars(vs.Def.Literal, scope, "dollar") + vs.Resolved = true + return true +} + +// buildCurrentScope generates a temporary evaluation context representing +// all currently resolved variables. +func (s *RunnerSession) buildCurrentScope() map[string]string { + scope := make(map[string]string) + for _, v := range s.Vars { + if v.Resolved { + scope[v.Def.Name] = v.Value + } + } + return scope +} + +// allVariablesResolved determines if all variables are fully resolved. +func (s *RunnerSession) allVariablesResolved() bool { + for _, v := range s.Vars { + if !v.Resolved { + return false + } + } + return true +} + +// areConditionDependenciesResolved verifies that any variables required to evaluate +// the conditions of this variable's variants have already been resolved. +func (s *RunnerSession) areConditionDependenciesResolved(vs *resolver.VarState) bool { + for _, variant := range vs.Variants { + if !s.isVariantConditionResolved(variant) { + return false + } + } + return true +} + +func (s *RunnerSession) isVariantConditionResolved(variant parser.VarDef) bool { + if variant.Condition == "" { + return true + } + deps := executor.FindAllVars(variant.Condition, "dollar") + return s.areDependenciesResolved(deps) +} + +func (s *RunnerSession) areDependenciesResolved(deps []string) bool { + for _, dep := range deps { + if !s.isDependencyResolved(dep) { + return false + } + } + return true +} + +// isDependencyResolved checks the resolution state of a single dependency. +func (s *RunnerSession) isDependencyResolved(depName string) bool { + for _, ov := range s.Vars { + if ov.Def.Name == depName && ov.Resolved { + return true + } + } + return false +} + +// allVariantsConditional determines if a variable purely consists of conditional variants +// allowing it to safely resolve to empty if no conditions match. +func (s *RunnerSession) allVariantsConditional(vs *resolver.VarState) bool { + allConditional := true + for _, v := range vs.Variants { + if v.Condition == "" { + allConditional = false + break + } + } + return allConditional && len(vs.Variants) > 0 +} + +// canAutoContinue enforces the configuration rules for automatically advancing +// through prefilled fields without prompting. +func (s *RunnerSession) canAutoContinue(vs *resolver.VarState) bool { + autoContinue := config.GetAutoContinue() + return autoContinue && vs.Prefill != "" && !vs.SkipAutoCont +} + +// areLiteralDependenciesResolved ensures that a literal parameter transformation +// has all required dependencies before attempting evaluation. +func (s *RunnerSession) areLiteralDependenciesResolved(literal string) bool { + deps := executor.FindAllVars(literal, "dollar") + return s.areDependenciesResolved(deps) +} + +// collectUnresolvedDependencies compiles the next batch of variables requiring user input, +// evaluating any dynamic shell scripts for option enumeration. +func (s *RunnerSession) collectUnresolvedDependencies() ([]promptVar, error) { + var promptVars []promptVar + scope := s.buildCurrentScope() + + for i := range s.Vars { + pv := s.buildPromptVarIfReady(&s.Vars[i], i, scope) + if pv != nil { + promptVars = append(promptVars, *pv) + } + } + + if len(promptVars) == 0 { + return nil, fmt.Errorf("resolution deadlock: cyclical dependencies detected, resolution stopped") + } + + return promptVars, nil +} + +func (s *RunnerSession) buildPromptVarIfReady(vs *resolver.VarState, idx int, scope map[string]string) *promptVar { + if vs.Resolved { + return nil + } + if vs.Def.Literal != "" { + return nil + } + if !s.areConditionDependenciesResolved(vs) { + return nil + } + + selectOpts := resolver.ParseSelectorOpts(vs.Def.Args) + options := s.evaluateShellOptions(vs, scope, selectOpts) + + return &promptVar{ + Name: vs.Def.Name, + Header: resolver.ExtractCustomHeader(vs.Def.Args), + Placeholder: vs.Prefill, + Options: options, + Multi: selectOpts.Multi, + varIdx: idx, + } +} + +func (s *RunnerSession) evaluateShellOptions(vs *resolver.VarState, scope map[string]string, selectOpts resolver.SelectOptions) []string { + if strings.TrimSpace(vs.Def.Shell) == "" { + return nil + } + + shellCmd := executor.SubstituteVars(vs.Def.Shell, scope, "dollar") + output, err := s.Exec.RunShell(shellCmd) + if err != nil { + return nil + } + + return s.parseShellOptions(output, selectOpts) +} + +func (s *RunnerSession) parseShellOptions(output string, selectOpts resolver.SelectOptions) []string { + var options []string + lines := parser.SplitLines(output) + for _, opt := range lines { + display := resolver.GetDisplayColumn(opt, selectOpts.Delimiter, selectOpts.Column) + options = append(options, display) + } + return options +} + +// promptClient sends a JSON-RPC request over the wire, requesting the user +// to manually supply the required variable values. +func (s *RunnerSession) promptClient(promptVars []promptVar) error { + reqBytes, err := s.marshalPromptRequest(promptVars) + if err != nil { + return err + } + + fmt.Println(string(reqBytes)) + + if !s.Scanner.Scan() { + return fmt.Errorf("client connection severed unexpectedly during variable prompt") + } + + promptRes, err := s.parsePromptResponse(s.Scanner.Text()) + if err != nil { + return err + } + + s.ingestPromptValues(promptVars, promptRes) + return nil +} + +func (s *RunnerSession) marshalPromptRequest(promptVars []promptVar) ([]byte, error) { + promptReq := map[string]interface{}{ + "jsonrpc": "2.0", + "method": "prompt", + "params": map[string]interface{}{ + "variables": promptVars, + }, + "id": 1, + } + + reqBytes, err := json.Marshal(promptReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal prompt request: %w", err) + } + return reqBytes, nil +} + +type promptResponse struct { + Jsonrpc string `json:"jsonrpc"` + Result struct { + Values map[string]string `json:"values"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + Id int `json:"id"` +} + +func (s *RunnerSession) parsePromptResponse(line string) (*promptResponse, error) { + var promptRes promptResponse + if err := json.Unmarshal([]byte(line), &promptRes); err != nil { + return nil, fmt.Errorf("failed to parse client prompt response: %w", err) + } + + if promptRes.Error != nil { + return nil, fmt.Errorf("client aborted prompt: %s", promptRes.Error.Message) + } + + return &promptRes, nil +} + +func (s *RunnerSession) ingestPromptValues(promptVars []promptVar, promptRes *promptResponse) { + for _, pv := range promptVars { + val := s.extractPromptValue(pv, promptRes) + s.applyResolvedValue(&s.Vars[pv.varIdx], val) + } +} + +func (s *RunnerSession) extractPromptValue(pv promptVar, promptRes *promptResponse) string { + val, ok := promptRes.Result.Values[pv.Name] + if !ok { + return pv.Placeholder + } + return val +} + +func (s *RunnerSession) applyResolvedValue(vs *resolver.VarState, val string) { + selectOpts := resolver.ParseSelectorOpts(vs.Def.Args) + if selectOpts.MapCmd != "" { + val = resolver.ApplyMapTransform(val, selectOpts) + } + + vs.Value = val + vs.Resolved = true +} + +// runCommand constructs the final command string, attaches any configured hooks, +// executes the command on the target shell, and reports the output via JSON-RPC. +func (s *RunnerSession) runCommand() error { + finalCmd := s.buildAndRecordCommand() + runErr, stdout, stderr := s.executeWithConfiguredMode(finalCmd) + return s.reportCompletion(finalCmd, stdout, stderr, runErr) +} + +func (s *RunnerSession) buildAndRecordCommand() string { + finalCmd := s.Exec.BuildFinalCommand(s.Cheat) + + if preHook := config.GetPreHook(); preHook != "" { + finalCmd = preHook + finalCmd + } + if postHook := config.GetPostHook(); postHook != "" { + finalCmd = finalCmd + postHook + } + return finalCmd +} + +func (s *RunnerSession) executeWithConfiguredMode(finalCmd string) (error, string, string) { + switch config.GetOutput() { + case "exec": + stdout, stderr, err := runCommandAndCapture(config.GetShell(), finalCmd) + return err, stdout, stderr + case "copy": + err := s.Exec.OutputWithMode(finalCmd, executor.OutputCopy) + return err, "", "" + default: // print + return nil, finalCmd, "" + } +} + +func (s *RunnerSession) reportCompletion(finalCmd, stdout, stderr string, runErr error) error { + status, errMsg := s.determineRunStatus(runErr) + + completedFrame := map[string]interface{}{ + "jsonrpc": "2.0", + "method": "completed", + "params": map[string]interface{}{ + "status": status, + "command": finalCmd, + "stdout": stdout, + "stderr": stderr, + "error": errMsg, + "exit_code": getExitCode(runErr), + }, + } + + resBytes, err := json.Marshal(completedFrame) + if err != nil { + return fmt.Errorf("failed to encode completion output: %w", err) + } + fmt.Println(string(resBytes)) + + return runErr +} + +func (s *RunnerSession) determineRunStatus(err error) (string, string) { + if err != nil { + return "error", err.Error() + } + return "success", "" +} + +// Helper Utilities +// ----------------------------------------------------------------------------- + +// runCommandAndCapture shells out the given command and intercepts both standard streams. +func runCommandAndCapture(shell, command string) (string, string, error) { + cmd := exec.Command(shell, "-c", command) + cmd.Env = os.Environ() + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + err := cmd.Run() + return stdoutBuf.String(), stderrBuf.String(), err +} + +// getExitCode extracts the OS-level exit code from an execution error, or -1 if unavailable. +func getExitCode(err error) int { + if err == nil { + return 0 + } + if exitError, ok := err.(*exec.ExitError); ok { + return exitError.ExitCode() + } + return -1 +} + +// cheatMatchesQuery performs a heuristic search across the cheat's metadata for targeting. +func cheatMatchesQuery(cheat *parser.Cheat, words []string) bool { + for _, word := range words { + if !wordMatchesCheat(cheat, word) { + return false + } + } + return true +} + +func wordMatchesCheat(cheat *parser.Cheat, word string) bool { + if matchesBasicMetadata(cheat, word) { + return true + } + return matchesAnyTag(cheat.Tags, word) +} + +func matchesBasicMetadata(cheat *parser.Cheat, word string) bool { + folder := strings.ToLower(filepath.Base(cheat.File)) + file := strings.ToLower(strings.TrimSuffix(filepath.Base(cheat.File), ".md")) + header := strings.ToLower(cheat.Header) + desc := strings.ToLower(cheat.Description) + command := strings.ToLower(cheat.Command) + + return strings.Contains(folder, word) || + strings.Contains(file, word) || + strings.Contains(header, word) || + strings.Contains(desc, word) || + strings.Contains(command, word) +} + +func matchesAnyTag(tags []string, word string) bool { + for _, tag := range tags { + if strings.Contains(strings.ToLower(tag), word) { + return true + } + } + return false +} diff --git a/internal/headless/headless_test.go b/internal/headless/headless_test.go new file mode 100644 index 0000000..2f277c7 --- /dev/null +++ b/internal/headless/headless_test.go @@ -0,0 +1,251 @@ +package headless + +import ( + "encoding/json" + "io" + "os" + "strings" + "testing" + + "github.com/gubarz/cheatmd/pkg/executor" + "github.com/gubarz/cheatmd/pkg/parser" +) + +type mockHeadlessExecutor struct { + shellResult string + shellErr error + finalCmd string + outputErr error +} + +func (m *mockHeadlessExecutor) RunShell(command string) (string, error) { + return m.shellResult, m.shellErr +} + +func (m *mockHeadlessExecutor) BuildFinalCommand(cheat *parser.Cheat) string { + if m.finalCmd != "" { + return m.finalCmd + } + return cheat.Command +} + +func (m *mockHeadlessExecutor) OutputWithMode(command string, mode executor.OutputMode) error { + return m.outputErr +} + +func TestRunHeadlessSuccess(t *testing.T) { + cheat := &parser.Cheat{ + File: "test.md", + Header: "Test Headless", + Command: "ping $ip", + Vars: []parser.VarDef{ + {Name: "ip"}, + }, + } + + index := parser.NewCheatIndex() + index.Cheats = []*parser.Cheat{cheat} + + // 1. Mock Stdin and Stdout Pipes + oldStdin := os.Stdin + oldStdout := os.Stdout + defer func() { + os.Stdin = oldStdin + os.Stdout = oldStdout + }() + + rIn, wIn, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + os.Stdin = rIn + + rOut, wOut, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + os.Stdout = wOut + + // 2. Prepare Mock responses + resBytes, _ := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]interface{}{ + "values": map[string]string{ + "ip": "127.0.0.1", + }, + }, + "id": 1, + }) + _, _ = wIn.Write(append(resBytes, '\n')) + _ = wIn.Close() // Close stdin writing so Scanner doesn't hang + + exec := &mockHeadlessExecutor{ + finalCmd: "ping 127.0.0.1", + } + + // Channel to capture stdout asynchronously + outChan := make(chan string) + go func() { + var buf strings.Builder + _, _ = io.Copy(&buf, rOut) + outChan <- buf.String() + }() + + // 3. Run Headless function + err = Run(index, exec, "Test Headless", "") + _ = wOut.Close() // Close stdout writing so copy goroutine finishes + + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + + capturedStdout := <-outChan + + // 4. Parse captured stdout lines + lines := strings.Split(strings.TrimSpace(capturedStdout), "\n") + if len(lines) < 2 { + t.Fatalf("expected at least 2 output frames, got: %d (%q)", len(lines), capturedStdout) + } + + // First line must be prompt request + var promptReq struct { + Jsonrpc string `json:"jsonrpc"` + Method string `json:"method"` + Params struct { + Variables []struct { + Name string `json:"name"` + } `json:"variables"` + } `json:"params"` + Id int `json:"id"` + } + if err := json.Unmarshal([]byte(lines[0]), &promptReq); err != nil { + t.Fatalf("failed to unmarshal prompt request: %v", err) + } + if promptReq.Method != "prompt" || len(promptReq.Params.Variables) == 0 || promptReq.Params.Variables[0].Name != "ip" { + t.Errorf("unexpected prompt request: %+v", promptReq) + } + + // Second line must be completed execution frame + var completed struct { + Jsonrpc string `json:"jsonrpc"` + Method string `json:"method"` + Params struct { + Status string `json:"status"` + Command string `json:"command"` + } `json:"params"` + } + if err := json.Unmarshal([]byte(lines[1]), &completed); err != nil { + t.Fatalf("failed to unmarshal completed frame: %v", err) + } + if completed.Method != "completed" || completed.Params.Status != "success" || completed.Params.Command != "ping 127.0.0.1" { + t.Errorf("unexpected completed frame: %+v", completed) + } +} + +func TestRunHeadlessConditionalDependencies(t *testing.T) { + cheat := &parser.Cheat{ + File: "test.md", + Header: "Test Dynamic Resolution", + Command: "curl https://$domain/api -u $user $auth_flags", + Vars: []parser.VarDef{ + {Name: "domain"}, + {Name: "user"}, + {Name: "auth_method"}, + {Name: "credential", Condition: "$auth_method != oauth"}, + {Name: "auth_flags", Literal: "-p $credential", Condition: "$auth_method == password"}, + {Name: "auth_flags", Literal: "-H 'Authorization: Bearer $credential'", Condition: "$auth_method == token"}, + {Name: "auth_flags", Literal: "--oauth2-bearer", Condition: "$auth_method == oauth"}, + }, + } + + index := parser.NewCheatIndex() + index.Cheats = []*parser.Cheat{cheat} + + oldStdin := os.Stdin + oldStdout := os.Stdout + defer func() { + os.Stdin = oldStdin + os.Stdout = oldStdout + }() + + rIn, wIn, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + os.Stdin = rIn + + rOut, wOut, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + os.Stdout = wOut + + // 1. Prepare Stdin replies for multi-pass dynamic prompts + // Pass 1: resolve domain, user, and auth_method + resBytes1, _ := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]interface{}{ + "values": map[string]string{ + "domain": "api.example.com", + "user": "admin", + "auth_method": "password", + }, + }, + "id": 1, + }) + + // Pass 2: resolve credential (now that auth_method == password is known) + resBytes2, _ := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]interface{}{ + "values": map[string]string{ + "credential": "secret123", + }, + }, + "id": 1, + }) + + _, _ = wIn.Write(append(resBytes1, '\n')) + _, _ = wIn.Write(append(resBytes2, '\n')) + _ = wIn.Close() + + exec := &mockHeadlessExecutor{ + finalCmd: "curl https://api.example.com/api -u admin -p secret123", + } + + outChan := make(chan string) + go func() { + var buf strings.Builder + _, _ = io.Copy(&buf, rOut) + outChan <- buf.String() + }() + + err = Run(index, exec, "Test Dynamic Resolution", "") + _ = wOut.Close() + + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + + capturedStdout := <-outChan + lines := strings.Split(strings.TrimSpace(capturedStdout), "\n") + if len(lines) < 3 { + t.Fatalf("expected at least 3 output frames (2 prompts + 1 completed), got: %d (%q)", len(lines), capturedStdout) + } + + // Parse first completed frame and verify it matches the correct final execution command + var completed struct { + Jsonrpc string `json:"jsonrpc"` + Method string `json:"method"` + Params struct { + Status string `json:"status"` + Command string `json:"command"` + } `json:"params"` + } + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &completed); err != nil { + t.Fatalf("failed to unmarshal completed frame: %v", err) + } + if completed.Params.Command != "curl https://api.example.com/api -u admin -p secret123" { + t.Errorf("unexpected command: %q", completed.Params.Command) + } +} diff --git a/internal/resolver/helpers.go b/internal/resolver/helpers.go new file mode 100644 index 0000000..1efc30f --- /dev/null +++ b/internal/resolver/helpers.go @@ -0,0 +1,84 @@ +package resolver + +import ( + "os/exec" + "strings" + + "github.com/gubarz/cheatmd/pkg/config" +) + +// GetDisplayColumn extracts the display column from a line based on a delimiter. +func GetDisplayColumn(line, delimiter string, column int) string { + if delimiter == "" || column == 0 { + return line + } + parts := strings.Split(line, delimiter) + if column > 0 && column <= len(parts) { + return strings.TrimSpace(parts[column-1]) + } + return line +} + +// ParseShellArgs parses a string into arguments, respecting quotes. +func ParseShellArgs(s string) []string { + var args []string + var current strings.Builder + var inQuote bool + var quoteChar byte + + for i := 0; i < len(s); i++ { + c := s[i] + + if inQuote { + if c == quoteChar { + inQuote = false + } else { + current.WriteByte(c) + } + } else { + switch c { + case '"', '\'': + inQuote = true + quoteChar = c + case ' ', '\t': + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + default: + current.WriteByte(c) + } + } + } + + if current.Len() > 0 { + args = append(args, current.String()) + } + + return args +} + +// ApplyMapTransform transforms the selected value based on delimiter, select column, and map command options. +func ApplyMapTransform(value string, opts SelectOptions) string { + // Apply select-column extraction first + if opts.SelectColumn > 0 && opts.Delimiter != "" { + parts := strings.Split(value, opts.Delimiter) + if opts.SelectColumn <= len(parts) { + value = strings.TrimSpace(parts[opts.SelectColumn-1]) + } + } + + // Then apply map command if present + if opts.MapCmd == "" { + return value + } + + // Run the map command with the value as stdin + cmd := exec.Command(config.GetShell(), "-c", opts.MapCmd) + cmd.Stdin = strings.NewReader(value) + out, err := cmd.Output() + if err != nil { + return value // fallback to original on error + } + return strings.TrimSpace(string(out)) +} diff --git a/internal/ui/infer_test.go b/internal/resolver/infer_test.go similarity index 95% rename from internal/ui/infer_test.go rename to internal/resolver/infer_test.go index 0f3f393..16af0f1 100644 --- a/internal/ui/infer_test.go +++ b/internal/resolver/infer_test.go @@ -1,4 +1,4 @@ -package ui +package resolver import ( "testing" @@ -113,7 +113,7 @@ func TestPrefillScopeFromMatch(t *testing.T) { t.Logf("NO MATCH") } - prefillScopeFromMatch(cheat, tt.input) + PrefillScopeFromMatch(cheat, tt.input) assertMapEq(t, tt.expectedScope, cheat.Scope) }) @@ -131,12 +131,12 @@ func TestFindMatchingCheatPrefersSpecificCommandOverVarOnly(t *testing.T) { Command: "make deploy SERVICE=$service ENV=$env LOG=run-$(date +%Y%m%d-%H%M%S).txt", } - got := findMatchingCheat([]*parser.Cheat{varOnly, deploy}, input) + got := FindMatchingCheat([]*parser.Cheat{varOnly, deploy}, input) if got != deploy { t.Fatalf("matched %q, want %q", got.Header, deploy.Header) } - prefillScopeFromMatch(got, input) + PrefillScopeFromMatch(got, input) if got.Scope["service"] != "api" { t.Fatalf("service = %q, want api", got.Scope["service"]) } @@ -146,7 +146,7 @@ func TestFindMatchingCheatPrefersSpecificCommandOverVarOnly(t *testing.T) { } func TestFindMatchingCheatDoesNotUseVarOnlyAsCatchAll(t *testing.T) { - got := findMatchingCheat([]*parser.Cheat{ + got := FindMatchingCheat([]*parser.Cheat{ {Header: "Single value", Command: "$item_name"}, }, "tool run thing --flag value") if got != nil { @@ -207,7 +207,7 @@ func TestInferDependentVars(t *testing.T) { cheat.Scope[k] = v } - inferDependentVars(cheat, index) + InferDependentVars(cheat, index) assertMapEq(t, tt.expectedScope, cheat.Scope) }) diff --git a/internal/ui/match.go b/internal/resolver/match.go similarity index 82% rename from internal/ui/match.go rename to internal/resolver/match.go index fc865d6..b751a5f 100644 --- a/internal/ui/match.go +++ b/internal/resolver/match.go @@ -1,4 +1,4 @@ -package ui +package resolver import ( "regexp" @@ -9,11 +9,10 @@ import ( "github.com/gubarz/cheatmd/pkg/parser" ) -// findMatchingCheat finds a cheat whose command pattern matches the input. +// FindMatchingCheat finds a cheat whose command pattern matches the input. // It builds a regex from the cheat command (replacing $var with capture groups) -// and returns the most specific match. A command that is only "$var" matches -// almost anything, so first-match behavior can steal a more exact command. -func findMatchingCheat(cheats []*parser.Cheat, input string) *parser.Cheat { +// and returns the most specific match. +func FindMatchingCheat(cheats []*parser.Cheat, input string) *parser.Cheat { input = strings.TrimSpace(input) if input == "" { return nil @@ -32,13 +31,6 @@ func findMatchingCheat(cheats []*parser.Cheat, input string) *parser.Cheat { } // buildMatchPattern converts a command template to a regex pattern for matching. -// -// "echo $name" -> "^echo (\S+)$" -// 'echo "$name"' -> '^echo "([^"]*)"$' -// -// Returns the pattern and a list of variable names for each capture group. -// The slice may have duplicates (one entry per capture group) because Go -// regex doesn't support backreferences. func buildMatchPattern(cmd string) (*regexp.Regexp, []string) { pattern, varOrder, _ := buildMatchPatternWithScore(cmd) return pattern, varOrder @@ -89,7 +81,6 @@ func buildMatchPatternWithScore(cmd string) (*regexp.Regexp, []string, int) { afterVar := cmd[varEnd:] if strings.HasSuffix(beforeVar, `"`) && strings.HasPrefix(afterVar, `"`) { - // Inside double quotes - don't include the quotes in capture. current := result.String() if strings.HasSuffix(current, `"`) { result.Reset() @@ -112,7 +103,6 @@ func buildMatchPatternWithScore(cmd string) (*regexp.Regexp, []string, int) { isLastVar := i == len(allMatches)-1 remainingText := strings.TrimSpace(cmd[varEnd:]) if isLastVar && remainingText == "" { - // Last variable at end of command - greedy to capture multi-word values. result.WriteString(`(.+)`) } else { nextLiteralStart := varEnd @@ -145,9 +135,9 @@ func buildMatchPatternWithScore(cmd string) (*regexp.Regexp, []string, int) { return re, varOrder, literalScore } -// prefillScopeFromMatch extracts variable values from the matched command and +// PrefillScopeFromMatch extracts variable values from the matched command and // writes them into cheat.Scope. -func prefillScopeFromMatch(cheat *parser.Cheat, input string) { +func PrefillScopeFromMatch(cheat *parser.Cheat, input string) { input = strings.TrimSpace(input) pattern, varNames := buildMatchPattern(cheat.Command) if pattern == nil || len(varNames) == 0 { @@ -172,10 +162,8 @@ func prefillScopeFromMatch(cheat *parser.Cheat, input string) { } } -// inferDependentVars reverse-engineers dependent variables from literal values. -// Example: if auth_flags=-k and we have "if $auth_method == kerberos then -// auth_flags := -k", we can infer auth_method=kerberos. -func inferDependentVars(cheat *parser.Cheat, index *parser.CheatIndex) { +// InferDependentVars reverse-engineers dependent variables from literal values. +func InferDependentVars(cheat *parser.Cheat, index *parser.CheatIndex) { if len(cheat.Scope) == 0 { return } @@ -227,7 +215,6 @@ func inferDependentVars(cheat *parser.Cheat, index *parser.CheatIndex) { } } -// parseCondition parses "$var == value" or "$var != value". func parseCondition(cond string) (varName, op, value string) { cond = strings.TrimSpace(cond) @@ -250,8 +237,6 @@ func parseCondition(cond string) (varName, op, value string) { return "", "", "" } -// extractEmbeddedVars extracts variable values embedded in a literal template. -// Example: template="-p $credential", actual="-p mypass" -> {credential: mypass}. func extractEmbeddedVars(template, actual string, existingScope map[string]string) map[string]string { result := make(map[string]string) @@ -280,7 +265,6 @@ func extractEmbeddedVars(template, actual string, existingScope map[string]strin regexParts.WriteString(regexp.QuoteMeta(pattern[lastEnd:varStart])) } - // Greedy for the last variable at end of string, non-greedy otherwise. if i == len(varMatches)-1 && varEnd == len(pattern) { regexParts.WriteString(`(.+)`) } else { diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go new file mode 100644 index 0000000..00d5766 --- /dev/null +++ b/internal/resolver/resolver.go @@ -0,0 +1,119 @@ +package resolver + +import ( + "fmt" + + "github.com/gubarz/cheatmd/pkg/executor" + "github.com/gubarz/cheatmd/pkg/parser" +) + +// SelectOptions holds display and extraction options for selectors. +type SelectOptions struct { + Delimiter string + Column int // 1-indexed, 0 = all (display column) + SelectColumn int // 1-indexed, 0 = no extraction (return full/original line) + MapCmd string // command to transform selected value + Multi bool // true if --multi is provided +} + +// VarState tracks a variable and its resolved value during resolution progress. +type VarState struct { + Def parser.VarDef // The active definition + Variants []parser.VarDef // All conditional variants (for if/fi blocks) + Value string + Resolved bool + Prefill string + SkipAutoCont bool // True if user went back to this var - don't auto-continue + MultiSelected []string + MultiSelectedSet map[string]bool +} + +// CollectVariables gathers all variable definitions from imports and local +// and wraps them in exported VarState objects. +func CollectVariables(cheat *parser.Cheat, index *parser.CheatIndex) []VarState { + orderedVars, varDefs := executor.CollectDependencies(cheat, index) + + var vars []VarState + for _, varName := range orderedVars { + if defs, ok := varDefs[varName]; ok && len(defs) > 0 { + vars = append(vars, VarState{ + Def: defs[0], + Variants: defs, + }) + } + } + return vars +} + +// SelectVariant picks the first variant whose condition matches, or nil if none match. +// Returns the first unconditional variant as fallback (default case). +func SelectVariant(variants []parser.VarDef, scope map[string]string) *parser.VarDef { + var defaultDef *parser.VarDef + + for i := range variants { + v := &variants[i] + if v.Condition == "" { + // Unconditional - this is the default/fallback + if defaultDef == nil { + defaultDef = v + } + continue + } + if executor.EvaluateCondition(v.Condition, scope) { + return v + } + } + + return defaultDef +} + +// ExtractCustomHeader parses --header from selector arguments. +func ExtractCustomHeader(selectorArgs string) string { + if selectorArgs == "" { + return "" + } + args := ParseShellArgs(selectorArgs) + for i := 0; i < len(args); i++ { + if args[i] == "--header" && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +// ParseSelectorOpts parses selector options from arguments. +func ParseSelectorOpts(selectorArgs string) SelectOptions { + opts := SelectOptions{} + if selectorArgs == "" { + return opts + } + + args := ParseShellArgs(selectorArgs) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--delimiter": + if i+1 < len(args) { + opts.Delimiter = args[i+1] + i++ + } + case "--column": + if i+1 < len(args) { + fmt.Sscanf(args[i+1], "%d", &opts.Column) + i++ + } + case "--select-column": + if i+1 < len(args) { + fmt.Sscanf(args[i+1], "%d", &opts.SelectColumn) + i++ + } + case "--map": + if i+1 < len(args) { + opts.MapCmd = args[i+1] + i++ + } + case "--multi": + opts.Multi = true + } + } + return opts +} diff --git a/internal/ui/selector_test.go b/internal/resolver/resolver_test.go similarity index 70% rename from internal/ui/selector_test.go rename to internal/resolver/resolver_test.go index 344e1a0..843aaae 100644 --- a/internal/ui/selector_test.go +++ b/internal/resolver/resolver_test.go @@ -1,4 +1,4 @@ -package ui +package resolver import ( "reflect" @@ -7,6 +7,60 @@ import ( "github.com/gubarz/cheatmd/pkg/parser" ) +func TestBuildMatchPattern(t *testing.T) { + tests := []struct { + name string + cmd string + wantVarNames []string + }{ + { + name: "simple var", + cmd: "echo $name", + wantVarNames: []string{"name"}, + }, + { + name: "var with double quotes", + cmd: `curl "$url"`, + wantVarNames: []string{"url"}, + }, + { + name: "var with single quotes", + cmd: `ssh '$user'@host`, + wantVarNames: []string{"user"}, + }, + { + name: "multiple vars", + cmd: "tool run --port $port $host", + wantVarNames: []string{"port", "host"}, + }, + { + name: "no vars", + cmd: "echo hello world", + wantVarNames: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pattern, varNames := buildMatchPattern(tt.cmd) + + if pattern == nil && len(tt.wantVarNames) > 0 { + t.Fatalf("buildMatchPattern() returned nil pattern, expected vars %v", tt.wantVarNames) + } + + if len(varNames) != len(tt.wantVarNames) { + t.Fatalf("buildMatchPattern() varNames = %v, want %v", varNames, tt.wantVarNames) + } + + for i := range varNames { + if varNames[i] != tt.wantVarNames[i] { + t.Errorf("buildMatchPattern() varNames[%d] = %q, want %q", i, varNames[i], tt.wantVarNames[i]) + } + } + }) + } +} + func TestParseShellArgs(t *testing.T) { tests := []struct { name string @@ -47,9 +101,9 @@ func TestParseShellArgs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := parseShellArgs(tt.input) + got := ParseShellArgs(tt.input) if !reflect.DeepEqual(got, tt.want) { - t.Errorf("parseShellArgs(%q) = %v, want %v", tt.input, got, tt.want) + t.Errorf("ParseShellArgs(%q) = %v, want %v", tt.input, got, tt.want) } }) } @@ -90,9 +144,9 @@ func TestParseSelectorOpts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := parseSelectorOpts(tt.args) + got := ParseSelectorOpts(tt.args) if got != tt.want { - t.Errorf("parseSelectorOpts(%q) = %+v, want %+v", tt.args, got, tt.want) + t.Errorf("ParseSelectorOpts(%q) = %+v, want %+v", tt.args, got, tt.want) } }) } @@ -152,16 +206,15 @@ func TestGetDisplayColumn(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := getDisplayColumn(tt.line, tt.delimiter, tt.column) + got := GetDisplayColumn(tt.line, tt.delimiter, tt.column) if got != tt.want { - t.Errorf("getDisplayColumn(%q, %q, %d) = %q, want %q", tt.line, tt.delimiter, tt.column, got, tt.want) + t.Errorf("GetDisplayColumn(%q, %q, %d) = %q, want %q", tt.line, tt.delimiter, tt.column, got, tt.want) } }) } } func TestApplyMapTransform_SelectColumn(t *testing.T) { - // Test select-column extraction (without --map, which requires shell) tests := []struct { name string value string @@ -202,72 +255,24 @@ func TestApplyMapTransform_SelectColumn(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := applyMapTransform(tt.value, tt.opts) + got := ApplyMapTransform(tt.value, tt.opts) if got != tt.want { - t.Errorf("applyMapTransform(%q, %+v) = %q, want %q", tt.value, tt.opts, got, tt.want) - } - }) - } -} - -func TestSplitLines(t *testing.T) { - tests := []struct { - name string - input string - want []string - }{ - { - name: "simple lines", - input: "one\ntwo\nthree", - want: []string{"one", "two", "three"}, - }, - { - name: "empty lines filtered", - input: "one\n\ntwo\n\n\nthree\n", - want: []string{"one", "two", "three"}, - }, - { - name: "whitespace trimmed", - input: " one \n\ttwo\t\n three ", - want: []string{"one", "two", "three"}, - }, - { - name: "carriage returns handled", - input: "one\r\ntwo\r\nthree\r\n", - want: []string{"one", "two", "three"}, - }, - { - name: "empty input", - input: "", - want: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := parser.SplitLines(tt.input) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("splitLines(%q) = %v, want %v", tt.input, got, tt.want) + t.Errorf("ApplyMapTransform(%q, %+v) = %q, want %q", tt.value, tt.opts, got, tt.want) } }) } } -// TestEndToEnd_DelimiterColumnPipeline tests the full pipeline: -// shell output -> splitLines -> parseSelectorOpts -> getDisplayColumn -> applyMapTransform func TestEndToEnd_DelimiterColumnPipeline(t *testing.T) { - // Simulate: var host = echo -e "192.168.1.1,webserver\n10.0.0.1,db" --- --delimiter "," --column 2 --select-column 1 shellOutput := "192.168.1.1,webserver\n10.0.0.1,db" selectorArgs := `--delimiter "," --column 2 --select-column 1` - // 1. Split shell output into lines lines := parser.SplitLines(shellOutput) if len(lines) != 2 { t.Fatalf("splitLines() = %d lines, want 2", len(lines)) } - // 2. Parse selector options - opts := parseSelectorOpts(selectorArgs) + opts := ParseSelectorOpts(selectorArgs) if opts.Delimiter != "," { t.Errorf("opts.Delimiter = %q, want %q", opts.Delimiter, ",") } @@ -278,9 +283,8 @@ func TestEndToEnd_DelimiterColumnPipeline(t *testing.T) { t.Errorf("opts.SelectColumn = %d, want 1", opts.SelectColumn) } - // 3. Display column (what the user sees in the picker) - display0 := getDisplayColumn(lines[0], opts.Delimiter, opts.Column) - display1 := getDisplayColumn(lines[1], opts.Delimiter, opts.Column) + display0 := GetDisplayColumn(lines[0], opts.Delimiter, opts.Column) + display1 := GetDisplayColumn(lines[1], opts.Delimiter, opts.Column) if display0 != "webserver" { t.Errorf("display[0] = %q, want %q", display0, "webserver") } @@ -288,16 +292,13 @@ func TestEndToEnd_DelimiterColumnPipeline(t *testing.T) { t.Errorf("display[1] = %q, want %q", display1, "db") } - // 4. Select column (the value that gets substituted into the command) - // User picks line 0 -> applyMapTransform extracts select-column 1 - selected := applyMapTransform(lines[0], opts) + selected := ApplyMapTransform(lines[0], opts) if selected != "192.168.1.1" { - t.Errorf("applyMapTransform() = %q, want %q", selected, "192.168.1.1") + t.Errorf("ApplyMapTransform() = %q, want %q", selected, "192.168.1.1") } - // User picks line 1 - selected2 := applyMapTransform(lines[1], opts) + selected2 := ApplyMapTransform(lines[1], opts) if selected2 != "10.0.0.1" { - t.Errorf("applyMapTransform() = %q, want %q", selected2, "10.0.0.1") + t.Errorf("ApplyMapTransform() = %q, want %q", selected2, "10.0.0.1") } } diff --git a/internal/ui/match_test.go b/internal/ui/match_test.go deleted file mode 100644 index 89dfe44..0000000 --- a/internal/ui/match_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package ui - -import ( - "testing" - - "github.com/gubarz/cheatmd/pkg/parser" -) - -func TestBuildMatchPattern(t *testing.T) { - tests := []struct { - name string - cmd string - wantVarNames []string - }{ - { - name: "simple var", - cmd: "echo $name", - wantVarNames: []string{"name"}, - }, - { - name: "var with double quotes", - cmd: `curl "$url"`, - wantVarNames: []string{"url"}, - }, - { - name: "var with single quotes", - cmd: `ssh '$user'@host`, - wantVarNames: []string{"user"}, - }, - { - name: "multiple vars", - cmd: "tool run --port $port $host", - wantVarNames: []string{"port", "host"}, - }, - { - name: "no vars", - cmd: "echo hello world", - wantVarNames: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - pattern, varNames := buildMatchPattern(tt.cmd) - - if pattern == nil && len(tt.wantVarNames) > 0 { - t.Fatalf("buildMatchPattern() returned nil pattern, expected vars %v", tt.wantVarNames) - } - - if len(varNames) != len(tt.wantVarNames) { - t.Fatalf("buildMatchPattern() varNames = %v, want %v", varNames, tt.wantVarNames) - } - - for i := range varNames { - if varNames[i] != tt.wantVarNames[i] { - t.Errorf("buildMatchPattern() varNames[%d] = %q, want %q", i, varNames[i], tt.wantVarNames[i]) - } - } - }) - } -} - -func TestCheatItemMatchesQuery(t *testing.T) { - cheat := &parser.Cheat{ - File: "/cheats/projects/deploy.md", - Header: "Deploy Service", - Description: "Deploy a selected service", - Command: "tool deploy $service", - Tags: []string{"release", "service"}, - } - item := newCheatItem(cheat) - - tests := []struct { - name string - words []string - want bool - }{ - {"matches folder", []string{"projects"}, true}, - {"matches file", []string{"deploy"}, true}, - {"matches header", []string{"service"}, true}, - {"matches description", []string{"selected"}, true}, - {"matches command", []string{"tool"}, true}, - {"matches tag", []string{"release"}, true}, - {"matches multiple words", []string{"deploy", "service"}, true}, - {"no match", []string{"invoice"}, false}, - {"partial multi match fails", []string{"deploy", "invoice"}, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := item.matchesQuery(tt.words) - if got != tt.want { - t.Errorf("matchesQuery(%v) = %v, want %v", tt.words, got, tt.want) - } - }) - } -} diff --git a/internal/ui/resolve.go b/internal/ui/resolve.go index 531a99a..11d86f0 100644 --- a/internal/ui/resolve.go +++ b/internal/ui/resolve.go @@ -1,11 +1,7 @@ package ui import ( - "os/exec" - "strings" - - "github.com/gubarz/cheatmd/pkg/config" - "github.com/gubarz/cheatmd/pkg/executor" + "github.com/gubarz/cheatmd/internal/resolver" "github.com/gubarz/cheatmd/pkg/parser" ) @@ -28,28 +24,10 @@ func RunHistory(index *parser.CheatIndex, exec Executor) (string, error) { // Variable Resolution // ============================================================================ -// SelectOptions holds display options for selection -type SelectOptions struct { - Delimiter string - Column int // 1-indexed, 0 = all (display column) - SelectColumn int // 1-indexed, 0 = no extraction (return full/original line) - MapCmd string // command to transform selected value - Multi bool // true if --multi is provided -} - -// getDisplayColumn extracts the display column from a line -func getDisplayColumn(line, delimiter string, column int) string { - if delimiter == "" || column == 0 { - return line - } - parts := strings.Split(line, delimiter) - if column > 0 && column <= len(parts) { - return strings.TrimSpace(parts[column-1]) - } - return line -} +// SelectOptions holds display options for selection. Aliased to resolver options. +type SelectOptions = resolver.SelectOptions -// varState tracks a variable and its resolved value +// varState tracks a variable and its resolved value. type varState struct { def parser.VarDef // The selected/active definition variants []parser.VarDef // All conditional variants (for if/fi blocks) @@ -64,124 +42,45 @@ type varState struct { // collectVariables gathers all variable definitions from imports and local // and wraps them in UI-specific varState objects. func collectVariables(cheat *parser.Cheat, index *parser.CheatIndex) []varState { - orderedVars, varDefs := executor.CollectDependencies(cheat, index) - - var vars []varState - for _, varName := range orderedVars { - if defs, ok := varDefs[varName]; ok && len(defs) > 0 { - vars = append(vars, varState{ - def: defs[0], - variants: defs, - }) + resVars := resolver.CollectVariables(cheat, index) + + vars := make([]varState, len(resVars)) + for i, rv := range resVars { + vars[i] = varState{ + def: rv.Def, + variants: rv.Variants, + value: rv.Value, + resolved: rv.Resolved, + prefill: rv.Prefill, + skipAutoCont: rv.SkipAutoCont, + multiSelected: rv.MultiSelected, + multiSelectedSet: rv.MultiSelectedSet, } } return vars } // selectVariant picks the first variant whose condition matches, or nil if none match -// Returns the first unconditional variant as fallback (default case) func selectVariant(variants []parser.VarDef, scope map[string]string) *parser.VarDef { - var defaultDef *parser.VarDef - - for i := range variants { - v := &variants[i] - if v.Condition == "" { - // Unconditional - this is the default/fallback - if defaultDef == nil { - defaultDef = v - } - continue - } - if executor.EvaluateCondition(v.Condition, scope) { - return v - } - } - - return defaultDef + return resolver.SelectVariant(variants, scope) } // extractCustomHeader parses --header from selector args func extractCustomHeader(selectorArgs string) string { - if selectorArgs == "" { - return "" - } - args := parseShellArgs(selectorArgs) - for i := 0; i < len(args); i++ { - if args[i] == "--header" && i+1 < len(args) { - return args[i+1] - } - } - return "" + return resolver.ExtractCustomHeader(selectorArgs) } -// ============================================================================ -// Output Handling -// ============================================================================ - -// ============================================================================ -// String Utilities -// ============================================================================ - // parseShellArgs parses a string into arguments, respecting quotes func parseShellArgs(s string) []string { - var args []string - var current strings.Builder - var inQuote bool - var quoteChar byte - - for i := 0; i < len(s); i++ { - c := s[i] - - if inQuote { - if c == quoteChar { - inQuote = false - } else { - current.WriteByte(c) - } - } else { - switch c { - case '"', '\'': - inQuote = true - quoteChar = c - case ' ', '\t': - if current.Len() > 0 { - args = append(args, current.String()) - current.Reset() - } - default: - current.WriteByte(c) - } - } - } - - if current.Len() > 0 { - args = append(args, current.String()) - } - - return args + return resolver.ParseShellArgs(s) } // applyMapTransform transforms the selected value based on options func applyMapTransform(value string, opts SelectOptions) string { - // Apply select-column extraction first - if opts.SelectColumn > 0 && opts.Delimiter != "" { - parts := strings.Split(value, opts.Delimiter) - if opts.SelectColumn <= len(parts) { - value = strings.TrimSpace(parts[opts.SelectColumn-1]) - } - } - - // Then apply map command if present - if opts.MapCmd == "" { - return value - } + return resolver.ApplyMapTransform(value, opts) +} - // Run the map command with the value as stdin - cmd := exec.Command(config.GetShell(), "-c", opts.MapCmd) - cmd.Stdin = strings.NewReader(value) - out, err := cmd.Output() - if err != nil { - return value // fallback to original on error - } - return strings.TrimSpace(string(out)) +// getDisplayColumn extracts the display column from a line +func getDisplayColumn(line, delimiter string, column int) string { + return resolver.GetDisplayColumn(line, delimiter, column) } diff --git a/internal/ui/run.go b/internal/ui/run.go index 8252b6a..767448f 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/gubarz/cheatmd/internal/resolver" "github.com/gubarz/cheatmd/pkg/chainstate" "github.com/gubarz/cheatmd/pkg/config" "github.com/gubarz/cheatmd/pkg/history" @@ -87,7 +88,7 @@ func RunTUIWithStart(index *parser.CheatIndex, exec Executor, initialQuery, matc requireCheatBlock := config.GetRequireCheatBlock() autoSelect := config.GetAutoSelect() - cheats := filterCheatsByConfig(index.Cheats, requireCheatBlock) + cheats := index.FilterByConfig(requireCheatBlock) if len(cheats) == 0 { return "", fmt.Errorf("no cheats found") } @@ -109,10 +110,10 @@ func RunTUIWithStart(index *parser.CheatIndex, exec Executor, initialQuery, matc } if matchCmd != "" { - if matched := findMatchingCheat(cheats, matchCmd); matched != nil { + if matched := resolver.FindMatchingCheat(cheats, matchCmd); matched != nil { m.selected = matched - prefillScopeFromMatch(matched, matchCmd) - inferDependentVars(matched, index) + resolver.PrefillScopeFromMatch(matched, matchCmd) + resolver.InferDependentVars(matched, index) m.startVarResolutionInternal() if m.phase != phaseVarResolve { @@ -217,23 +218,6 @@ func advanceChain(index *parser.CheatIndex, cheat *parser.Cheat, path string, st _ = chainstate.Save(path, state) } -// filterCheatsByConfig returns cheats matching configuration. When -// requireCheatBlock is true, cheats without a block are -// excluded. -func filterCheatsByConfig(cheats []*parser.Cheat, requireCheatBlock bool) []*parser.Cheat { - if !requireCheatBlock { - return cheats - } - - result := make([]*parser.Cheat, 0, len(cheats)) - for _, cheat := range cheats { - if cheat.HasCheatBlock { - result = append(result, cheat) - } - } - return result -} - // openFileInViewer opens filePath in the configured editor or system default. func openFileInViewer(filePath string) { var cmd *exec.Cmd diff --git a/pkg/parser/types.go b/pkg/parser/types.go index 4840266..1af000b 100644 --- a/pkg/parser/types.go +++ b/pkg/parser/types.go @@ -166,3 +166,17 @@ func (idx *CheatIndex) RegisterModule(cheat *Cheat) { } idx.Modules[cheat.Export] = NewModule(cheat) } + +// FilterByConfig filters the cheats, enforcing configuration requirements. +func (idx *CheatIndex) FilterByConfig(requireCheatBlock bool) []*Cheat { + if !requireCheatBlock { + return idx.Cheats + } + var result []*Cheat + for _, cheat := range idx.Cheats { + if cheat.HasCheatBlock { + result = append(result, cheat) + } + } + return result +}