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