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