use std::io::{self, Write as _};
use anyhow::{Result, anyhow};
use qa_spec::{FormSpec, QuestionSpec, QuestionType, VisibilityMode, resolve_visibility};
use rpassword::prompt_password;
use serde_json::{Map as JsonMap, Value};
use crate::qa::bridge;
use crate::setup_to_formspec;
pub fn prompt_form_spec_answers(
spec: &FormSpec,
provider_id: &str,
advanced: bool,
) -> Result<Value> {
prompt_form_spec_answers_with_existing(
spec,
provider_id,
advanced,
&Value::Object(JsonMap::new()),
)
}
pub fn prompt_form_spec_answers_with_existing(
spec: &FormSpec,
provider_id: &str,
advanced: bool,
initial_answers: &Value,
) -> Result<Value> {
let display = setup_to_formspec::strip_domain_prefix(provider_id);
let mode_label = if advanced { " (advanced)" } else { "" };
println!("\nConfiguring {display}: {}{mode_label}", spec.title);
if let Some(ref pres) = spec.presentation
&& let Some(ref intro) = pres.intro
{
println!("{intro}");
}
let mut answers = initial_answers.as_object().cloned().unwrap_or_default();
for question in &spec.questions {
if question.id.is_empty() {
continue;
}
if question.visible_if.is_some() {
let current = Value::Object(answers.clone());
let vis = resolve_visibility(spec, ¤t, VisibilityMode::Visible);
if !vis.get(&question.id).copied().unwrap_or(true) {
continue;
}
}
let existing = answers.get(&question.id);
if existing
.filter(|value| answer_satisfies_question(question, value))
.is_some()
{
continue;
}
if !advanced && !question.required {
continue;
}
if let Some(value) = ask_form_spec_question(question)? {
answers.insert(question.id.clone(), value);
}
}
Ok(Value::Object(answers))
}
pub fn answer_satisfies_question(question: &QuestionSpec, value: &Value) -> bool {
if value.is_null() {
return false;
}
if let Some(s) = value.as_str()
&& s.trim().is_empty()
{
return false;
}
if let Some(s) = value.as_str()
&& s.starts_with("${")
&& s.ends_with('}')
{
return true;
}
if let Some(ref choices) = question.choices
&& !choices.is_empty()
{
let Some(candidate) = value.as_str() else {
return false;
};
if !choices.iter().any(|choice| choice == candidate) {
return false;
}
}
if let Some(ref constraint) = question.constraint
&& let Some(ref pattern) = constraint.pattern
&& let Some(candidate) = value.as_str()
&& !matches_pattern(candidate, pattern)
{
return false;
}
true
}
pub fn ask_form_spec_question(question: &QuestionSpec) -> Result<Option<Value>> {
let marker = if question.required {
" (required)"
} else {
" (optional)"
};
println!();
println!(" {}{marker}", question.title);
if let Some(ref desc) = question.description
&& !desc.is_empty()
{
println!(" {desc}");
}
if let Some(ref choices) = question.choices {
println!();
for (idx, choice) in choices.iter().enumerate() {
println!(" {}) {choice}", idx + 1);
}
}
loop {
let prompt = build_form_spec_prompt(question);
let input = read_input(&prompt, question.secret)?;
let trimmed = input.trim();
if trimmed.is_empty() {
if let Some(ref default) = question.default_value {
return Ok(Some(parse_typed_value(question.kind, default)));
}
if question.required {
println!(" This field is required.");
continue;
}
return Ok(None);
}
let normalized = bridge::normalize_answer(trimmed, question.kind);
if let Some(ref constraint) = question.constraint
&& let Some(ref pattern) = constraint.pattern
&& !matches_pattern(&normalized, pattern)
{
println!(" Invalid format. Expected pattern: {pattern}");
continue;
}
if let Some(ref choices) = question.choices
&& !choices.is_empty()
{
if let Ok(idx) = normalized.parse::<usize>()
&& let Some(choice) = choices.get(idx - 1)
{
return Ok(Some(Value::String(choice.clone())));
}
if !choices.contains(&normalized) {
println!(" Invalid choice. Options: {}", choices.join(", "));
continue;
}
}
return Ok(Some(parse_typed_value(question.kind, &normalized)));
}
}
fn build_form_spec_prompt(question: &QuestionSpec) -> String {
let mut prompt = String::from(" > ");
match question.kind {
QuestionType::Boolean => prompt.push_str("[yes/no] "),
QuestionType::Number | QuestionType::Integer => prompt.push_str("[number] "),
QuestionType::Enum => prompt.push_str("[choice] "),
_ => {}
}
if let Some(ref default) = question.default_value
&& !default.is_empty()
{
prompt.push_str(&format!("(default: {default}) "));
}
prompt
}
fn read_input(prompt: &str, secret: bool) -> Result<String> {
if secret {
prompt_password(prompt).map_err(|err| anyhow!("read secret: {err}"))
} else {
print!("{prompt}");
io::stdout().flush()?;
let mut buffer = String::new();
io::stdin().read_line(&mut buffer)?;
Ok(buffer)
}
}
pub fn matches_pattern(value: &str, pattern: &str) -> bool {
if pattern == r"^https?://\S+" {
(value.starts_with("http://") || value.starts_with("https://"))
&& value.len() > 8
&& !value.contains(char::is_whitespace)
} else {
true
}
}
pub fn parse_typed_value(kind: QuestionType, input: &str) -> Value {
match kind {
QuestionType::Boolean => match input.to_ascii_lowercase().as_str() {
"true" | "yes" | "y" | "1" | "on" => Value::Bool(true),
"false" | "no" | "n" | "0" | "off" => Value::Bool(false),
_ => Value::String(input.to_string()),
},
QuestionType::Number | QuestionType::Integer => {
if let Ok(n) = input.parse::<i64>() {
Value::Number(n.into())
} else if let Ok(n) = input.parse::<f64>() {
serde_json::Number::from_f64(n)
.map(Value::Number)
.unwrap_or_else(|| Value::String(input.to_string()))
} else {
Value::String(input.to_string())
}
}
_ => Value::String(input.to_string()),
}
}
pub fn has_required_questions(spec: Option<&FormSpec>) -> bool {
spec.map(|s| s.questions.iter().any(|q| q.required))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_typed_values() {
assert_eq!(
parse_typed_value(QuestionType::Boolean, "true"),
Value::Bool(true)
);
assert_eq!(
parse_typed_value(QuestionType::Boolean, "no"),
Value::Bool(false)
);
assert_eq!(parse_typed_value(QuestionType::Number, "42"), json!(42));
assert_eq!(
parse_typed_value(QuestionType::String, "hello"),
Value::String("hello".into())
);
}
#[test]
fn matches_url_pattern() {
assert!(matches_pattern("https://example.com", r"^https?://\S+"));
assert!(matches_pattern("http://localhost:8080", r"^https?://\S+"));
assert!(!matches_pattern("not-a-url", r"^https?://\S+"));
assert!(!matches_pattern("https://", r"^https?://\S+")); }
}