mod convert;
mod inference;
mod pack;
pub use convert::setup_spec_to_form_spec;
pub use inference::{
capitalize, extract_default_from_help, infer_default_for_id, infer_question_properties,
strip_domain_prefix,
};
pub use pack::pack_to_form_spec;
#[cfg(test)]
mod tests {
use qa_spec::QuestionType;
use serde_json::json;
use super::*;
use crate::setup_input::{SetupQuestion, SetupSpec};
fn sample_setup_spec() -> SetupSpec {
SetupSpec {
title: Some("Telegram Setup".to_string()),
description: None,
questions: vec![
SetupQuestion {
name: "enabled".to_string(),
kind: "boolean".to_string(),
required: true,
help: Some("Enable this provider".to_string()),
choices: vec![],
default: Some(json!(true)),
secret: false,
title: Some("Enable provider".to_string()),
visible_if: None,
..Default::default()
},
SetupQuestion {
name: "public_base_url".to_string(),
kind: "string".to_string(),
required: true,
help: Some("Public URL for webhook callbacks".to_string()),
choices: vec![],
default: None,
secret: false,
title: None,
visible_if: None,
..Default::default()
},
SetupQuestion {
name: "bot_token".to_string(),
kind: "string".to_string(),
required: true,
help: Some("Telegram bot token".to_string()),
choices: vec![],
default: None,
secret: true,
title: Some("Bot Token".to_string()),
visible_if: None,
..Default::default()
},
SetupQuestion {
name: "log_level".to_string(),
kind: "choice".to_string(),
required: false,
help: None,
choices: vec!["debug".into(), "info".into(), "warn".into()],
default: Some(json!("info")),
secret: false,
title: Some("Log Level".to_string()),
visible_if: None,
..Default::default()
},
],
}
}
#[test]
fn converts_setup_spec_to_form_spec() {
let form = setup_spec_to_form_spec(&sample_setup_spec(), "messaging-telegram");
assert_eq!(form.id, "messaging-telegram-setup");
assert_eq!(form.title, "Telegram Setup");
assert_eq!(form.questions.len(), 4);
}
#[test]
fn maps_question_types_correctly() {
let form = setup_spec_to_form_spec(&sample_setup_spec(), "messaging-telegram");
assert_eq!(form.questions[0].kind, QuestionType::Boolean);
assert_eq!(form.questions[1].kind, QuestionType::String);
assert_eq!(form.questions[3].kind, QuestionType::Enum);
}
#[test]
fn detects_url_constraint() {
let form = setup_spec_to_form_spec(&sample_setup_spec(), "messaging-telegram");
let url_q = &form.questions[1];
assert!(url_q.constraint.is_some());
assert!(
url_q
.constraint
.as_ref()
.unwrap()
.pattern
.as_ref()
.unwrap()
.contains("https?")
);
}
#[test]
fn detects_secret_fields() {
let form = setup_spec_to_form_spec(&sample_setup_spec(), "messaging-telegram");
assert!(form.questions[2].secret);
assert!(!form.questions[0].secret);
}
#[test]
fn preserves_choices_and_defaults() {
let form = setup_spec_to_form_spec(&sample_setup_spec(), "messaging-telegram");
let log_q = &form.questions[3];
assert_eq!(log_q.choices.as_ref().unwrap(), &["debug", "info", "warn"]);
assert_eq!(log_q.default_value.as_deref(), Some("info"));
}
#[test]
fn handles_empty_spec() {
let spec = SetupSpec {
title: None,
description: None,
questions: vec![],
};
let form = setup_spec_to_form_spec(&spec, "messaging-dummy");
assert_eq!(form.id, "messaging-dummy-setup");
assert_eq!(form.title, "Dummy setup");
assert!(form.questions.is_empty());
}
#[test]
fn pack_to_form_spec_falls_back_to_qa_json() {
use std::io::Write;
use zip::write::{FileOptions, ZipWriter};
let qa_json = serde_json::json!({
"mode": "setup",
"title": {"key": "state-redis.qa.setup.title"},
"questions": [
{"id": "redis_url", "label": "Redis URL", "required": true},
{
"id": "redis_password",
"label": "Redis password",
"required": false,
"visible_if": {"field": "redis_auth_enabled", "eq": "true"}
}
]
});
let temp_dir = tempfile::tempdir().unwrap();
let pack_path = temp_dir.path().join("state-redis.gtpack");
let file = std::fs::File::create(&pack_path).unwrap();
let mut writer = ZipWriter::new(file);
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(zip::CompressionMethod::Stored);
writer.start_file("assets/setup.yaml", options).unwrap();
writer
.write_all(b"title: State Redis\nquestions: []\n")
.unwrap();
writer
.start_file("qa/state-redis-setup.json", options)
.unwrap();
writer
.write_all(serde_json::to_string(&qa_json).unwrap().as_bytes())
.unwrap();
writer.finish().unwrap();
let form = pack_to_form_spec(&pack_path, "state-redis").expect("should find QA JSON");
assert_eq!(form.questions.len(), 2);
assert_eq!(form.questions[0].id, "redis_url");
assert!(form.questions[1].visible_if.is_some());
}
#[test]
fn pack_to_form_spec_prefers_setup_yaml_with_questions() {
use std::io::Write;
use zip::write::{FileOptions, ZipWriter};
let temp_dir = tempfile::tempdir().unwrap();
let pack_path = temp_dir.path().join("messaging-test.gtpack");
let file = std::fs::File::create(&pack_path).unwrap();
let mut writer = ZipWriter::new(file);
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(zip::CompressionMethod::Stored);
writer.start_file("assets/setup.yaml", options).unwrap();
writer
.write_all(
b"title: Test\nquestions:\n - name: enabled\n kind: boolean\n required: true\n",
)
.unwrap();
writer.start_file("qa/test-setup.json", options).unwrap();
writer
.write_all(
br#"{"mode":"setup","title":{"key":"t"},"questions":[{"id":"other","label":"Other"}]}"#,
)
.unwrap();
writer.finish().unwrap();
let form = pack_to_form_spec(&pack_path, "messaging-test").expect("should find setup.yaml");
assert_eq!(form.questions.len(), 1);
assert_eq!(form.questions[0].id, "enabled");
}
#[test]
fn pack_to_form_spec_falls_back_to_secret_requirements() {
use std::io::Write;
use zip::write::{FileOptions, ZipWriter};
let temp_dir = tempfile::tempdir().unwrap();
let pack_path = temp_dir.path().join("weather-app.gtpack");
let file = std::fs::File::create(&pack_path).unwrap();
let mut writer = ZipWriter::new(file);
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(zip::CompressionMethod::Stored);
writer.start_file("pack.manifest.json", options).unwrap();
writer
.write_all(br#"{"pack_id":"weather-app","display_name":"Weather App"}"#)
.unwrap();
writer
.start_file("assets/secret-requirements.json", options)
.unwrap();
writer.write_all(br#"[{"key":"WEATHER_API_KEY"}]"#).unwrap();
writer.finish().unwrap();
let form = pack_to_form_spec(&pack_path, "weather-app").expect("should synthesize form");
assert_eq!(form.questions.len(), 1);
assert_eq!(form.questions[0].id, "weather_api_key");
assert!(form.questions[0].secret);
assert!(form.questions[0].required);
}
#[test]
fn pack_to_form_spec_reads_secret_requirements_from_cbor_manifest() {
use std::io::Write;
use zip::write::{FileOptions, ZipWriter};
let temp_dir = tempfile::tempdir().unwrap();
let pack_path = temp_dir.path().join("weatherapi-pack.gtpack");
let file = std::fs::File::create(&pack_path).unwrap();
let mut writer = ZipWriter::new(file);
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(zip::CompressionMethod::Stored);
writer.start_file("manifest.cbor", options).unwrap();
let manifest = serde_json::json!({
"components": [
{
"host": {
"secrets": {
"required": [
{
"key": "auth.param.get_weather.key",
"required": true,
"description": "WeatherAPI key for current weather requests.",
"scope": {"env": "runtime", "tenant": "runtime"},
"format": "text"
}
]
}
}
}
]
});
writer
.write_all(&serde_cbor::to_vec(&manifest).unwrap())
.unwrap();
writer.finish().unwrap();
let form =
pack_to_form_spec(&pack_path, "weatherapi-pack").expect("should synthesize form");
assert_eq!(form.questions.len(), 1);
assert_eq!(form.questions[0].id, "auth_param_get_weather_key");
assert_eq!(
form.questions[0].description.as_deref(),
Some("WeatherAPI key for current weather requests.")
);
}
#[test]
fn pack_to_form_spec_does_not_duplicate_existing_secret_question() {
use std::io::Write;
use zip::write::{FileOptions, ZipWriter};
let temp_dir = tempfile::tempdir().unwrap();
let pack_path = temp_dir.path().join("webex-app.gtpack");
let file = std::fs::File::create(&pack_path).unwrap();
let mut writer = ZipWriter::new(file);
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(zip::CompressionMethod::Stored);
writer.start_file("assets/setup.yaml", options).unwrap();
writer
.write_all(
b"title: Webex\nquestions:\n - name: bot_token\n required: true\n secret: true\n",
)
.unwrap();
writer
.start_file("assets/secret-requirements.json", options)
.unwrap();
writer.write_all(br#"[{"key":"WEBEX_BOT_TOKEN"}]"#).unwrap();
writer.finish().unwrap();
let form = pack_to_form_spec(&pack_path, "webex-app").expect("should keep setup form");
assert_eq!(form.questions.len(), 1);
assert_eq!(form.questions[0].id, "bot_token");
}
#[test]
fn extract_default_from_help_slack_format() {
let help = "Slack API base URL (default: https://slack.com/api)";
let result = extract_default_from_help(help);
assert_eq!(result, Some("https://slack.com/api".to_string()));
}
#[test]
fn extract_default_from_help_various_formats() {
assert_eq!(
extract_default_from_help("Some help (default: value)"),
Some("value".to_string())
);
assert_eq!(
extract_default_from_help("Some help [default: value]"),
Some("value".to_string())
);
assert_eq!(
extract_default_from_help("(Default: VALUE)"),
Some("VALUE".to_string())
);
assert_eq!(
extract_default_from_help("Help (default: value.)"),
Some("value".to_string())
);
assert_eq!(extract_default_from_help("Just some help text"), None);
}
#[test]
fn converts_help_default_to_question_default_value() {
let spec = SetupSpec {
title: None,
description: None,
questions: vec![SetupQuestion {
name: "api_base_url".to_string(),
kind: "string".to_string(),
required: true,
help: Some("Slack API base URL (default: https://slack.com/api)".to_string()),
choices: vec![],
default: None, secret: false,
title: Some("API base URL".to_string()),
visible_if: None,
..Default::default()
}],
};
let form = setup_spec_to_form_spec(&spec, "messaging-slack");
assert_eq!(form.questions.len(), 1);
assert_eq!(
form.questions[0].default_value,
Some("https://slack.com/api".to_string())
);
}
}