greentic_setup/setup_to_formspec/
mod.rs1mod convert;
8mod inference;
9mod pack;
10
11pub 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 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 writer.start_file("assets/setup.yaml", options).unwrap();
176 writer
177 .write_all(b"title: State Redis\nquestions: []\n")
178 .unwrap();
179
180 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 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 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 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 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 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 assert_eq!(
348 extract_default_from_help("Some help (default: value)"),
349 Some("value".to_string())
350 );
351 assert_eq!(
353 extract_default_from_help("Some help [default: value]"),
354 Some("value".to_string())
355 );
356 assert_eq!(
358 extract_default_from_help("(Default: VALUE)"),
359 Some("VALUE".to_string())
360 );
361 assert_eq!(
363 extract_default_from_help("Help (default: value.)"),
364 Some("value".to_string())
365 );
366 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, 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 assert_eq!(
394 form.questions[0].default_value,
395 Some("https://slack.com/api".to_string())
396 );
397 }
398}