use std::{
collections::BTreeSet,
fs::{self, File},
io::{self, Read, Write},
path::Path,
str::FromStr,
};
use anyhow::{Context, anyhow};
use rpassword::prompt_password;
use serde::Deserialize;
use serde_json::{Map as JsonMap, Value};
use zip::{ZipArchive, result::ZipError};
#[derive(Clone)]
pub struct SetupInputAnswers {
raw: Value,
provider_keys: BTreeSet<String>,
}
impl SetupInputAnswers {
pub fn new(raw: Value, provider_keys: BTreeSet<String>) -> anyhow::Result<Self> {
Ok(Self { raw, provider_keys })
}
pub fn answers_for_provider(&self, provider: &str) -> Option<&Value> {
if let Some(map) = self.raw.as_object() {
if let Some(value) = map.get(provider) {
return Some(value);
}
if !self.provider_keys.is_empty()
&& map.keys().all(|key| self.provider_keys.contains(key))
{
return None;
}
}
Some(&self.raw)
}
}
pub fn load_setup_input(path: &Path) -> anyhow::Result<Value> {
let raw = fs::read_to_string(path)?;
serde_json::from_str(&raw)
.or_else(|_| serde_yaml_bw::from_str(&raw))
.with_context(|| format!("parse setup input {}", path.display()))
}
#[derive(Debug, Deserialize)]
pub struct SetupSpec {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub questions: Vec<SetupQuestion>,
}
#[derive(Debug, Deserialize)]
pub struct SetupQuestion {
#[serde(default)]
pub name: String,
#[serde(default = "default_kind")]
pub kind: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub help: Option<String>,
#[serde(default)]
pub choices: Vec<String>,
#[serde(default)]
pub default: Option<Value>,
#[serde(default)]
pub secret: bool,
#[serde(default)]
pub title: Option<String>,
}
fn default_kind() -> String {
"string".to_string()
}
pub fn load_setup_spec(pack_path: &Path) -> anyhow::Result<Option<SetupSpec>> {
let file = File::open(pack_path)?;
let mut archive = match ZipArchive::new(file) {
Ok(archive) => archive,
Err(ZipError::InvalidArchive(_)) | Err(ZipError::UnsupportedArchive(_)) => return Ok(None),
Err(err) => return Err(err.into()),
};
let contents = match read_setup_yaml(&mut archive)? {
Some(value) => value,
None => return Ok(None),
};
let spec: SetupSpec =
serde_yaml_bw::from_str(&contents).context("parse provider setup spec")?;
Ok(Some(spec))
}
fn read_setup_yaml(archive: &mut ZipArchive<File>) -> anyhow::Result<Option<String>> {
for entry in ["assets/setup.yaml", "setup.yaml"] {
match archive.by_name(entry) {
Ok(mut file) => {
let mut contents = String::new();
file.read_to_string(&mut contents)?;
return Ok(Some(contents));
}
Err(ZipError::FileNotFound) => continue,
Err(err) => return Err(err.into()),
}
}
Ok(None)
}
pub fn collect_setup_answers(
pack_path: &Path,
provider_id: &str,
setup_input: Option<&SetupInputAnswers>,
interactive: bool,
) -> anyhow::Result<Value> {
let spec = load_setup_spec(pack_path)?;
if let Some(input) = setup_input {
if let Some(value) = input.answers_for_provider(provider_id) {
let answers = ensure_object(value.clone())?;
ensure_required_answers(spec.as_ref(), &answers)?;
return Ok(answers);
}
if has_required_questions(spec.as_ref()) {
return Err(anyhow!("setup input missing answers for {provider_id}"));
}
return Ok(Value::Object(JsonMap::new()));
}
if let Some(spec) = spec {
if spec.questions.is_empty() {
return Ok(Value::Object(JsonMap::new()));
}
if interactive {
let answers = prompt_setup_answers(&spec, provider_id)?;
ensure_required_answers(Some(&spec), &answers)?;
return Ok(answers);
}
return Err(anyhow!(
"setup answers required for {provider_id} but run is non-interactive"
));
}
Ok(Value::Object(JsonMap::new()))
}
fn has_required_questions(spec: Option<&SetupSpec>) -> bool {
spec.map(|spec| spec.questions.iter().any(|question| question.required))
.unwrap_or(false)
}
fn ensure_required_answers(spec: Option<&SetupSpec>, answers: &Value) -> anyhow::Result<()> {
let map = answers
.as_object()
.ok_or_else(|| anyhow!("setup answers must be an object"))?;
if let Some(spec) = spec {
for question in spec.questions.iter().filter(|question| question.required) {
match map.get(&question.name) {
Some(value) if !value.is_null() => continue,
_ => {
return Err(anyhow!(
"missing required setup answer for {}",
question.name
));
}
}
}
}
Ok(())
}
fn ensure_object(value: Value) -> anyhow::Result<Value> {
match value {
Value::Object(_) => Ok(value),
other => Err(anyhow!(
"setup answers must be a JSON object, got {}",
other
)),
}
}
fn prompt_setup_answers(spec: &SetupSpec, provider: &str) -> anyhow::Result<Value> {
if spec.questions.is_empty() {
return Ok(Value::Object(JsonMap::new()));
}
let title = spec.title.as_deref().unwrap_or(provider).to_string();
println!("\nConfiguring {provider}: {title}");
let mut answers = JsonMap::new();
for question in &spec.questions {
if question.name.trim().is_empty() {
continue;
}
if let Some(value) = ask_setup_question(question)? {
answers.insert(question.name.clone(), value);
}
}
Ok(Value::Object(answers))
}
fn ask_setup_question(question: &SetupQuestion) -> anyhow::Result<Option<Value>> {
if let Some(help) = question.help.as_ref()
&& !help.trim().is_empty()
{
println!(" {help}");
}
if !question.choices.is_empty() {
println!(" Choices:");
for (idx, choice) in question.choices.iter().enumerate() {
println!(" {}) {}", idx + 1, choice);
}
}
loop {
let prompt = build_question_prompt(question);
let input = read_question_input(&prompt, question.secret)?;
let trimmed = input.trim();
if trimmed.is_empty() {
if let Some(default) = question.default.clone() {
return Ok(Some(default));
}
if question.required {
println!(" This field is required.");
continue;
}
return Ok(None);
}
match parse_question_value(question, trimmed) {
Ok(value) => return Ok(Some(value)),
Err(err) => {
println!(" {err}");
continue;
}
}
}
}
fn build_question_prompt(question: &SetupQuestion) -> String {
let mut prompt = question
.title
.as_deref()
.unwrap_or(&question.name)
.to_string();
if question.kind != "string" {
prompt = format!("{prompt} [{}]", question.kind);
}
if let Some(default) = &question.default {
prompt = format!("{prompt} [default: {}]", display_value(default));
}
prompt.push_str(": ");
prompt
}
fn read_question_input(prompt: &str, secret: bool) -> anyhow::Result<String> {
if secret {
prompt_password(prompt).map_err(|err| anyhow!("read secret: {err}"))
} else {
print!("{prompt}");
io::stdout().flush()?;
let mut buffer = String::new();
io::stdin().read_line(&mut buffer)?;
Ok(buffer)
}
}
fn parse_question_value(question: &SetupQuestion, input: &str) -> anyhow::Result<Value> {
let kind = question.kind.to_lowercase();
match kind.as_str() {
"number" => serde_json::Number::from_str(input)
.map(Value::Number)
.map_err(|err| anyhow!("invalid number: {err}")),
"choice" => {
if question.choices.is_empty() {
return Ok(Value::String(input.to_string()));
}
if let Ok(index) = input.parse::<usize>()
&& let Some(choice) = question.choices.get(index - 1)
{
return Ok(Value::String(choice.clone()));
}
for choice in &question.choices {
if choice == input {
return Ok(Value::String(choice.clone()));
}
}
Err(anyhow!("invalid choice '{input}'"))
}
"boolean" => match input.to_lowercase().as_str() {
"true" | "t" | "yes" | "y" => Ok(Value::Bool(true)),
"false" | "f" | "no" | "n" => Ok(Value::Bool(false)),
_ => Err(anyhow!("invalid boolean value")),
},
_ => Ok(Value::String(input.to_string())),
}
}
fn display_value(value: &Value) -> String {
match value {
Value::String(value) => value.clone(),
Value::Number(number) => number.to_string(),
Value::Bool(flag) => flag.to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use serde_json::json;
use std::collections::BTreeSet;
use std::io::Write;
use std::path::PathBuf;
use tempfile::TempDir;
use zip::write::{FileOptions, ZipWriter};
fn create_test_pack(yaml: &str) -> Result<(TempDir, PathBuf)> {
let temp_dir = tempfile::tempdir()?;
let pack_path = temp_dir.path().join("messaging-test.gtpack");
let file = File::create(&pack_path)?;
let mut writer = ZipWriter::new(file);
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(zip::CompressionMethod::Stored);
writer.start_file("assets/setup.yaml", options)?;
writer.write_all(yaml.as_bytes())?;
writer.finish()?;
Ok((temp_dir, pack_path))
}
#[test]
fn parse_setup_yaml_questions() -> Result<()> {
let yaml = r#"
provider_id: dummy
questions:
- name: public_base_url
required: true
"#;
let (_dir, pack_path) = create_test_pack(yaml)?;
let spec = load_setup_spec(&pack_path)?.expect("expected spec");
assert_eq!(spec.questions.len(), 1);
assert_eq!(spec.questions[0].name, "public_base_url");
assert!(spec.questions[0].required);
Ok(())
}
#[test]
fn collect_setup_answers_uses_input() -> Result<()> {
let yaml = r#"
provider_id: telegram
questions:
- name: public_base_url
required: true
"#;
let (_dir, pack_path) = create_test_pack(yaml)?;
let provider_keys = BTreeSet::from(["messaging-telegram".to_string()]);
let raw = json!({
"messaging-telegram": {
"public_base_url": "https://example.com"
}
});
let answers = SetupInputAnswers::new(raw, provider_keys)?;
let collected =
collect_setup_answers(&pack_path, "messaging-telegram", Some(&answers), false)?;
assert_eq!(
collected.get("public_base_url"),
Some(&Value::String("https://example.com".to_string()))
);
Ok(())
}
#[test]
fn collect_setup_answers_missing_required_errors() -> Result<()> {
let yaml = r#"
provider_id: slack
questions:
- name: slack_bot_token
required: true
"#;
let (_dir, pack_path) = create_test_pack(yaml)?;
let provider_keys = BTreeSet::from(["messaging-slack".to_string()]);
let raw = json!({
"messaging-slack": {}
});
let answers = SetupInputAnswers::new(raw, provider_keys)?;
let error = collect_setup_answers(&pack_path, "messaging-slack", Some(&answers), false)
.unwrap_err();
assert!(error.to_string().contains("missing required setup answer"));
Ok(())
}
}