use crate::error::{FlowError, FlowErrorLocation, Result};
use crate::i18n::{I18nCatalog, resolve_cli_template, resolve_cli_text, resolve_text};
use greentic_types::schemas::component::v0_6_0::{ComponentQaSpec, QuestionKind};
use qa_spec::FormSpec;
use qa_spec::spec::question::{QuestionSpec, QuestionType};
use serde_json::{Map, Number, Value};
use std::collections::HashMap;
use std::io::{self, Write};
pub fn warn_unknown_keys(
answers: &HashMap<String, Value>,
spec: &ComponentQaSpec,
catalog: &I18nCatalog,
locale: &str,
) {
let mut known = std::collections::BTreeSet::new();
for question in &spec.questions {
known.insert(question.id.as_str());
}
let mut unknown = Vec::new();
for key in answers.keys() {
if !known.contains(key.as_str()) {
unknown.push(key.clone());
}
}
if !unknown.is_empty() {
let unknown_csv = unknown.join(", ");
eprintln!(
"{}",
resolve_cli_template(
catalog,
locale,
"cli.qa.warning.unknown_answers_keys",
"warning: answers include unknown keys: {}",
&[unknown_csv.as_str()],
)
);
}
}
pub fn run_interactive(
spec: &ComponentQaSpec,
catalog: &I18nCatalog,
locale: &str,
mut answers: HashMap<String, Value>,
) -> Result<HashMap<String, Value>> {
let form = component_spec_to_form(spec, catalog, locale);
println!("{}", resolve_text(&spec.title, catalog, locale));
if let Some(desc) = spec.description.as_ref() {
println!("{}", resolve_text(desc, catalog, locale));
}
for question in &spec.questions {
if answers.contains_key(&question.id) {
continue;
}
loop {
let label = resolve_text(&question.label, catalog, locale);
if let Some(help) = question.help.as_ref() {
println!("{} ({})", label, resolve_text(help, catalog, locale));
} else {
println!("{label}");
}
let prompt = match &question.kind {
QuestionKind::Choice { options } => {
let mut idx = 1usize;
for option in options {
let option_label = resolve_text(&option.label, catalog, locale);
println!(" {idx}. {option_label} ({})", option.value);
idx += 1;
}
resolve_cli_text(
catalog,
locale,
"cli.qa.prompt.select_option",
"Select option",
)
}
QuestionKind::Bool => resolve_cli_text(
catalog,
locale,
"cli.qa.prompt.enter_true_false",
"Enter true/false",
),
QuestionKind::Number => resolve_cli_text(
catalog,
locale,
"cli.qa.prompt.enter_number",
"Enter number",
),
QuestionKind::Text => {
resolve_cli_text(catalog, locale, "cli.qa.prompt.enter_text", "Enter text")
}
QuestionKind::InlineJson { .. } => {
resolve_cli_text(catalog, locale, "cli.qa.prompt.enter_json", "Enter JSON")
}
QuestionKind::AssetRef { .. } => {
resolve_cli_text(catalog, locale, "cli.qa.prompt.enter_path", "Enter path")
}
};
let default = question
.default
.as_ref()
.and_then(|value| crate::wizard_ops::cbor_value_to_json(value).ok());
let raw = prompt_line(&prompt, default.as_ref())?;
let value = if raw.trim().is_empty() {
if let Some(default) = default.clone() {
default
} else if question.required {
println!(
"{}",
resolve_cli_text(
catalog,
locale,
"cli.qa.required_field",
"This field is required.",
)
);
continue;
} else {
Value::Null
}
} else {
parse_answer(&question.kind, &raw)?
};
if value.is_null() && question.required {
println!(
"{}",
resolve_cli_text(
catalog,
locale,
"cli.qa.required_field",
"This field is required.",
)
);
continue;
}
answers.insert(question.id.clone(), value);
if validate_answers_with_form(&form, &answers, true)? {
break;
}
}
}
Ok(answers)
}
pub fn validate_required(
spec: &ComponentQaSpec,
catalog: &I18nCatalog,
locale: &str,
answers: &HashMap<String, Value>,
) -> Result<()> {
let form = component_spec_to_form(spec, catalog, locale);
let _ = validate_answers_with_form(&form, answers, false)?;
Ok(())
}
fn validate_answers_with_form(
form: &FormSpec,
answers: &HashMap<String, Value>,
allow_incomplete: bool,
) -> Result<bool> {
let value = Value::Object(map_from_answers(answers));
let result = qa_spec::validate(form, &value);
if result.valid {
return Ok(true);
}
if !allow_incomplete && !result.missing_required.is_empty() {
return Err(FlowError::Internal {
message: format!(
"missing required answers: {}",
result.missing_required.join(", ")
),
location: FlowErrorLocation::new(None, None, None),
});
}
if !result.errors.is_empty() {
let lines: Vec<String> = result
.errors
.iter()
.map(|err| err.message.clone())
.collect();
return Err(FlowError::Internal {
message: format!("answers failed validation: {}", lines.join("; ")),
location: FlowErrorLocation::new(None, None, None),
});
}
Ok(false)
}
fn map_from_answers(answers: &HashMap<String, Value>) -> Map<String, Value> {
let mut map = Map::new();
for (key, value) in answers {
if !value.is_null() {
map.insert(key.clone(), value.clone());
}
}
map
}
fn component_spec_to_form(spec: &ComponentQaSpec, catalog: &I18nCatalog, locale: &str) -> FormSpec {
let title = resolve_text(&spec.title, catalog, locale);
let description = spec
.description
.as_ref()
.map(|text| resolve_text(text, catalog, locale));
let mut questions = Vec::new();
for question in &spec.questions {
let title = resolve_text(&question.label, catalog, locale);
let description = question
.help
.as_ref()
.map(|text| resolve_text(text, catalog, locale));
let (kind, choices) = match &question.kind {
QuestionKind::Text => (QuestionType::String, None),
QuestionKind::Number => (QuestionType::Number, None),
QuestionKind::Bool => (QuestionType::Boolean, None),
QuestionKind::InlineJson { .. } => (QuestionType::String, None),
QuestionKind::AssetRef { .. } => (QuestionType::String, None),
QuestionKind::Choice { options } => {
let list = options.iter().map(|opt| opt.value.clone()).collect();
(QuestionType::Enum, Some(list))
}
};
let default_value = question
.default
.as_ref()
.and_then(|value| crate::wizard_ops::cbor_value_to_json(value).ok())
.map(|value| value.to_string());
questions.push(QuestionSpec {
id: question.id.clone(),
kind,
title,
title_i18n: None,
description,
description_i18n: None,
required: question.required,
choices,
default_value,
secret: false,
visible_if: None,
constraint: None,
list: None,
computed: None,
policy: Default::default(),
computed_overridable: false,
});
}
FormSpec {
id: "component-setup".to_string(),
title,
version: "0.6.0".to_string(),
description,
presentation: None,
progress_policy: None,
secrets_policy: None,
store: Vec::new(),
validations: Vec::new(),
includes: Vec::new(),
questions,
}
}
fn parse_answer(kind: &QuestionKind, raw: &str) -> Result<Value> {
let trimmed = raw.trim();
match kind {
QuestionKind::Text => Ok(Value::String(trimmed.to_string())),
QuestionKind::Number => {
let number: f64 = trimmed.parse().map_err(|err| FlowError::Internal {
message: format!("invalid number: {err}"),
location: FlowErrorLocation::new(None, None, None),
})?;
Number::from_f64(number)
.map(Value::Number)
.ok_or_else(|| FlowError::Internal {
message: "number out of range".to_string(),
location: FlowErrorLocation::new(None, None, None),
})
}
QuestionKind::Bool => {
let lower = trimmed.to_ascii_lowercase();
let value = matches!(lower.as_str(), "true" | "t" | "yes" | "y" | "1");
Ok(Value::Bool(value))
}
QuestionKind::InlineJson { .. } => {
serde_json::from_str(trimmed).map_err(|err| FlowError::Internal {
message: format!("invalid json: {err}"),
location: FlowErrorLocation::new(None, None, None),
})
}
QuestionKind::AssetRef { .. } => Ok(Value::String(trimmed.to_string())),
QuestionKind::Choice { options } => {
if let Ok(idx) = trimmed.parse::<usize>()
&& idx > 0
&& idx <= options.len()
{
return Ok(Value::String(options[idx - 1].value.clone()));
}
let matched = options
.iter()
.find(|opt| opt.value == trimmed)
.map(|opt| opt.value.clone())
.ok_or_else(|| FlowError::Internal {
message: "invalid choice".to_string(),
location: FlowErrorLocation::new(None, None, None),
})?;
Ok(Value::String(matched))
}
}
}
fn prompt_line(prompt: &str, default: Option<&Value>) -> Result<String> {
let mut line = String::new();
if let Some(default) = default {
print!("{prompt} [{default}]: ");
} else {
print!("{prompt}: ");
}
io::stdout().flush().map_err(|err| FlowError::Internal {
message: format!("flush stdout: {err}"),
location: FlowErrorLocation::new(None, None, None),
})?;
io::stdin()
.read_line(&mut line)
.map_err(|err| FlowError::Internal {
message: format!("read input: {err}"),
location: FlowErrorLocation::new(None, None, None),
})?;
Ok(line.trim_end().to_string())
}