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