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 = crate::http_client::api_agent()
62            .get(raw.as_ref())
63            .call()
64            .map_err(|err| anyhow!("failed to fetch {}: {err}", raw))?;
65        return response
66            .into_body()
67            .read_to_string()
68            .map_err(|err| anyhow!("failed to read {}: {err}", raw));
69    }
70    fs::read_to_string(path).with_context(|| format!("read setup input {}", path.display()))
71}
72
73/// Represents a provider setup spec extracted from `assets/setup.yaml`.
74#[derive(Debug, Deserialize)]
75pub struct SetupSpec {
76    #[serde(default)]
77    pub title: Option<String>,
78    #[serde(default)]
79    pub description: Option<String>,
80    #[serde(default)]
81    pub questions: Vec<SetupQuestion>,
82    #[serde(default)]
83    pub setup_actions: Vec<Value>,
84}
85
86/// A single setup question definition.
87#[derive(Debug, Default, Deserialize)]
88pub struct SetupQuestion {
89    #[serde(default)]
90    pub name: String,
91    #[serde(default = "default_kind")]
92    pub kind: String,
93    #[serde(default)]
94    pub required: bool,
95    #[serde(default)]
96    pub help: Option<String>,
97    #[serde(default)]
98    pub choices: Vec<String>,
99    #[serde(default)]
100    pub default: Option<Value>,
101    #[serde(default)]
102    pub secret: bool,
103    #[serde(default)]
104    pub title: Option<String>,
105    #[serde(default)]
106    pub visible_if: Option<SetupVisibleIf>,
107    /// Example value shown as placeholder in the input field.
108    #[serde(default)]
109    pub placeholder: Option<String>,
110    /// Group/section name for organizing questions in the UI.
111    #[serde(default)]
112    pub group: Option<String>,
113    /// URL to external setup documentation.
114    #[serde(default)]
115    pub docs_url: Option<String>,
116    /// URL to where the operator can create this credential (provider dev
117    /// portal). Rendered as a guided "Create it" link next to the field.
118    #[serde(default)]
119    pub create_url: Option<String>,
120    /// Column definitions for `kind: table` questions. Each row's answer is a
121    /// JSON object whose keys match the columns' `key` field.
122    #[serde(default)]
123    pub columns: Vec<SetupTableColumn>,
124    /// Minimum required row count for a `kind: table` question.
125    #[serde(default)]
126    pub min_rows: Option<u16>,
127    /// Maximum row count for a `kind: table` question.
128    #[serde(default)]
129    pub max_rows: Option<u16>,
130}
131
132/// One column in a `kind: table` setup question.
133#[derive(Debug, Default, Deserialize)]
134pub struct SetupTableColumn {
135    /// JSON object key the column's value is stored under (e.g. `"label"`).
136    /// Stable identifier — do not rename without a migration.
137    #[serde(default)]
138    pub key: String,
139    /// Header label shown above the column / next to each row's input.
140    #[serde(default)]
141    pub title: Option<String>,
142    /// Column scalar kind. Same vocabulary as top-level `kind` — but nested
143    /// tables are not supported.
144    #[serde(default = "default_kind")]
145    pub kind: String,
146    /// Whether the column must be filled for a row to count as non-empty.
147    #[serde(default)]
148    pub required: bool,
149    /// Optional inline help.
150    #[serde(default)]
151    pub help: Option<String>,
152    /// Optional placeholder shown when the cell is empty.
153    #[serde(default)]
154    pub placeholder: Option<String>,
155    /// Optional pre-defined choices for `kind: choice` columns.
156    #[serde(default)]
157    pub choices: Vec<String>,
158    /// Optional per-row default applied to new rows.
159    #[serde(default)]
160    pub default: Option<Value>,
161    /// When true, the wizard renders a multi-locale cell instead of a
162    /// scalar input. Operator types the primary (English) value and may
163    /// add per-locale translations via "+ Add language". Persisted as a
164    /// locale-keyed object `{en: "...", id: "...", ...}` (or a plain
165    /// string when only one locale was filled). Only meaningful for
166    /// `kind: string` columns.
167    #[serde(default)]
168    pub multilingual: bool,
169}
170
171/// Conditional visibility for a setup question.
172///
173/// Example in setup.yaml (struct format):
174/// ```yaml
175/// visible_if:
176///   field: public_base_url_mode
177///   eq: static
178/// ```
179///
180/// Or string expression format:
181/// ```yaml
182/// visible_if: "preset != 'stdout'"
183/// ```
184#[derive(Debug)]
185pub enum SetupVisibleIf {
186    /// Struct format with field and optional eq
187    Struct { field: String, eq: Option<String> },
188    /// String expression format (e.g., "preset != 'stdout'")
189    Expr(String),
190}
191
192impl SetupVisibleIf {
193    /// Get the field name (for struct format, or parse from expr format).
194    pub fn field(&self) -> Option<&str> {
195        match self {
196            SetupVisibleIf::Struct { field, .. } => Some(field),
197            SetupVisibleIf::Expr(_) => None,
198        }
199    }
200
201    /// Get the equality value (for struct format only).
202    pub fn eq(&self) -> Option<&str> {
203        match self {
204            SetupVisibleIf::Struct { eq, .. } => eq.as_deref(),
205            SetupVisibleIf::Expr(_) => None,
206        }
207    }
208}
209
210impl<'de> serde::Deserialize<'de> for SetupVisibleIf {
211    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
212    where
213        D: serde::Deserializer<'de>,
214    {
215        use serde::de::{self, MapAccess, Visitor};
216
217        struct SetupVisibleIfVisitor;
218
219        impl<'de> Visitor<'de> for SetupVisibleIfVisitor {
220            type Value = SetupVisibleIf;
221
222            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
223                formatter
224                    .write_str("a string expression or a struct with 'field' and optional 'eq'")
225            }
226
227            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
228            where
229                E: de::Error,
230            {
231                Ok(SetupVisibleIf::Expr(value.to_string()))
232            }
233
234            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
235            where
236                E: de::Error,
237            {
238                Ok(SetupVisibleIf::Expr(value))
239            }
240
241            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
242            where
243                M: MapAccess<'de>,
244            {
245                let mut field: Option<String> = None;
246                let mut eq: Option<String> = None;
247
248                while let Some(key) = map.next_key::<String>()? {
249                    match key.as_str() {
250                        "field" => {
251                            field = Some(map.next_value()?);
252                        }
253                        "eq" => {
254                            eq = Some(map.next_value()?);
255                        }
256                        _ => {
257                            let _: serde::de::IgnoredAny = map.next_value()?;
258                        }
259                    }
260                }
261
262                let field = field.ok_or_else(|| de::Error::missing_field("field"))?;
263                Ok(SetupVisibleIf::Struct { field, eq })
264            }
265        }
266
267        deserializer.deserialize_any(SetupVisibleIfVisitor)
268    }
269}
270
271fn default_kind() -> String {
272    "string".to_string()
273}
274
275/// Load a `SetupSpec` from `assets/setup.yaml` inside a `.gtpack` archive.
276///
277/// Falls back to reading `setup.yaml` from the filesystem next to the pack
278/// (sibling or `assets/` subdirectory) when the archive does not contain it.
279pub fn load_setup_spec(pack_path: &Path) -> anyhow::Result<Option<SetupSpec>> {
280    let file = File::open(pack_path)?;
281    let mut archive = match ZipArchive::new(file) {
282        Ok(archive) => archive,
283        Err(ZipError::InvalidArchive(_)) | Err(ZipError::UnsupportedArchive(_)) => return Ok(None),
284        Err(err) => return Err(err.into()),
285    };
286    let contents = match read_setup_yaml(&mut archive)? {
287        Some(value) => value,
288        None => match read_setup_yaml_from_filesystem(pack_path)? {
289            Some(value) => value,
290            None => return Ok(None),
291        },
292    };
293    let spec: SetupSpec =
294        serde_yaml_bw::from_str(&contents).context("parse provider setup spec")?;
295    Ok(Some(spec))
296}
297
298fn read_setup_yaml(archive: &mut ZipArchive<File>) -> anyhow::Result<Option<String>> {
299    for entry in ["assets/setup.yaml", "setup.yaml"] {
300        match archive.by_name(entry) {
301            Ok(mut file) => {
302                let mut contents = String::new();
303                file.read_to_string(&mut contents)?;
304                return Ok(Some(contents));
305            }
306            Err(ZipError::FileNotFound) => continue,
307            Err(err) => return Err(err.into()),
308        }
309    }
310    Ok(None)
311}
312
313/// Fallback: look for `setup.yaml` on the filesystem near the `.gtpack` file.
314///
315/// Searches sibling paths relative to the pack file:
316///   1. `<pack_dir>/assets/setup.yaml`
317///   2. `<pack_dir>/setup.yaml`
318///
319/// Also searches based on pack filename (e.g. for `messaging-telegram.gtpack`):
320///   3. `<pack_dir>/../../../packs/messaging-telegram/assets/setup.yaml`
321///   4. `<pack_dir>/../../../packs/messaging-telegram/setup.yaml`
322fn read_setup_yaml_from_filesystem(pack_path: &Path) -> anyhow::Result<Option<String>> {
323    let pack_dir = pack_path.parent().unwrap_or(Path::new("."));
324    let pack_stem = pack_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
325
326    let candidates = [
327        pack_dir.join("assets/setup.yaml"),
328        pack_dir.join("setup.yaml"),
329    ];
330
331    // Also try a source-layout path: packs/<pack_stem>/assets/setup.yaml
332    let mut all_candidates: Vec<std::path::PathBuf> = candidates.to_vec();
333    if !pack_stem.is_empty() {
334        // Walk up to find a packs/ directory (common in greentic-messaging-providers layout)
335        for ancestor in pack_dir.ancestors().skip(1).take(4) {
336            let source_dir = ancestor.join("packs").join(pack_stem);
337            if source_dir.is_dir() {
338                all_candidates.push(source_dir.join("assets/setup.yaml"));
339                all_candidates.push(source_dir.join("setup.yaml"));
340                break;
341            }
342        }
343    }
344
345    for candidate in &all_candidates {
346        if candidate.is_file() {
347            let contents = fs::read_to_string(candidate)?;
348            return Ok(Some(contents));
349        }
350    }
351    Ok(None)
352}
353
354/// Collect setup answers for a provider pack.
355///
356/// Uses provided input answers if available, otherwise falls back to
357/// interactive prompting (if `interactive` is true) or returns an error.
358pub fn collect_setup_answers(
359    pack_path: &Path,
360    provider_id: &str,
361    setup_input: Option<&SetupInputAnswers>,
362    interactive: bool,
363) -> anyhow::Result<Value> {
364    let spec = load_setup_spec(pack_path)?;
365    if let Some(input) = setup_input {
366        if let Some(value) = input.answers_for_provider(provider_id) {
367            let mut answers = ensure_object(value.clone())?;
368            // Auto-fill the public URL placeholder for packs that declare it,
369            // so an answers file that omits the never-prompted field still
370            // satisfies the required-answer check. See
371            // `qa::shared_questions::PUBLIC_URL_FILLER`.
372            if let (Some(spec), Some(map)) = (spec.as_ref(), answers.as_object_mut()) {
373                crate::qa::shared_questions::fill_public_url_placeholders(
374                    spec.questions.iter().map(|q| q.name.as_str()),
375                    map,
376                );
377            }
378            ensure_required_answers(spec.as_ref(), &answers)?;
379            return Ok(answers);
380        }
381        if has_required_questions(spec.as_ref()) {
382            return Err(anyhow!("setup input missing answers for {provider_id}"));
383        }
384        return Ok(Value::Object(JsonMap::new()));
385    }
386    if let Some(spec) = spec {
387        if spec.questions.is_empty() {
388            return Ok(Value::Object(JsonMap::new()));
389        }
390        if interactive {
391            let answers = prompt_setup_answers(&spec, provider_id)?;
392            ensure_required_answers(Some(&spec), &answers)?;
393            return Ok(answers);
394        }
395        return Err(anyhow!(
396            "setup answers required for {provider_id} but run is non-interactive"
397        ));
398    }
399    Ok(Value::Object(JsonMap::new()))
400}
401
402fn has_required_questions(spec: Option<&SetupSpec>) -> bool {
403    spec.map(|spec| spec.questions.iter().any(|q| q.required))
404        .unwrap_or(false)
405}
406
407/// Validate that all required answers are present.
408pub fn ensure_required_answers(spec: Option<&SetupSpec>, answers: &Value) -> anyhow::Result<()> {
409    let map = answers
410        .as_object()
411        .ok_or_else(|| anyhow!("setup answers must be an object"))?;
412    if let Some(spec) = spec {
413        for question in spec.questions.iter().filter(|q| q.required) {
414            match map.get(&question.name) {
415                Some(value) if !value.is_null() => continue,
416                _ => {
417                    return Err(anyhow!(
418                        "missing required setup answer for {}",
419                        question.name
420                    ));
421                }
422            }
423        }
424    }
425    Ok(())
426}
427
428/// Ensure a JSON value is an object.
429pub fn ensure_object(value: Value) -> anyhow::Result<Value> {
430    match value {
431        Value::Object(_) => Ok(value),
432        other => Err(anyhow!(
433            "setup answers must be a JSON object, got {}",
434            other
435        )),
436    }
437}
438
439/// Interactively prompt the user for setup answers.
440pub fn prompt_setup_answers(spec: &SetupSpec, provider: &str) -> anyhow::Result<Value> {
441    if spec.questions.is_empty() {
442        return Ok(Value::Object(JsonMap::new()));
443    }
444    let title = spec.title.as_deref().unwrap_or(provider).to_string();
445    println!("\nConfiguring {provider}: {title}");
446    let mut answers = JsonMap::new();
447    for question in &spec.questions {
448        if question.name.trim().is_empty() {
449            continue;
450        }
451        // The public URL question is never surfaced: its answer is always
452        // overwritten by the runtime, so we persist a placeholder instead of
453        // asking. See `qa::shared_questions::PUBLIC_URL_FILLER`.
454        if crate::qa::shared_questions::is_public_url_question(&question.name) {
455            continue;
456        }
457        if let Some(value) = ask_setup_question(question)? {
458            answers.insert(question.name.clone(), value);
459        }
460    }
461    crate::qa::shared_questions::fill_public_url_placeholders(
462        spec.questions.iter().map(|q| q.name.as_str()),
463        &mut answers,
464    );
465    Ok(Value::Object(answers))
466}
467
468fn ask_setup_question(question: &SetupQuestion) -> anyhow::Result<Option<Value>> {
469    if let Some(help) = question.help.as_ref()
470        && !help.trim().is_empty()
471    {
472        println!("  {help}");
473    }
474    if !question.choices.is_empty() {
475        println!("  Choices:");
476        for (idx, choice) in question.choices.iter().enumerate() {
477            println!("    {}) {}", idx + 1, choice);
478        }
479    }
480    loop {
481        let prompt = build_question_prompt(question);
482        let input = read_question_input(&prompt, question.secret)?;
483        let trimmed = input.trim();
484        if trimmed.is_empty() {
485            if let Some(default) = question.default.clone() {
486                return Ok(Some(default));
487            }
488            if question.required {
489                println!("  This field is required.");
490                continue;
491            }
492            return Ok(None);
493        }
494        match parse_question_value(question, trimmed) {
495            Ok(value) => return Ok(Some(value)),
496            Err(err) => {
497                println!("  {err}");
498                continue;
499            }
500        }
501    }
502}
503
504fn build_question_prompt(question: &SetupQuestion) -> String {
505    let mut prompt = question
506        .title
507        .as_deref()
508        .unwrap_or(&question.name)
509        .to_string();
510    if question.kind != "string" {
511        prompt = format!("{prompt} [{}]", question.kind);
512    }
513    if let Some(default) = &question.default {
514        prompt = format!("{prompt} [default: {}]", display_value(default));
515    }
516    prompt.push_str(": ");
517    prompt
518}
519
520fn read_question_input(prompt: &str, secret: bool) -> anyhow::Result<String> {
521    if secret {
522        prompt_password(prompt).map_err(|err| anyhow!("read secret: {err}"))
523    } else {
524        print!("{prompt}");
525        io::stdout().flush()?;
526        let mut buffer = String::new();
527        io::stdin().read_line(&mut buffer)?;
528        Ok(buffer)
529    }
530}
531
532fn parse_question_value(question: &SetupQuestion, input: &str) -> anyhow::Result<Value> {
533    let kind = question.kind.to_lowercase();
534    match kind.as_str() {
535        "number" => serde_json::Number::from_str(input)
536            .map(Value::Number)
537            .map_err(|err| anyhow!("invalid number: {err}")),
538        "choice" => {
539            if question.choices.is_empty() {
540                return Ok(Value::String(input.to_string()));
541            }
542            if let Ok(index) = input.parse::<usize>()
543                && let Some(choice) = question.choices.get(index - 1)
544            {
545                return Ok(Value::String(choice.clone()));
546            }
547            for choice in &question.choices {
548                if choice == input {
549                    return Ok(Value::String(choice.clone()));
550                }
551            }
552            Err(anyhow!("invalid choice '{input}'"))
553        }
554        "boolean" => match input.to_lowercase().as_str() {
555            "true" | "t" | "yes" | "y" => Ok(Value::Bool(true)),
556            "false" | "f" | "no" | "n" => Ok(Value::Bool(false)),
557            _ => Err(anyhow!("invalid boolean value")),
558        },
559        _ => Ok(Value::String(input.to_string())),
560    }
561}
562
563fn display_value(value: &Value) -> String {
564    match value {
565        Value::String(v) => v.clone(),
566        Value::Number(n) => n.to_string(),
567        Value::Bool(b) => b.to_string(),
568        other => other.to_string(),
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use serde_json::json;
576    use std::io::Write;
577    use zip::write::{FileOptions, ZipWriter};
578
579    fn create_test_pack(yaml: &str) -> anyhow::Result<(tempfile::TempDir, std::path::PathBuf)> {
580        let temp_dir = tempfile::tempdir()?;
581        let pack_path = temp_dir.path().join("messaging-test.gtpack");
582        let file = File::create(&pack_path)?;
583        let mut writer = ZipWriter::new(file);
584        let options: FileOptions<'_, ()> =
585            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
586        writer.start_file("assets/setup.yaml", options)?;
587        writer.write_all(yaml.as_bytes())?;
588        writer.finish()?;
589        Ok((temp_dir, pack_path))
590    }
591
592    #[test]
593    fn parse_setup_yaml_questions() -> anyhow::Result<()> {
594        let yaml =
595            "provider_id: dummy\nquestions:\n  - name: public_base_url\n    required: true\n";
596        let (_dir, pack_path) = create_test_pack(yaml)?;
597        let spec = load_setup_spec(&pack_path)?.expect("expected spec");
598        assert_eq!(spec.questions.len(), 1);
599        assert_eq!(spec.questions[0].name, "public_base_url");
600        assert!(spec.questions[0].required);
601        Ok(())
602    }
603
604    #[test]
605    fn parse_setup_yaml_setup_actions() -> anyhow::Result<()> {
606        let yaml = r#"
607provider_id: slack
608questions: []
609setup_actions:
610  - id: add_to_slack
611    label: Add to Slack
612    kind: oauth_install_button
613"#;
614        let (_dir, pack_path) = create_test_pack(yaml)?;
615        let spec = load_setup_spec(&pack_path)?.expect("expected spec");
616        assert!(spec.questions.is_empty());
617        assert_eq!(spec.setup_actions.len(), 1);
618        assert_eq!(spec.setup_actions[0]["id"], json!("add_to_slack"));
619        assert_eq!(spec.setup_actions[0]["kind"], json!("oauth_install_button"));
620        Ok(())
621    }
622
623    #[test]
624    fn collect_setup_answers_uses_input() -> anyhow::Result<()> {
625        let yaml =
626            "provider_id: telegram\nquestions:\n  - name: public_base_url\n    required: true\n";
627        let (_dir, pack_path) = create_test_pack(yaml)?;
628        let provider_keys = BTreeSet::from(["messaging-telegram".to_string()]);
629        let raw = json!({ "messaging-telegram": { "public_base_url": "https://example.com" } });
630        let answers = SetupInputAnswers::new(raw, provider_keys)?;
631        let collected =
632            collect_setup_answers(&pack_path, "messaging-telegram", Some(&answers), false)?;
633        assert_eq!(
634            collected.get("public_base_url"),
635            Some(&Value::String("https://example.com".to_string()))
636        );
637        Ok(())
638    }
639
640    #[test]
641    fn collect_setup_answers_missing_required_errors() -> anyhow::Result<()> {
642        let yaml =
643            "provider_id: slack\nquestions:\n  - name: slack_bot_token\n    required: true\n";
644        let (_dir, pack_path) = create_test_pack(yaml)?;
645        let provider_keys = BTreeSet::from(["messaging-slack".to_string()]);
646        let raw = json!({ "messaging-slack": {} });
647        let answers = SetupInputAnswers::new(raw, provider_keys)?;
648        let error = collect_setup_answers(&pack_path, "messaging-slack", Some(&answers), false)
649            .unwrap_err();
650        assert!(error.to_string().contains("missing required setup answer"));
651        Ok(())
652    }
653
654    #[test]
655    fn collect_setup_answers_autofills_public_url_from_input() -> anyhow::Result<()> {
656        // A pack that requires `public_base_url` but an answers file that omits
657        // it: the never-prompted field is auto-filled with the placeholder so
658        // the required-answer check still passes.
659        let yaml =
660            "provider_id: telegram\nquestions:\n  - name: public_base_url\n    required: true\n";
661        let (_dir, pack_path) = create_test_pack(yaml)?;
662        let provider_keys = BTreeSet::from(["messaging-telegram".to_string()]);
663        let raw = json!({ "messaging-telegram": {} });
664        let answers = SetupInputAnswers::new(raw, provider_keys)?;
665        let collected =
666            collect_setup_answers(&pack_path, "messaging-telegram", Some(&answers), false)?;
667        assert_eq!(
668            collected.get("public_base_url"),
669            Some(&Value::String(
670                crate::qa::shared_questions::PUBLIC_URL_FILLER.to_string()
671            ))
672        );
673        Ok(())
674    }
675
676    #[test]
677    fn collect_setup_answers_keeps_explicit_public_url() -> anyhow::Result<()> {
678        // An operator-supplied value is never clobbered by the placeholder.
679        let yaml =
680            "provider_id: telegram\nquestions:\n  - name: public_base_url\n    required: true\n";
681        let (_dir, pack_path) = create_test_pack(yaml)?;
682        let provider_keys = BTreeSet::from(["messaging-telegram".to_string()]);
683        let raw =
684            json!({ "messaging-telegram": { "public_base_url": "https://real.example.com" } });
685        let answers = SetupInputAnswers::new(raw, provider_keys)?;
686        let collected =
687            collect_setup_answers(&pack_path, "messaging-telegram", Some(&answers), false)?;
688        assert_eq!(
689            collected.get("public_base_url"),
690            Some(&Value::String("https://real.example.com".to_string()))
691        );
692        Ok(())
693    }
694}