github.com/jfrog/jfrog-cli-core/v2@v2.52.0/utils/ioutils/questionnaire.go (about)

     1  package ioutils
     2  
     3  import (
     4  	"regexp"
     5  	"strconv"
     6  	"strings"
     7  
     8  	"github.com/c-bata/go-prompt"
     9  	"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
    10  	"github.com/jfrog/jfrog-client-go/utils/log"
    11  )
    12  
    13  const (
    14  	insertValuePromptMsg = "Insert the value for "
    15  	DummyDefaultAnswer   = "-"
    16  )
    17  
    18  // The interactive questionnaire works as follows:
    19  //
    20  //	We have to provide a map of QuestionInfo which include all possible questions may be asked.
    21  //	1. Mandatory Questions:
    22  //		* We will ask all the questions in MandatoryQuestionsKeys list one after the other.
    23  //	2. Optional questions:
    24  //		* We have to provide a slice of prompt.Suggest, in which each suggest.Text is a key of a question in the map.
    25  //		* After a suggestion was chosen from the list, the corresponding question from the map will be asked.
    26  //		* Each answer is written to the configMap using its writer, under the MapKey specified in the questionInfo.
    27  //		* We will execute the previous step until the SaveAndExit string was inserted.
    28  type InteractiveQuestionnaire struct {
    29  	QuestionsMap           map[string]QuestionInfo
    30  	MandatoryQuestionsKeys []string
    31  	OptionalKeysSuggests   []prompt.Suggest
    32  	AnswersMap             map[string]interface{}
    33  }
    34  
    35  // Each question can have the following properties:
    36  //   - Msg - will be printed in separate line
    37  //   - PromptPrefix - will be printed before the input cursor in the answer line
    38  //   - Options - In case the answer must be selected from a predefined list
    39  //   - AllowVars - a flag indicates whether a variable (in form of ${var}) is an acceptable answer despite the predefined list
    40  //   - Writer - how to write the answer to the final config map
    41  //   - MapKey - the key under which the answer will be written to the configMap
    42  //   - Callback - optional function can be executed after the answer was inserted. Can be used to implement some dependencies between questions.
    43  type AnswerWriter func(resultMap *map[string]interface{}, key, value string) error
    44  type questionCallback func(*InteractiveQuestionnaire, string) (string, error)
    45  
    46  type QuestionInfo struct {
    47  	Msg          string
    48  	PromptPrefix string
    49  	Options      []prompt.Suggest
    50  	AllowVars    bool
    51  	Writer       AnswerWriter
    52  	MapKey       string
    53  	Callback     questionCallback
    54  }
    55  
    56  const (
    57  	PressTabMsg      = " (press Tab for options):"
    58  	InvalidAnswerMsg = "Invalid answer. Please select value from the suggestions list."
    59  	VariableUseMsg   = " You may use dynamic variable in the form of ${key}."
    60  	EmptyValueMsg    = "The value cannot be empty. Please enter a valid value."
    61  	OptionalKey      = "OptionalKey"
    62  	SaveAndExit      = ":x"
    63  
    64  	// Boolean answers
    65  	True  = "true"
    66  	False = "false"
    67  
    68  	CommaSeparatedListMsg = "The value should be a comma separated list"
    69  )
    70  
    71  // Var can be inserted in the form of ${key}
    72  var VarPattern = regexp.MustCompile(`^\$\{\w+}+$`)
    73  
    74  func prefixCompleter(options []prompt.Suggest) prompt.Completer {
    75  	return func(document prompt.Document) []prompt.Suggest {
    76  		return prompt.FilterHasPrefix(options, document.GetWordBeforeCursor(), true)
    77  	}
    78  }
    79  
    80  // Bind ctrl+c key to interrupt the command
    81  func interruptKeyBind() prompt.Option {
    82  	interrupt := prompt.KeyBind{
    83  		Key: prompt.ControlC,
    84  		Fn: func(buf *prompt.Buffer) {
    85  			panic("Interrupted")
    86  		},
    87  	}
    88  	return prompt.OptionAddKeyBind(interrupt)
    89  }
    90  
    91  // Ask question with free string answer.
    92  // If answer is empty and defaultValue isn't, return defaultValue.
    93  // Otherwise, answer cannot be empty.
    94  // Variable aren't checked and can be part of the answer.
    95  func AskStringWithDefault(msg, promptPrefix, defaultValue string) string {
    96  	return askString(msg, promptPrefix, defaultValue, false, false)
    97  }
    98  
    99  // Ask question with free string answer, allow an empty string as an answer
   100  func AskString(msg, promptPrefix string, allowEmpty bool, allowVars bool) string {
   101  	return askString(msg, promptPrefix, "", allowEmpty, allowVars)
   102  }
   103  
   104  // Ask question with free string answer.
   105  // If an empty answer is allowed, the answer returned as is,
   106  // if not and a default value was provided, the default value is returned.
   107  func askString(msg, promptPrefix, defaultValue string, allowEmpty bool, allowVars bool) string {
   108  	if msg != "" {
   109  		log.Output(msg + ":")
   110  	}
   111  	errMsg := EmptyValueMsg
   112  	if allowVars {
   113  		errMsg += VariableUseMsg
   114  	}
   115  	promptPrefix = addDefaultValueToPrompt(promptPrefix, defaultValue)
   116  	for {
   117  		answer := prompt.Input(promptPrefix, prefixCompleter(nil), interruptKeyBind())
   118  		answer = strings.TrimSpace(answer)
   119  		if allowEmpty || answer != "" {
   120  			return answer
   121  		}
   122  		// An empty answer wan given, default value wad provided.
   123  		if defaultValue != "" {
   124  			return defaultValue
   125  		}
   126  		log.Output(errMsg)
   127  	}
   128  }
   129  
   130  // Ask question with list of possible answers.
   131  // If answer is empty and defaultValue isn't, return defaultValue.
   132  // Otherwise, the answer must be chosen from the list, but can be a variable if allowVars set to true.
   133  func AskFromList(msg, promptPrefix string, allowVars bool, options []prompt.Suggest, defaultValue string) string {
   134  	if msg != "" {
   135  		log.Output(msg + PressTabMsg)
   136  	}
   137  	errMsg := InvalidAnswerMsg
   138  	if allowVars {
   139  		errMsg += VariableUseMsg
   140  	}
   141  	promptPrefix = addDefaultValueToPrompt(promptPrefix, defaultValue)
   142  	for {
   143  		answer := prompt.Input(promptPrefix, prefixCompleter(options), interruptKeyBind())
   144  		answer = strings.TrimSpace(answer)
   145  		if answer == "" && defaultValue != "" {
   146  			return defaultValue
   147  		}
   148  		if validateAnswer(answer, options, allowVars) {
   149  			return answer
   150  		}
   151  		log.Output(errMsg)
   152  	}
   153  }
   154  
   155  func addDefaultValueToPrompt(promptPrefix, defaultValue string) string {
   156  	if defaultValue != "" && defaultValue != DummyDefaultAnswer {
   157  		return promptPrefix + " [" + defaultValue + "]: "
   158  	}
   159  	return promptPrefix + " "
   160  }
   161  
   162  func validateAnswer(answer string, options []prompt.Suggest, allowVars bool) bool {
   163  	if allowVars {
   164  		if regexMatch := VarPattern.FindStringSubmatch(answer); regexMatch != nil {
   165  			return true
   166  		}
   167  	}
   168  	for _, option := range options {
   169  		if answer == option.Text {
   170  			return true
   171  		}
   172  	}
   173  	return false
   174  }
   175  
   176  // Ask question with list of possible answers.
   177  // If the provided answer does not appear in list, confirm the choice.
   178  func AskFromListWithMismatchConfirmation(promptPrefix, misMatchMsg string, options []prompt.Suggest) string {
   179  	for {
   180  		answer := prompt.Input(promptPrefix+" ", prefixCompleter(options), interruptKeyBind())
   181  		if answer == "" {
   182  			log.Output(EmptyValueMsg)
   183  		}
   184  		for _, option := range options {
   185  			if answer == option.Text {
   186  				return answer
   187  			}
   188  		}
   189  		if coreutils.AskYesNo(misMatchMsg+" continue anyway?", false) {
   190  			return answer
   191  		}
   192  	}
   193  }
   194  
   195  // Ask question steps:
   196  //  1. Ask for string/from list
   197  //  2. Write the answer to answersMap (if writer provided)
   198  //  3. Run callback (if provided)
   199  func (iq *InteractiveQuestionnaire) AskQuestion(question QuestionInfo) (value string, err error) {
   200  	var answer string
   201  	if question.Options != nil {
   202  		answer = AskFromList(question.Msg, question.PromptPrefix, question.AllowVars, question.Options, "")
   203  	} else {
   204  		answer = AskString(question.Msg, question.PromptPrefix, false, question.AllowVars)
   205  	}
   206  	if question.Writer != nil {
   207  		err = question.Writer(&iq.AnswersMap, question.MapKey, answer)
   208  		if err != nil {
   209  			return "", err
   210  		}
   211  	}
   212  	if question.Callback != nil {
   213  		_, err = question.Callback(iq, answer)
   214  		if err != nil {
   215  			return "", err
   216  		}
   217  	}
   218  	return answer, nil
   219  }
   220  
   221  // The main function to perform the questionnaire
   222  func (iq *InteractiveQuestionnaire) Perform() error {
   223  	iq.AnswersMap = make(map[string]interface{})
   224  	for i := 0; i < len(iq.MandatoryQuestionsKeys); i++ {
   225  		_, err := iq.AskQuestion(iq.QuestionsMap[iq.MandatoryQuestionsKeys[i]])
   226  		if err != nil {
   227  			return err
   228  		}
   229  	}
   230  	log.Output("You can type \":x\" at any time to save and exit.")
   231  	OptionalKeyQuestion := iq.QuestionsMap[OptionalKey]
   232  	OptionalKeyQuestion.Options = iq.OptionalKeysSuggests
   233  	for {
   234  		key, err := iq.AskQuestion(OptionalKeyQuestion)
   235  		if err != nil {
   236  			return err
   237  		}
   238  		if key == SaveAndExit {
   239  			break
   240  		}
   241  	}
   242  	return nil
   243  }
   244  
   245  // Common questions
   246  var FreeStringQuestionInfo = QuestionInfo{
   247  	Options:   nil,
   248  	AllowVars: false,
   249  	Writer:    WriteStringAnswer,
   250  }
   251  
   252  func GetBoolSuggests() []prompt.Suggest {
   253  	return []prompt.Suggest{
   254  		{Text: True},
   255  		{Text: False},
   256  	}
   257  }
   258  
   259  // Common writers
   260  func WriteStringAnswer(resultMap *map[string]interface{}, key, value string) error {
   261  	(*resultMap)[key] = value
   262  	return nil
   263  }
   264  
   265  func WriteBoolAnswer(resultMap *map[string]interface{}, key, value string) error {
   266  	if regexMatch := VarPattern.FindStringSubmatch(value); regexMatch != nil {
   267  		return WriteStringAnswer(resultMap, key, value)
   268  	}
   269  	boolValue, err := strconv.ParseBool(value)
   270  	if err != nil {
   271  		return err
   272  	}
   273  	(*resultMap)[key] = boolValue
   274  	return nil
   275  }
   276  
   277  func WriteIntAnswer(resultMap *map[string]interface{}, key, value string) error {
   278  	if regexMatch := VarPattern.FindStringSubmatch(value); regexMatch != nil {
   279  		return WriteStringAnswer(resultMap, key, value)
   280  	}
   281  	intValue, err := strconv.Atoi(value)
   282  	if err != nil {
   283  		return err
   284  	}
   285  	(*resultMap)[key] = intValue
   286  	return nil
   287  }
   288  
   289  func WriteStringArrayAnswer(resultMap *map[string]interface{}, key, value string) error {
   290  	if regexMatch := VarPattern.FindStringSubmatch(value); regexMatch != nil {
   291  		return WriteStringAnswer(resultMap, key, value)
   292  	}
   293  	arrValue := strings.Split(value, ",")
   294  	(*resultMap)[key] = arrValue
   295  	return nil
   296  }
   297  
   298  func GetSuggestsFromKeys(keys []string, suggestionMap map[string]prompt.Suggest) []prompt.Suggest {
   299  	var suggests []prompt.Suggest
   300  	for _, key := range keys {
   301  		suggests = append(suggests, suggestionMap[key])
   302  	}
   303  	return suggests
   304  }
   305  
   306  func ConvertToSuggests(options []string) []prompt.Suggest {
   307  	var suggests []prompt.Suggest
   308  	for _, opt := range options {
   309  		suggests = append(suggests, prompt.Suggest{Text: opt})
   310  	}
   311  	return suggests
   312  }
   313  
   314  // After an optional value was chosen we'll ask for its value.
   315  func OptionalKeyCallback(iq *InteractiveQuestionnaire, key string) (value string, err error) {
   316  	if key != SaveAndExit {
   317  		valueQuestion := iq.QuestionsMap[key]
   318  		// Since we are using default question in most of the cases we set the map key here.
   319  		if valueQuestion.MapKey == "" {
   320  			valueQuestion.MapKey = key
   321  		}
   322  		editOptionalQuestionPromptPrefix(&valueQuestion, key)
   323  		value, err = iq.AskQuestion(valueQuestion)
   324  	}
   325  	return value, err
   326  }
   327  
   328  func editOptionalQuestionPromptPrefix(question *QuestionInfo, key string) {
   329  	if question.PromptPrefix == "" {
   330  		question.PromptPrefix = insertValuePromptMsg + key
   331  	}
   332  	if question.Options != nil {
   333  		question.PromptPrefix += PressTabMsg
   334  	}
   335  	if !strings.HasSuffix(question.PromptPrefix, " >") {
   336  		question.PromptPrefix += " >"
   337  	}
   338  }