github.com/osievert/jfrog-cli-core@v1.2.7/artifactory/commands/utils/questionnaire.go (about)

     1  package utils
     2  
     3  import (
     4  	"fmt"
     5  	"regexp"
     6  	"strconv"
     7  	"strings"
     8  
     9  	"github.com/c-bata/go-prompt"
    10  
    11  	"github.com/jfrog/jfrog-cli-core/utils/coreutils"
    12  )
    13  
    14  const (
    15  	InsertValuePromptMsg = "Insert the value for "
    16  	DummyDefaultAnswer   = "-"
    17  )
    18  
    19  // The interactive questionnaire works as follows:
    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 suggest was chosen from the list, the corresponding question from the map will be asked.
    26  //		* Each answer is written to 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  		fmt.Println(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  		fmt.Println(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  		fmt.Println(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  		fmt.Println(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  			fmt.Println(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  		iq.AskQuestion(iq.QuestionsMap[iq.MandatoryQuestionsKeys[i]])
   226  	}
   227  	fmt.Println("You can type \":x\" at any time to save and exit.")
   228  	OptionalKeyQuestion := iq.QuestionsMap[OptionalKey]
   229  	OptionalKeyQuestion.Options = iq.OptionalKeysSuggests
   230  	for {
   231  		key, err := iq.AskQuestion(OptionalKeyQuestion)
   232  		if err != nil {
   233  			return err
   234  		}
   235  		if key == SaveAndExit {
   236  			break
   237  		}
   238  	}
   239  	return nil
   240  }
   241  
   242  // Common questions
   243  var FreeStringQuestionInfo = QuestionInfo{
   244  	Options:   nil,
   245  	AllowVars: false,
   246  	Writer:    WriteStringAnswer,
   247  }
   248  
   249  func GetBoolSuggests() []prompt.Suggest {
   250  	return []prompt.Suggest{
   251  		{Text: True},
   252  		{Text: False},
   253  	}
   254  }
   255  
   256  var BoolQuestionInfo = QuestionInfo{
   257  	Options:   GetBoolSuggests(),
   258  	AllowVars: true,
   259  	Writer:    WriteBoolAnswer,
   260  }
   261  
   262  var IntQuestionInfo = QuestionInfo{
   263  	Options:   nil,
   264  	AllowVars: true,
   265  	Writer:    WriteIntAnswer,
   266  }
   267  
   268  var StringListQuestionInfo = QuestionInfo{
   269  	Msg:       CommaSeparatedListMsg,
   270  	Options:   nil,
   271  	AllowVars: true,
   272  	Writer:    WriteStringArrayAnswer,
   273  }
   274  
   275  // Common writers
   276  func WriteStringAnswer(resultMap *map[string]interface{}, key, value string) error {
   277  	(*resultMap)[key] = value
   278  	return nil
   279  }
   280  
   281  func WriteBoolAnswer(resultMap *map[string]interface{}, key, value string) error {
   282  	if regexMatch := VarPattern.FindStringSubmatch(value); regexMatch != nil {
   283  		return WriteStringAnswer(resultMap, key, value)
   284  	}
   285  	boolValue, err := strconv.ParseBool(value)
   286  	if err != nil {
   287  		return err
   288  	}
   289  	(*resultMap)[key] = boolValue
   290  	return nil
   291  }
   292  
   293  func WriteIntAnswer(resultMap *map[string]interface{}, key, value string) error {
   294  	if regexMatch := VarPattern.FindStringSubmatch(value); regexMatch != nil {
   295  		return WriteStringAnswer(resultMap, key, value)
   296  	}
   297  	intValue, err := strconv.Atoi(value)
   298  	if err != nil {
   299  		return err
   300  	}
   301  	(*resultMap)[key] = intValue
   302  	return nil
   303  }
   304  
   305  func WriteStringArrayAnswer(resultMap *map[string]interface{}, key, value string) error {
   306  	if regexMatch := VarPattern.FindStringSubmatch(value); regexMatch != nil {
   307  		return WriteStringAnswer(resultMap, key, value)
   308  	}
   309  	arrValue := strings.Split(value, ",")
   310  	(*resultMap)[key] = arrValue
   311  	return nil
   312  }
   313  
   314  func GetSuggestsFromKeys(keys []string, SuggestionMap map[string]prompt.Suggest) []prompt.Suggest {
   315  	var suggests []prompt.Suggest
   316  	for _, key := range keys {
   317  		suggests = append(suggests, SuggestionMap[key])
   318  	}
   319  	return suggests
   320  }
   321  
   322  func ConvertToSuggests(options []string) []prompt.Suggest {
   323  	var suggests []prompt.Suggest
   324  	for _, opt := range options {
   325  		suggests = append(suggests, prompt.Suggest{Text: opt})
   326  	}
   327  	return suggests
   328  }
   329  
   330  // After an optional value was chosen we'll ask for its value.
   331  func OptionalKeyCallback(iq *InteractiveQuestionnaire, key string) (value string, err error) {
   332  	if key != SaveAndExit {
   333  		valueQuestion := iq.QuestionsMap[key]
   334  		// Since we are using default question in most of the cases we set the map key here.
   335  		valueQuestion.MapKey = key
   336  		valueQuestion.PromptPrefix = InsertValuePromptMsg + key
   337  		if valueQuestion.Options != nil {
   338  			valueQuestion.PromptPrefix += PressTabMsg
   339  		}
   340  		valueQuestion.PromptPrefix += " >"
   341  		value, err = iq.AskQuestion(valueQuestion)
   342  	}
   343  	return value, err
   344  }