Skip to main content

greentic_setup/setup_to_formspec/
mod.rs

1//! Converts legacy `setup.yaml` (`SetupSpec`) into `qa_spec::FormSpec`.
2//!
3//! This allows setup logic to drive provider configuration through a single
4//! FormSpec-based wizard regardless of whether the provider ships a WASM
5//! `qa-spec` op or a static `setup.yaml` file.
6
7mod convert;
8mod inference;
9mod pack;
10
11// Re-export public API
12pub use convert::setup_spec_to_form_spec;
13pub use inference::{
14    capitalize, extract_default_from_help, infer_default_for_id, infer_question_properties,
15    strip_domain_prefix,
16};
17pub use pack::pack_to_form_spec;
18
19#[cfg(test)]
20mod tests {
21    use qa_spec::QuestionType;
22    use serde_json::json;
23
24    use super::*;
25    use crate::setup_input::{SetupQuestion, SetupSpec};
26
27    fn sample_setup_spec() -> SetupSpec {
28        SetupSpec {
29            title: Some("Telegram Setup".to_string()),
30            description: None,
31            setup_actions: vec![],
32            questions: vec![
33                SetupQuestion {
34                    name: "enabled".to_string(),
35                    kind: "boolean".to_string(),
36                    required: true,
37                    help: Some("Enable this provider".to_string()),
38                    choices: vec![],
39                    default: Some(json!(true)),
40                    secret: false,
41                    title: Some("Enable provider".to_string()),
42                    visible_if: None,
43                    ..Default::default()
44                },
45                SetupQuestion {
46                    name: "public_base_url".to_string(),
47                    kind: "string".to_string(),
48                    required: true,
49                    help: Some("Public URL for webhook callbacks".to_string()),
50                    choices: vec![],
51                    default: None,
52                    secret: false,
53                    title: None,
54                    visible_if: None,
55                    ..Default::default()
56                },
57                SetupQuestion {
58                    name: "bot_token".to_string(),
59                    kind: "string".to_string(),
60                    required: true,
61                    help: Some("Telegram bot token".to_string()),
62                    choices: vec![],
63                    default: None,
64                    secret: true,
65                    title: Some("Bot Token".to_string()),
66                    visible_if: None,
67                    ..Default::default()
68                },
69                SetupQuestion {
70                    name: "log_level".to_string(),
71                    kind: "choice".to_string(),
72                    required: false,
73                    help: None,
74                    choices: vec!["debug".into(), "info".into(), "warn".into()],
75                    default: Some(json!("info")),
76                    secret: false,
77                    title: Some("Log Level".to_string()),
78                    visible_if: None,
79                    ..Default::default()
80                },
81            ],
82        }
83    }
84
85    #[test]
86    fn converts_setup_spec_to_form_spec() {
87        let form = setup_spec_to_form_spec(&sample_setup_spec(), "messaging-telegram");
88        assert_eq!(form.id, "messaging-telegram-setup");
89        assert_eq!(form.title, "Telegram Setup");
90        assert_eq!(form.questions.len(), 4);
91    }
92
93    #[test]
94    fn maps_question_types_correctly() {
95        let form = setup_spec_to_form_spec(&sample_setup_spec(), "messaging-telegram");
96        assert_eq!(form.questions[0].kind, QuestionType::Boolean);
97        assert_eq!(form.questions[1].kind, QuestionType::String);
98        assert_eq!(form.questions[3].kind, QuestionType::Enum);
99    }
100
101    #[test]
102    fn detects_url_constraint() {
103        let form = setup_spec_to_form_spec(&sample_setup_spec(), "messaging-telegram");
104        let url_q = &form.questions[1];
105        assert!(url_q.constraint.is_some());
106        assert!(
107            url_q
108                .constraint
109                .as_ref()
110                .unwrap()
111                .pattern
112                .as_ref()
113                .unwrap()
114                .contains("https?")
115        );
116    }
117
118    #[test]
119    fn detects_secret_fields() {
120        let form = setup_spec_to_form_spec(&sample_setup_spec(), "messaging-telegram");
121        assert!(form.questions[2].secret);
122        assert!(!form.questions[0].secret);
123    }
124
125    #[test]
126    fn preserves_choices_and_defaults() {
127        let form = setup_spec_to_form_spec(&sample_setup_spec(), "messaging-telegram");
128        let log_q = &form.questions[3];
129        assert_eq!(log_q.choices.as_ref().unwrap(), &["debug", "info", "warn"]);
130        assert_eq!(log_q.default_value.as_deref(), Some("info"));
131    }
132
133    #[test]
134    fn handles_empty_spec() {
135        let spec = SetupSpec {
136            title: None,
137            description: None,
138            setup_actions: vec![],
139            questions: vec![],
140        };
141        let form = setup_spec_to_form_spec(&spec, "messaging-dummy");
142        assert_eq!(form.id, "messaging-dummy-setup");
143        assert_eq!(form.title, "Dummy setup");
144        assert!(form.questions.is_empty());
145    }
146
147    #[test]
148    fn pack_to_form_spec_falls_back_to_qa_json() {
149        use std::io::Write;
150        use zip::write::{FileOptions, ZipWriter};
151
152        let qa_json = serde_json::json!({
153            "mode": "setup",
154            "title": {"key": "state-redis.qa.setup.title"},
155            "questions": [
156                {"id": "redis_url", "label": "Redis URL", "required": true},
157                {
158                    "id": "redis_password",
159                    "label": "Redis password",
160                    "required": false,
161                    "visible_if": {"field": "redis_auth_enabled", "eq": "true"}
162                }
163            ]
164        });
165
166        // Create a gtpack with empty setup.yaml but valid qa/*.json
167        let temp_dir = tempfile::tempdir().unwrap();
168        let pack_path = temp_dir.path().join("state-redis.gtpack");
169        let file = std::fs::File::create(&pack_path).unwrap();
170        let mut writer = ZipWriter::new(file);
171        let options: FileOptions<'_, ()> =
172            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
173
174        // Empty setup.yaml (no questions)
175        writer.start_file("assets/setup.yaml", options).unwrap();
176        writer
177            .write_all(b"title: State Redis\nquestions: []\n")
178            .unwrap();
179
180        // QA JSON with real questions
181        writer
182            .start_file("qa/state-redis-setup.json", options)
183            .unwrap();
184        writer
185            .write_all(serde_json::to_string(&qa_json).unwrap().as_bytes())
186            .unwrap();
187        writer.finish().unwrap();
188
189        let form = pack_to_form_spec(&pack_path, "state-redis").expect("should find QA JSON");
190        assert_eq!(form.questions.len(), 2);
191        assert_eq!(form.questions[0].id, "redis_url");
192        assert!(form.questions[1].visible_if.is_some());
193    }
194
195    #[test]
196    fn pack_to_form_spec_prefers_setup_yaml_with_questions() {
197        use std::io::Write;
198        use zip::write::{FileOptions, ZipWriter};
199
200        // Create a gtpack with both setup.yaml questions and qa/*.json
201        let temp_dir = tempfile::tempdir().unwrap();
202        let pack_path = temp_dir.path().join("messaging-test.gtpack");
203        let file = std::fs::File::create(&pack_path).unwrap();
204        let mut writer = ZipWriter::new(file);
205        let options: FileOptions<'_, ()> =
206            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
207
208        // setup.yaml with questions
209        writer.start_file("assets/setup.yaml", options).unwrap();
210        writer
211            .write_all(
212                b"title: Test\nquestions:\n  - name: enabled\n    kind: boolean\n    required: true\n",
213            )
214            .unwrap();
215
216        // QA JSON with different questions
217        writer.start_file("qa/test-setup.json", options).unwrap();
218        writer
219            .write_all(
220                br#"{"mode":"setup","title":{"key":"t"},"questions":[{"id":"other","label":"Other"}]}"#,
221            )
222            .unwrap();
223        writer.finish().unwrap();
224
225        let form = pack_to_form_spec(&pack_path, "messaging-test").expect("should find setup.yaml");
226        // Should use setup.yaml, not qa JSON
227        assert_eq!(form.questions.len(), 1);
228        assert_eq!(form.questions[0].id, "enabled");
229    }
230
231    #[test]
232    fn pack_to_form_spec_falls_back_to_secret_requirements() {
233        use std::io::Write;
234        use zip::write::{FileOptions, ZipWriter};
235
236        let temp_dir = tempfile::tempdir().unwrap();
237        let pack_path = temp_dir.path().join("weather-app.gtpack");
238        let file = std::fs::File::create(&pack_path).unwrap();
239        let mut writer = ZipWriter::new(file);
240        let options: FileOptions<'_, ()> =
241            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
242
243        writer.start_file("pack.manifest.json", options).unwrap();
244        writer
245            .write_all(br#"{"pack_id":"weather-app","display_name":"Weather App"}"#)
246            .unwrap();
247        writer
248            .start_file("assets/secret-requirements.json", options)
249            .unwrap();
250        writer.write_all(br#"[{"key":"WEATHER_API_KEY"}]"#).unwrap();
251        writer.finish().unwrap();
252
253        let form = pack_to_form_spec(&pack_path, "weather-app").expect("should synthesize form");
254        assert_eq!(form.questions.len(), 1);
255        assert_eq!(form.questions[0].id, "weather_api_key");
256        assert!(form.questions[0].secret);
257        assert!(form.questions[0].required);
258    }
259
260    #[test]
261    fn pack_to_form_spec_reads_secret_requirements_from_cbor_manifest() {
262        use std::io::Write;
263        use zip::write::{FileOptions, ZipWriter};
264
265        let temp_dir = tempfile::tempdir().unwrap();
266        let pack_path = temp_dir.path().join("weatherapi-pack.gtpack");
267        let file = std::fs::File::create(&pack_path).unwrap();
268        let mut writer = ZipWriter::new(file);
269        let options: FileOptions<'_, ()> =
270            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
271
272        writer.start_file("manifest.cbor", options).unwrap();
273        let manifest = serde_json::json!({
274            "components": [
275                {
276                    "host": {
277                        "secrets": {
278                            "required": [
279                                {
280                                    "key": "auth.param.get_weather.key",
281                                    "required": true,
282                                    "description": "WeatherAPI key for current weather requests.",
283                                    "scope": {"env": "runtime", "tenant": "runtime"},
284                                    "format": "text"
285                                }
286                            ]
287                        }
288                    }
289                }
290            ]
291        });
292        writer
293            .write_all(&serde_cbor::to_vec(&manifest).unwrap())
294            .unwrap();
295        writer.finish().unwrap();
296
297        let form =
298            pack_to_form_spec(&pack_path, "weatherapi-pack").expect("should synthesize form");
299        assert_eq!(form.questions.len(), 1);
300        assert_eq!(form.questions[0].id, "auth_param_get_weather_key");
301        assert_eq!(
302            form.questions[0].description.as_deref(),
303            Some("WeatherAPI key for current weather requests.")
304        );
305    }
306
307    #[test]
308    fn pack_to_form_spec_does_not_duplicate_existing_secret_question() {
309        use std::io::Write;
310        use zip::write::{FileOptions, ZipWriter};
311
312        let temp_dir = tempfile::tempdir().unwrap();
313        let pack_path = temp_dir.path().join("webex-app.gtpack");
314        let file = std::fs::File::create(&pack_path).unwrap();
315        let mut writer = ZipWriter::new(file);
316        let options: FileOptions<'_, ()> =
317            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
318
319        writer.start_file("assets/setup.yaml", options).unwrap();
320        writer
321            .write_all(
322                b"title: Webex\nquestions:\n  - name: bot_token\n    required: true\n    secret: true\n",
323            )
324            .unwrap();
325        writer
326            .start_file("assets/secret-requirements.json", options)
327            .unwrap();
328        writer.write_all(br#"[{"key":"WEBEX_BOT_TOKEN"}]"#).unwrap();
329        writer.finish().unwrap();
330
331        let form = pack_to_form_spec(&pack_path, "webex-app").expect("should keep setup form");
332        assert_eq!(form.questions.len(), 1);
333        assert_eq!(form.questions[0].id, "bot_token");
334    }
335
336    #[test]
337    fn extract_default_from_help_slack_format() {
338        // Exact format from Slack's setup.yaml
339        let help = "Slack API base URL (default: https://slack.com/api)";
340        let result = extract_default_from_help(help);
341        assert_eq!(result, Some("https://slack.com/api".to_string()));
342    }
343
344    #[test]
345    fn extract_default_from_help_various_formats() {
346        // Parenthesized
347        assert_eq!(
348            extract_default_from_help("Some help (default: value)"),
349            Some("value".to_string())
350        );
351        // Bracketed
352        assert_eq!(
353            extract_default_from_help("Some help [default: value]"),
354            Some("value".to_string())
355        );
356        // Case insensitive
357        assert_eq!(
358            extract_default_from_help("(Default: VALUE)"),
359            Some("VALUE".to_string())
360        );
361        // With trailing punctuation
362        assert_eq!(
363            extract_default_from_help("Help (default: value.)"),
364            Some("value".to_string())
365        );
366        // No default
367        assert_eq!(extract_default_from_help("Just some help text"), None);
368    }
369
370    #[test]
371    fn converts_help_default_to_question_default_value() {
372        let spec = SetupSpec {
373            title: None,
374            description: None,
375            setup_actions: vec![],
376            questions: vec![SetupQuestion {
377                name: "api_base_url".to_string(),
378                kind: "string".to_string(),
379                required: true,
380                help: Some("Slack API base URL (default: https://slack.com/api)".to_string()),
381                choices: vec![],
382                default: None, // No explicit default
383                secret: false,
384                title: Some("API base URL".to_string()),
385                visible_if: None,
386                ..Default::default()
387            }],
388        };
389
390        let form = setup_spec_to_form_spec(&spec, "messaging-slack");
391        assert_eq!(form.questions.len(), 1);
392        // Should extract default from help text
393        assert_eq!(
394            form.questions[0].default_value,
395            Some("https://slack.com/api".to_string())
396        );
397    }
398}