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 = load_text_from_path_or_url(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
58fn load_text_from_path_or_url(path: &Path) -> anyhow::Result<String> {
59 let raw = path.to_string_lossy();
60 if raw.starts_with("https://") || raw.starts_with("http://") {
61 let response = ureq::get(raw.as_ref())
62 .call()
63 .map_err(|err| anyhow!("failed to fetch {}: {err}", raw))?;
64 return response
65 .into_body()
66 .read_to_string()
67 .map_err(|err| anyhow!("failed to read {}: {err}", raw));
68 }
69 fs::read_to_string(path).with_context(|| format!("read setup input {}", path.display()))
70}
71
72#[derive(Debug, Deserialize)]
74pub struct SetupSpec {
75 #[serde(default)]
76 pub title: Option<String>,
77 #[serde(default)]
78 pub description: Option<String>,
79 #[serde(default)]
80 pub questions: Vec<SetupQuestion>,
81 #[serde(default)]
82 pub setup_actions: Vec<Value>,
83}
84
85#[derive(Debug, Default, Deserialize)]
87pub struct SetupQuestion {
88 #[serde(default)]
89 pub name: String,
90 #[serde(default = "default_kind")]
91 pub kind: String,
92 #[serde(default)]
93 pub required: bool,
94 #[serde(default)]
95 pub help: Option<String>,
96 #[serde(default)]
97 pub choices: Vec<String>,
98 #[serde(default)]
99 pub default: Option<Value>,
100 #[serde(default)]
101 pub secret: bool,
102 #[serde(default)]
103 pub title: Option<String>,
104 #[serde(default)]
105 pub visible_if: Option<SetupVisibleIf>,
106 #[serde(default)]
108 pub placeholder: Option<String>,
109 #[serde(default)]
111 pub group: Option<String>,
112 #[serde(default)]
114 pub docs_url: Option<String>,
115 #[serde(default)]
118 pub create_url: Option<String>,
119 #[serde(default)]
122 pub columns: Vec<SetupTableColumn>,
123 #[serde(default)]
125 pub min_rows: Option<u16>,
126 #[serde(default)]
128 pub max_rows: Option<u16>,
129}
130
131#[derive(Debug, Default, Deserialize)]
133pub struct SetupTableColumn {
134 #[serde(default)]
137 pub key: String,
138 #[serde(default)]
140 pub title: Option<String>,
141 #[serde(default = "default_kind")]
144 pub kind: String,
145 #[serde(default)]
147 pub required: bool,
148 #[serde(default)]
150 pub help: Option<String>,
151 #[serde(default)]
153 pub placeholder: Option<String>,
154 #[serde(default)]
156 pub choices: Vec<String>,
157 #[serde(default)]
159 pub default: Option<Value>,
160 #[serde(default)]
167 pub multilingual: bool,
168}
169
170#[derive(Debug)]
184pub enum SetupVisibleIf {
185 Struct { field: String, eq: Option<String> },
187 Expr(String),
189}
190
191impl SetupVisibleIf {
192 pub fn field(&self) -> Option<&str> {
194 match self {
195 SetupVisibleIf::Struct { field, .. } => Some(field),
196 SetupVisibleIf::Expr(_) => None,
197 }
198 }
199
200 pub fn eq(&self) -> Option<&str> {
202 match self {
203 SetupVisibleIf::Struct { eq, .. } => eq.as_deref(),
204 SetupVisibleIf::Expr(_) => None,
205 }
206 }
207}
208
209impl<'de> serde::Deserialize<'de> for SetupVisibleIf {
210 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
211 where
212 D: serde::Deserializer<'de>,
213 {
214 use serde::de::{self, MapAccess, Visitor};
215
216 struct SetupVisibleIfVisitor;
217
218 impl<'de> Visitor<'de> for SetupVisibleIfVisitor {
219 type Value = SetupVisibleIf;
220
221 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
222 formatter
223 .write_str("a string expression or a struct with 'field' and optional 'eq'")
224 }
225
226 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
227 where
228 E: de::Error,
229 {
230 Ok(SetupVisibleIf::Expr(value.to_string()))
231 }
232
233 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
234 where
235 E: de::Error,
236 {
237 Ok(SetupVisibleIf::Expr(value))
238 }
239
240 fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
241 where
242 M: MapAccess<'de>,
243 {
244 let mut field: Option<String> = None;
245 let mut eq: Option<String> = None;
246
247 while let Some(key) = map.next_key::<String>()? {
248 match key.as_str() {
249 "field" => {
250 field = Some(map.next_value()?);
251 }
252 "eq" => {
253 eq = Some(map.next_value()?);
254 }
255 _ => {
256 let _: serde::de::IgnoredAny = map.next_value()?;
257 }
258 }
259 }
260
261 let field = field.ok_or_else(|| de::Error::missing_field("field"))?;
262 Ok(SetupVisibleIf::Struct { field, eq })
263 }
264 }
265
266 deserializer.deserialize_any(SetupVisibleIfVisitor)
267 }
268}
269
270fn default_kind() -> String {
271 "string".to_string()
272}
273
274pub fn load_setup_spec(pack_path: &Path) -> anyhow::Result<Option<SetupSpec>> {
279 let file = File::open(pack_path)?;
280 let mut archive = match ZipArchive::new(file) {
281 Ok(archive) => archive,
282 Err(ZipError::InvalidArchive(_)) | Err(ZipError::UnsupportedArchive(_)) => return Ok(None),
283 Err(err) => return Err(err.into()),
284 };
285 let contents = match read_setup_yaml(&mut archive)? {
286 Some(value) => value,
287 None => match read_setup_yaml_from_filesystem(pack_path)? {
288 Some(value) => value,
289 None => return Ok(None),
290 },
291 };
292 let spec: SetupSpec =
293 serde_yaml_bw::from_str(&contents).context("parse provider setup spec")?;
294 Ok(Some(spec))
295}
296
297fn read_setup_yaml(archive: &mut ZipArchive<File>) -> anyhow::Result<Option<String>> {
298 for entry in ["assets/setup.yaml", "setup.yaml"] {
299 match archive.by_name(entry) {
300 Ok(mut file) => {
301 let mut contents = String::new();
302 file.read_to_string(&mut contents)?;
303 return Ok(Some(contents));
304 }
305 Err(ZipError::FileNotFound) => continue,
306 Err(err) => return Err(err.into()),
307 }
308 }
309 Ok(None)
310}
311
312fn read_setup_yaml_from_filesystem(pack_path: &Path) -> anyhow::Result<Option<String>> {
322 let pack_dir = pack_path.parent().unwrap_or(Path::new("."));
323 let pack_stem = pack_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
324
325 let candidates = [
326 pack_dir.join("assets/setup.yaml"),
327 pack_dir.join("setup.yaml"),
328 ];
329
330 let mut all_candidates: Vec<std::path::PathBuf> = candidates.to_vec();
332 if !pack_stem.is_empty() {
333 for ancestor in pack_dir.ancestors().skip(1).take(4) {
335 let source_dir = ancestor.join("packs").join(pack_stem);
336 if source_dir.is_dir() {
337 all_candidates.push(source_dir.join("assets/setup.yaml"));
338 all_candidates.push(source_dir.join("setup.yaml"));
339 break;
340 }
341 }
342 }
343
344 for candidate in &all_candidates {
345 if candidate.is_file() {
346 let contents = fs::read_to_string(candidate)?;
347 return Ok(Some(contents));
348 }
349 }
350 Ok(None)
351}
352
353pub fn collect_setup_answers(
358 pack_path: &Path,
359 provider_id: &str,
360 setup_input: Option<&SetupInputAnswers>,
361 interactive: bool,
362) -> anyhow::Result<Value> {
363 let spec = load_setup_spec(pack_path)?;
364 if let Some(input) = setup_input {
365 if let Some(value) = input.answers_for_provider(provider_id) {
366 let answers = ensure_object(value.clone())?;
367 ensure_required_answers(spec.as_ref(), &answers)?;
368 return Ok(answers);
369 }
370 if has_required_questions(spec.as_ref()) {
371 return Err(anyhow!("setup input missing answers for {provider_id}"));
372 }
373 return Ok(Value::Object(JsonMap::new()));
374 }
375 if let Some(spec) = spec {
376 if spec.questions.is_empty() {
377 return Ok(Value::Object(JsonMap::new()));
378 }
379 if interactive {
380 let answers = prompt_setup_answers(&spec, provider_id)?;
381 ensure_required_answers(Some(&spec), &answers)?;
382 return Ok(answers);
383 }
384 return Err(anyhow!(
385 "setup answers required for {provider_id} but run is non-interactive"
386 ));
387 }
388 Ok(Value::Object(JsonMap::new()))
389}
390
391fn has_required_questions(spec: Option<&SetupSpec>) -> bool {
392 spec.map(|spec| spec.questions.iter().any(|q| q.required))
393 .unwrap_or(false)
394}
395
396pub fn ensure_required_answers(spec: Option<&SetupSpec>, answers: &Value) -> anyhow::Result<()> {
398 let map = answers
399 .as_object()
400 .ok_or_else(|| anyhow!("setup answers must be an object"))?;
401 if let Some(spec) = spec {
402 for question in spec.questions.iter().filter(|q| q.required) {
403 match map.get(&question.name) {
404 Some(value) if !value.is_null() => continue,
405 _ => {
406 return Err(anyhow!(
407 "missing required setup answer for {}",
408 question.name
409 ));
410 }
411 }
412 }
413 }
414 Ok(())
415}
416
417pub fn ensure_object(value: Value) -> anyhow::Result<Value> {
419 match value {
420 Value::Object(_) => Ok(value),
421 other => Err(anyhow!(
422 "setup answers must be a JSON object, got {}",
423 other
424 )),
425 }
426}
427
428pub fn prompt_setup_answers(spec: &SetupSpec, provider: &str) -> anyhow::Result<Value> {
430 if spec.questions.is_empty() {
431 return Ok(Value::Object(JsonMap::new()));
432 }
433 let title = spec.title.as_deref().unwrap_or(provider).to_string();
434 println!("\nConfiguring {provider}: {title}");
435 let mut answers = JsonMap::new();
436 for question in &spec.questions {
437 if question.name.trim().is_empty() {
438 continue;
439 }
440 if let Some(value) = ask_setup_question(question)? {
441 answers.insert(question.name.clone(), value);
442 }
443 }
444 Ok(Value::Object(answers))
445}
446
447fn ask_setup_question(question: &SetupQuestion) -> anyhow::Result<Option<Value>> {
448 if let Some(help) = question.help.as_ref()
449 && !help.trim().is_empty()
450 {
451 println!(" {help}");
452 }
453 if !question.choices.is_empty() {
454 println!(" Choices:");
455 for (idx, choice) in question.choices.iter().enumerate() {
456 println!(" {}) {}", idx + 1, choice);
457 }
458 }
459 loop {
460 let prompt = build_question_prompt(question);
461 let input = read_question_input(&prompt, question.secret)?;
462 let trimmed = input.trim();
463 if trimmed.is_empty() {
464 if let Some(default) = question.default.clone() {
465 return Ok(Some(default));
466 }
467 if question.required {
468 println!(" This field is required.");
469 continue;
470 }
471 return Ok(None);
472 }
473 match parse_question_value(question, trimmed) {
474 Ok(value) => return Ok(Some(value)),
475 Err(err) => {
476 println!(" {err}");
477 continue;
478 }
479 }
480 }
481}
482
483fn build_question_prompt(question: &SetupQuestion) -> String {
484 let mut prompt = question
485 .title
486 .as_deref()
487 .unwrap_or(&question.name)
488 .to_string();
489 if question.kind != "string" {
490 prompt = format!("{prompt} [{}]", question.kind);
491 }
492 if let Some(default) = &question.default {
493 prompt = format!("{prompt} [default: {}]", display_value(default));
494 }
495 prompt.push_str(": ");
496 prompt
497}
498
499fn read_question_input(prompt: &str, secret: bool) -> anyhow::Result<String> {
500 if secret {
501 prompt_password(prompt).map_err(|err| anyhow!("read secret: {err}"))
502 } else {
503 print!("{prompt}");
504 io::stdout().flush()?;
505 let mut buffer = String::new();
506 io::stdin().read_line(&mut buffer)?;
507 Ok(buffer)
508 }
509}
510
511fn parse_question_value(question: &SetupQuestion, input: &str) -> anyhow::Result<Value> {
512 let kind = question.kind.to_lowercase();
513 match kind.as_str() {
514 "number" => serde_json::Number::from_str(input)
515 .map(Value::Number)
516 .map_err(|err| anyhow!("invalid number: {err}")),
517 "choice" => {
518 if question.choices.is_empty() {
519 return Ok(Value::String(input.to_string()));
520 }
521 if let Ok(index) = input.parse::<usize>()
522 && let Some(choice) = question.choices.get(index - 1)
523 {
524 return Ok(Value::String(choice.clone()));
525 }
526 for choice in &question.choices {
527 if choice == input {
528 return Ok(Value::String(choice.clone()));
529 }
530 }
531 Err(anyhow!("invalid choice '{input}'"))
532 }
533 "boolean" => match input.to_lowercase().as_str() {
534 "true" | "t" | "yes" | "y" => Ok(Value::Bool(true)),
535 "false" | "f" | "no" | "n" => Ok(Value::Bool(false)),
536 _ => Err(anyhow!("invalid boolean value")),
537 },
538 _ => Ok(Value::String(input.to_string())),
539 }
540}
541
542fn display_value(value: &Value) -> String {
543 match value {
544 Value::String(v) => v.clone(),
545 Value::Number(n) => n.to_string(),
546 Value::Bool(b) => b.to_string(),
547 other => other.to_string(),
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use serde_json::json;
555 use std::io::Write;
556 use zip::write::{FileOptions, ZipWriter};
557
558 fn create_test_pack(yaml: &str) -> anyhow::Result<(tempfile::TempDir, std::path::PathBuf)> {
559 let temp_dir = tempfile::tempdir()?;
560 let pack_path = temp_dir.path().join("messaging-test.gtpack");
561 let file = File::create(&pack_path)?;
562 let mut writer = ZipWriter::new(file);
563 let options: FileOptions<'_, ()> =
564 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
565 writer.start_file("assets/setup.yaml", options)?;
566 writer.write_all(yaml.as_bytes())?;
567 writer.finish()?;
568 Ok((temp_dir, pack_path))
569 }
570
571 #[test]
572 fn parse_setup_yaml_questions() -> anyhow::Result<()> {
573 let yaml =
574 "provider_id: dummy\nquestions:\n - name: public_base_url\n required: true\n";
575 let (_dir, pack_path) = create_test_pack(yaml)?;
576 let spec = load_setup_spec(&pack_path)?.expect("expected spec");
577 assert_eq!(spec.questions.len(), 1);
578 assert_eq!(spec.questions[0].name, "public_base_url");
579 assert!(spec.questions[0].required);
580 Ok(())
581 }
582
583 #[test]
584 fn parse_setup_yaml_setup_actions() -> anyhow::Result<()> {
585 let yaml = r#"
586provider_id: slack
587questions: []
588setup_actions:
589 - id: add_to_slack
590 label: Add to Slack
591 kind: oauth_install_button
592"#;
593 let (_dir, pack_path) = create_test_pack(yaml)?;
594 let spec = load_setup_spec(&pack_path)?.expect("expected spec");
595 assert!(spec.questions.is_empty());
596 assert_eq!(spec.setup_actions.len(), 1);
597 assert_eq!(spec.setup_actions[0]["id"], json!("add_to_slack"));
598 assert_eq!(spec.setup_actions[0]["kind"], json!("oauth_install_button"));
599 Ok(())
600 }
601
602 #[test]
603 fn collect_setup_answers_uses_input() -> anyhow::Result<()> {
604 let yaml =
605 "provider_id: telegram\nquestions:\n - name: public_base_url\n required: true\n";
606 let (_dir, pack_path) = create_test_pack(yaml)?;
607 let provider_keys = BTreeSet::from(["messaging-telegram".to_string()]);
608 let raw = json!({ "messaging-telegram": { "public_base_url": "https://example.com" } });
609 let answers = SetupInputAnswers::new(raw, provider_keys)?;
610 let collected =
611 collect_setup_answers(&pack_path, "messaging-telegram", Some(&answers), false)?;
612 assert_eq!(
613 collected.get("public_base_url"),
614 Some(&Value::String("https://example.com".to_string()))
615 );
616 Ok(())
617 }
618
619 #[test]
620 fn collect_setup_answers_missing_required_errors() -> anyhow::Result<()> {
621 let yaml =
622 "provider_id: slack\nquestions:\n - name: slack_bot_token\n required: true\n";
623 let (_dir, pack_path) = create_test_pack(yaml)?;
624 let provider_keys = BTreeSet::from(["messaging-slack".to_string()]);
625 let raw = json!({ "messaging-slack": {} });
626 let answers = SetupInputAnswers::new(raw, provider_keys)?;
627 let error = collect_setup_answers(&pack_path, "messaging-slack", Some(&answers), false)
628 .unwrap_err();
629 assert!(error.to_string().contains("missing required setup answer"));
630 Ok(())
631 }
632}