Skip to main content

greentic_setup/qa/
shared_questions.rs

1//! Shared Questions Support for multi-provider setup.
2//!
3//! When setting up multiple providers, some questions (like `public_base_url`)
4//! appear in all providers. Instead of asking the same question repeatedly,
5//! we identify shared questions and prompt for them once upfront.
6
7use anyhow::Result;
8use qa_spec::{FormSpec, QuestionSpec};
9use serde_json::{Map as JsonMap, Value};
10use std::collections::HashMap;
11
12use crate::qa::prompts::ask_form_spec_question;
13use crate::setup_to_formspec;
14
15/// Well-known question IDs that are commonly shared across providers.
16///
17/// These questions will be prompted once at the beginning of a multi-provider
18/// setup wizard, and their answers will be applied to all providers.
19pub const SHARED_QUESTION_IDS: &[&str] = &[
20    "public_base_url",
21    // NOTE: api_base_url is NOT shared - each provider has different API endpoints
22    // (e.g., slack.com, telegram.org, webexapis.com)
23];
24
25/// Placeholder persisted for the public base URL question.
26///
27/// The externally-reachable URL is never known at setup time — it is provided
28/// by an active tunnel, the `PUBLIC_BASE_URL` environment variable, or whatever
29/// the runtime resolves — so anything an operator typed here would be
30/// overwritten anyway. Rather than surface a question whose answer is always
31/// discarded, we skip the prompt and persist this loopback filler; the runtime
32/// replaces it with the real public URL when the endpoint comes up.
33pub const PUBLIC_URL_FILLER: &str = "http://localhost:8080";
34
35/// Whether `question_id` names the environment's public base URL question.
36///
37/// These questions are never surfaced interactively — they are auto-filled with
38/// [`PUBLIC_URL_FILLER`] and overwritten at runtime. See [`HIDDEN_FROM_PROMPTS`].
39pub fn is_public_url_question(question_id: &str) -> bool {
40    question_id == "public_base_url"
41}
42
43/// Insert [`PUBLIC_URL_FILLER`] for any public-URL question in `question_ids`
44/// that lacks a non-empty answer in `answers`.
45///
46/// Callers pass the question ids straight off whatever spec they hold (FormSpec
47/// or the legacy setup spec). Called after prompting so the persisted answers
48/// carry a well-formed placeholder the runtime can overwrite, without ever
49/// asking the operator.
50pub fn fill_public_url_placeholders<'a>(
51    question_ids: impl IntoIterator<Item = &'a str>,
52    answers: &mut JsonMap<String, Value>,
53) {
54    for id in question_ids {
55        if !is_public_url_question(id) {
56            continue;
57        }
58        let has_value = answers
59            .get(id)
60            .is_some_and(|v| !v.is_null() && v.as_str() != Some(""));
61        if !has_value {
62            answers.insert(id.to_string(), Value::String(PUBLIC_URL_FILLER.to_string()));
63        }
64    }
65}
66
67/// Questions hidden from interactive prompts (both terminal and web UI).
68///
69/// Values may still be supplied via `--answers` file or prefill. Keep this list
70/// empty by default. The public URL question is handled separately via
71/// [`is_public_url_question`] rather than here: the terminal wizard skips it
72/// outright, but the web UI keeps it in the form so a selected tunnel can still
73/// auto-generate a real HTTPS URL before falling back to [`PUBLIC_URL_FILLER`].
74pub const HIDDEN_FROM_PROMPTS: &[&str] = &[];
75
76/// Information about a provider and its FormSpec for multi-provider setup.
77#[derive(Clone)]
78pub struct ProviderFormSpec {
79    /// Provider identifier (e.g., "messaging-telegram")
80    pub provider_id: String,
81    /// The FormSpec for this provider
82    pub form_spec: FormSpec,
83}
84
85/// Result of collecting shared questions across multiple providers.
86#[derive(Clone, Default)]
87pub struct SharedQuestionsResult {
88    /// Questions that appear in multiple providers (deduplicated).
89    /// Each question is taken from the first provider that defines it.
90    pub shared_questions: Vec<QuestionSpec>,
91    /// Provider IDs that contain each shared question ID.
92    pub question_providers: HashMap<String, Vec<String>>,
93}
94
95/// Collect questions that are shared across multiple providers.
96///
97/// A question is considered "shared" if:
98/// 1. Its ID is in `SHARED_QUESTION_IDS`, OR
99/// 2. It appears in 2+ providers with the same ID
100///
101/// Returns deduplicated questions (taking the first occurrence) along with
102/// which providers contain each question.
103pub fn collect_shared_questions(providers: &[ProviderFormSpec]) -> SharedQuestionsResult {
104    if providers.len() <= 1 {
105        return SharedQuestionsResult::default();
106    }
107
108    // Count occurrences by borrowed question ID and retain borrowed provider
109    // IDs in the same pass. Only allocate owned strings for final shared rows.
110    let mut questions: HashMap<&str, (usize, &QuestionSpec, Vec<&str>)> = HashMap::new();
111
112    for provider in providers {
113        for question in &provider.form_spec.questions {
114            if question.id.is_empty() {
115                continue;
116            }
117            questions
118                .entry(question.id.as_str())
119                .and_modify(|(count, _, provider_ids)| {
120                    *count += 1;
121                    provider_ids.push(provider.provider_id.as_str());
122                })
123                .or_insert_with(|| (1, question, vec![provider.provider_id.as_str()]));
124        }
125    }
126
127    // Find shared questions (must appear in 2+ providers to be truly shared)
128    // SHARED_QUESTION_IDS are hints for what questions are commonly shared,
129    // but we only share them if they actually appear in multiple providers.
130    //
131    // IMPORTANT: Exclude secrets and provider-specific fields from sharing.
132    // Each provider needs unique values for these fields.
133    let mut shared_questions = Vec::new();
134    let mut question_providers = HashMap::new();
135
136    fn is_never_shared(question_id: &str) -> bool {
137        matches!(
138            question_id,
139            "api_base_url"
140                | "bot_token"
141                | "access_token"
142                | "token"
143                | "app_id"
144                | "app_secret"
145                | "client_id"
146                | "client_secret"
147                | "webhook_secret"
148                | "signing_secret"
149        )
150    }
151
152    for (question_id, (count, question, provider_ids)) in questions {
153        // Only share questions that actually appear in 2+ providers
154        if count >= 2 {
155            // Skip secrets - they should never be shared across providers
156            if question.secret {
157                continue;
158            }
159
160            // Skip provider-specific fields that happen to have the same ID
161            if is_never_shared(question_id) {
162                continue;
163            }
164
165            shared_questions.push(question.clone());
166            question_providers.insert(
167                question_id.to_string(),
168                provider_ids.into_iter().map(str::to_string).collect(),
169            );
170        }
171    }
172
173    // Sort by question ID for deterministic ordering
174    shared_questions.sort_by(|a, b| a.id.cmp(&b.id));
175
176    SharedQuestionsResult {
177        shared_questions,
178        question_providers,
179    }
180}
181
182/// Prompt for shared questions that apply to multiple providers.
183///
184/// Takes existing answers from loaded setup file and only prompts for
185/// questions that don't already have a valid (non-empty) value.
186pub fn prompt_shared_questions(
187    shared: &SharedQuestionsResult,
188    advanced: bool,
189    existing_answers: &Value,
190) -> Result<Value> {
191    if shared.shared_questions.is_empty() {
192        return Ok(Value::Object(JsonMap::new()));
193    }
194
195    let existing_map = existing_answers.as_object();
196
197    // Check if all shared questions already have valid answers
198    let questions_needing_prompt: Vec<_> = shared
199        .shared_questions
200        .iter()
201        .filter(|q| {
202            // Skip questions hidden from interactive prompts (auto-injected by operator)
203            if HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()) {
204                return false;
205            }
206            // The public URL question is never surfaced in the terminal wizard;
207            // it is auto-filled below. See `PUBLIC_URL_FILLER`.
208            if is_public_url_question(&q.id) {
209                return false;
210            }
211            // Skip optional questions in normal mode
212            if !advanced && !q.required {
213                return false;
214            }
215            // Check if this question already has a non-empty value
216            if let Some(map) = existing_map
217                && let Some(value) = map.get(&q.id)
218            {
219                // Skip if value is non-null and non-empty string
220                if !value.is_null() {
221                    if let Some(s) = value.as_str() {
222                        return s.is_empty(); // Need prompt if empty string
223                    }
224                    return false; // Has value, skip
225                }
226            }
227            true // Need prompt
228        })
229        .collect();
230
231    // If no questions need prompting, return existing answers
232    if questions_needing_prompt.is_empty() {
233        let mut answers = JsonMap::new();
234        if let Some(map) = existing_map {
235            for question in &shared.shared_questions {
236                if let Some(value) = map.get(&question.id) {
237                    answers.insert(question.id.clone(), value.clone());
238                }
239            }
240        }
241        fill_public_url_placeholders(
242            shared.shared_questions.iter().map(|q| q.id.as_str()),
243            &mut answers,
244        );
245        return Ok(Value::Object(answers));
246    }
247
248    println!("\n── Shared Configuration ──");
249    println!("The following settings apply to all providers:\n");
250
251    let mut answers = JsonMap::new();
252
253    // Copy existing values first
254    if let Some(map) = existing_map {
255        for question in &shared.shared_questions {
256            if let Some(value) = map.get(&question.id)
257                && !value.is_null()
258                && !(value.is_string() && value.as_str() == Some(""))
259            {
260                answers.insert(question.id.clone(), value.clone());
261            }
262        }
263    }
264
265    for question in &shared.shared_questions {
266        // Skip questions hidden from interactive prompts (auto-injected by operator)
267        if HIDDEN_FROM_PROMPTS.contains(&question.id.as_str()) {
268            continue;
269        }
270
271        // The public URL question is never surfaced; it is auto-filled below.
272        if is_public_url_question(&question.id) {
273            continue;
274        }
275
276        // Skip if we already have a valid answer
277        if answers.contains_key(&question.id) {
278            continue;
279        }
280
281        // Skip optional questions in normal mode
282        if !advanced && !question.required {
283            continue;
284        }
285
286        // Show which providers use this question
287        if let Some(provider_ids) = shared.question_providers.get(&question.id) {
288            let providers_str = provider_ids
289                .iter()
290                .map(|id| setup_to_formspec::strip_domain_prefix(id))
291                .collect::<Vec<_>>()
292                .join(", ");
293            println!("  Used by: {providers_str}");
294        }
295
296        // None: shared-question prompts keep English chrome (provider-setup flow).
297        if let Some(value) = ask_form_spec_question(question, None)? {
298            answers.insert(question.id.clone(), value);
299        }
300    }
301
302    fill_public_url_placeholders(
303        shared.shared_questions.iter().map(|q| q.id.as_str()),
304        &mut answers,
305    );
306
307    println!();
308    Ok(Value::Object(answers))
309}
310
311/// Merge shared answers with provider-specific answers.
312///
313/// Shared answers take precedence for non-empty values, but provider-specific
314/// answers can override if the shared value is empty.
315pub fn merge_shared_with_provider_answers(
316    shared: &Value,
317    provider_specific: Option<&Value>,
318) -> Value {
319    let mut merged = JsonMap::new();
320
321    // Start with shared answers
322    if let Some(shared_map) = shared.as_object() {
323        for (key, value) in shared_map {
324            // Only include non-empty values
325            if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
326                merged.insert(key.clone(), value.clone());
327            }
328        }
329    }
330
331    // Add provider-specific answers (don't override non-empty shared values)
332    if let Some(provider_map) = provider_specific.and_then(Value::as_object) {
333        for (key, value) in provider_map {
334            // Only add if not already present with a non-empty value
335            if !merged.contains_key(key) {
336                merged.insert(key.clone(), value.clone());
337            }
338        }
339    }
340
341    Value::Object(merged)
342}
343
344/// Build FormSpecs for multiple providers from their pack paths.
345///
346/// Convenience function to prepare input for `collect_shared_questions`.
347pub fn build_provider_form_specs(
348    providers: &[(std::path::PathBuf, String)], // (pack_path, provider_id)
349) -> Vec<ProviderFormSpec> {
350    providers
351        .iter()
352        .filter_map(|(pack_path, provider_id)| {
353            setup_to_formspec::pack_to_form_spec(pack_path, provider_id).map(|form_spec| {
354                ProviderFormSpec {
355                    provider_id: provider_id.clone(),
356                    form_spec,
357                }
358            })
359        })
360        .collect()
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use qa_spec::QuestionType;
367
368    fn make_provider_form_spec(provider_id: &str, question_ids: &[&str]) -> ProviderFormSpec {
369        let questions = question_ids
370            .iter()
371            .map(|id| QuestionSpec {
372                id: id.to_string(),
373                kind: QuestionType::String,
374                title: format!("{} Question", id),
375                title_i18n: None,
376                description: None,
377                description_i18n: None,
378                required: true,
379                choices: None,
380                default_value: None,
381                secret: false,
382                visible_if: None,
383                constraint: None,
384                list: None,
385                computed: None,
386                policy: Default::default(),
387                computed_overridable: false,
388            })
389            .collect();
390
391        ProviderFormSpec {
392            provider_id: provider_id.to_string(),
393            form_spec: FormSpec {
394                id: format!("{}-setup", provider_id),
395                title: format!("{} Setup", provider_id),
396                version: "1.0.0".into(),
397                description: None,
398                presentation: None,
399                progress_policy: None,
400                secrets_policy: None,
401                store: vec![],
402                validations: vec![],
403                includes: vec![],
404                questions,
405            },
406        }
407    }
408
409    #[test]
410    fn collect_shared_questions_finds_common_questions() {
411        let providers = vec![
412            make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
413            make_provider_form_spec("messaging-slack", &["public_base_url", "slack_token"]),
414            make_provider_form_spec("messaging-teams", &["public_base_url", "teams_app_id"]),
415        ];
416
417        let result = collect_shared_questions(&providers);
418
419        // public_base_url appears in all 3 providers
420        assert_eq!(result.shared_questions.len(), 1);
421        assert_eq!(result.shared_questions[0].id, "public_base_url");
422
423        // Check provider mapping
424        let providers_for_url = result.question_providers.get("public_base_url").unwrap();
425        assert_eq!(providers_for_url.len(), 3);
426        assert!(providers_for_url.contains(&"messaging-telegram".to_string()));
427        assert!(providers_for_url.contains(&"messaging-slack".to_string()));
428        assert!(providers_for_url.contains(&"messaging-teams".to_string()));
429    }
430
431    #[test]
432    fn collect_shared_questions_excludes_single_provider_questions() {
433        let providers = vec![
434            make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
435            make_provider_form_spec("messaging-slack", &["slack_token"]), // no public_base_url
436        ];
437
438        let result = collect_shared_questions(&providers);
439        assert!(result.shared_questions.is_empty());
440    }
441
442    #[test]
443    fn collect_shared_questions_returns_empty_for_single_provider() {
444        let providers = vec![make_provider_form_spec(
445            "messaging-telegram",
446            &["public_base_url", "bot_token"],
447        )];
448
449        let result = collect_shared_questions(&providers);
450        assert!(result.shared_questions.is_empty());
451    }
452
453    #[test]
454    fn collect_shared_questions_finds_non_wellknown_duplicates() {
455        let providers = vec![
456            make_provider_form_spec("provider-a", &["custom_field", "field_a"]),
457            make_provider_form_spec("provider-b", &["custom_field", "field_b"]),
458        ];
459
460        let result = collect_shared_questions(&providers);
461        assert_eq!(result.shared_questions.len(), 1);
462        assert_eq!(result.shared_questions[0].id, "custom_field");
463    }
464
465    #[test]
466    fn collect_shared_questions_deduplicates() {
467        let providers = vec![
468            make_provider_form_spec("provider-a", &["public_base_url"]),
469            make_provider_form_spec("provider-b", &["public_base_url"]),
470            make_provider_form_spec("provider-c", &["public_base_url"]),
471        ];
472
473        let result = collect_shared_questions(&providers);
474        assert_eq!(result.shared_questions.len(), 1);
475    }
476
477    #[test]
478    fn public_url_question_is_recognized() {
479        assert!(is_public_url_question("public_base_url"));
480        assert!(!is_public_url_question("api_base_url"));
481        assert!(!is_public_url_question("bot_token"));
482    }
483
484    #[test]
485    fn fill_public_url_placeholders_injects_filler_when_absent() {
486        let form = make_provider_form_spec("p", &["public_base_url", "bot_token"]);
487        let mut answers = JsonMap::new();
488        fill_public_url_placeholders(
489            form.form_spec.questions.iter().map(|q| q.id.as_str()),
490            &mut answers,
491        );
492        assert_eq!(
493            answers.get("public_base_url"),
494            Some(&Value::String(PUBLIC_URL_FILLER.to_string()))
495        );
496        // Non-public-URL questions are left untouched.
497        assert!(answers.get("bot_token").is_none());
498    }
499
500    #[test]
501    fn fill_public_url_placeholders_keeps_existing_value() {
502        let form = make_provider_form_spec("p", &["public_base_url"]);
503        let mut answers = JsonMap::new();
504        answers.insert(
505            "public_base_url".to_string(),
506            Value::String("https://real.example.com".to_string()),
507        );
508        fill_public_url_placeholders(
509            form.form_spec.questions.iter().map(|q| q.id.as_str()),
510            &mut answers,
511        );
512        assert_eq!(
513            answers.get("public_base_url"),
514            Some(&Value::String("https://real.example.com".to_string()))
515        );
516    }
517
518    #[test]
519    fn prompt_shared_questions_fills_public_url_without_prompting() {
520        // public_base_url is the only shared question and it is hidden from
521        // prompts, so this returns the placeholder without reading stdin.
522        let providers = vec![
523            make_provider_form_spec("provider-a", &["public_base_url"]),
524            make_provider_form_spec("provider-b", &["public_base_url"]),
525        ];
526        let shared = collect_shared_questions(&providers);
527        let answers =
528            prompt_shared_questions(&shared, false, &Value::Object(JsonMap::new())).unwrap();
529        assert_eq!(
530            answers.get("public_base_url"),
531            Some(&Value::String(PUBLIC_URL_FILLER.to_string()))
532        );
533    }
534}