Skip to main content

greentic_setup/qa/
wizard.rs

1//! QA-aware setup wizard that unifies WASM-based `qa-spec` and legacy
2//! `setup.yaml` into a single FormSpec-driven flow.
3//!
4//! Provides both interactive CLI prompts and Adaptive Card rendering
5//! for collecting provider configuration answers.
6
7use std::path::Path;
8
9use anyhow::{Result, anyhow};
10use qa_spec::spec::form::ProgressPolicy;
11use qa_spec::{FormSpec, VisibilityMode, build_render_payload, render_card, resolve_visibility};
12use serde_json::{Map as JsonMap, Value};
13
14use crate::setup_input::SetupInputAnswers;
15use crate::setup_to_formspec;
16
17// Re-exports for backward compatibility (these are the public API)
18pub use crate::qa::prompts::{
19    answer_satisfies_question, ask_form_spec_question, has_required_questions, matches_pattern,
20    parse_typed_value, prompt_form_spec_answers, prompt_form_spec_answers_with_existing,
21};
22pub use crate::qa::shared_questions::{
23    ProviderFormSpec, SHARED_QUESTION_IDS, SharedQuestionsResult, build_provider_form_specs,
24    collect_shared_questions, merge_shared_with_provider_answers, prompt_shared_questions,
25};
26
27// Internal imports for use within this module (aliased to avoid conflicts with re-exports)
28use crate::qa::prompts::{
29    ask_form_spec_question as prompt_question, has_required_questions as check_required,
30    matches_pattern as pattern_match, prompt_form_spec_answers as do_prompt_answers,
31    prompt_form_spec_answers_with_existing as do_prompt_with_existing,
32};
33use crate::qa::shared_questions::merge_shared_with_provider_answers as merge_answers;
34
35/// Run the QA setup wizard for a provider pack.
36///
37/// Builds a `FormSpec` from `setup.yaml` (or uses a pre-built one from a
38/// component `qa-spec` invocation), then collects and validates answers.
39///
40/// Returns `(answers, form_spec)` where `form_spec` is `Some` if one was found.
41pub fn run_qa_setup(
42    pack_path: &Path,
43    provider_id: &str,
44    setup_input: Option<&SetupInputAnswers>,
45    interactive: bool,
46    qa_form_spec: Option<FormSpec>,
47    advanced: bool,
48) -> Result<(Value, Option<FormSpec>)> {
49    let form_spec =
50        qa_form_spec.or_else(|| setup_to_formspec::pack_to_form_spec(pack_path, provider_id));
51
52    let answers = if let Some(input) = setup_input {
53        if let Some(value) = input.answers_for_provider(provider_id) {
54            let mut answers = crate::setup_input::ensure_object(value.clone())?;
55            if let Some(ref spec) = form_spec {
56                // Check for missing required fields and prompt if needed
57                let missing = find_missing_required_fields(spec, &answers);
58                if !missing.is_empty() {
59                    let display = setup_to_formspec::strip_domain_prefix(provider_id);
60                    println!("\n⚠️  Missing required fields for {display}. Please provide values:");
61                    answers = prompt_for_missing_fields(spec, &answers, &missing)?;
62                }
63                validate_answers_against_form_spec(spec, &answers)?;
64            }
65            answers
66        } else if check_required(form_spec.as_ref()) {
67            return Err(anyhow!("setup input missing answers for {provider_id}"));
68        } else {
69            Value::Object(JsonMap::new())
70        }
71    } else if let Some(ref spec) = form_spec {
72        if spec.questions.is_empty() {
73            Value::Object(JsonMap::new())
74        } else if interactive {
75            // Provider-setup flow opts out of wizard localization (None) — its
76            // prompt chrome stays in English, unchanged by the env-wizard work.
77            do_prompt_answers(spec, provider_id, advanced, None)?
78        } else {
79            return Err(anyhow!(
80                "setup answers required for {provider_id} but run is non-interactive"
81            ));
82        }
83    } else {
84        Value::Object(JsonMap::new())
85    };
86
87    Ok((answers, form_spec))
88}
89
90/// Render a QA setup step as an Adaptive Card v1.3.
91///
92/// Returns `(card_json, next_question_id)` where `next_question_id` is `None`
93/// when all visible questions have been answered.
94pub fn render_qa_card(form_spec: &FormSpec, answers: &Value) -> (Value, Option<String>) {
95    let mut spec = form_spec.clone();
96    spec.progress_policy = Some(
97        spec.progress_policy
98            .map(|mut p| {
99                p.skip_answered = true;
100                p
101            })
102            .unwrap_or(ProgressPolicy {
103                skip_answered: true,
104                ..ProgressPolicy::default()
105            }),
106    );
107
108    let ctx = serde_json::json!({});
109    let payload = build_render_payload(&spec, &ctx, answers);
110    let next_id = payload.next_question_id.clone();
111    let mut card = render_card(&payload);
112
113    // Ensure Action.Submit has an `id` field for the REPL's @click.
114    if let Some(actions) = card.get_mut("actions").and_then(Value::as_array_mut) {
115        for action in actions.iter_mut() {
116            if action.get("id").is_none() {
117                action["id"] = Value::String("submit".into());
118            }
119        }
120    }
121
122    (card, next_id)
123}
124
125/// Validate answers against a FormSpec, checking required fields and constraints.
126///
127/// Questions with `visible_if` expressions that evaluate to `false` are skipped.
128pub fn validate_answers_against_form_spec(spec: &FormSpec, answers: &Value) -> Result<()> {
129    let map = answers
130        .as_object()
131        .ok_or_else(|| anyhow!("setup answers must be an object"))?;
132
133    let visibility = resolve_visibility(spec, answers, VisibilityMode::Visible);
134
135    for question in &spec.questions {
136        let visible = visibility.get(&question.id).copied().unwrap_or(true);
137        if !visible {
138            continue;
139        }
140
141        if question.required {
142            match map.get(&question.id) {
143                Some(value) if !value.is_null() => {}
144                _ => {
145                    return Err(anyhow!(
146                        "missing required setup answer for '{}'{}",
147                        question.id,
148                        question
149                            .description
150                            .as_ref()
151                            .map(|d| format!(" ({d})"))
152                            .unwrap_or_default()
153                    ));
154                }
155            }
156        }
157
158        if let Some(value) = map.get(&question.id)
159            && let Some(s) = value.as_str()
160            && let Some(ref constraint) = question.constraint
161            && let Some(ref pattern) = constraint.pattern
162            && !pattern_match(s, pattern)
163        {
164            return Err(anyhow!(
165                "answer for '{}' does not match pattern: {}",
166                question.id,
167                pattern
168            ));
169        }
170    }
171
172    Ok(())
173}
174
175/// Compute the visibility map for a FormSpec given the current answers.
176///
177/// Returns a map of `question_id → visible`. Questions without `visible_if`
178/// default to visible.
179pub fn compute_visibility(spec: &FormSpec, answers: &Value) -> qa_spec::VisibilityMap {
180    resolve_visibility(spec, answers, VisibilityMode::Visible)
181}
182
183/// Run QA setup for a provider with pre-filled shared answers.
184///
185/// This is a convenience wrapper around `run_qa_setup` that merges shared
186/// answers with provider-specific answers from `setup_input`.
187///
188/// When using `--answers` file (non-interactive mode), if any required fields
189/// are missing or empty, the user will be prompted to fill them in.
190pub fn run_qa_setup_with_shared(
191    pack_path: &Path,
192    provider_id: &str,
193    setup_input: Option<&SetupInputAnswers>,
194    interactive: bool,
195    qa_form_spec: Option<FormSpec>,
196    advanced: bool,
197    shared_answers: &Value,
198) -> Result<(Value, Option<FormSpec>)> {
199    let form_spec =
200        qa_form_spec.or_else(|| setup_to_formspec::pack_to_form_spec(pack_path, provider_id));
201
202    // Merge shared answers with provider-specific answers from setup_input
203    let merged_initial = merge_answers(
204        shared_answers,
205        setup_input.and_then(|i| i.answers_for_provider(provider_id)),
206    );
207
208    let answers = if let Some(ref spec) = form_spec {
209        if spec.questions.is_empty() {
210            Value::Object(JsonMap::new())
211        } else if interactive {
212            // Prompt with merged initial answers (shared + provider-specific).
213            // None: provider-setup flow keeps English prompt chrome.
214            do_prompt_with_existing(spec, provider_id, advanced, &merged_initial, None)?
215        } else {
216            // Non-interactive: check for missing required fields
217            let mut answers = crate::setup_input::ensure_object(merged_initial)?;
218            let missing = find_missing_required_fields(spec, &answers);
219
220            if !missing.is_empty() {
221                // Prompt for missing required fields
222                let display = setup_to_formspec::strip_domain_prefix(provider_id);
223                println!("\n⚠️  Missing required fields for {display}. Please provide values:");
224                answers = prompt_for_missing_fields(spec, &answers, &missing)?;
225            }
226
227            validate_answers_against_form_spec(spec, &answers)?;
228            answers
229        }
230    } else {
231        Value::Object(JsonMap::new())
232    };
233
234    Ok((answers, form_spec))
235}
236
237/// Find required fields that are missing or have empty values.
238///
239/// Returns a list of question IDs that are required, visible, and either:
240/// - Missing from answers
241/// - Have null value
242/// - Have empty string value
243fn find_missing_required_fields(spec: &FormSpec, answers: &Value) -> Vec<String> {
244    let map = answers.as_object();
245    let visibility = resolve_visibility(spec, answers, VisibilityMode::Visible);
246
247    spec.questions
248        .iter()
249        .filter(|q| {
250            // Must be required
251            if !q.required {
252                return false;
253            }
254            // Must be visible
255            let visible = visibility.get(&q.id).copied().unwrap_or(true);
256            if !visible {
257                return false;
258            }
259            // Check if missing or empty
260            match map.and_then(|m| m.get(&q.id)) {
261                None => true,                                   // Missing
262                Some(Value::Null) => true,                      // Null
263                Some(Value::String(s)) if s.is_empty() => true, // Empty string
264                _ => false,                                     // Has value
265            }
266        })
267        .map(|q| q.id.clone())
268        .collect()
269}
270
271/// Prompt for specific missing required fields.
272///
273/// Only prompts for the questions whose IDs are in `missing_ids`.
274fn prompt_for_missing_fields(
275    spec: &FormSpec,
276    existing_answers: &Value,
277    missing_ids: &[String],
278) -> Result<Value> {
279    let mut answers = existing_answers.as_object().cloned().unwrap_or_default();
280
281    for question in &spec.questions {
282        if !missing_ids.contains(&question.id) {
283            continue;
284        }
285
286        // Re-evaluate visibility with answers collected so far
287        if question.visible_if.is_some() {
288            let current = Value::Object(answers.clone());
289            let vis = resolve_visibility(spec, &current, VisibilityMode::Visible);
290            if !vis.get(&question.id).copied().unwrap_or(true) {
291                continue;
292            }
293        }
294
295        if let Some(value) = prompt_question(question, None)? {
296            answers.insert(question.id.clone(), value);
297        }
298    }
299
300    Ok(Value::Object(answers))
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use qa_spec::{QuestionSpec, QuestionType};
307    use serde_json::json;
308
309    fn test_form_spec() -> FormSpec {
310        FormSpec {
311            id: "test-setup".into(),
312            title: "Test Setup".into(),
313            version: "1.0.0".into(),
314            description: None,
315            presentation: None,
316            progress_policy: None,
317            secrets_policy: None,
318            store: vec![],
319            validations: vec![],
320            includes: vec![],
321            questions: vec![
322                QuestionSpec {
323                    id: "api_url".into(),
324                    kind: QuestionType::String,
325                    title: "API URL".into(),
326                    title_i18n: None,
327                    description: None,
328                    description_i18n: None,
329                    required: true,
330                    choices: None,
331                    default_value: None,
332                    secret: false,
333                    visible_if: None,
334                    constraint: Some(qa_spec::spec::Constraint {
335                        pattern: Some(r"^https?://\S+".into()),
336                        min: None,
337                        max: None,
338                        min_len: None,
339                        max_len: None,
340                    }),
341                    list: None,
342                    computed: None,
343                    policy: Default::default(),
344                    computed_overridable: false,
345                },
346                QuestionSpec {
347                    id: "token".into(),
348                    kind: QuestionType::String,
349                    title: "Token".into(),
350                    title_i18n: None,
351                    description: None,
352                    description_i18n: None,
353                    required: true,
354                    choices: None,
355                    default_value: None,
356                    secret: true,
357                    visible_if: None,
358                    constraint: None,
359                    list: None,
360                    computed: None,
361                    policy: Default::default(),
362                    computed_overridable: false,
363                },
364                QuestionSpec {
365                    id: "optional".into(),
366                    kind: QuestionType::String,
367                    title: "Optional Field".into(),
368                    title_i18n: None,
369                    description: None,
370                    description_i18n: None,
371                    required: false,
372                    choices: None,
373                    default_value: Some("default_val".into()),
374                    secret: false,
375                    visible_if: None,
376                    constraint: None,
377                    list: None,
378                    computed: None,
379                    policy: Default::default(),
380                    computed_overridable: false,
381                },
382            ],
383        }
384    }
385
386    #[test]
387    fn validates_required_answers() {
388        let spec = test_form_spec();
389        let answers = json!({"api_url": "https://example.com", "token": "abc"});
390        assert!(validate_answers_against_form_spec(&spec, &answers).is_ok());
391    }
392
393    #[test]
394    fn rejects_missing_required() {
395        let spec = test_form_spec();
396        let answers = json!({"api_url": "https://example.com"});
397        let err = validate_answers_against_form_spec(&spec, &answers).unwrap_err();
398        assert!(err.to_string().contains("token"));
399    }
400
401    #[test]
402    fn rejects_invalid_url_pattern() {
403        let spec = test_form_spec();
404        let answers = json!({"api_url": "not-a-url", "token": "abc"});
405        let err = validate_answers_against_form_spec(&spec, &answers).unwrap_err();
406        assert!(err.to_string().contains("pattern"));
407    }
408
409    #[test]
410    fn skips_invisible_required_in_validation() {
411        use qa_spec::Expr;
412
413        let spec = FormSpec {
414            id: "vis-test".into(),
415            title: "Visibility Test".into(),
416            version: "1.0.0".into(),
417            description: None,
418            presentation: None,
419            progress_policy: None,
420            secrets_policy: None,
421            store: vec![],
422            validations: vec![],
423            includes: vec![],
424            questions: vec![
425                QuestionSpec {
426                    id: "trigger".into(),
427                    kind: QuestionType::Boolean,
428                    title: "Enable feature".into(),
429                    title_i18n: None,
430                    description: None,
431                    description_i18n: None,
432                    required: true,
433                    choices: None,
434                    default_value: None,
435                    secret: false,
436                    visible_if: None,
437                    constraint: None,
438                    list: None,
439                    computed: None,
440                    policy: Default::default(),
441                    computed_overridable: false,
442                },
443                QuestionSpec {
444                    id: "dependent".into(),
445                    kind: QuestionType::String,
446                    title: "Dependent field".into(),
447                    title_i18n: None,
448                    description: None,
449                    description_i18n: None,
450                    required: true,
451                    choices: None,
452                    default_value: None,
453                    secret: false,
454                    visible_if: Some(Expr::Answer {
455                        path: "trigger".to_string(),
456                    }),
457                    constraint: None,
458                    list: None,
459                    computed: None,
460                    policy: Default::default(),
461                    computed_overridable: false,
462                },
463            ],
464        };
465
466        // trigger=false → dependent is invisible → should pass without "dependent"
467        let answers = json!({"trigger": false});
468        assert!(validate_answers_against_form_spec(&spec, &answers).is_ok());
469
470        // trigger=true → dependent is visible → should fail without "dependent"
471        let answers = json!({"trigger": true});
472        let err = validate_answers_against_form_spec(&spec, &answers);
473        assert!(err.is_err());
474        assert!(err.unwrap_err().to_string().contains("dependent"));
475    }
476
477    #[test]
478    fn compute_visibility_returns_map() {
479        use qa_spec::Expr;
480
481        let spec = FormSpec {
482            id: "vis-test".into(),
483            title: "Test".into(),
484            version: "1.0.0".into(),
485            description: None,
486            presentation: None,
487            progress_policy: None,
488            secrets_policy: None,
489            store: vec![],
490            validations: vec![],
491            includes: vec![],
492            questions: vec![QuestionSpec {
493                id: "conditional".into(),
494                kind: QuestionType::String,
495                title: "Cond".into(),
496                title_i18n: None,
497                description: None,
498                description_i18n: None,
499                required: false,
500                choices: None,
501                default_value: None,
502                secret: false,
503                visible_if: Some(Expr::Answer {
504                    path: "flag".to_string(),
505                }),
506                constraint: None,
507                list: None,
508                computed: None,
509                policy: Default::default(),
510                computed_overridable: false,
511            }],
512        };
513
514        let vis = compute_visibility(&spec, &json!({"flag": true}));
515        assert_eq!(vis.get("conditional"), Some(&true));
516
517        let vis = compute_visibility(&spec, &json!({"flag": false}));
518        assert_eq!(vis.get("conditional"), Some(&false));
519    }
520
521    #[test]
522    fn normal_mode_skips_optional_questions() {
523        let spec = test_form_spec();
524        let advanced = false;
525        let visible: Vec<&str> = spec
526            .questions
527            .iter()
528            .filter(|q| !q.id.is_empty() && (advanced || q.required))
529            .map(|q| q.id.as_str())
530            .collect();
531        assert_eq!(visible, vec!["api_url", "token"]);
532        assert!(!visible.contains(&"optional"));
533    }
534
535    #[test]
536    fn advanced_mode_shows_all_questions() {
537        let spec = test_form_spec();
538        let advanced = true;
539        let visible: Vec<&str> = spec
540            .questions
541            .iter()
542            .filter(|q| !q.id.is_empty() && (advanced || q.required))
543            .map(|q| q.id.as_str())
544            .collect();
545        assert_eq!(visible, vec!["api_url", "token", "optional"]);
546    }
547
548    // ── Missing Required Fields Tests ──────────────────────────────────────────
549
550    #[test]
551    fn find_missing_required_fields_detects_missing() {
552        let spec = test_form_spec();
553        let answers = json!({"api_url": "https://example.com"});
554
555        let missing = find_missing_required_fields(&spec, &answers);
556
557        assert_eq!(missing.len(), 1);
558        assert!(missing.contains(&"token".to_string()));
559    }
560
561    #[test]
562    fn find_missing_required_fields_detects_empty_string() {
563        let spec = test_form_spec();
564        let answers = json!({"api_url": "https://example.com", "token": ""});
565
566        let missing = find_missing_required_fields(&spec, &answers);
567
568        assert_eq!(missing.len(), 1);
569        assert!(missing.contains(&"token".to_string()));
570    }
571
572    #[test]
573    fn find_missing_required_fields_detects_null() {
574        let spec = test_form_spec();
575        let answers = json!({"api_url": "https://example.com", "token": null});
576
577        let missing = find_missing_required_fields(&spec, &answers);
578
579        assert_eq!(missing.len(), 1);
580        assert!(missing.contains(&"token".to_string()));
581    }
582
583    #[test]
584    fn find_missing_required_fields_returns_empty_when_all_filled() {
585        let spec = test_form_spec();
586        let answers = json!({"api_url": "https://example.com", "token": "abc123"});
587
588        let missing = find_missing_required_fields(&spec, &answers);
589
590        assert!(missing.is_empty());
591    }
592
593    #[test]
594    fn find_missing_required_fields_ignores_optional() {
595        let spec = test_form_spec();
596        let answers = json!({"api_url": "https://example.com", "token": "abc"});
597
598        let missing = find_missing_required_fields(&spec, &answers);
599
600        assert!(missing.is_empty());
601        assert!(!missing.contains(&"optional".to_string()));
602    }
603
604    #[test]
605    fn find_missing_required_fields_respects_visibility() {
606        use qa_spec::Expr;
607
608        let spec = FormSpec {
609            id: "vis-test".into(),
610            title: "Visibility Test".into(),
611            version: "1.0.0".into(),
612            description: None,
613            presentation: None,
614            progress_policy: None,
615            secrets_policy: None,
616            store: vec![],
617            validations: vec![],
618            includes: vec![],
619            questions: vec![
620                QuestionSpec {
621                    id: "trigger".into(),
622                    kind: QuestionType::Boolean,
623                    title: "Enable feature".into(),
624                    title_i18n: None,
625                    description: None,
626                    description_i18n: None,
627                    required: true,
628                    choices: None,
629                    default_value: None,
630                    secret: false,
631                    visible_if: None,
632                    constraint: None,
633                    list: None,
634                    computed: None,
635                    policy: Default::default(),
636                    computed_overridable: false,
637                },
638                QuestionSpec {
639                    id: "dependent".into(),
640                    kind: QuestionType::String,
641                    title: "Dependent field".into(),
642                    title_i18n: None,
643                    description: None,
644                    description_i18n: None,
645                    required: true,
646                    choices: None,
647                    default_value: None,
648                    secret: false,
649                    visible_if: Some(Expr::Answer {
650                        path: "trigger".to_string(),
651                    }),
652                    constraint: None,
653                    list: None,
654                    computed: None,
655                    policy: Default::default(),
656                    computed_overridable: false,
657                },
658            ],
659        };
660
661        // trigger=false → dependent is invisible → should NOT be in missing list
662        let answers = json!({"trigger": false});
663        let missing = find_missing_required_fields(&spec, &answers);
664        assert!(missing.is_empty());
665
666        // trigger=true → dependent is visible → should BE in missing list
667        let answers = json!({"trigger": true});
668        let missing = find_missing_required_fields(&spec, &answers);
669        assert_eq!(missing.len(), 1);
670        assert!(missing.contains(&"dependent".to_string()));
671    }
672}