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
25pub const HIDDEN_FROM_PROMPTS: &[&str] = &[];
31
32#[derive(Clone)]
34pub struct ProviderFormSpec {
35 pub provider_id: String,
37 pub form_spec: FormSpec,
39}
40
41#[derive(Clone, Default)]
43pub struct SharedQuestionsResult {
44 pub shared_questions: Vec<QuestionSpec>,
47 pub question_providers: HashMap<String, Vec<String>>,
49}
50
51pub fn collect_shared_questions(providers: &[ProviderFormSpec]) -> SharedQuestionsResult {
60 if providers.len() <= 1 {
61 return SharedQuestionsResult::default();
62 }
63
64 let mut question_count: HashMap<String, usize> = HashMap::new();
68 let mut first_question: HashMap<String, QuestionSpec> = HashMap::new();
69
70 for provider in providers {
71 for question in &provider.form_spec.questions {
72 if question.id.is_empty() {
73 continue;
74 }
75 *question_count.entry(question.id.clone()).or_insert(0) += 1;
76
77 first_question
79 .entry(question.id.clone())
80 .or_insert_with(|| question.clone());
81 }
82 }
83
84 let mut shared_questions = Vec::new();
91 let mut question_providers = HashMap::new();
92
93 fn is_never_shared(question_id: &str) -> bool {
94 matches!(
95 question_id,
96 "api_base_url"
97 | "bot_token"
98 | "access_token"
99 | "token"
100 | "app_id"
101 | "app_secret"
102 | "client_id"
103 | "client_secret"
104 | "webhook_secret"
105 | "signing_secret"
106 )
107 }
108
109 let mut shared_ids = HashSet::new();
110 for (question_id, count) in &question_count {
111 if *count >= 2
113 && let Some(question) = first_question.get(question_id)
114 {
115 if question.secret {
117 continue;
118 }
119
120 if is_never_shared(question_id) {
122 continue;
123 }
124
125 shared_questions.push(question.clone());
126 question_providers.insert(question_id.clone(), Vec::new());
127 shared_ids.insert(question_id.clone());
128 }
129 }
130
131 if !shared_ids.is_empty() {
132 for provider in providers {
133 for question in &provider.form_spec.questions {
134 if shared_ids.contains(&question.id)
135 && let Some(provider_ids) = question_providers.get_mut(&question.id)
136 {
137 provider_ids.push(provider.provider_id.clone());
138 }
139 }
140 }
141 }
142
143 shared_questions.sort_by(|a, b| a.id.cmp(&b.id));
145
146 SharedQuestionsResult {
147 shared_questions,
148 question_providers,
149 }
150}
151
152pub fn prompt_shared_questions(
157 shared: &SharedQuestionsResult,
158 advanced: bool,
159 existing_answers: &Value,
160) -> Result<Value> {
161 if shared.shared_questions.is_empty() {
162 return Ok(Value::Object(JsonMap::new()));
163 }
164
165 let existing_map = existing_answers.as_object();
166
167 let questions_needing_prompt: Vec<_> = shared
169 .shared_questions
170 .iter()
171 .filter(|q| {
172 if HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()) {
174 return false;
175 }
176 if !advanced && !q.required {
178 return false;
179 }
180 if let Some(map) = existing_map
182 && let Some(value) = map.get(&q.id)
183 {
184 if !value.is_null() {
186 if let Some(s) = value.as_str() {
187 return s.is_empty(); }
189 return false; }
191 }
192 true })
194 .collect();
195
196 if questions_needing_prompt.is_empty() {
198 let mut answers = JsonMap::new();
199 if let Some(map) = existing_map {
200 for question in &shared.shared_questions {
201 if let Some(value) = map.get(&question.id) {
202 answers.insert(question.id.clone(), value.clone());
203 }
204 }
205 }
206 return Ok(Value::Object(answers));
207 }
208
209 println!("\n── Shared Configuration ──");
210 println!("The following settings apply to all providers:\n");
211
212 let mut answers = JsonMap::new();
213
214 if let Some(map) = existing_map {
216 for question in &shared.shared_questions {
217 if let Some(value) = map.get(&question.id)
218 && !value.is_null()
219 && !(value.is_string() && value.as_str() == Some(""))
220 {
221 answers.insert(question.id.clone(), value.clone());
222 }
223 }
224 }
225
226 for question in &shared.shared_questions {
227 if HIDDEN_FROM_PROMPTS.contains(&question.id.as_str()) {
229 continue;
230 }
231
232 if answers.contains_key(&question.id) {
234 continue;
235 }
236
237 if !advanced && !question.required {
239 continue;
240 }
241
242 if let Some(provider_ids) = shared.question_providers.get(&question.id) {
244 let providers_str = provider_ids
245 .iter()
246 .map(|id| setup_to_formspec::strip_domain_prefix(id))
247 .collect::<Vec<_>>()
248 .join(", ");
249 println!(" Used by: {providers_str}");
250 }
251
252 if let Some(value) = ask_form_spec_question(question, None)? {
254 answers.insert(question.id.clone(), value);
255 }
256 }
257
258 println!();
259 Ok(Value::Object(answers))
260}
261
262pub fn merge_shared_with_provider_answers(
267 shared: &Value,
268 provider_specific: Option<&Value>,
269) -> Value {
270 let mut merged = JsonMap::new();
271
272 if let Some(shared_map) = shared.as_object() {
274 for (key, value) in shared_map {
275 if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
277 merged.insert(key.clone(), value.clone());
278 }
279 }
280 }
281
282 if let Some(provider_map) = provider_specific.and_then(Value::as_object) {
284 for (key, value) in provider_map {
285 if !merged.contains_key(key) {
287 merged.insert(key.clone(), value.clone());
288 }
289 }
290 }
291
292 Value::Object(merged)
293}
294
295pub fn build_provider_form_specs(
299 providers: &[(std::path::PathBuf, String)], ) -> Vec<ProviderFormSpec> {
301 providers
302 .iter()
303 .filter_map(|(pack_path, provider_id)| {
304 setup_to_formspec::pack_to_form_spec(pack_path, provider_id).map(|form_spec| {
305 ProviderFormSpec {
306 provider_id: provider_id.clone(),
307 form_spec,
308 }
309 })
310 })
311 .collect()
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use qa_spec::QuestionType;
318
319 fn make_provider_form_spec(provider_id: &str, question_ids: &[&str]) -> ProviderFormSpec {
320 let questions = question_ids
321 .iter()
322 .map(|id| QuestionSpec {
323 id: id.to_string(),
324 kind: QuestionType::String,
325 title: format!("{} Question", id),
326 title_i18n: None,
327 description: None,
328 description_i18n: None,
329 required: true,
330 choices: None,
331 default_value: None,
332 secret: false,
333 visible_if: None,
334 constraint: None,
335 list: None,
336 computed: None,
337 policy: Default::default(),
338 computed_overridable: false,
339 })
340 .collect();
341
342 ProviderFormSpec {
343 provider_id: provider_id.to_string(),
344 form_spec: FormSpec {
345 id: format!("{}-setup", provider_id),
346 title: format!("{} Setup", provider_id),
347 version: "1.0.0".into(),
348 description: None,
349 presentation: None,
350 progress_policy: None,
351 secrets_policy: None,
352 store: vec![],
353 validations: vec![],
354 includes: vec![],
355 questions,
356 },
357 }
358 }
359
360 #[test]
361 fn collect_shared_questions_finds_common_questions() {
362 let providers = vec![
363 make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
364 make_provider_form_spec("messaging-slack", &["public_base_url", "slack_token"]),
365 make_provider_form_spec("messaging-teams", &["public_base_url", "teams_app_id"]),
366 ];
367
368 let result = collect_shared_questions(&providers);
369
370 assert_eq!(result.shared_questions.len(), 1);
372 assert_eq!(result.shared_questions[0].id, "public_base_url");
373
374 let providers_for_url = result.question_providers.get("public_base_url").unwrap();
376 assert_eq!(providers_for_url.len(), 3);
377 assert!(providers_for_url.contains(&"messaging-telegram".to_string()));
378 assert!(providers_for_url.contains(&"messaging-slack".to_string()));
379 assert!(providers_for_url.contains(&"messaging-teams".to_string()));
380 }
381
382 #[test]
383 fn collect_shared_questions_excludes_single_provider_questions() {
384 let providers = vec![
385 make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
386 make_provider_form_spec("messaging-slack", &["slack_token"]), ];
388
389 let result = collect_shared_questions(&providers);
390 assert!(result.shared_questions.is_empty());
391 }
392
393 #[test]
394 fn collect_shared_questions_returns_empty_for_single_provider() {
395 let providers = vec![make_provider_form_spec(
396 "messaging-telegram",
397 &["public_base_url", "bot_token"],
398 )];
399
400 let result = collect_shared_questions(&providers);
401 assert!(result.shared_questions.is_empty());
402 }
403
404 #[test]
405 fn collect_shared_questions_finds_non_wellknown_duplicates() {
406 let providers = vec![
407 make_provider_form_spec("provider-a", &["custom_field", "field_a"]),
408 make_provider_form_spec("provider-b", &["custom_field", "field_b"]),
409 ];
410
411 let result = collect_shared_questions(&providers);
412 assert_eq!(result.shared_questions.len(), 1);
413 assert_eq!(result.shared_questions[0].id, "custom_field");
414 }
415
416 #[test]
417 fn collect_shared_questions_deduplicates() {
418 let providers = vec![
419 make_provider_form_spec("provider-a", &["public_base_url"]),
420 make_provider_form_spec("provider-b", &["public_base_url"]),
421 make_provider_form_spec("provider-c", &["public_base_url"]),
422 ];
423
424 let result = collect_shared_questions(&providers);
425 assert_eq!(result.shared_questions.len(), 1);
426 }
427}