1use std::collections::{BTreeMap, HashMap};
9
10use qa_spec::{
11 Expr, FormSpec, I18nText, QuestionSpec, QuestionType, ResolvedI18nMap,
12 spec::{FormPresentation, ProgressPolicy},
13};
14use serde_json::Value;
15
16use crate::setup_to_formspec::{
17 capitalize, extract_default_from_help, infer_default_for_id, infer_question_properties,
18 strip_domain_prefix,
19};
20
21pub fn provider_qa_to_form_spec(
34 qa_output: &Value,
35 i18n: &HashMap<String, String>,
36 provider: &str,
37) -> FormSpec {
38 let mode = qa_output
39 .get("mode")
40 .and_then(Value::as_str)
41 .unwrap_or("setup");
42
43 let title_key = qa_output
44 .get("title")
45 .and_then(|t| t.get("key").and_then(Value::as_str))
46 .unwrap_or("");
47 let title = i18n
48 .get(title_key)
49 .cloned()
50 .unwrap_or_else(|| format!("{} setup", provider));
51
52 let questions: Vec<QuestionSpec> = qa_output
53 .get("questions")
54 .and_then(Value::as_array)
55 .map(|arr| {
56 arr.iter()
57 .filter_map(|q| convert_question(q, i18n, provider))
58 .collect()
59 })
60 .unwrap_or_default();
61
62 let questions: Vec<QuestionSpec> = questions
64 .into_iter()
65 .map(|mut q| {
66 if q.default_value.is_none() {
67 q.default_value = infer_default_for_id(&q.id, provider);
68 }
69 q
70 })
71 .collect();
72
73 let display_name = capitalize(&strip_domain_prefix(provider));
74
75 FormSpec {
76 id: format!("{provider}-{mode}"),
77 title,
78 version: "1.0.0".to_string(),
79 description: Some(format!("{display_name} provider configuration")),
80 presentation: Some(FormPresentation {
81 intro: Some(format!(
82 "Configure {display_name} provider settings.\n\
83 Fields marked with * are required."
84 )),
85 theme: None,
86 default_locale: Some("en".to_string()),
87 }),
88 progress_policy: Some(ProgressPolicy {
89 skip_answered: false,
90 autofill_defaults: false,
91 treat_default_as_answered: false,
92 }),
93 secrets_policy: None,
94 store: vec![],
95 validations: vec![],
96 includes: vec![],
97 questions,
98 }
99}
100
101fn convert_question(
102 q: &Value,
103 i18n: &HashMap<String, String>,
104 _provider: &str,
105) -> Option<QuestionSpec> {
106 let id = q.get("id").and_then(Value::as_str)?.to_string();
107
108 let label_key = q
109 .get("label")
110 .and_then(|v| {
111 v.as_str()
112 .map(|s| s.to_string())
113 .or_else(|| v.get("key").and_then(Value::as_str).map(String::from))
114 })
115 .unwrap_or_else(|| id.clone());
116
117 let title = i18n.get(&label_key).cloned().unwrap_or_else(|| id.clone());
118 let description =
119 description_key_for(&label_key, &id).and_then(|desc_key| i18n.get(&desc_key).cloned());
120
121 let required = q.get("required").and_then(Value::as_bool).unwrap_or(false);
122 let (kind, secret, constraint) = infer_question_properties(&id);
123
124 let default_value = q
125 .get("default")
126 .and_then(|v| match v {
127 Value::String(s) => Some(s.clone()),
128 Value::Bool(b) => Some(b.to_string()),
129 Value::Number(n) => Some(n.to_string()),
130 _ => None,
131 })
132 .or_else(|| {
134 description
135 .as_ref()
136 .and_then(|d| extract_default_from_help(d))
137 })
138 .or_else(|| infer_default(&kind));
139
140 let visible_if = parse_visible_if(q);
141
142 Some(QuestionSpec {
143 id,
144 kind,
145 title: title.clone(),
146 title_i18n: Some(I18nText {
147 key: label_key.clone(),
148 args: None,
149 }),
150 description: description.clone(),
151 description_i18n: description_key_for(
152 &label_key,
153 q.get("id").and_then(Value::as_str).unwrap_or(""),
154 )
155 .map(|key| I18nText { key, args: None }),
156 required,
157 choices: None,
158 default_value,
159 secret,
160 visible_if,
161 constraint,
162 list: None,
163 computed: None,
164 policy: Default::default(),
165 computed_overridable: false,
166 })
167}
168
169fn parse_visible_if(q: &Value) -> Option<Expr> {
176 let vis = q.get("visible_if")?;
177
178 if let Some(field) = vis.get("field").and_then(Value::as_str) {
180 if let Some(eq_val) = vis.get("eq").and_then(Value::as_str) {
181 return Some(Expr::Eq {
182 left: Box::new(Expr::Answer {
183 path: field.to_string(),
184 }),
185 right: Box::new(Expr::Literal {
186 value: Value::String(eq_val.to_string()),
187 }),
188 });
189 }
190 return Some(Expr::Answer {
192 path: field.to_string(),
193 });
194 }
195
196 if vis.get("op").is_some()
198 && let Ok(expr) = serde_json::from_value::<Expr>(vis.clone())
199 {
200 return Some(expr);
201 }
202
203 serde_json::from_value::<Expr>(vis.clone()).ok()
205}
206
207fn infer_default(kind: &QuestionType) -> Option<String> {
208 match kind {
209 QuestionType::Boolean => Some("true".to_string()),
210 _ => None,
211 }
212}
213
214fn description_key_for(label_key: &str, question_id: &str) -> Option<String> {
218 let prefix = label_key.split(".qa.").next()?;
219 Some(format!("{prefix}.schema.config.{question_id}.description"))
220}
221
222pub fn build_resolved_i18n(i18n: &HashMap<String, String>) -> ResolvedI18nMap {
227 let mut resolved = BTreeMap::new();
228 for (key, value) in i18n {
229 resolved.insert(key.clone(), value.clone());
230 resolved.insert(format!("en:{key}"), value.clone());
231 }
232 resolved
233}
234
235pub fn normalize_answer(answer: &str, kind: QuestionType) -> String {
239 match kind {
240 QuestionType::Boolean => match answer.to_ascii_lowercase().as_str() {
241 "yes" | "y" | "true" | "1" | "on" => "true".to_string(),
242 "no" | "n" | "false" | "0" | "off" => "false".to_string(),
243 _ => answer.to_string(),
244 },
245 _ => answer.to_string(),
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use serde_json::json;
253
254 fn sample_qa_output() -> Value {
255 json!({
256 "mode": "setup",
257 "title": {"key": "telegram.qa.setup.title"},
258 "questions": [
259 {"id": "enabled", "label": {"key": "telegram.qa.setup.enabled"}, "required": true},
260 {"id": "public_base_url", "label": {"key": "telegram.qa.setup.public_base_url"}, "required": true},
261 {"id": "default_chat_id", "label": {"key": "telegram.qa.setup.default_chat_id"}, "required": false},
262 {"id": "api_base_url", "label": {"key": "telegram.qa.setup.api_base_url"}, "required": true},
263 {"id": "bot_token", "label": {"key": "telegram.qa.setup.bot_token"}, "required": false},
264 ]
265 })
266 }
267
268 fn sample_i18n() -> HashMap<String, String> {
269 let mut m = HashMap::new();
270 m.insert("telegram.qa.setup.title".into(), "Setup".into());
271 m.insert("telegram.qa.setup.enabled".into(), "Enable provider".into());
272 m.insert(
273 "telegram.qa.setup.public_base_url".into(),
274 "Public base URL".into(),
275 );
276 m.insert(
277 "telegram.qa.setup.default_chat_id".into(),
278 "Default chat ID".into(),
279 );
280 m.insert(
281 "telegram.qa.setup.api_base_url".into(),
282 "API base URL".into(),
283 );
284 m.insert("telegram.qa.setup.bot_token".into(), "Bot token".into());
285 m.insert(
286 "telegram.schema.config.enabled.description".into(),
287 "Enable this provider".into(),
288 );
289 m.insert(
290 "telegram.schema.config.public_base_url.description".into(),
291 "Public URL for webhook callbacks".into(),
292 );
293 m.insert(
294 "telegram.schema.config.bot_token.description".into(),
295 "Bot token for Telegram API calls".into(),
296 );
297 m
298 }
299
300 #[test]
301 fn converts_provider_qa_to_form_spec() {
302 let form =
303 provider_qa_to_form_spec(&sample_qa_output(), &sample_i18n(), "messaging-telegram");
304 assert_eq!(form.id, "messaging-telegram-setup");
305 assert_eq!(form.title, "Setup");
306 assert_eq!(form.questions.len(), 5);
307 }
308
309 #[test]
310 fn infers_question_types() {
311 let form =
312 provider_qa_to_form_spec(&sample_qa_output(), &sample_i18n(), "messaging-telegram");
313 assert_eq!(form.questions[0].kind, QuestionType::Boolean);
314 assert_eq!(form.questions[1].kind, QuestionType::String);
315 assert!(form.questions[1].constraint.is_some());
316 assert!(form.questions[4].secret);
317 }
318
319 #[test]
320 fn resolves_titles_from_i18n() {
321 let form =
322 provider_qa_to_form_spec(&sample_qa_output(), &sample_i18n(), "messaging-telegram");
323 assert_eq!(form.questions[0].title, "Enable provider");
324 assert_eq!(form.questions[4].title, "Bot token");
325 }
326
327 #[test]
328 fn resolves_descriptions_from_i18n() {
329 let form =
330 provider_qa_to_form_spec(&sample_qa_output(), &sample_i18n(), "messaging-telegram");
331 assert_eq!(
332 form.questions[0].description.as_deref(),
333 Some("Enable this provider")
334 );
335 assert_eq!(
336 form.questions[4].description.as_deref(),
337 Some("Bot token for Telegram API calls")
338 );
339 }
340
341 #[test]
342 fn normalizes_boolean_answers() {
343 assert_eq!(normalize_answer("yes", QuestionType::Boolean), "true");
344 assert_eq!(normalize_answer("No", QuestionType::Boolean), "false");
345 assert_eq!(normalize_answer("y", QuestionType::Boolean), "true");
346 assert_eq!(normalize_answer("hello", QuestionType::String), "hello");
347 }
348
349 #[test]
350 fn parses_visible_if_field_eq() {
351 let qa = json!({
352 "mode": "setup",
353 "title": {"key": "test.title"},
354 "questions": [
355 {"id": "enable_redis", "label": "Enable Redis", "required": false},
356 {
357 "id": "redis_password",
358 "label": "Redis password",
359 "required": false,
360 "visible_if": {"field": "enable_redis", "eq": "true"}
361 }
362 ]
363 });
364 let form = provider_qa_to_form_spec(&qa, &HashMap::new(), "state-redis");
365 assert!(form.questions[0].visible_if.is_none());
366 assert!(form.questions[1].visible_if.is_some());
367 }
368
369 #[test]
370 fn parses_visible_if_truthy_field() {
371 let qa = json!({
372 "mode": "setup",
373 "title": {"key": "test.title"},
374 "questions": [
375 {"id": "advanced", "label": "Advanced mode", "required": false},
376 {
377 "id": "debug_level",
378 "label": "Debug level",
379 "required": false,
380 "visible_if": {"field": "advanced"}
381 }
382 ]
383 });
384 let form = provider_qa_to_form_spec(&qa, &HashMap::new(), "test-provider");
385 assert!(form.questions[1].visible_if.is_some());
386 }
387
388 #[test]
389 fn builds_resolved_i18n_map() {
390 let i18n = sample_i18n();
391 let resolved = build_resolved_i18n(&i18n);
392 assert_eq!(
393 resolved
394 .get("telegram.qa.setup.enabled")
395 .map(String::as_str),
396 Some("Enable provider")
397 );
398 assert_eq!(
399 resolved
400 .get("en:telegram.qa.setup.enabled")
401 .map(String::as_str),
402 Some("Enable provider")
403 );
404 }
405}