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 = fs::read_to_string(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
58/// Represents a provider setup spec extracted from `assets/setup.yaml`.
59#[derive(Debug, Deserialize)]
60pub struct SetupSpec {
61    #[serde(default)]
62    pub title: Option<String>,
63    #[serde(default)]
64    pub description: Option<String>,
65    #[serde(default)]
66    pub questions: Vec<SetupQuestion>,
67}
68
69/// A single setup question definition.
70#[derive(Debug, Deserialize)]
71pub struct SetupQuestion {
72    #[serde(default)]
73    pub name: String,
74    #[serde(default = "default_kind")]
75    pub kind: String,
76    #[serde(default)]
77    pub required: bool,
78    #[serde(default)]
79    pub help: Option<String>,
80    #[serde(default)]
81    pub choices: Vec<String>,
82    #[serde(default)]
83    pub default: Option<Value>,
84    #[serde(default)]
85    pub secret: bool,
86    #[serde(default)]
87    pub title: Option<String>,
88    #[serde(default)]
89    pub visible_if: Option<SetupVisibleIf>,
90}
91
92/// Conditional visibility for a setup question.
93///
94/// Example in setup.yaml:
95/// ```yaml
96/// visible_if:
97///   field: public_base_url_mode
98///   eq: static
99/// ```
100#[derive(Debug, Deserialize)]
101pub struct SetupVisibleIf {
102    pub field: String,
103    #[serde(default)]
104    pub eq: Option<String>,
105}
106
107fn default_kind() -> String {
108    "string".to_string()
109}
110
111/// Load a `SetupSpec` from `assets/setup.yaml` inside a `.gtpack` archive.
112///
113/// Falls back to reading `setup.yaml` from the filesystem next to the pack
114/// (sibling or `assets/` subdirectory) when the archive does not contain it.
115pub fn load_setup_spec(pack_path: &Path) -> anyhow::Result<Option<SetupSpec>> {
116    let file = File::open(pack_path)?;
117    let mut archive = match ZipArchive::new(file) {
118        Ok(archive) => archive,
119        Err(ZipError::InvalidArchive(_)) | Err(ZipError::UnsupportedArchive(_)) => return Ok(None),
120        Err(err) => return Err(err.into()),
121    };
122    let contents = match read_setup_yaml(&mut archive)? {
123        Some(value) => value,
124        None => match read_setup_yaml_from_filesystem(pack_path)? {
125            Some(value) => value,
126            None => return Ok(None),
127        },
128    };
129    let spec: SetupSpec =
130        serde_yaml_bw::from_str(&contents).context("parse provider setup spec")?;
131    Ok(Some(spec))
132}
133
134fn read_setup_yaml(archive: &mut ZipArchive<File>) -> anyhow::Result<Option<String>> {
135    for entry in ["assets/setup.yaml", "setup.yaml"] {
136        match archive.by_name(entry) {
137            Ok(mut file) => {
138                let mut contents = String::new();
139                file.read_to_string(&mut contents)?;
140                return Ok(Some(contents));
141            }
142            Err(ZipError::FileNotFound) => continue,
143            Err(err) => return Err(err.into()),
144        }
145    }
146    Ok(None)
147}
148
149/// Fallback: look for `setup.yaml` on the filesystem near the `.gtpack` file.
150///
151/// Searches sibling paths relative to the pack file:
152///   1. `<pack_dir>/assets/setup.yaml`
153///   2. `<pack_dir>/setup.yaml`
154///
155/// Also searches based on pack filename (e.g. for `messaging-telegram.gtpack`):
156///   3. `<pack_dir>/../../../packs/messaging-telegram/assets/setup.yaml`
157///   4. `<pack_dir>/../../../packs/messaging-telegram/setup.yaml`
158fn read_setup_yaml_from_filesystem(pack_path: &Path) -> anyhow::Result<Option<String>> {
159    let pack_dir = pack_path.parent().unwrap_or(Path::new("."));
160    let pack_stem = pack_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
161
162    let candidates = [
163        pack_dir.join("assets/setup.yaml"),
164        pack_dir.join("setup.yaml"),
165    ];
166
167    // Also try a source-layout path: packs/<pack_stem>/assets/setup.yaml
168    let mut all_candidates: Vec<std::path::PathBuf> = candidates.to_vec();
169    if !pack_stem.is_empty() {
170        // Walk up to find a packs/ directory (common in greentic-messaging-providers layout)
171        for ancestor in pack_dir.ancestors().skip(1).take(4) {
172            let source_dir = ancestor.join("packs").join(pack_stem);
173            if source_dir.is_dir() {
174                all_candidates.push(source_dir.join("assets/setup.yaml"));
175                all_candidates.push(source_dir.join("setup.yaml"));
176                break;
177            }
178        }
179    }
180
181    for candidate in &all_candidates {
182        if candidate.is_file() {
183            let contents = fs::read_to_string(candidate)?;
184            return Ok(Some(contents));
185        }
186    }
187    Ok(None)
188}
189
190/// Collect setup answers for a provider pack.
191///
192/// Uses provided input answers if available, otherwise falls back to
193/// interactive prompting (if `interactive` is true) or returns an error.
194pub fn collect_setup_answers(
195    pack_path: &Path,
196    provider_id: &str,
197    setup_input: Option<&SetupInputAnswers>,
198    interactive: bool,
199) -> anyhow::Result<Value> {
200    let spec = load_setup_spec(pack_path)?;
201    if let Some(input) = setup_input {
202        if let Some(value) = input.answers_for_provider(provider_id) {
203            let answers = ensure_object(value.clone())?;
204            ensure_required_answers(spec.as_ref(), &answers)?;
205            return Ok(answers);
206        }
207        if has_required_questions(spec.as_ref()) {
208            return Err(anyhow!("setup input missing answers for {provider_id}"));
209        }
210        return Ok(Value::Object(JsonMap::new()));
211    }
212    if let Some(spec) = spec {
213        if spec.questions.is_empty() {
214            return Ok(Value::Object(JsonMap::new()));
215        }
216        if interactive {
217            let answers = prompt_setup_answers(&spec, provider_id)?;
218            ensure_required_answers(Some(&spec), &answers)?;
219            return Ok(answers);
220        }
221        return Err(anyhow!(
222            "setup answers required for {provider_id} but run is non-interactive"
223        ));
224    }
225    Ok(Value::Object(JsonMap::new()))
226}
227
228fn has_required_questions(spec: Option<&SetupSpec>) -> bool {
229    spec.map(|spec| spec.questions.iter().any(|q| q.required))
230        .unwrap_or(false)
231}
232
233/// Validate that all required answers are present.
234pub fn ensure_required_answers(spec: Option<&SetupSpec>, answers: &Value) -> anyhow::Result<()> {
235    let map = answers
236        .as_object()
237        .ok_or_else(|| anyhow!("setup answers must be an object"))?;
238    if let Some(spec) = spec {
239        for question in spec.questions.iter().filter(|q| q.required) {
240            match map.get(&question.name) {
241                Some(value) if !value.is_null() => continue,
242                _ => {
243                    return Err(anyhow!(
244                        "missing required setup answer for {}",
245                        question.name
246                    ));
247                }
248            }
249        }
250    }
251    Ok(())
252}
253
254/// Ensure a JSON value is an object.
255pub fn ensure_object(value: Value) -> anyhow::Result<Value> {
256    match value {
257        Value::Object(_) => Ok(value),
258        other => Err(anyhow!(
259            "setup answers must be a JSON object, got {}",
260            other
261        )),
262    }
263}
264
265/// Interactively prompt the user for setup answers.
266pub fn prompt_setup_answers(spec: &SetupSpec, provider: &str) -> anyhow::Result<Value> {
267    if spec.questions.is_empty() {
268        return Ok(Value::Object(JsonMap::new()));
269    }
270    let title = spec.title.as_deref().unwrap_or(provider).to_string();
271    println!("\nConfiguring {provider}: {title}");
272    let mut answers = JsonMap::new();
273    for question in &spec.questions {
274        if question.name.trim().is_empty() {
275            continue;
276        }
277        if let Some(value) = ask_setup_question(question)? {
278            answers.insert(question.name.clone(), value);
279        }
280    }
281    Ok(Value::Object(answers))
282}
283
284fn ask_setup_question(question: &SetupQuestion) -> anyhow::Result<Option<Value>> {
285    if let Some(help) = question.help.as_ref()
286        && !help.trim().is_empty()
287    {
288        println!("  {help}");
289    }
290    if !question.choices.is_empty() {
291        println!("  Choices:");
292        for (idx, choice) in question.choices.iter().enumerate() {
293            println!("    {}) {}", idx + 1, choice);
294        }
295    }
296    loop {
297        let prompt = build_question_prompt(question);
298        let input = read_question_input(&prompt, question.secret)?;
299        let trimmed = input.trim();
300        if trimmed.is_empty() {
301            if let Some(default) = question.default.clone() {
302                return Ok(Some(default));
303            }
304            if question.required {
305                println!("  This field is required.");
306                continue;
307            }
308            return Ok(None);
309        }
310        match parse_question_value(question, trimmed) {
311            Ok(value) => return Ok(Some(value)),
312            Err(err) => {
313                println!("  {err}");
314                continue;
315            }
316        }
317    }
318}
319
320fn build_question_prompt(question: &SetupQuestion) -> String {
321    let mut prompt = question
322        .title
323        .as_deref()
324        .unwrap_or(&question.name)
325        .to_string();
326    if question.kind != "string" {
327        prompt = format!("{prompt} [{}]", question.kind);
328    }
329    if let Some(default) = &question.default {
330        prompt = format!("{prompt} [default: {}]", display_value(default));
331    }
332    prompt.push_str(": ");
333    prompt
334}
335
336fn read_question_input(prompt: &str, secret: bool) -> anyhow::Result<String> {
337    if secret {
338        prompt_password(prompt).map_err(|err| anyhow!("read secret: {err}"))
339    } else {
340        print!("{prompt}");
341        io::stdout().flush()?;
342        let mut buffer = String::new();
343        io::stdin().read_line(&mut buffer)?;
344        Ok(buffer)
345    }
346}
347
348fn parse_question_value(question: &SetupQuestion, input: &str) -> anyhow::Result<Value> {
349    let kind = question.kind.to_lowercase();
350    match kind.as_str() {
351        "number" => serde_json::Number::from_str(input)
352            .map(Value::Number)
353            .map_err(|err| anyhow!("invalid number: {err}")),
354        "choice" => {
355            if question.choices.is_empty() {
356                return Ok(Value::String(input.to_string()));
357            }
358            if let Ok(index) = input.parse::<usize>()
359                && let Some(choice) = question.choices.get(index - 1)
360            {
361                return Ok(Value::String(choice.clone()));
362            }
363            for choice in &question.choices {
364                if choice == input {
365                    return Ok(Value::String(choice.clone()));
366                }
367            }
368            Err(anyhow!("invalid choice '{input}'"))
369        }
370        "boolean" => match input.to_lowercase().as_str() {
371            "true" | "t" | "yes" | "y" => Ok(Value::Bool(true)),
372            "false" | "f" | "no" | "n" => Ok(Value::Bool(false)),
373            _ => Err(anyhow!("invalid boolean value")),
374        },
375        _ => Ok(Value::String(input.to_string())),
376    }
377}
378
379fn display_value(value: &Value) -> String {
380    match value {
381        Value::String(v) => v.clone(),
382        Value::Number(n) => n.to_string(),
383        Value::Bool(b) => b.to_string(),
384        other => other.to_string(),
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use serde_json::json;
392    use std::io::Write;
393    use zip::write::{FileOptions, ZipWriter};
394
395    fn create_test_pack(yaml: &str) -> anyhow::Result<(tempfile::TempDir, std::path::PathBuf)> {
396        let temp_dir = tempfile::tempdir()?;
397        let pack_path = temp_dir.path().join("messaging-test.gtpack");
398        let file = File::create(&pack_path)?;
399        let mut writer = ZipWriter::new(file);
400        let options: FileOptions<'_, ()> =
401            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
402        writer.start_file("assets/setup.yaml", options)?;
403        writer.write_all(yaml.as_bytes())?;
404        writer.finish()?;
405        Ok((temp_dir, pack_path))
406    }
407
408    #[test]
409    fn parse_setup_yaml_questions() -> anyhow::Result<()> {
410        let yaml =
411            "provider_id: dummy\nquestions:\n  - name: public_base_url\n    required: true\n";
412        let (_dir, pack_path) = create_test_pack(yaml)?;
413        let spec = load_setup_spec(&pack_path)?.expect("expected spec");
414        assert_eq!(spec.questions.len(), 1);
415        assert_eq!(spec.questions[0].name, "public_base_url");
416        assert!(spec.questions[0].required);
417        Ok(())
418    }
419
420    #[test]
421    fn collect_setup_answers_uses_input() -> anyhow::Result<()> {
422        let yaml =
423            "provider_id: telegram\nquestions:\n  - name: public_base_url\n    required: true\n";
424        let (_dir, pack_path) = create_test_pack(yaml)?;
425        let provider_keys = BTreeSet::from(["messaging-telegram".to_string()]);
426        let raw = json!({ "messaging-telegram": { "public_base_url": "https://example.com" } });
427        let answers = SetupInputAnswers::new(raw, provider_keys)?;
428        let collected =
429            collect_setup_answers(&pack_path, "messaging-telegram", Some(&answers), false)?;
430        assert_eq!(
431            collected.get("public_base_url"),
432            Some(&Value::String("https://example.com".to_string()))
433        );
434        Ok(())
435    }
436
437    #[test]
438    fn collect_setup_answers_missing_required_errors() -> anyhow::Result<()> {
439        let yaml =
440            "provider_id: slack\nquestions:\n  - name: slack_bot_token\n    required: true\n";
441        let (_dir, pack_path) = create_test_pack(yaml)?;
442        let provider_keys = BTreeSet::from(["messaging-slack".to_string()]);
443        let raw = json!({ "messaging-slack": {} });
444        let answers = SetupInputAnswers::new(raw, provider_keys)?;
445        let error = collect_setup_answers(&pack_path, "messaging-slack", Some(&answers), false)
446            .unwrap_err();
447        assert!(error.to_string().contains("missing required setup answer"));
448        Ok(())
449    }
450}