From f71f14236e2b10160bf64dacf27487e0ef105386 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Mon, 11 May 2026 00:55:43 -0600 Subject: [PATCH] feat search variables added the ability to search environment and history set variables after one has selected a cheat to use that has variables. by default ctrl+t. config can be set to use a different hotkey and which sources to use or none --- README.md | 15 ++ cheatmd.example.yaml | 7 + internal/config/config.go | 32 +++- internal/ui/main_model.go | 309 ++++++++++++++++++++++++++++++++- internal/ui/substitute.go | 295 +++++++++++++++++++++++++++++++ internal/ui/substitute_test.go | 110 ++++++++++++ 6 files changed, 762 insertions(+), 6 deletions(-) create mode 100644 internal/ui/substitute.go create mode 100644 internal/ui/substitute_test.go diff --git a/README.md b/README.md index a6dc99b..f4fa882 100644 --- a/README.md +++ b/README.md @@ -119,8 +119,23 @@ output: print # print, copy, exec shell: /bin/bash require_cheat_block: false auto_continue: false # Auto-accept env vars without prompting + +# Substitute search (while resolving a variable, press Ctrl-T to fuzzy-search +# environment variables and shell history for a value to insert) +key_substitute: "ctrl+t" +substitute_sources: ["env", "history"] # set to [] to disable ``` +### Substitute search + +When a cheat asks for a variable (say `$host`), press `Ctrl-T` to open a +fuzzy-search picker over your environment variables and any assignments +(`VAR=value`, `export VAR=value`, `declare -x VAR=value`, leading inline +assignments) found in shell history. Plain commands in history are ignored. +Pick a row, its value is loaded into the prompt; press `Enter` to accept or +edit it first. `Esc` cancels back to the var prompt. History is read from +`$HISTFILE`, falling back to `~/.bash_history` or `~/.zsh_history`. + ## DSL ``` diff --git a/cheatmd.example.yaml b/cheatmd.example.yaml index a772641..e2ac660 100644 --- a/cheatmd.example.yaml +++ b/cheatmd.example.yaml @@ -15,6 +15,13 @@ shell: /bin/bash # key_open: TUI key for opening markdown in editor (e.g., ctrl+o, ctrl+e) key_widget: "\\C-g" key_open: "ctrl+o" +# key_substitute: opens a fuzzy search of env vars + shell history during +# variable resolution, lets you pick a value to substitute into the prompt. +key_substitute: "ctrl+t" + +# Substitute search sources. Valid entries: "env", "history". +# Empty list disables the feature. +substitute_sources: ["env", "history"] # Display options # Control what appears in the title/list path column diff --git a/internal/config/config.go b/internal/config/config.go index 62d83f4..a4e2013 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,8 +25,12 @@ type Config struct { AutoContinue bool `mapstructure:"auto_continue"` // Keybindings - KeyWidget string `mapstructure:"key_widget"` - KeyOpen string `mapstructure:"key_open"` + KeyWidget string `mapstructure:"key_widget"` + KeyOpen string `mapstructure:"key_open"` + KeySubstitute string `mapstructure:"key_substitute"` + + // Substitute search + SubstituteSources []string `mapstructure:"substitute_sources"` // Display options ShowFolder bool `mapstructure:"show_folder"` @@ -77,6 +81,8 @@ var defaults = struct { autoContinue bool keyWidget string keyOpen string + keySubstitute string + substituteSources []string showFolder bool showFile bool previewHeight int @@ -92,8 +98,10 @@ var defaults = struct { requireCheatBlock: false, autoSelect: false, autoContinue: false, - keyWidget: "\\C-g", // Ctrl+G for shell widgets - keyOpen: "ctrl+o", // Ctrl+O in TUI + keyWidget: "\\C-g", // Ctrl+G for shell widgets + keyOpen: "ctrl+o", // Ctrl+O in TUI + keySubstitute: "ctrl+t", // Ctrl+T opens substitute search during var resolution + substituteSources: []string{"env", "history"}, showFolder: true, showFile: true, previewHeight: 6, @@ -156,6 +164,10 @@ func setDefaults() { // Keybindings viper.SetDefault("key_widget", defaults.keyWidget) viper.SetDefault("key_open", defaults.keyOpen) + viper.SetDefault("key_substitute", defaults.keySubstitute) + + // Substitute search + viper.SetDefault("substitute_sources", defaults.substituteSources) // Display options viper.SetDefault("show_folder", defaults.showFolder) @@ -257,6 +269,18 @@ func GetKeyOpen() string { return viper.GetString("key_open") } +// GetKeySubstitute returns the keybinding for opening the substitute search +// during variable resolution (e.g., "ctrl+t"). +func GetKeySubstitute() string { + return viper.GetString("key_substitute") +} + +// GetSubstituteSources returns the enabled sources for substitute search. +// Valid entries: "env", "history". Empty disables the feature. +func GetSubstituteSources() []string { + return viper.GetStringSlice("substitute_sources") +} + // ============================================================================ // Getters - Display Options // ============================================================================ diff --git a/internal/ui/main_model.go b/internal/ui/main_model.go index a0b77ac..f3b8859 100644 --- a/internal/ui/main_model.go +++ b/internal/ui/main_model.go @@ -172,8 +172,9 @@ func debounceFilter() tea.Cmd { type uiPhase int const ( - phaseCheatSelect uiPhase = iota // Selecting a cheat - phaseVarResolve // Resolving variables + phaseCheatSelect uiPhase = iota // Selecting a cheat + phaseVarResolve // Resolving variables + phaseSubstituteSearch // Substitute-search overlay during var resolution ) // mainModel is the Bubble Tea model for cheat selection AND variable resolution @@ -200,11 +201,28 @@ type mainModel struct { // Variable resolution state (only used in phaseVarResolve) varState *varResolveState + // Substitute search state (only used in phaseSubstituteSearch) + subState *substituteSearchState + // Dependencies for variable resolution cheatIndex *parser.CheatIndex executor *executor.Executor } +// substituteSearchState holds the overlay state for substitute search. +// The user enters via the configured key during phaseVarResolve, picks an +// env/history value, and returns to phaseVarResolve with the chosen value +// loaded into the var prompt. +type substituteSearchState struct { + options []substituteOption + filtered []substituteOption + cursor int + offset int + prevInput string // textInput value before entering the overlay + prevCursor int + prevOffset int +} + // varResolveState holds state for resolving variables within the unified TUI type varResolveState struct { cheat *parser.Cheat @@ -269,6 +287,8 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Dispatch based on phase switch m.phase { + case phaseSubstituteSearch: + return m.updateSubstituteSearch(msg) case phaseVarResolve: return m.updateVarResolve(msg) default: @@ -276,6 +296,37 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } +// updateSubstituteSearch handles updates while the substitute overlay is open. +func (m mainModel) updateSubstituteSearch(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if cmd := m.handleSubstituteSearchKey(msg); cmd != nil { + return m, cmd + } + // If the key was a navigation/accept/cancel key we already handled it; + // otherwise fall through and let the text input absorb it. + if isSubstituteNavKey(msg.String()) { + return m, nil + } + } + + prev := m.textInput.Value() + var tiCmd tea.Cmd + m.textInput, tiCmd = m.textInput.Update(msg) + if m.textInput.Value() != prev { + m.filterSubstituteOptions() + } + return m, tiCmd +} + +func isSubstituteNavKey(key string) bool { + switch key { + case "ctrl+c", "esc", "enter", "up", "down", "ctrl+p", "ctrl+n", "pgup", "pgdown": + return true + } + return false +} + // updateCheatSelect handles updates during cheat selection phase func (m mainModel) updateCheatSelect(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -798,6 +849,139 @@ func (m *mainModel) handleVarResolveKey(msg tea.KeyMsg) tea.Cmd { openFileInViewer(m.varState.cheat.File) } } + // Check for substitute-search key + if msg.String() == config.GetKeySubstitute() { + if m.enterSubstituteSearch() { + // Force a clean repaint and start the textinput blink so + // the overlay renders correctly on the very first frame. + return tea.Batch(tea.ClearScreen, textinput.Blink) + } + } + } + return nil +} + +// enterSubstituteSearch transitions from phaseVarResolve into the substitute +// search overlay. Returns true if the transition happened; false if disabled +// or there are no sources to show. +func (m *mainModel) enterSubstituteSearch() bool { + sources := config.GetSubstituteSources() + if len(sources) == 0 { + return false + } + opts := collectSubstituteOptions(sources) + if len(opts) == 0 { + return false + } + m.subState = &substituteSearchState{ + options: opts, + filtered: opts, + prevInput: m.textInput.Value(), + prevCursor: m.cursor, + prevOffset: m.offset, + } + m.textInput.SetValue("") + m.textInput.Placeholder = "Search env / history..." + m.cursor = 0 + m.offset = 0 + m.phase = phaseSubstituteSearch + return true +} + +// exitSubstituteSearch returns to phaseVarResolve. If accept is true the +// currently highlighted option's Value is loaded into the var prompt; +// otherwise the previous input is restored. Returns a Cmd that the caller +// should propagate to bubbletea to clear any overlay artifacts. +func (m *mainModel) exitSubstituteSearch(accept bool) { + if m.subState == nil { + m.phase = phaseVarResolve + return + } + + if accept && m.subState.cursor < len(m.subState.filtered) { + m.textInput.SetValue(m.subState.filtered[m.subState.cursor].Value) + m.textInput.CursorEnd() + } else { + m.textInput.SetValue(m.subState.prevInput) + m.textInput.CursorEnd() + } + m.cursor = m.subState.prevCursor + m.offset = m.subState.prevOffset + m.subState = nil + m.textInput.Placeholder = "Type to filter or enter value..." + m.phase = phaseVarResolve + + // Refilter var options if we're in select mode (the input may have changed). + if m.varState != nil && !m.varState.isPromptOnly { + m.filterVarOptions() + } +} + +// filterSubstituteOptions applies the textInput's current value as a +// space-separated AND fuzzy filter over the substitute option list. +func (m *mainModel) filterSubstituteOptions() { + if m.subState == nil { + return + } + query := strings.ToLower(strings.TrimSpace(m.textInput.Value())) + if query == "" { + m.subState.filtered = m.subState.options + m.subState.cursor = 0 + m.subState.offset = 0 + return + } + words := strings.Fields(query) + result := make([]substituteOption, 0, len(m.subState.options)) + for _, opt := range m.subState.options { + hay := strings.ToLower(opt.Display) + if matchesAllWords(hay, words) { + result = append(result, opt) + } + } + m.subState.filtered = result + if m.subState.cursor >= len(result) { + m.subState.cursor = max(0, len(result)-1) + } + if m.subState.offset > m.subState.cursor { + m.subState.offset = m.subState.cursor + } +} + +// moveSubstituteCursor clamps the cursor; offset is reconciled by scrollWindow +// at render time using the actual list height. +func (m *mainModel) moveSubstituteCursor(delta int) { + if m.subState == nil { + return + } + m.subState.cursor += delta + m.subState.cursor = clamp(m.subState.cursor, 0, max(0, len(m.subState.filtered)-1)) +} + +// handleSubstituteSearchKey processes keys while in phaseSubstituteSearch. +func (m *mainModel) handleSubstituteSearchKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "ctrl+c": + m.quitting = true + m.selected = nil + return tea.Quit + case "esc": + m.exitSubstituteSearch(false) + return tea.ClearScreen + case "enter": + m.exitSubstituteSearch(true) + return tea.ClearScreen + case "up", "ctrl+p": + m.moveSubstituteCursor(-1) + return nil + case "down", "ctrl+n": + m.moveSubstituteCursor(1) + return nil + case "pgup": + m.moveSubstituteCursor(-10) + return nil + case "pgdown": + m.moveSubstituteCursor(10) + return nil } return nil } @@ -997,6 +1181,8 @@ func (m mainModel) View() string { // Dispatch based on phase switch m.phase { + case phaseSubstituteSearch: + return m.renderSubstituteSearch() case phaseVarResolve: return m.renderVarResolve() default: @@ -1004,6 +1190,125 @@ func (m mainModel) View() string { } } +// renderSubstituteSearch renders the env/history picker overlay using the +// same layout shape as renderCheatSelect: fixed-height preview at top, +// scrolling list in the middle, padding, divider + info + input at bottom. +func (m mainModel) renderSubstituteSearch() string { + width := maxInt(m.width, 80) + height := m.height + if height < 1 { + height = 24 + } + + inputLines := 3 // divider + info + input + + // Preview block is just two lines: title + divider. Match the cheat + // select pattern that uses a padded fixed-height block. + previewHeight := 2 + + preview := m.renderSubstitutePreview(width, previewHeight) + previewLines := countLines(preview) + + listHeight := maxInt(height-previewLines-inputLines, 1) + list := m.renderSubstituteList(listHeight) + listLines := countLines(list) + + padding := maxInt(height-previewLines-listLines-inputLines, 0) + + b := getBuilder() + defer putBuilder(b) + b.WriteString(preview) + b.WriteString(list) + b.WriteString(strings.Repeat("\n", padding)) + b.WriteString(m.renderSubstituteInput(width)) + + return b.String() +} + +// renderSubstitutePreview renders the title header at fixed height (padded), +// followed by a divider. Mirrors renderPreviewWithHeight's shape. +func (m mainModel) renderSubstitutePreview(width, maxLines int) string { + b := getBuilder() + defer putBuilder(b) + lines := 0 + + if lines < maxLines { + var varName string + if m.varState != nil && m.varState.currentIdx < len(m.varState.vars) { + varName = m.varState.vars[m.varState.currentIdx].def.Name + } + b.WriteString(styles.Header.Render("Substitute search")) + if varName != "" { + b.WriteString(" ") + b.WriteString(styles.Dim.Render("→ ")) + b.WriteString(styles.Cursor.Render("$" + varName)) + } + b.WriteString("\n") + lines++ + } + + for lines < maxLines { + b.WriteString("\n") + lines++ + } + + b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) + b.WriteString("\n") + return b.String() +} + +// renderSubstituteList renders the scrolling list, mirrors renderList shape. +// Each row is hard-truncated to terminal width so long env values (e.g. PATH) +// can't wrap and push other rows off-screen. +func (m *mainModel) renderSubstituteList(maxHeight int) string { + if m.subState == nil || len(m.subState.filtered) == 0 { + return "" + } + + start, end := scrollWindow(m.subState.cursor, len(m.subState.filtered), maxHeight, &m.subState.offset) + width := maxInt(m.width, 80) + // 2 chars for the "▶ " or " " prefix. + maxLen := maxInt(width-2, 10) + + b := getBuilder() + defer putBuilder(b) + for i := start; i < end; i++ { + opt := m.subState.filtered[i] + display := truncateString(opt.Display, maxLen) + if i == m.subState.cursor { + b.WriteString(styles.Cursor.Render("▶ ")) + b.WriteString(styles.Selected.Render(styles.Command.Render(display))) + } else { + b.WriteString(" ") + b.WriteString(styles.Command.Render(display)) + } + b.WriteString("\n") + } + return b.String() +} + +// renderSubstituteInput renders the bottom divider + hint + input, mirrors +// renderInput's shape. +func (m mainModel) renderSubstituteInput(width int) string { + b := getBuilder() + defer putBuilder(b) + b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) + b.WriteString("\n") + matchCount := 0 + if m.subState != nil { + matchCount = len(m.subState.filtered) + } + b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d matches", matchCount))) + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("ESC cancel")) + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("Enter use value")) + b.WriteString("\n") + b.WriteString(m.textInput.View()) + return b.String() +} + + // renderCheatSelect builds the cheat selection view func (m mainModel) renderCheatSelect() string { width := maxInt(m.width, 80) diff --git a/internal/ui/substitute.go b/internal/ui/substitute.go new file mode 100644 index 0000000..f829acc --- /dev/null +++ b/internal/ui/substitute.go @@ -0,0 +1,295 @@ +package ui + +import ( + "bufio" + "os" + "path/filepath" + "sort" + "strings" +) + +// substituteOption is one row shown in the substitute search picker. +// +// Display is what the user sees while filtering ("env: USER=alice"). +// Value is what gets inserted into the var prompt if the row is chosen. +type substituteOption struct { + Display string + Value string +} + +// collectSubstituteOptions builds the picker list from the configured sources. +// `sources` may contain "env" and/or "history". +// +// Order: env vars first (sorted by name), then history (newest first). +func collectSubstituteOptions(sources []string) []substituteOption { + wantEnv, wantHistory := false, false + for _, s := range sources { + switch strings.ToLower(strings.TrimSpace(s)) { + case "env": + wantEnv = true + case "history": + wantHistory = true + } + } + + var out []substituteOption + if wantEnv { + out = append(out, collectEnvOptions()...) + } + if wantHistory { + out = append(out, collectHistoryOptions()...) + } + return out +} + +// collectEnvOptions returns one row per exported environment variable. +// Display: "env: NAME=value", Value: the value alone. +func collectEnvOptions() []substituteOption { + entries := os.Environ() + sort.Strings(entries) + + out := make([]substituteOption, 0, len(entries)) + for _, e := range entries { + eq := strings.IndexByte(e, '=') + if eq <= 0 { + continue + } + name := e[:eq] + value := e[eq+1:] + if value == "" { + continue + } + // Multiline values (e.g. exported bash functions) would explode the + // list into many visible rows. Collapse control chars so each option + // stays exactly one row tall. + displayValue := sanitizeOneLine(value) + out = append(out, substituteOption{ + Display: "env: " + name + "=" + displayValue, + Value: value, + }) + } + return out +} + +// sanitizeOneLine collapses newlines, tabs, and other control characters into +// single spaces so a value renders as exactly one visible row. The underlying +// Value is preserved separately for substitution. +func sanitizeOneLine(s string) string { + var b strings.Builder + b.Grow(len(s)) + lastSpace := false + for _, r := range s { + if r == '\n' || r == '\r' || r == '\t' || (r >= 0 && r < 32) { + if !lastSpace { + b.WriteByte(' ') + lastSpace = true + } + continue + } + b.WriteRune(r) + lastSpace = false + } + return b.String() +} + +// collectHistoryOptions scans shell history for variable assignments +// (`VAR=value`, `export VAR=value`, etc.) and returns one row per unique +// assignment, newest first. Plain commands are ignored. Empty result if no +// readable history file is found. +func collectHistoryOptions() []substituteOption { + path := findHistoryFile() + if path == "" { + return nil + } + + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + type entry struct { + name, value string + } + var entries []entry + for scanner.Scan() { + line := strings.TrimSpace(stripHistoryPrefix(scanner.Text())) + if line == "" { + continue + } + for _, a := range extractAssignments(line) { + entries = append(entries, entry{a.name, a.value}) + } + } + + // Newest first, dedupe by "name=value". + seen := make(map[string]struct{}, len(entries)) + out := make([]substituteOption, 0, len(entries)) + for i := len(entries) - 1; i >= 0; i-- { + key := entries[i].name + "=" + entries[i].value + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, substituteOption{ + Display: "hist: " + entries[i].name + "=" + sanitizeOneLine(entries[i].value), + Value: entries[i].value, + }) + } + return out +} + +// assignment is a single VAR=value pair extracted from a history line. +type assignment struct { + name, value string +} + +// extractAssignments returns every assignment-shaped token in a line. +// Handles: +// +// VAR=value +// export VAR=value +// declare -x VAR=value +// typeset VAR=value +// set VAR=value +// FOO=bar BAR=baz some-command (leading assignments) +// +// Values may be quoted with ' or "; the quotes are stripped. +func extractAssignments(line string) []assignment { + tokens := splitShellTokens(line) + var out []assignment + for i := 0; i < len(tokens); i++ { + tok := tokens[i] + switch tok { + case "export", "set", "typeset", "local", "readonly": + continue + case "declare": + // skip any flag-like tokens that follow (e.g. -x, -gx) + for i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "-") { + i++ + } + continue + } + if a, ok := parseAssignment(tok); ok { + out = append(out, a) + } + } + return out +} + +// parseAssignment splits "NAME=value" if NAME is a valid shell var name. +// Returns false on anything else (commands, paths, flags, etc.). +func parseAssignment(tok string) (assignment, bool) { + eq := strings.IndexByte(tok, '=') + if eq <= 0 { + return assignment{}, false + } + name := tok[:eq] + value := tok[eq+1:] + if !isValidVarName(name) { + return assignment{}, false + } + // Strip a single layer of matching quotes. + if n := len(value); n >= 2 { + if (value[0] == '"' && value[n-1] == '"') || (value[0] == '\'' && value[n-1] == '\'') { + value = value[1 : n-1] + } + } + if value == "" { + return assignment{}, false + } + return assignment{name: name, value: value}, true +} + +// isValidVarName reports whether s is a valid POSIX shell variable name: +// starts with letter or underscore, then letters/digits/underscores. +func isValidVarName(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + isLetter := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' + if i == 0 { + if !isLetter { + return false + } + continue + } + isDigit := c >= '0' && c <= '9' + if !isLetter && !isDigit { + return false + } + } + return true +} + +// splitShellTokens splits a command line into whitespace-separated tokens, +// keeping quoted regions intact. It's not a full shell parser — just enough +// to handle the common cases in history. +func splitShellTokens(line string) []string { + var tokens []string + var cur strings.Builder + var quote byte // 0, '\'', or '"' + for i := 0; i < len(line); i++ { + c := line[i] + switch { + case quote != 0: + cur.WriteByte(c) + if c == quote { + quote = 0 + } + case c == '\'' || c == '"': + cur.WriteByte(c) + quote = c + case c == ' ' || c == '\t': + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + cur.Reset() + } + default: + cur.WriteByte(c) + } + } + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + } + return tokens +} + +// findHistoryFile returns the first readable history file path, or "". +func findHistoryFile() string { + if p := strings.TrimSpace(os.Getenv("HISTFILE")); p != "" { + if _, err := os.Stat(p); err == nil { + return p + } + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + for _, name := range []string{".bash_history", ".zsh_history", ".history"} { + p := filepath.Join(home, name) + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// stripHistoryPrefix removes the leading metadata that zsh's extended history +// format prepends to each command (": :;command"). +// Bash plain history has no prefix and is returned unchanged. +func stripHistoryPrefix(line string) string { + if !strings.HasPrefix(line, ": ") { + return line + } + semi := strings.IndexByte(line, ';') + if semi < 0 { + return line + } + return line[semi+1:] +} diff --git a/internal/ui/substitute_test.go b/internal/ui/substitute_test.go new file mode 100644 index 0000000..f15eee3 --- /dev/null +++ b/internal/ui/substitute_test.go @@ -0,0 +1,110 @@ +package ui + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCollectEnvOptions_HasCurrentEnv(t *testing.T) { + t.Setenv("CHEATMD_TEST_VAR", "hello-world") + opts := collectEnvOptions() + if len(opts) == 0 { + t.Fatal("expected at least one env option") + } + var found bool + for _, o := range opts { + if strings.Contains(o.Display, "CHEATMD_TEST_VAR=hello-world") && o.Value == "hello-world" { + found = true + break + } + } + if !found { + t.Fatal("expected to find CHEATMD_TEST_VAR=hello-world in env options") + } +} + +func TestCollectHistoryOptions_OnlyAssignments(t *testing.T) { + dir := t.TempDir() + histPath := filepath.Join(dir, "history") + content := strings.Join([]string{ + "ls -la", // plain command, ignored + "export DOMAIN=corp.example.com", // export-style + "USER=alice ssh prod.example.com", // leading inline assignment + "grep foo bar", // ignored + "export TOKEN=\"abc 123\"", // double-quoted value + "FOO=bar BAR=baz some-command --flag", // two assignments in one line + "declare -x DB_HOST=db.example.com", // declare with flag + ": 1700000000:0;export ZSH_VAR=zvalue", // zsh-prefixed + "ls -la", // ignored + "DOMAIN=corp.example.com", // duplicate value, deduped + }, "\n") + "\n" + if err := os.WriteFile(histPath, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("HISTFILE", histPath) + + opts := collectHistoryOptions() + + // Build a map name -> value for easy assertions. + got := make(map[string]string, len(opts)) + for _, o := range opts { + // Display is "hist: NAME=value"; split out the key for indexing. + body := strings.TrimPrefix(o.Display, "hist: ") + eq := strings.IndexByte(body, '=') + if eq <= 0 { + t.Fatalf("unexpected display shape: %q", o.Display) + } + got[body[:eq]] = o.Value + } + + want := map[string]string{ + "DOMAIN": "corp.example.com", + "USER": "alice", + "TOKEN": "abc 123", + "FOO": "bar", + "BAR": "baz", + "DB_HOST": "db.example.com", + "ZSH_VAR": "zvalue", + } + for k, v := range want { + if got[k] != v { + t.Errorf("expected %s=%q, got %q", k, v, got[k]) + } + } + if _, ok := got["ls"]; ok { + t.Errorf("plain command 'ls' should not appear as an assignment") + } + // Dedup: DOMAIN=corp.example.com appears twice in the file but should + // produce a single row. + domainCount := 0 + for _, o := range opts { + if o.Display == "hist: DOMAIN=corp.example.com" { + domainCount++ + } + } + if domainCount != 1 { + t.Errorf("expected DOMAIN=corp.example.com deduped to 1 row, got %d", domainCount) + } +} + +func TestCollectSubstituteOptions_RespectsSources(t *testing.T) { + t.Setenv("HISTFILE", "/dev/null/does-not-exist") + t.Setenv("CHEATMD_TEST_ONLY", "yes") + + envOnly := collectSubstituteOptions([]string{"env"}) + if len(envOnly) == 0 { + t.Fatal("expected env entries with sources=[env]") + } + for _, o := range envOnly { + if !strings.HasPrefix(o.Display, "env: ") { + t.Errorf("env-only mode returned non-env row: %q", o.Display) + } + } + + none := collectSubstituteOptions([]string{}) + if len(none) != 0 { + t.Errorf("empty sources should return empty, got %d rows", len(none)) + } +}