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}
82
83/// A single setup question definition.
84#[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    /// Example value shown as placeholder in the input field.
105    #[serde(default)]
106    pub placeholder: Option<String>,
107    /// Group/section name for organizing questions in the UI.
108    #[serde(default)]
109    pub group: Option<String>,
110    /// URL to external setup documentation.
111    #[serde(default)]
112    pub docs_url: Option<String>,
113}
114
115/// Conditional visibility for a setup question.
116///
117/// Example in setup.yaml (struct format):
118/// ```yaml
119/// visible_if:
120///   field: public_base_url_mode
121///   eq: static
122/// ```
123///
124/// Or string expression format:
125/// ```yaml
126/// visible_if: "preset != 'stdout'"
127/// ```
128#[derive(Debug)]
129pub enum SetupVisibleIf {
130    /// Struct format with field and optional eq
131    Struct { field: String, eq: Option<String> },
132    /// String expression format (e.g., "preset != 'stdout'")
133    Expr(String),
134}
135
136impl SetupVisibleIf {
137    /// Get the field name (for struct format, or parse from expr format).
138    pub fn field(&self) -> Option<&str> {
139        match self {
140            SetupVisibleIf::Struct { field, .. } => Some(field),
141            SetupVisibleIf::Expr(_) => None,
142        }
143    }
144
145    /// Get the equality value (for struct format only).
146    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
219/// Load a `SetupSpec` from `assets/setup.yaml` inside a `.gtpack` archive.
220///
221/// Falls back to reading `setup.yaml` from the filesystem next to the pack
222/// (sibling or `assets/` subdirectory) when the archive does not contain it.
223pub 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
257/// Fallback: look for `setup.yaml` on the filesystem near the `.gtpack` file.
258///
259/// Searches sibling paths relative to the pack file:
260///   1. `<pack_dir>/assets/setup.yaml`
261///   2. `<pack_dir>/setup.yaml`
262///
263/// Also searches based on pack filename (e.g. for `messaging-telegram.gtpack`):
264///   3. `<pack_dir>/../../../packs/messaging-telegram/assets/setup.yaml`
265///   4. `<pack_dir>/../../../packs/messaging-telegram/setup.yaml`
266fn 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    // Also try a source-layout path: packs/<pack_stem>/assets/setup.yaml
276    let mut all_candidates: Vec<std::path::PathBuf> = candidates.to_vec();
277    if !pack_stem.is_empty() {
278        // Walk up to find a packs/ directory (common in greentic-messaging-providers layout)
279        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
298/// Collect setup answers for a provider pack.
299///
300/// Uses provided input answers if available, otherwise falls back to
301/// interactive prompting (if `interactive` is true) or returns an error.
302pub 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
341/// Validate that all required answers are present.
342pub 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
362/// Ensure a JSON value is an object.
363pub 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
373/// Interactively prompt the user for setup answers.
374pub 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}