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