derive_wizard/backend/
requestty_backend.rs

1use crate::backend::{BackendError, InterviewBackend};
2use crate::interview::{Question, QuestionKind};
3use crate::{AnswerValue, Answers};
4
5/// Requestty backend for interactive CLI prompts
6pub struct RequesttyBackend;
7
8impl RequesttyBackend {
9    pub const fn new() -> Self {
10        Self
11    }
12
13    fn execute_question_with_validator(
14        &self,
15        question: &Question,
16        answers: &mut Answers,
17        validator: &(dyn Fn(&str, &str, &Answers) -> Result<(), String> + Send + Sync),
18    ) -> Result<(), BackendError> {
19        let id = question.id().unwrap_or_else(|| question.name());
20
21        match question.kind() {
22            QuestionKind::Input(input_q) => {
23                let build_question = || {
24                    let mut q = requestty::Question::input(id).message(question.prompt());
25
26                    if let Some(default) = &input_q.default {
27                        q = q.default(default.clone());
28                    }
29
30                    let answers_for_submit = answers.clone();
31                    let answers_for_key = answers.clone();
32                    q = q
33                        .validate(move |value: &str, _prev_answers| -> Result<(), String> {
34                            validator(id, value, &answers_for_submit)
35                        })
36                        .validate_on_key(move |value: &str, _prev_answers| {
37                            validator(id, value, &answers_for_key).is_ok()
38                        });
39
40                    q.build()
41                };
42
43                loop {
44                    match requestty::prompt_one(build_question()) {
45                        Ok(requestty::Answer::String(s)) => {
46                            // Double-check with validator in case requestty didn't intercept
47                            if let Err(msg) = validator(id, &s, answers) {
48                                println!("{msg}");
49                                continue;
50                            }
51                            answers.insert(id.to_string(), AnswerValue::String(s));
52                            break;
53                        }
54                        Ok(_) => {
55                            return Err(BackendError::ExecutionError(
56                                "Expected string answer".to_string(),
57                            ));
58                        }
59                        Err(e) => {
60                            println!("{e}");
61                            continue;
62                        }
63                    }
64                }
65            }
66            QuestionKind::Multiline(multiline_q) => {
67                let build_question = || {
68                    let mut q = requestty::Question::editor(id).message(question.prompt());
69
70                    if let Some(default) = &multiline_q.default {
71                        q = q.default(default.clone());
72                    }
73
74                    let answers_for_submit = answers.clone();
75                    q = q.validate(move |value: &str, _prev_answers| -> Result<(), String> {
76                        validator(id, value, &answers_for_submit)
77                    });
78
79                    q.build()
80                };
81
82                loop {
83                    match requestty::prompt_one(build_question()) {
84                        Ok(requestty::Answer::String(s)) => {
85                            answers.insert(id.to_string(), AnswerValue::String(s));
86                            break;
87                        }
88                        Ok(_) => {
89                            return Err(BackendError::ExecutionError(
90                                "Expected string answer".to_string(),
91                            ));
92                        }
93                        Err(e) => {
94                            println!("{e}");
95                            continue;
96                        }
97                    }
98                }
99            }
100            QuestionKind::Masked(masked_q) => {
101                let build_question = || {
102                    let mut q = requestty::Question::password(id).message(question.prompt());
103
104                    if let Some(mask) = masked_q.mask {
105                        q = q.mask(mask);
106                    }
107
108                    let answers_for_submit = answers.clone();
109                    q = q.validate(move |value: &str, _prev_answers| -> Result<(), String> {
110                        validator(id, value, &answers_for_submit)
111                    });
112
113                    q.build()
114                };
115
116                loop {
117                    match requestty::prompt_one(build_question()) {
118                        Ok(requestty::Answer::String(s)) => {
119                            answers.insert(id.to_string(), AnswerValue::String(s));
120                            break;
121                        }
122                        Ok(_) => {
123                            return Err(BackendError::ExecutionError(
124                                "Expected string answer".to_string(),
125                            ));
126                        }
127                        Err(e) => {
128                            println!("{e}");
129                            continue;
130                        }
131                    }
132                }
133            }
134            QuestionKind::Sequence(questions) => {
135                // Keep enum handling consistent with execute_question
136                if question.kind().is_enum_alternatives() {
137                    let choices: Vec<String> =
138                        questions.iter().map(|q| q.name().to_string()).collect();
139
140                    let q = requestty::Question::select(id)
141                        .message(question.prompt())
142                        .choices(choices)
143                        .default(0)
144                        .build();
145
146                    let answer = requestty::prompt_one(q).map_err(|e| {
147                        BackendError::ExecutionError(format!("Failed to prompt: {e}"))
148                    })?;
149
150                    let selection = match answer {
151                        requestty::Answer::ListItem(item) => item.index,
152                        _ => return Err(BackendError::ExecutionError("Expected list item".into())),
153                    };
154
155                    let parent_prefix = id.strip_suffix(".alternatives");
156                    let answer_key = parent_prefix.map_or_else(
157                        || {
158                            if id == "alternatives" {
159                                crate::SELECTED_ALTERNATIVE_KEY.to_string()
160                            } else {
161                                format!("{}.{}", id, crate::SELECTED_ALTERNATIVE_KEY)
162                            }
163                        },
164                        |prefix| format!("{}.{}", prefix, crate::SELECTED_ALTERNATIVE_KEY),
165                    );
166
167                    answers.insert(answer_key, AnswerValue::Int(selection as i64));
168
169                    let selected_variant = &questions[selection];
170                    if let QuestionKind::Alternative(_, fields) = selected_variant.kind() {
171                        for field_q in fields {
172                            if let Some(prefix) = parent_prefix {
173                                let field_id = field_q.id().unwrap_or_else(|| field_q.name());
174                                let prefixed_id = format!("{}.{}", prefix, field_id);
175                                let prefixed_question = Question::new(
176                                    Some(prefixed_id.clone()),
177                                    prefixed_id,
178                                    field_q.prompt().to_string(),
179                                    field_q.kind().clone(),
180                                );
181                                self.execute_question_with_validator(
182                                    &prefixed_question,
183                                    answers,
184                                    validator,
185                                )?;
186                            } else {
187                                self.execute_question_with_validator(field_q, answers, validator)?;
188                            }
189                        }
190                    }
191                } else {
192                    for q in questions {
193                        self.execute_question_with_validator(q, answers, validator)?;
194                    }
195                }
196            }
197            _ => self.execute_question(question, answers)?,
198        }
199
200        Ok(())
201    }
202
203    fn execute_question(
204        &self,
205        question: &Question,
206        answers: &mut Answers,
207    ) -> Result<(), BackendError> {
208        let id = question.id().unwrap_or_else(|| question.name());
209
210        match question.kind() {
211            QuestionKind::Input(input_q) => {
212                let mut q = requestty::Question::input(id).message(question.prompt());
213
214                if let Some(default) = &input_q.default {
215                    q = q.default(default.clone());
216                }
217
218                let answer = requestty::prompt_one(q.build())
219                    .map_err(|e| BackendError::ExecutionError(format!("Failed to prompt: {e}")))?;
220
221                if let requestty::Answer::String(s) = answer {
222                    answers.insert(id.to_string(), AnswerValue::String(s));
223                }
224            }
225            QuestionKind::Multiline(multiline_q) => {
226                let mut q = requestty::Question::editor(id).message(question.prompt());
227
228                if let Some(default) = &multiline_q.default {
229                    q = q.default(default.clone());
230                }
231
232                let answer = requestty::prompt_one(q.build())
233                    .map_err(|e| BackendError::ExecutionError(format!("Failed to prompt: {e}")))?;
234
235                if let requestty::Answer::String(s) = answer {
236                    answers.insert(id.to_string(), AnswerValue::String(s));
237                }
238            }
239            QuestionKind::Masked(masked_q) => {
240                let mut q = requestty::Question::password(id).message(question.prompt());
241
242                if let Some(mask) = masked_q.mask {
243                    q = q.mask(mask);
244                }
245
246                let answer = requestty::prompt_one(q.build())
247                    .map_err(|e| BackendError::ExecutionError(format!("Failed to prompt: {e}")))?;
248
249                if let requestty::Answer::String(s) = answer {
250                    answers.insert(id.to_string(), AnswerValue::String(s));
251                }
252            }
253            QuestionKind::Int(int_q) => {
254                let mut q = requestty::Question::int(id).message(question.prompt());
255
256                if let Some(default) = int_q.default {
257                    q = q.default(default);
258                }
259
260                // Add validation for min/max
261                let min = int_q.min;
262                let max = int_q.max;
263                if min.is_some() || max.is_some() {
264                    q = q.validate(move |value, _| {
265                        if let Some(min_val) = min
266                            && value < min_val
267                        {
268                            return Err(format!("Value must be at least {min_val}"));
269                        }
270                        if let Some(max_val) = max
271                            && value > max_val
272                        {
273                            return Err(format!("Value must be at most {max_val}"));
274                        }
275                        Ok(())
276                    });
277                }
278
279                let answer = requestty::prompt_one(q.build())
280                    .map_err(|e| BackendError::ExecutionError(format!("Failed to prompt: {e}")))?;
281
282                if let requestty::Answer::Int(i) = answer {
283                    answers.insert(id.to_string(), AnswerValue::Int(i));
284                }
285            }
286            QuestionKind::Float(float_q) => {
287                let mut q = requestty::Question::float(id).message(question.prompt());
288
289                if let Some(default) = float_q.default {
290                    q = q.default(default);
291                }
292
293                // Add validation for min/max
294                let min = float_q.min;
295                let max = float_q.max;
296                if min.is_some() || max.is_some() {
297                    q = q.validate(move |value, _| {
298                        if let Some(min_val) = min
299                            && value < min_val
300                        {
301                            return Err(format!("Value must be at least {min_val}"));
302                        }
303                        if let Some(max_val) = max
304                            && value > max_val
305                        {
306                            return Err(format!("Value must be at most {max_val}"));
307                        }
308                        Ok(())
309                    });
310                }
311
312                let answer = requestty::prompt_one(q.build())
313                    .map_err(|e| BackendError::ExecutionError(format!("Failed to prompt: {e}")))?;
314
315                if let requestty::Answer::Float(f) = answer {
316                    answers.insert(id.to_string(), AnswerValue::Float(f));
317                }
318            }
319            QuestionKind::Confirm(confirm_q) => {
320                let q = requestty::Question::confirm(id)
321                    .message(question.prompt())
322                    .default(confirm_q.default)
323                    .build();
324
325                let answer = requestty::prompt_one(q)
326                    .map_err(|e| BackendError::ExecutionError(format!("Failed to prompt: {e}")))?;
327
328                if let requestty::Answer::Bool(b) = answer {
329                    answers.insert(id.to_string(), AnswerValue::Bool(b));
330                }
331            }
332            QuestionKind::MultiSelect(multi_q) => {
333                // Build choices with default selections marked
334                let choices: Vec<_> = multi_q
335                    .options
336                    .iter()
337                    .enumerate()
338                    .map(|(idx, opt)| {
339                        let selected = multi_q.defaults.contains(&idx);
340                        (opt.clone(), selected)
341                    })
342                    .collect();
343
344                let q = requestty::Question::multi_select(id)
345                    .message(question.prompt())
346                    .choices_with_default(choices)
347                    .build();
348
349                let answer = requestty::prompt_one(q)
350                    .map_err(|e| BackendError::ExecutionError(format!("Failed to prompt: {e}")))?;
351
352                if let requestty::Answer::ListItems(items) = answer {
353                    let indices: Vec<i64> =
354                        items.into_iter().map(|item| item.index as i64).collect();
355                    answers.insert(id.to_string(), AnswerValue::IntList(indices));
356                }
357            }
358            QuestionKind::Sequence(questions) => {
359                if question.kind().is_enum_alternatives() {
360                    // This is an enum - present a selection menu
361                    let choices: Vec<String> =
362                        questions.iter().map(|q| q.name().to_string()).collect();
363
364                    let q = requestty::Question::select(id)
365                        .message(question.prompt())
366                        .choices(choices)
367                        .default(0)
368                        .build();
369
370                    let answer = requestty::prompt_one(q).map_err(|e| {
371                        BackendError::ExecutionError(format!("Failed to prompt: {e}"))
372                    })?;
373
374                    let selection = match answer {
375                        requestty::Answer::ListItem(item) => item.index,
376                        _ => return Err(BackendError::ExecutionError("Expected list item".into())),
377                    };
378
379                    // Store the selected variant index
380                    // The question name/id for enum alternatives is "alternatives"
381                    // When nested in a struct field, it becomes "fieldname.alternatives"
382                    // We need to replace ".alternatives" with ".SELECTED_ALTERNATIVE_KEY"
383                    // or just use SELECTED_ALTERNATIVE_KEY for standalone enums
384                    let parent_prefix = id.strip_suffix(".alternatives");
385
386                    let answer_key = parent_prefix.map_or_else(
387                        || {
388                            if id == "alternatives" {
389                                crate::SELECTED_ALTERNATIVE_KEY.to_string()
390                            } else {
391                                // Fallback: shouldn't happen but handle it
392                                format!("{}.{}", id, crate::SELECTED_ALTERNATIVE_KEY)
393                            }
394                        },
395                        |prefix| format!("{}.{}", prefix, crate::SELECTED_ALTERNATIVE_KEY),
396                    );
397
398                    answers.insert(answer_key, AnswerValue::Int(selection as i64));
399
400                    // Execute the selected variant's fields
401                    // Need to prefix them if this enum is a field in a struct
402                    let selected_variant = &questions[selection];
403                    if let QuestionKind::Alternative(_, fields) = selected_variant.kind() {
404                        for field_q in fields {
405                            // If there's a parent prefix (e.g., "payment"), prefix the field questions
406                            if let Some(prefix) = parent_prefix {
407                                let field_id = field_q.id().unwrap_or_else(|| field_q.name());
408                                let prefixed_id = format!("{}.{}", prefix, field_id);
409                                let prefixed_question = Question::new(
410                                    Some(prefixed_id.clone()),
411                                    prefixed_id,
412                                    field_q.prompt().to_string(),
413                                    field_q.kind().clone(),
414                                );
415                                self.execute_question(&prefixed_question, answers)?;
416                            } else {
417                                self.execute_question(field_q, answers)?;
418                            }
419                        }
420                    }
421                } else {
422                    // Regular sequence - execute all questions
423                    for q in questions {
424                        self.execute_question(q, answers)?;
425                    }
426                }
427            }
428            QuestionKind::Alternative(default_idx, alternatives) => {
429                // Build the select question for alternatives
430                let choices: Vec<String> = alternatives
431                    .iter()
432                    .map(|alt| alt.name().to_string())
433                    .collect();
434
435                let q = requestty::Question::select(id)
436                    .message(question.prompt())
437                    .choices(choices)
438                    .default(*default_idx)
439                    .build();
440
441                let answer = requestty::prompt_one(q)
442                    .map_err(|e| BackendError::ExecutionError(format!("Failed to prompt: {e}")))?;
443
444                let selected_idx = match answer {
445                    requestty::Answer::ListItem(item) => item.index,
446                    _ => return Err(BackendError::ExecutionError("Expected list item".into())),
447                };
448
449                // Store the selected alternative index
450                answers.insert(
451                    crate::SELECTED_ALTERNATIVE_KEY.to_string(),
452                    AnswerValue::Int(selected_idx as i64),
453                );
454
455                // Execute the selected alternative's questions
456                if let QuestionKind::Alternative(_, alts) = alternatives[selected_idx].kind() {
457                    for q in alts {
458                        self.execute_question(q, answers)?;
459                    }
460                }
461            }
462        }
463
464        Ok(())
465    }
466}
467
468impl Default for RequesttyBackend {
469    fn default() -> Self {
470        Self::new()
471    }
472}
473
474impl InterviewBackend for RequesttyBackend {
475    fn execute(&self, interview: &crate::interview::Interview) -> Result<Answers, BackendError> {
476        // Display prelude if present
477        if let Some(prelude) = &interview.prelude {
478            println!("{}", prelude);
479            println!();
480        }
481
482        let mut answers = Answers::new();
483
484        for question in &interview.sections {
485            // Check if question has an assumption - if so, use it and skip prompting
486            if let Some(assumed) = question.assumed() {
487                answers.insert(question.name().to_string(), assumed.into());
488                continue;
489            }
490
491            self.execute_question(question, &mut answers)?;
492        }
493
494        // Display epilogue if present
495        if let Some(epilogue) = &interview.epilogue {
496            println!();
497            println!("{}", epilogue);
498        }
499
500        Ok(answers)
501    }
502
503    fn execute_with_validator(
504        &self,
505        interview: &crate::interview::Interview,
506        validator: &(dyn Fn(&str, &str, &Answers) -> Result<(), String> + Send + Sync),
507    ) -> Result<Answers, BackendError> {
508        // Display prelude if present
509        if let Some(prelude) = &interview.prelude {
510            println!("{}", prelude);
511            println!();
512        }
513
514        let mut answers = Answers::new();
515
516        for question in &interview.sections {
517            // Check if question has an assumption - if so, use it and skip prompting
518            if let Some(assumed) = question.assumed() {
519                answers.insert(question.name().to_string(), assumed.into());
520                continue;
521            }
522
523            // Execute with validation support for nested questions
524            self.execute_question_with_validator(question, &mut answers, validator)?;
525        }
526
527        // Display epilogue if present
528        if let Some(epilogue) = &interview.epilogue {
529            println!();
530            println!("{}", epilogue);
531        }
532
533        Ok(answers)
534    }
535}