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