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