github.com/jfrog/jfrog-cli-core/v2@v2.51.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 }