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, HashSet};
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/// Questions hidden from interactive prompts (both terminal and web UI).
26///
27/// Values may still be supplied via `--answers` file or prefill. Keep this list
28/// empty by default: providers such as OAuth/webhook integrations often need a
29/// real HTTPS `public_base_url` during setup-time app registration.
30pub const HIDDEN_FROM_PROMPTS: &[&str] = &[];
31
32/// Information about a provider and its FormSpec for multi-provider setup.
33#[derive(Clone)]
34pub struct ProviderFormSpec {
35    /// Provider identifier (e.g., "messaging-telegram")
36    pub provider_id: String,
37    /// The FormSpec for this provider
38    pub form_spec: FormSpec,
39}
40
41/// Result of collecting shared questions across multiple providers.
42#[derive(Clone, Default)]
43pub struct SharedQuestionsResult {
44    /// Questions that appear in multiple providers (deduplicated).
45    /// Each question is taken from the first provider that defines it.
46    pub shared_questions: Vec<QuestionSpec>,
47    /// Provider IDs that contain each shared question ID.
48    pub question_providers: HashMap<String, Vec<String>>,
49}
50
51/// Collect questions that are shared across multiple providers.
52///
53/// A question is considered "shared" if:
54/// 1. Its ID is in `SHARED_QUESTION_IDS`, OR
55/// 2. It appears in 2+ providers with the same ID
56///
57/// Returns deduplicated questions (taking the first occurrence) along with
58/// which providers contain each question.
59pub fn collect_shared_questions(providers: &[ProviderFormSpec]) -> SharedQuestionsResult {
60    if providers.len() <= 1 {
61        return SharedQuestionsResult::default();
62    }
63
64    // Count occurrences of each question ID across providers.
65    // Store counts only (not provider IDs) to avoid excessive cloning/allocation
66    // for questions that are not ultimately shared.
67    let mut question_count: HashMap<String, usize> = HashMap::new();
68    let mut first_question: HashMap<String, QuestionSpec> = HashMap::new();
69
70    for provider in providers {
71        for question in &provider.form_spec.questions {
72            if question.id.is_empty() {
73                continue;
74            }
75            *question_count.entry(question.id.clone()).or_insert(0) += 1;
76
77            // Keep the first occurrence of each question
78            first_question
79                .entry(question.id.clone())
80                .or_insert_with(|| question.clone());
81        }
82    }
83
84    // Find shared questions (must appear in 2+ providers to be truly shared)
85    // SHARED_QUESTION_IDS are hints for what questions are commonly shared,
86    // but we only share them if they actually appear in multiple providers.
87    //
88    // IMPORTANT: Exclude secrets and provider-specific fields from sharing.
89    // Each provider needs unique values for these fields.
90    let mut shared_questions = Vec::new();
91    let mut question_providers = HashMap::new();
92
93    fn is_never_shared(question_id: &str) -> bool {
94        matches!(
95            question_id,
96            "api_base_url"
97                | "bot_token"
98                | "access_token"
99                | "token"
100                | "app_id"
101                | "app_secret"
102                | "client_id"
103                | "client_secret"
104                | "webhook_secret"
105                | "signing_secret"
106        )
107    }
108
109    let mut shared_ids = HashSet::new();
110    for (question_id, count) in &question_count {
111        // Only share questions that actually appear in 2+ providers
112        if *count >= 2
113            && let Some(question) = first_question.get(question_id)
114        {
115            // Skip secrets - they should never be shared across providers
116            if question.secret {
117                continue;
118            }
119
120            // Skip provider-specific fields that happen to have the same ID
121            if is_never_shared(question_id) {
122                continue;
123            }
124
125            shared_questions.push(question.clone());
126            question_providers.insert(question_id.clone(), Vec::new());
127            shared_ids.insert(question_id.clone());
128        }
129    }
130
131    if !shared_ids.is_empty() {
132        for provider in providers {
133            for question in &provider.form_spec.questions {
134                if shared_ids.contains(&question.id)
135                    && let Some(provider_ids) = question_providers.get_mut(&question.id)
136                {
137                    provider_ids.push(provider.provider_id.clone());
138                }
139            }
140        }
141    }
142
143    // Sort by question ID for deterministic ordering
144    shared_questions.sort_by(|a, b| a.id.cmp(&b.id));
145
146    SharedQuestionsResult {
147        shared_questions,
148        question_providers,
149    }
150}
151
152/// Prompt for shared questions that apply to multiple providers.
153///
154/// Takes existing answers from loaded setup file and only prompts for
155/// questions that don't already have a valid (non-empty) value.
156pub fn prompt_shared_questions(
157    shared: &SharedQuestionsResult,
158    advanced: bool,
159    existing_answers: &Value,
160) -> Result<Value> {
161    if shared.shared_questions.is_empty() {
162        return Ok(Value::Object(JsonMap::new()));
163    }
164
165    let existing_map = existing_answers.as_object();
166
167    // Check if all shared questions already have valid answers
168    let questions_needing_prompt: Vec<_> = shared
169        .shared_questions
170        .iter()
171        .filter(|q| {
172            // Skip questions hidden from interactive prompts (auto-injected by operator)
173            if HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()) {
174                return false;
175            }
176            // Skip optional questions in normal mode
177            if !advanced && !q.required {
178                return false;
179            }
180            // Check if this question already has a non-empty value
181            if let Some(map) = existing_map
182                && let Some(value) = map.get(&q.id)
183            {
184                // Skip if value is non-null and non-empty string
185                if !value.is_null() {
186                    if let Some(s) = value.as_str() {
187                        return s.is_empty(); // Need prompt if empty string
188                    }
189                    return false; // Has value, skip
190                }
191            }
192            true // Need prompt
193        })
194        .collect();
195
196    // If no questions need prompting, return existing answers
197    if questions_needing_prompt.is_empty() {
198        let mut answers = JsonMap::new();
199        if let Some(map) = existing_map {
200            for question in &shared.shared_questions {
201                if let Some(value) = map.get(&question.id) {
202                    answers.insert(question.id.clone(), value.clone());
203                }
204            }
205        }
206        return Ok(Value::Object(answers));
207    }
208
209    println!("\n── Shared Configuration ──");
210    println!("The following settings apply to all providers:\n");
211
212    let mut answers = JsonMap::new();
213
214    // Copy existing values first
215    if let Some(map) = existing_map {
216        for question in &shared.shared_questions {
217            if let Some(value) = map.get(&question.id)
218                && !value.is_null()
219                && !(value.is_string() && value.as_str() == Some(""))
220            {
221                answers.insert(question.id.clone(), value.clone());
222            }
223        }
224    }
225
226    for question in &shared.shared_questions {
227        // Skip questions hidden from interactive prompts (auto-injected by operator)
228        if HIDDEN_FROM_PROMPTS.contains(&question.id.as_str()) {
229            continue;
230        }
231
232        // Skip if we already have a valid answer
233        if answers.contains_key(&question.id) {
234            continue;
235        }
236
237        // Skip optional questions in normal mode
238        if !advanced && !question.required {
239            continue;
240        }
241
242        // Show which providers use this question
243        if let Some(provider_ids) = shared.question_providers.get(&question.id) {
244            let providers_str = provider_ids
245                .iter()
246                .map(|id| setup_to_formspec::strip_domain_prefix(id))
247                .collect::<Vec<_>>()
248                .join(", ");
249            println!("  Used by: {providers_str}");
250        }
251
252        // None: shared-question prompts keep English chrome (provider-setup flow).
253        if let Some(value) = ask_form_spec_question(question, None)? {
254            answers.insert(question.id.clone(), value);
255        }
256    }
257
258    println!();
259    Ok(Value::Object(answers))
260}
261
262/// Merge shared answers with provider-specific answers.
263///
264/// Shared answers take precedence for non-empty values, but provider-specific
265/// answers can override if the shared value is empty.
266pub fn merge_shared_with_provider_answers(
267    shared: &Value,
268    provider_specific: Option<&Value>,
269) -> Value {
270    let mut merged = JsonMap::new();
271
272    // Start with shared answers
273    if let Some(shared_map) = shared.as_object() {
274        for (key, value) in shared_map {
275            // Only include non-empty values
276            if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
277                merged.insert(key.clone(), value.clone());
278            }
279        }
280    }
281
282    // Add provider-specific answers (don't override non-empty shared values)
283    if let Some(provider_map) = provider_specific.and_then(Value::as_object) {
284        for (key, value) in provider_map {
285            // Only add if not already present with a non-empty value
286            if !merged.contains_key(key) {
287                merged.insert(key.clone(), value.clone());
288            }
289        }
290    }
291
292    Value::Object(merged)
293}
294
295/// Build FormSpecs for multiple providers from their pack paths.
296///
297/// Convenience function to prepare input for `collect_shared_questions`.
298pub fn build_provider_form_specs(
299    providers: &[(std::path::PathBuf, String)], // (pack_path, provider_id)
300) -> Vec<ProviderFormSpec> {
301    providers
302        .iter()
303        .filter_map(|(pack_path, provider_id)| {
304            setup_to_formspec::pack_to_form_spec(pack_path, provider_id).map(|form_spec| {
305                ProviderFormSpec {
306                    provider_id: provider_id.clone(),
307                    form_spec,
308                }
309            })
310        })
311        .collect()
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use qa_spec::QuestionType;
318
319    fn make_provider_form_spec(provider_id: &str, question_ids: &[&str]) -> ProviderFormSpec {
320        let questions = question_ids
321            .iter()
322            .map(|id| QuestionSpec {
323                id: id.to_string(),
324                kind: QuestionType::String,
325                title: format!("{} Question", id),
326                title_i18n: None,
327                description: None,
328                description_i18n: None,
329                required: true,
330                choices: None,
331                default_value: None,
332                secret: false,
333                visible_if: None,
334                constraint: None,
335                list: None,
336                computed: None,
337                policy: Default::default(),
338                computed_overridable: false,
339            })
340            .collect();
341
342        ProviderFormSpec {
343            provider_id: provider_id.to_string(),
344            form_spec: FormSpec {
345                id: format!("{}-setup", provider_id),
346                title: format!("{} Setup", provider_id),
347                version: "1.0.0".into(),
348                description: None,
349                presentation: None,
350                progress_policy: None,
351                secrets_policy: None,
352                store: vec![],
353                validations: vec![],
354                includes: vec![],
355                questions,
356            },
357        }
358    }
359
360    #[test]
361    fn collect_shared_questions_finds_common_questions() {
362        let providers = vec![
363            make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
364            make_provider_form_spec("messaging-slack", &["public_base_url", "slack_token"]),
365            make_provider_form_spec("messaging-teams", &["public_base_url", "teams_app_id"]),
366        ];
367
368        let result = collect_shared_questions(&providers);
369
370        // public_base_url appears in all 3 providers
371        assert_eq!(result.shared_questions.len(), 1);
372        assert_eq!(result.shared_questions[0].id, "public_base_url");
373
374        // Check provider mapping
375        let providers_for_url = result.question_providers.get("public_base_url").unwrap();
376        assert_eq!(providers_for_url.len(), 3);
377        assert!(providers_for_url.contains(&"messaging-telegram".to_string()));
378        assert!(providers_for_url.contains(&"messaging-slack".to_string()));
379        assert!(providers_for_url.contains(&"messaging-teams".to_string()));
380    }
381
382    #[test]
383    fn collect_shared_questions_excludes_single_provider_questions() {
384        let providers = vec![
385            make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
386            make_provider_form_spec("messaging-slack", &["slack_token"]), // no public_base_url
387        ];
388
389        let result = collect_shared_questions(&providers);
390        assert!(result.shared_questions.is_empty());
391    }
392
393    #[test]
394    fn collect_shared_questions_returns_empty_for_single_provider() {
395        let providers = vec![make_provider_form_spec(
396            "messaging-telegram",
397            &["public_base_url", "bot_token"],
398        )];
399
400        let result = collect_shared_questions(&providers);
401        assert!(result.shared_questions.is_empty());
402    }
403
404    #[test]
405    fn collect_shared_questions_finds_non_wellknown_duplicates() {
406        let providers = vec![
407            make_provider_form_spec("provider-a", &["custom_field", "field_a"]),
408            make_provider_form_spec("provider-b", &["custom_field", "field_b"]),
409        ];
410
411        let result = collect_shared_questions(&providers);
412        assert_eq!(result.shared_questions.len(), 1);
413        assert_eq!(result.shared_questions[0].id, "custom_field");
414    }
415
416    #[test]
417    fn collect_shared_questions_deduplicates() {
418        let providers = vec![
419            make_provider_form_spec("provider-a", &["public_base_url"]),
420            make_provider_form_spec("provider-b", &["public_base_url"]),
421            make_provider_form_spec("provider-c", &["public_base_url"]),
422        ];
423
424        let result = collect_shared_questions(&providers);
425        assert_eq!(result.shared_questions.len(), 1);
426    }
427}