Skip to main content

greentic_setup/qa/
bridge.rs

1//! Bridge between provider QA specs and greentic-qa's FormSpec engine.
2//!
3//! Providers return a simple list of `(id, i18n_key, required)` questions.
4//! This module converts that into a full `qa_spec::FormSpec` so the setup
5//! engine can drive wizard flows using greentic-qa's visibility, progress,
6//! validation, and rendering.
7
8use 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
21/// Convert provider QA spec JSON output + i18n translations into a `FormSpec`.
22///
23/// The provider's `qa-spec` output looks like:
24/// ```json
25/// {
26///   "mode": "setup",
27///   "title": {"key": "telegram.qa.setup.title"},
28///   "questions": [
29///     {"id": "enabled", "label": {"key": "telegram.qa.setup.enabled"}, "required": true}
30///   ]
31/// }
32/// ```
33pub 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    // Apply inferred defaults for well-known question IDs
63    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        // Try extracting default from description text (e.g., "(default: https://...)")
133        .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
169/// Parse a `visible_if` expression from the provider QA question JSON.
170///
171/// Supports three formats:
172/// - `{"field": "q1", "eq": "true"}` → `Eq(Answer(q1), Literal(true))`
173/// - `{"op": "answer", "path": "q1"}` → `Answer(q1)` (truthy check)
174/// - Full `Expr` JSON (serde-compatible)
175fn parse_visible_if(q: &Value) -> Option<Expr> {
176    let vis = q.get("visible_if")?;
177
178    // Format 1: {"field": "q1", "eq": "value"}
179    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        // No "eq" → truthy check on the field
191        return Some(Expr::Answer {
192            path: field.to_string(),
193        });
194    }
195
196    // Format 2: {"op": "answer"|"var"|"is_set"|"not", ...}
197    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    // Format 3: Full Expr serde-compatible JSON
204    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
214/// Derive the i18n description key from a label key and question id.
215///
216/// `telegram.qa.setup.enabled` + `enabled` → `telegram.schema.config.enabled.description`
217fn 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
222/// Build a `ResolvedI18nMap` from the provider's i18n bundle.
223///
224/// greentic-qa expects keys like `"key"` or `"en:key"`.
225/// We insert both forms for maximum compatibility.
226pub 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
235/// Normalize a user's answer based on the question type.
236///
237/// For boolean questions, converts natural language (yes/no/y/n) to "true"/"false".
238pub 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}