1use std::collections::BTreeSet;
7use std::fs::{self, File};
8use std::io::{self, Read, Write};
9use std::path::Path;
10use std::str::FromStr;
11
12use anyhow::{Context, anyhow};
13use rpassword::prompt_password;
14use serde::Deserialize;
15use serde_json::{Map as JsonMap, Value};
16use zip::{ZipArchive, result::ZipError};
17
18#[derive(Clone)]
20pub struct SetupInputAnswers {
21 raw: Value,
22 provider_keys: BTreeSet<String>,
23}
24
25impl SetupInputAnswers {
26 pub fn new(raw: Value, provider_keys: BTreeSet<String>) -> anyhow::Result<Self> {
28 Ok(Self { raw, provider_keys })
29 }
30
31 pub fn answers_for_provider(&self, provider: &str) -> Option<&Value> {
36 if let Some(map) = self.raw.as_object() {
37 if let Some(value) = map.get(provider) {
38 return Some(value);
39 }
40 if !self.provider_keys.is_empty()
41 && map.keys().all(|key| self.provider_keys.contains(key))
42 {
43 return None;
44 }
45 }
46 Some(&self.raw)
47 }
48}
49
50pub fn load_setup_input(path: &Path) -> anyhow::Result<Value> {
52 let raw = fs::read_to_string(path)?;
53 serde_json::from_str(&raw)
54 .or_else(|_| serde_yaml_bw::from_str(&raw))
55 .with_context(|| format!("parse setup input {}", path.display()))
56}
57
58#[derive(Debug, Deserialize)]
60pub struct SetupSpec {
61 #[serde(default)]
62 pub title: Option<String>,
63 #[serde(default)]
64 pub description: Option<String>,
65 #[serde(default)]
66 pub questions: Vec<SetupQuestion>,
67}
68
69#[derive(Debug, Deserialize)]
71pub struct SetupQuestion {
72 #[serde(default)]
73 pub name: String,
74 #[serde(default = "default_kind")]
75 pub kind: String,
76 #[serde(default)]
77 pub required: bool,
78 #[serde(default)]
79 pub help: Option<String>,
80 #[serde(default)]
81 pub choices: Vec<String>,
82 #[serde(default)]
83 pub default: Option<Value>,
84 #[serde(default)]
85 pub secret: bool,
86 #[serde(default)]
87 pub title: Option<String>,
88 #[serde(default)]
89 pub visible_if: Option<SetupVisibleIf>,
90}
91
92#[derive(Debug, Deserialize)]
101pub struct SetupVisibleIf {
102 pub field: String,
103 #[serde(default)]
104 pub eq: Option<String>,
105}
106
107fn default_kind() -> String {
108 "string".to_string()
109}
110
111pub fn load_setup_spec(pack_path: &Path) -> anyhow::Result<Option<SetupSpec>> {
116 let file = File::open(pack_path)?;
117 let mut archive = match ZipArchive::new(file) {
118 Ok(archive) => archive,
119 Err(ZipError::InvalidArchive(_)) | Err(ZipError::UnsupportedArchive(_)) => return Ok(None),
120 Err(err) => return Err(err.into()),
121 };
122 let contents = match read_setup_yaml(&mut archive)? {
123 Some(value) => value,
124 None => match read_setup_yaml_from_filesystem(pack_path)? {
125 Some(value) => value,
126 None => return Ok(None),
127 },
128 };
129 let spec: SetupSpec =
130 serde_yaml_bw::from_str(&contents).context("parse provider setup spec")?;
131 Ok(Some(spec))
132}
133
134fn read_setup_yaml(archive: &mut ZipArchive<File>) -> anyhow::Result<Option<String>> {
135 for entry in ["assets/setup.yaml", "setup.yaml"] {
136 match archive.by_name(entry) {
137 Ok(mut file) => {
138 let mut contents = String::new();
139 file.read_to_string(&mut contents)?;
140 return Ok(Some(contents));
141 }
142 Err(ZipError::FileNotFound) => continue,
143 Err(err) => return Err(err.into()),
144 }
145 }
146 Ok(None)
147}
148
149fn read_setup_yaml_from_filesystem(pack_path: &Path) -> anyhow::Result<Option<String>> {
159 let pack_dir = pack_path.parent().unwrap_or(Path::new("."));
160 let pack_stem = pack_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
161
162 let candidates = [
163 pack_dir.join("assets/setup.yaml"),
164 pack_dir.join("setup.yaml"),
165 ];
166
167 let mut all_candidates: Vec<std::path::PathBuf> = candidates.to_vec();
169 if !pack_stem.is_empty() {
170 for ancestor in pack_dir.ancestors().skip(1).take(4) {
172 let source_dir = ancestor.join("packs").join(pack_stem);
173 if source_dir.is_dir() {
174 all_candidates.push(source_dir.join("assets/setup.yaml"));
175 all_candidates.push(source_dir.join("setup.yaml"));
176 break;
177 }
178 }
179 }
180
181 for candidate in &all_candidates {
182 if candidate.is_file() {
183 let contents = fs::read_to_string(candidate)?;
184 return Ok(Some(contents));
185 }
186 }
187 Ok(None)
188}
189
190pub fn collect_setup_answers(
195 pack_path: &Path,
196 provider_id: &str,
197 setup_input: Option<&SetupInputAnswers>,
198 interactive: bool,
199) -> anyhow::Result<Value> {
200 let spec = load_setup_spec(pack_path)?;
201 if let Some(input) = setup_input {
202 if let Some(value) = input.answers_for_provider(provider_id) {
203 let answers = ensure_object(value.clone())?;
204 ensure_required_answers(spec.as_ref(), &answers)?;
205 return Ok(answers);
206 }
207 if has_required_questions(spec.as_ref()) {
208 return Err(anyhow!("setup input missing answers for {provider_id}"));
209 }
210 return Ok(Value::Object(JsonMap::new()));
211 }
212 if let Some(spec) = spec {
213 if spec.questions.is_empty() {
214 return Ok(Value::Object(JsonMap::new()));
215 }
216 if interactive {
217 let answers = prompt_setup_answers(&spec, provider_id)?;
218 ensure_required_answers(Some(&spec), &answers)?;
219 return Ok(answers);
220 }
221 return Err(anyhow!(
222 "setup answers required for {provider_id} but run is non-interactive"
223 ));
224 }
225 Ok(Value::Object(JsonMap::new()))
226}
227
228fn has_required_questions(spec: Option<&SetupSpec>) -> bool {
229 spec.map(|spec| spec.questions.iter().any(|q| q.required))
230 .unwrap_or(false)
231}
232
233pub fn ensure_required_answers(spec: Option<&SetupSpec>, answers: &Value) -> anyhow::Result<()> {
235 let map = answers
236 .as_object()
237 .ok_or_else(|| anyhow!("setup answers must be an object"))?;
238 if let Some(spec) = spec {
239 for question in spec.questions.iter().filter(|q| q.required) {
240 match map.get(&question.name) {
241 Some(value) if !value.is_null() => continue,
242 _ => {
243 return Err(anyhow!(
244 "missing required setup answer for {}",
245 question.name
246 ));
247 }
248 }
249 }
250 }
251 Ok(())
252}
253
254pub fn ensure_object(value: Value) -> anyhow::Result<Value> {
256 match value {
257 Value::Object(_) => Ok(value),
258 other => Err(anyhow!(
259 "setup answers must be a JSON object, got {}",
260 other
261 )),
262 }
263}
264
265pub fn prompt_setup_answers(spec: &SetupSpec, provider: &str) -> anyhow::Result<Value> {
267 if spec.questions.is_empty() {
268 return Ok(Value::Object(JsonMap::new()));
269 }
270 let title = spec.title.as_deref().unwrap_or(provider).to_string();
271 println!("\nConfiguring {provider}: {title}");
272 let mut answers = JsonMap::new();
273 for question in &spec.questions {
274 if question.name.trim().is_empty() {
275 continue;
276 }
277 if let Some(value) = ask_setup_question(question)? {
278 answers.insert(question.name.clone(), value);
279 }
280 }
281 Ok(Value::Object(answers))
282}
283
284fn ask_setup_question(question: &SetupQuestion) -> anyhow::Result<Option<Value>> {
285 if let Some(help) = question.help.as_ref()
286 && !help.trim().is_empty()
287 {
288 println!(" {help}");
289 }
290 if !question.choices.is_empty() {
291 println!(" Choices:");
292 for (idx, choice) in question.choices.iter().enumerate() {
293 println!(" {}) {}", idx + 1, choice);
294 }
295 }
296 loop {
297 let prompt = build_question_prompt(question);
298 let input = read_question_input(&prompt, question.secret)?;
299 let trimmed = input.trim();
300 if trimmed.is_empty() {
301 if let Some(default) = question.default.clone() {
302 return Ok(Some(default));
303 }
304 if question.required {
305 println!(" This field is required.");
306 continue;
307 }
308 return Ok(None);
309 }
310 match parse_question_value(question, trimmed) {
311 Ok(value) => return Ok(Some(value)),
312 Err(err) => {
313 println!(" {err}");
314 continue;
315 }
316 }
317 }
318}
319
320fn build_question_prompt(question: &SetupQuestion) -> String {
321 let mut prompt = question
322 .title
323 .as_deref()
324 .unwrap_or(&question.name)
325 .to_string();
326 if question.kind != "string" {
327 prompt = format!("{prompt} [{}]", question.kind);
328 }
329 if let Some(default) = &question.default {
330 prompt = format!("{prompt} [default: {}]", display_value(default));
331 }
332 prompt.push_str(": ");
333 prompt
334}
335
336fn read_question_input(prompt: &str, secret: bool) -> anyhow::Result<String> {
337 if secret {
338 prompt_password(prompt).map_err(|err| anyhow!("read secret: {err}"))
339 } else {
340 print!("{prompt}");
341 io::stdout().flush()?;
342 let mut buffer = String::new();
343 io::stdin().read_line(&mut buffer)?;
344 Ok(buffer)
345 }
346}
347
348fn parse_question_value(question: &SetupQuestion, input: &str) -> anyhow::Result<Value> {
349 let kind = question.kind.to_lowercase();
350 match kind.as_str() {
351 "number" => serde_json::Number::from_str(input)
352 .map(Value::Number)
353 .map_err(|err| anyhow!("invalid number: {err}")),
354 "choice" => {
355 if question.choices.is_empty() {
356 return Ok(Value::String(input.to_string()));
357 }
358 if let Ok(index) = input.parse::<usize>()
359 && let Some(choice) = question.choices.get(index - 1)
360 {
361 return Ok(Value::String(choice.clone()));
362 }
363 for choice in &question.choices {
364 if choice == input {
365 return Ok(Value::String(choice.clone()));
366 }
367 }
368 Err(anyhow!("invalid choice '{input}'"))
369 }
370 "boolean" => match input.to_lowercase().as_str() {
371 "true" | "t" | "yes" | "y" => Ok(Value::Bool(true)),
372 "false" | "f" | "no" | "n" => Ok(Value::Bool(false)),
373 _ => Err(anyhow!("invalid boolean value")),
374 },
375 _ => Ok(Value::String(input.to_string())),
376 }
377}
378
379fn display_value(value: &Value) -> String {
380 match value {
381 Value::String(v) => v.clone(),
382 Value::Number(n) => n.to_string(),
383 Value::Bool(b) => b.to_string(),
384 other => other.to_string(),
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use serde_json::json;
392 use std::io::Write;
393 use zip::write::{FileOptions, ZipWriter};
394
395 fn create_test_pack(yaml: &str) -> anyhow::Result<(tempfile::TempDir, std::path::PathBuf)> {
396 let temp_dir = tempfile::tempdir()?;
397 let pack_path = temp_dir.path().join("messaging-test.gtpack");
398 let file = File::create(&pack_path)?;
399 let mut writer = ZipWriter::new(file);
400 let options: FileOptions<'_, ()> =
401 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
402 writer.start_file("assets/setup.yaml", options)?;
403 writer.write_all(yaml.as_bytes())?;
404 writer.finish()?;
405 Ok((temp_dir, pack_path))
406 }
407
408 #[test]
409 fn parse_setup_yaml_questions() -> anyhow::Result<()> {
410 let yaml =
411 "provider_id: dummy\nquestions:\n - name: public_base_url\n required: true\n";
412 let (_dir, pack_path) = create_test_pack(yaml)?;
413 let spec = load_setup_spec(&pack_path)?.expect("expected spec");
414 assert_eq!(spec.questions.len(), 1);
415 assert_eq!(spec.questions[0].name, "public_base_url");
416 assert!(spec.questions[0].required);
417 Ok(())
418 }
419
420 #[test]
421 fn collect_setup_answers_uses_input() -> anyhow::Result<()> {
422 let yaml =
423 "provider_id: telegram\nquestions:\n - name: public_base_url\n required: true\n";
424 let (_dir, pack_path) = create_test_pack(yaml)?;
425 let provider_keys = BTreeSet::from(["messaging-telegram".to_string()]);
426 let raw = json!({ "messaging-telegram": { "public_base_url": "https://example.com" } });
427 let answers = SetupInputAnswers::new(raw, provider_keys)?;
428 let collected =
429 collect_setup_answers(&pack_path, "messaging-telegram", Some(&answers), false)?;
430 assert_eq!(
431 collected.get("public_base_url"),
432 Some(&Value::String("https://example.com".to_string()))
433 );
434 Ok(())
435 }
436
437 #[test]
438 fn collect_setup_answers_missing_required_errors() -> anyhow::Result<()> {
439 let yaml =
440 "provider_id: slack\nquestions:\n - name: slack_bot_token\n required: true\n";
441 let (_dir, pack_path) = create_test_pack(yaml)?;
442 let provider_keys = BTreeSet::from(["messaging-slack".to_string()]);
443 let raw = json!({ "messaging-slack": {} });
444 let answers = SetupInputAnswers::new(raw, provider_keys)?;
445 let error = collect_setup_answers(&pack_path, "messaging-slack", Some(&answers), false)
446 .unwrap_err();
447 assert!(error.to_string().contains("missing required setup answer"));
448 Ok(())
449 }
450}