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