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}
114
115#[derive(Debug)]
129pub enum SetupVisibleIf {
130 Struct { field: String, eq: Option<String> },
132 Expr(String),
134}
135
136impl SetupVisibleIf {
137 pub fn field(&self) -> Option<&str> {
139 match self {
140 SetupVisibleIf::Struct { field, .. } => Some(field),
141 SetupVisibleIf::Expr(_) => None,
142 }
143 }
144
145 pub fn eq(&self) -> Option<&str> {
147 match self {
148 SetupVisibleIf::Struct { eq, .. } => eq.as_deref(),
149 SetupVisibleIf::Expr(_) => None,
150 }
151 }
152}
153
154impl<'de> serde::Deserialize<'de> for SetupVisibleIf {
155 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
156 where
157 D: serde::Deserializer<'de>,
158 {
159 use serde::de::{self, MapAccess, Visitor};
160
161 struct SetupVisibleIfVisitor;
162
163 impl<'de> Visitor<'de> for SetupVisibleIfVisitor {
164 type Value = SetupVisibleIf;
165
166 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
167 formatter
168 .write_str("a string expression or a struct with 'field' and optional 'eq'")
169 }
170
171 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
172 where
173 E: de::Error,
174 {
175 Ok(SetupVisibleIf::Expr(value.to_string()))
176 }
177
178 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
179 where
180 E: de::Error,
181 {
182 Ok(SetupVisibleIf::Expr(value))
183 }
184
185 fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
186 where
187 M: MapAccess<'de>,
188 {
189 let mut field: Option<String> = None;
190 let mut eq: Option<String> = None;
191
192 while let Some(key) = map.next_key::<String>()? {
193 match key.as_str() {
194 "field" => {
195 field = Some(map.next_value()?);
196 }
197 "eq" => {
198 eq = Some(map.next_value()?);
199 }
200 _ => {
201 let _: serde::de::IgnoredAny = map.next_value()?;
202 }
203 }
204 }
205
206 let field = field.ok_or_else(|| de::Error::missing_field("field"))?;
207 Ok(SetupVisibleIf::Struct { field, eq })
208 }
209 }
210
211 deserializer.deserialize_any(SetupVisibleIfVisitor)
212 }
213}
214
215fn default_kind() -> String {
216 "string".to_string()
217}
218
219pub fn load_setup_spec(pack_path: &Path) -> anyhow::Result<Option<SetupSpec>> {
224 let file = File::open(pack_path)?;
225 let mut archive = match ZipArchive::new(file) {
226 Ok(archive) => archive,
227 Err(ZipError::InvalidArchive(_)) | Err(ZipError::UnsupportedArchive(_)) => return Ok(None),
228 Err(err) => return Err(err.into()),
229 };
230 let contents = match read_setup_yaml(&mut archive)? {
231 Some(value) => value,
232 None => match read_setup_yaml_from_filesystem(pack_path)? {
233 Some(value) => value,
234 None => return Ok(None),
235 },
236 };
237 let spec: SetupSpec =
238 serde_yaml_bw::from_str(&contents).context("parse provider setup spec")?;
239 Ok(Some(spec))
240}
241
242fn read_setup_yaml(archive: &mut ZipArchive<File>) -> anyhow::Result<Option<String>> {
243 for entry in ["assets/setup.yaml", "setup.yaml"] {
244 match archive.by_name(entry) {
245 Ok(mut file) => {
246 let mut contents = String::new();
247 file.read_to_string(&mut contents)?;
248 return Ok(Some(contents));
249 }
250 Err(ZipError::FileNotFound) => continue,
251 Err(err) => return Err(err.into()),
252 }
253 }
254 Ok(None)
255}
256
257fn read_setup_yaml_from_filesystem(pack_path: &Path) -> anyhow::Result<Option<String>> {
267 let pack_dir = pack_path.parent().unwrap_or(Path::new("."));
268 let pack_stem = pack_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
269
270 let candidates = [
271 pack_dir.join("assets/setup.yaml"),
272 pack_dir.join("setup.yaml"),
273 ];
274
275 let mut all_candidates: Vec<std::path::PathBuf> = candidates.to_vec();
277 if !pack_stem.is_empty() {
278 for ancestor in pack_dir.ancestors().skip(1).take(4) {
280 let source_dir = ancestor.join("packs").join(pack_stem);
281 if source_dir.is_dir() {
282 all_candidates.push(source_dir.join("assets/setup.yaml"));
283 all_candidates.push(source_dir.join("setup.yaml"));
284 break;
285 }
286 }
287 }
288
289 for candidate in &all_candidates {
290 if candidate.is_file() {
291 let contents = fs::read_to_string(candidate)?;
292 return Ok(Some(contents));
293 }
294 }
295 Ok(None)
296}
297
298pub fn collect_setup_answers(
303 pack_path: &Path,
304 provider_id: &str,
305 setup_input: Option<&SetupInputAnswers>,
306 interactive: bool,
307) -> anyhow::Result<Value> {
308 let spec = load_setup_spec(pack_path)?;
309 if let Some(input) = setup_input {
310 if let Some(value) = input.answers_for_provider(provider_id) {
311 let answers = ensure_object(value.clone())?;
312 ensure_required_answers(spec.as_ref(), &answers)?;
313 return Ok(answers);
314 }
315 if has_required_questions(spec.as_ref()) {
316 return Err(anyhow!("setup input missing answers for {provider_id}"));
317 }
318 return Ok(Value::Object(JsonMap::new()));
319 }
320 if let Some(spec) = spec {
321 if spec.questions.is_empty() {
322 return Ok(Value::Object(JsonMap::new()));
323 }
324 if interactive {
325 let answers = prompt_setup_answers(&spec, provider_id)?;
326 ensure_required_answers(Some(&spec), &answers)?;
327 return Ok(answers);
328 }
329 return Err(anyhow!(
330 "setup answers required for {provider_id} but run is non-interactive"
331 ));
332 }
333 Ok(Value::Object(JsonMap::new()))
334}
335
336fn has_required_questions(spec: Option<&SetupSpec>) -> bool {
337 spec.map(|spec| spec.questions.iter().any(|q| q.required))
338 .unwrap_or(false)
339}
340
341pub fn ensure_required_answers(spec: Option<&SetupSpec>, answers: &Value) -> anyhow::Result<()> {
343 let map = answers
344 .as_object()
345 .ok_or_else(|| anyhow!("setup answers must be an object"))?;
346 if let Some(spec) = spec {
347 for question in spec.questions.iter().filter(|q| q.required) {
348 match map.get(&question.name) {
349 Some(value) if !value.is_null() => continue,
350 _ => {
351 return Err(anyhow!(
352 "missing required setup answer for {}",
353 question.name
354 ));
355 }
356 }
357 }
358 }
359 Ok(())
360}
361
362pub fn ensure_object(value: Value) -> anyhow::Result<Value> {
364 match value {
365 Value::Object(_) => Ok(value),
366 other => Err(anyhow!(
367 "setup answers must be a JSON object, got {}",
368 other
369 )),
370 }
371}
372
373pub fn prompt_setup_answers(spec: &SetupSpec, provider: &str) -> anyhow::Result<Value> {
375 if spec.questions.is_empty() {
376 return Ok(Value::Object(JsonMap::new()));
377 }
378 let title = spec.title.as_deref().unwrap_or(provider).to_string();
379 println!("\nConfiguring {provider}: {title}");
380 let mut answers = JsonMap::new();
381 for question in &spec.questions {
382 if question.name.trim().is_empty() {
383 continue;
384 }
385 if let Some(value) = ask_setup_question(question)? {
386 answers.insert(question.name.clone(), value);
387 }
388 }
389 Ok(Value::Object(answers))
390}
391
392fn ask_setup_question(question: &SetupQuestion) -> anyhow::Result<Option<Value>> {
393 if let Some(help) = question.help.as_ref()
394 && !help.trim().is_empty()
395 {
396 println!(" {help}");
397 }
398 if !question.choices.is_empty() {
399 println!(" Choices:");
400 for (idx, choice) in question.choices.iter().enumerate() {
401 println!(" {}) {}", idx + 1, choice);
402 }
403 }
404 loop {
405 let prompt = build_question_prompt(question);
406 let input = read_question_input(&prompt, question.secret)?;
407 let trimmed = input.trim();
408 if trimmed.is_empty() {
409 if let Some(default) = question.default.clone() {
410 return Ok(Some(default));
411 }
412 if question.required {
413 println!(" This field is required.");
414 continue;
415 }
416 return Ok(None);
417 }
418 match parse_question_value(question, trimmed) {
419 Ok(value) => return Ok(Some(value)),
420 Err(err) => {
421 println!(" {err}");
422 continue;
423 }
424 }
425 }
426}
427
428fn build_question_prompt(question: &SetupQuestion) -> String {
429 let mut prompt = question
430 .title
431 .as_deref()
432 .unwrap_or(&question.name)
433 .to_string();
434 if question.kind != "string" {
435 prompt = format!("{prompt} [{}]", question.kind);
436 }
437 if let Some(default) = &question.default {
438 prompt = format!("{prompt} [default: {}]", display_value(default));
439 }
440 prompt.push_str(": ");
441 prompt
442}
443
444fn read_question_input(prompt: &str, secret: bool) -> anyhow::Result<String> {
445 if secret {
446 prompt_password(prompt).map_err(|err| anyhow!("read secret: {err}"))
447 } else {
448 print!("{prompt}");
449 io::stdout().flush()?;
450 let mut buffer = String::new();
451 io::stdin().read_line(&mut buffer)?;
452 Ok(buffer)
453 }
454}
455
456fn parse_question_value(question: &SetupQuestion, input: &str) -> anyhow::Result<Value> {
457 let kind = question.kind.to_lowercase();
458 match kind.as_str() {
459 "number" => serde_json::Number::from_str(input)
460 .map(Value::Number)
461 .map_err(|err| anyhow!("invalid number: {err}")),
462 "choice" => {
463 if question.choices.is_empty() {
464 return Ok(Value::String(input.to_string()));
465 }
466 if let Ok(index) = input.parse::<usize>()
467 && let Some(choice) = question.choices.get(index - 1)
468 {
469 return Ok(Value::String(choice.clone()));
470 }
471 for choice in &question.choices {
472 if choice == input {
473 return Ok(Value::String(choice.clone()));
474 }
475 }
476 Err(anyhow!("invalid choice '{input}'"))
477 }
478 "boolean" => match input.to_lowercase().as_str() {
479 "true" | "t" | "yes" | "y" => Ok(Value::Bool(true)),
480 "false" | "f" | "no" | "n" => Ok(Value::Bool(false)),
481 _ => Err(anyhow!("invalid boolean value")),
482 },
483 _ => Ok(Value::String(input.to_string())),
484 }
485}
486
487fn display_value(value: &Value) -> String {
488 match value {
489 Value::String(v) => v.clone(),
490 Value::Number(n) => n.to_string(),
491 Value::Bool(b) => b.to_string(),
492 other => other.to_string(),
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499 use serde_json::json;
500 use std::io::Write;
501 use zip::write::{FileOptions, ZipWriter};
502
503 fn create_test_pack(yaml: &str) -> anyhow::Result<(tempfile::TempDir, std::path::PathBuf)> {
504 let temp_dir = tempfile::tempdir()?;
505 let pack_path = temp_dir.path().join("messaging-test.gtpack");
506 let file = File::create(&pack_path)?;
507 let mut writer = ZipWriter::new(file);
508 let options: FileOptions<'_, ()> =
509 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
510 writer.start_file("assets/setup.yaml", options)?;
511 writer.write_all(yaml.as_bytes())?;
512 writer.finish()?;
513 Ok((temp_dir, pack_path))
514 }
515
516 #[test]
517 fn parse_setup_yaml_questions() -> anyhow::Result<()> {
518 let yaml =
519 "provider_id: dummy\nquestions:\n - name: public_base_url\n required: true\n";
520 let (_dir, pack_path) = create_test_pack(yaml)?;
521 let spec = load_setup_spec(&pack_path)?.expect("expected spec");
522 assert_eq!(spec.questions.len(), 1);
523 assert_eq!(spec.questions[0].name, "public_base_url");
524 assert!(spec.questions[0].required);
525 Ok(())
526 }
527
528 #[test]
529 fn collect_setup_answers_uses_input() -> anyhow::Result<()> {
530 let yaml =
531 "provider_id: telegram\nquestions:\n - name: public_base_url\n required: true\n";
532 let (_dir, pack_path) = create_test_pack(yaml)?;
533 let provider_keys = BTreeSet::from(["messaging-telegram".to_string()]);
534 let raw = json!({ "messaging-telegram": { "public_base_url": "https://example.com" } });
535 let answers = SetupInputAnswers::new(raw, provider_keys)?;
536 let collected =
537 collect_setup_answers(&pack_path, "messaging-telegram", Some(&answers), false)?;
538 assert_eq!(
539 collected.get("public_base_url"),
540 Some(&Value::String("https://example.com".to_string()))
541 );
542 Ok(())
543 }
544
545 #[test]
546 fn collect_setup_answers_missing_required_errors() -> anyhow::Result<()> {
547 let yaml =
548 "provider_id: slack\nquestions:\n - name: slack_bot_token\n required: true\n";
549 let (_dir, pack_path) = create_test_pack(yaml)?;
550 let provider_keys = BTreeSet::from(["messaging-slack".to_string()]);
551 let raw = json!({ "messaging-slack": {} });
552 let answers = SetupInputAnswers::new(raw, provider_keys)?;
553 let error = collect_setup_answers(&pack_path, "messaging-slack", Some(&answers), false)
554 .unwrap_err();
555 assert!(error.to_string().contains("missing required setup answer"));
556 Ok(())
557 }
558}