1use 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
17pub 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
27use 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
35pub 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 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, 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
90pub 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 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
125pub 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
175pub fn compute_visibility(spec: &FormSpec, answers: &Value) -> qa_spec::VisibilityMap {
180 resolve_visibility(spec, answers, VisibilityMode::Visible)
181}
182
183pub 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 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 do_prompt_with_existing(spec, provider_id, advanced, &merged_initial, None)?
215 } else {
216 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 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
237fn 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 if !q.required {
252 return false;
253 }
254 let visible = visibility.get(&q.id).copied().unwrap_or(true);
256 if !visible {
257 return false;
258 }
259 match map.and_then(|m| m.get(&q.id)) {
261 None => true, Some(Value::Null) => true, Some(Value::String(s)) if s.is_empty() => true, _ => false, }
266 })
267 .map(|q| q.id.clone())
268 .collect()
269}
270
271fn 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 if question.visible_if.is_some() {
288 let current = Value::Object(answers.clone());
289 let vis = resolve_visibility(spec, ¤t, 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 let answers = json!({"trigger": false});
468 assert!(validate_answers_against_form_spec(&spec, &answers).is_ok());
469
470 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 #[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 let answers = json!({"trigger": false});
663 let missing = find_missing_required_fields(&spec, &answers);
664 assert!(missing.is_empty());
665
666 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}