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] = &["public_base_url"];
30
31#[derive(Clone)]
33pub struct ProviderFormSpec {
34 pub provider_id: String,
36 pub form_spec: FormSpec,
38}
39
40#[derive(Clone, Default)]
42pub struct SharedQuestionsResult {
43 pub shared_questions: Vec<QuestionSpec>,
46 pub question_providers: HashMap<String, Vec<String>>,
48}
49
50pub fn collect_shared_questions(providers: &[ProviderFormSpec]) -> SharedQuestionsResult {
59 if providers.len() <= 1 {
60 return SharedQuestionsResult::default();
61 }
62
63 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 first_question
78 .entry(question.id.clone())
79 .or_insert_with(|| question.clone());
80 }
81 }
82
83 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 if *count >= 2
112 && let Some(question) = first_question.get(question_id)
113 {
114 if question.secret {
116 continue;
117 }
118
119 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 shared_questions.sort_by(|a, b| a.id.cmp(&b.id));
144
145 SharedQuestionsResult {
146 shared_questions,
147 question_providers,
148 }
149}
150
151pub 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 let questions_needing_prompt: Vec<_> = shared
168 .shared_questions
169 .iter()
170 .filter(|q| {
171 if HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()) {
173 return false;
174 }
175 if !advanced && !q.required {
177 return false;
178 }
179 if let Some(map) = existing_map
181 && let Some(value) = map.get(&q.id)
182 {
183 if !value.is_null() {
185 if let Some(s) = value.as_str() {
186 return s.is_empty(); }
188 return false; }
190 }
191 true })
193 .collect();
194
195 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 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 if HIDDEN_FROM_PROMPTS.contains(&question.id.as_str()) {
228 continue;
229 }
230
231 if answers.contains_key(&question.id) {
233 continue;
234 }
235
236 if !advanced && !question.required {
238 continue;
239 }
240
241 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
260pub fn merge_shared_with_provider_answers(
265 shared: &Value,
266 provider_specific: Option<&Value>,
267) -> Value {
268 let mut merged = JsonMap::new();
269
270 if let Some(shared_map) = shared.as_object() {
272 for (key, value) in shared_map {
273 if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
275 merged.insert(key.clone(), value.clone());
276 }
277 }
278 }
279
280 if let Some(provider_map) = provider_specific.and_then(Value::as_object) {
282 for (key, value) in provider_map {
283 if !merged.contains_key(key) {
285 merged.insert(key.clone(), value.clone());
286 }
287 }
288 }
289
290 Value::Object(merged)
291}
292
293pub fn build_provider_form_specs(
297 providers: &[(std::path::PathBuf, String)], ) -> 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 assert_eq!(result.shared_questions.len(), 1);
370 assert_eq!(result.shared_questions[0].id, "public_base_url");
371
372 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"]), ];
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}