1use 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
15pub const SHARED_QUESTION_IDS: &[&str] = &[
20 "public_base_url",
21 ];
24
25#[derive(Clone)]
27pub struct ProviderFormSpec {
28 pub provider_id: String,
30 pub form_spec: FormSpec,
32}
33
34#[derive(Clone, Default)]
36pub struct SharedQuestionsResult {
37 pub shared_questions: Vec<QuestionSpec>,
40 pub question_providers: HashMap<String, Vec<String>>,
42}
43
44pub fn collect_shared_questions(providers: &[ProviderFormSpec]) -> SharedQuestionsResult {
53 if providers.len() <= 1 {
54 return SharedQuestionsResult::default();
55 }
56
57 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 first_question
72 .entry(question.id.clone())
73 .or_insert_with(|| question.clone());
74 }
75 }
76
77 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 if *count >= 2
106 && let Some(question) = first_question.get(question_id)
107 {
108 if question.secret {
110 continue;
111 }
112
113 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 shared_questions.sort_by(|a, b| a.id.cmp(&b.id));
138
139 SharedQuestionsResult {
140 shared_questions,
141 question_providers,
142 }
143}
144
145pub 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 let questions_needing_prompt: Vec<_> = shared
162 .shared_questions
163 .iter()
164 .filter(|q| {
165 if !advanced && !q.required {
167 return false;
168 }
169 if let Some(map) = existing_map
171 && let Some(value) = map.get(&q.id)
172 {
173 if !value.is_null() {
175 if let Some(s) = value.as_str() {
176 return s.is_empty(); }
178 return false; }
180 }
181 true })
183 .collect();
184
185 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 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 if answers.contains_key(&question.id) {
218 continue;
219 }
220
221 if !advanced && !question.required {
223 continue;
224 }
225
226 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
245pub fn merge_shared_with_provider_answers(
250 shared: &Value,
251 provider_specific: Option<&Value>,
252) -> Value {
253 let mut merged = JsonMap::new();
254
255 if let Some(shared_map) = shared.as_object() {
257 for (key, value) in shared_map {
258 if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
260 merged.insert(key.clone(), value.clone());
261 }
262 }
263 }
264
265 if let Some(provider_map) = provider_specific.and_then(Value::as_object) {
267 for (key, value) in provider_map {
268 if !merged.contains_key(key) {
270 merged.insert(key.clone(), value.clone());
271 }
272 }
273 }
274
275 Value::Object(merged)
276}
277
278pub fn build_provider_form_specs(
282 providers: &[(std::path::PathBuf, String)], ) -> 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 assert_eq!(result.shared_questions.len(), 1);
355 assert_eq!(result.shared_questions[0].id, "public_base_url");
356
357 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"]), ];
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}