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)?
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
88pub 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 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
123pub 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
173pub fn compute_visibility(spec: &FormSpec, answers: &Value) -> qa_spec::VisibilityMap {
178 resolve_visibility(spec, answers, VisibilityMode::Visible)
179}
180
181pub 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 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 do_prompt_with_existing(spec, provider_id, advanced, &merged_initial)?
212 } else {
213 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 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
234fn 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 if !q.required {
249 return false;
250 }
251 let visible = visibility.get(&q.id).copied().unwrap_or(true);
253 if !visible {
254 return false;
255 }
256 match map.and_then(|m| m.get(&q.id)) {
258 None => true, Some(Value::Null) => true, Some(Value::String(s)) if s.is_empty() => true, _ => false, }
263 })
264 .map(|q| q.id.clone())
265 .collect()
266}
267
268fn 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 if question.visible_if.is_some() {
285 let current = Value::Object(answers.clone());
286 let vis = resolve_visibility(spec, ¤t, 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 let answers = json!({"trigger": false});
465 assert!(validate_answers_against_form_spec(&spec, &answers).is_ok());
466
467 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 #[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 let answers = json!({"trigger": false});
660 let missing = find_missing_required_fields(&spec, &answers);
661 assert!(missing.is_empty());
662
663 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}