diff --git a/prompts/file.go b/prompts/file.go new file mode 100644 index 0000000..cbe232f --- /dev/null +++ b/prompts/file.go @@ -0,0 +1,136 @@ +package prompts + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/spectrocloud-labs/prompts-tui/prompts/mocks" +) + +var ( + editorBinary = "vi" + editorPath = "" + GetCmdExecutor = getEditorExecutor +) + +func init() { + visual := os.Getenv("VISUAL") + editor := os.Getenv("EDITOR") + if visual != "" { + editorBinary = visual + logger.Info(fmt.Sprintf("Detected VISUAL env var. Overrode default editor (vi) with %s.", editorBinary)) + } else if editor != "" { + editorBinary = editor + logger.Info(fmt.Sprintf("Detected EDITOR env var. Overrode default editor (vi) with %s.", editorBinary)) + } + var err error + editorPath, err = exec.LookPath(editorBinary) + if err != nil { + logger.Info(fmt.Sprintf("Error: %s not found on PATH. Either install vi or export VISUAL or EDITOR to an editor of your choosing.", editorBinary)) + os.Exit(1) + } +} + +func getEditorExecutor(editor, filename string) mocks.CommandExecutor { + cmd := exec.Command(editor, filename) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd +} + +func EditFile(initialContent []byte) ([]byte, error) { + tmpFile, err := os.CreateTemp(os.TempDir(), "validator") + if err != nil { + return nil, err + } + filename := tmpFile.Name() + if err := tmpFile.Close(); err != nil { + return nil, err + } + + if initialContent != nil { + if err := os.WriteFile(filename, initialContent, 0600); err != nil { + return nil, err + } + } + + cmd := GetCmdExecutor(editorPath, filename) + if err := cmd.Start(); err != nil { + return nil, err + } + if err := cmd.Wait(); err != nil { + return nil, err + } + + data, err := os.ReadFile(filename) //#nosec + if err != nil { + return nil, err + } + if err := os.Remove(filename); err != nil { + return nil, err + } + return data, nil +} + +// EditFileValidated prompts a user to edit a file with a predefined prompt, initial content, and separator. +// An optional validation function can be specified to validate the content of each line. +// Entries within the file must be newline-separated. Additionally, a minimum number of entries can be specified. +// The values on each line are joined by the separator and returned to the caller. +func EditFileValidated(prompt, content, separator string, validate func(input string) error, minEntries int) (string, error) { + if separator == "" { + return "", errors.New("a non-empty separator is required") + } + + for { + var partsBytes []byte + if content != "" { + parts := bytes.Split([]byte(content), []byte(separator)) + partsBytes = bytes.Join(parts, []byte("\n")) + } + + partsBytes, err := EditFile(append([]byte(prompt), partsBytes...)) + if err != nil { + return content, err + } + lines := strings.Split(string(partsBytes), "\n") + + // Parse final lines, skipping comments and optionally validating each line + finalLines := make([]string, 0) + for _, l := range lines { + l = strings.TrimSpace(l) + if l != "" && !strings.HasPrefix(l, "#") { + if validate != nil { + if err = validate(l); err != nil { + break + } + } + finalLines = append(finalLines, l) + } + } + + if err != nil && errors.Is(err, ValidationError) { + // for integration tests, return the error + if os.Getenv("IS_TEST") == "true" { + return "", err + } + // otherwise, we assume the validation function logged + // a meaningful error message and let the user try again + time.Sleep(5 * time.Second) + continue + } + if minEntries > 0 && len(finalLines) < minEntries { + logger.Info(fmt.Sprintf("Error editing file: %d or more entries are required", minEntries)) + time.Sleep(5 * time.Second) + continue + } + + content = strings.TrimRight(strings.Join(finalLines, separator), separator) + return content, err + } +} diff --git a/prompts/mocks/file_editor_mocks.go b/prompts/mocks/file_editor_mocks.go new file mode 100644 index 0000000..6c62b7b --- /dev/null +++ b/prompts/mocks/file_editor_mocks.go @@ -0,0 +1,32 @@ +package mocks + +import ( + "os" +) + +type CommandExecutor interface { + Start() error + Wait() error +} + +type MockFileEditor struct { + FileContents []string + filename string +} + +func (m *MockFileEditor) Start() error { + if err := os.WriteFile(m.filename, []byte(m.FileContents[0]), 0600); err != nil { + return err + } + m.FileContents = m.FileContents[1:] + return nil +} + +func (m *MockFileEditor) Wait() error { + return nil +} + +func (m *MockFileEditor) GetCmdExecutor(vimPath string, filename string) CommandExecutor { + m.filename = filename + return m +}