Skip to main content

greentic_setup/
setup_input.rs

1//! Load and validate user-provided setup answers from JSON/YAML files.
2//!
3//! Supports both per-provider keyed answers (where the top-level JSON object
4//! maps provider IDs to their answers) and flat single-provider answers.
5
6use 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/// Answers loaded from a user-provided `--setup-input` file.
19#[derive(Clone)]
20pub struct SetupInputAnswers {
21    raw: Value,
22    provider_keys: BTreeSet<String>,
23}
24
25impl SetupInputAnswers {
26    /// Creates a new helper with the raw file data and the set of known provider IDs.
27    pub fn new(raw: Value, provider_keys: BTreeSet<String>) -> anyhow::Result<Self> {
28        Ok(Self { raw, provider_keys })
29    }
30
31    /// Returns the answers that correspond to a provider/pack.
32    ///
33    /// If the raw value is keyed by provider ID, returns only that provider's
34    /// answers.  Otherwise, returns the entire raw value (flat mode).
35    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
50/// Reads a JSON/YAML answers file.
51pub 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/// Represents a provider setup spec extracted from `assets/setup.yaml`.
73#[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/// A single setup question definition.
86#[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    /// Example value shown as placeholder in the input field.
107    #[serde(default)]
108    pub placeholder: Option<String>,
109    /// Group/section name for organizing questions in the UI.
110    #[serde(default)]
111    pub group: Option<String>,
112    /// URL to external setup documentation.
113    #[serde(default)]
114    pub docs_url: Option<String>,
115    /// URL to where the operator can create this credential (provider dev
116    /// portal). Rendered as a guided "Create it" link next to the field.
117    #[serde(default)]
118    pub create_url: Option<String>,
119    /// Column definitions for `kind: table` questions. Each row's answer is a
120    /// JSON object whose keys match the columns' `key` field.
121    #[serde(default)]
122    pub columns: Vec<SetupTableColumn>,
123    /// Minimum required row count for a `kind: table` question.
124    #[serde(default)]
125    pub min_rows: Option<u16>,
126    /// Maximum row count for a `kind: table` question.
127    #[serde(default)]
128    pub max_rows: Option<u16>,
129}
130
131/// One column in a `kind: table` setup question.
132#[derive(Debug, Default, Deserialize)]
133pub struct SetupTableColumn {
134    /// JSON object key the column's value is stored under (e.g. `"label"`).
135    /// Stable identifier — do not rename without a migration.
136    #[serde(default)]
137    pub key: String,
138    /// Header label shown above the column / next to each row's input.
139    #[serde(default)]
140    pub title: Option<String>,
141    /// Column scalar kind. Same vocabulary as top-level `kind` — but nested
142    /// tables are not supported.
143    #[serde(default = "default_kind")]
144    pub kind: String,
145    /// Whether the column must be filled for a row to count as non-empty.
146    #[serde(default)]
147    pub required: bool,
148    /// Optional inline help.
149    #[serde(default)]
150    pub help: Option<String>,
151    /// Optional placeholder shown when the cell is empty.
152    #[serde(default)]
153    pub placeholder: Option<String>,
154    /// Optional pre-defined choices for `kind: choice` columns.
155    #[serde(default)]
156    pub choices: Vec<String>,
157    /// Optional per-row default applied to new rows.
158    #[serde(default)]
159    pub default: Option<Value>,
160    /// When true, the wizard renders a multi-locale cell instead of a
161    /// scalar input. Operator types the primary (English) value and may
162    /// add per-locale translations via "+ Add language". Persisted as a
163    /// locale-keyed object `{en: "...", id: "...", ...}` (or a plain
164    /// string when only one locale was filled). Only meaningful for
165    /// `kind: string` columns.
166    #[serde(default)]
167    pub multilingual: bool,
168}
169
170/// Conditional visibility for a setup question.
171///
172/// Example in setup.yaml (struct format):
173/// ```yaml
174/// visible_if:
175///   field: public_base_url_mode
176///   eq: static
177/// ```
178///
179/// Or string expression format:
180/// ```yaml
181/// visible_if: "preset != 'stdout'"
182/// ```
183#[derive(Debug)]
184pub enum SetupVisibleIf {
185    /// Struct format with field and optional eq
186    Struct { field: String, eq: Option<String> },
187    /// String expression format (e.g., "preset != 'stdout'")
188    Expr(String),
189}
190
191impl SetupVisibleIf {
192    /// Get the field name (for struct format, or parse from expr format).
193    pub fn field(&self) -> Option<&str> {
194        match self {
195            SetupVisibleIf::Struct { field, .. } => Some(field),
196            SetupVisibleIf::Expr(_) => None,
197        }
198    }
199
200    /// Get the equality value (for struct format only).
201    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
274/// Load a `SetupSpec` from `assets/setup.yaml` inside a `.gtpack` archive.
275///
276/// Falls back to reading `setup.yaml` from the filesystem next to the pack
277/// (sibling or `assets/` subdirectory) when the archive does not contain it.
278pub 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
312/// Fallback: look for `setup.yaml` on the filesystem near the `.gtpack` file.
313///
314/// Searches sibling paths relative to the pack file:
315///   1. `<pack_dir>/assets/setup.yaml`
316///   2. `<pack_dir>/setup.yaml`
317///
318/// Also searches based on pack filename (e.g. for `messaging-telegram.gtpack`):
319///   3. `<pack_dir>/../../../packs/messaging-telegram/assets/setup.yaml`
320///   4. `<pack_dir>/../../../packs/messaging-telegram/setup.yaml`
321fn 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    // Also try a source-layout path: packs/<pack_stem>/assets/setup.yaml
331    let mut all_candidates: Vec<std::path::PathBuf> = candidates.to_vec();
332    if !pack_stem.is_empty() {
333        // Walk up to find a packs/ directory (common in greentic-messaging-providers layout)
334        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
353/// Collect setup answers for a provider pack.
354///
355/// Uses provided input answers if available, otherwise falls back to
356/// interactive prompting (if `interactive` is true) or returns an error.
357pub 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
396/// Validate that all required answers are present.
397pub 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
417/// Ensure a JSON value is an object.
418pub 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
428/// Interactively prompt the user for setup answers.
429pub 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}