1use anyhow::Result;
8use qa_spec::{FormSpec, QuestionSpec};
9use serde_json::{Map as JsonMap, Value};
10use std::collections::HashMap;
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 questions: HashMap<&str, (usize, &QuestionSpec, Vec<&str>)> = HashMap::new();
67
68 for provider in providers {
69 for question in &provider.form_spec.questions {
70 if question.id.is_empty() {
71 continue;
72 }
73 questions
74 .entry(question.id.as_str())
75 .and_modify(|(count, _, provider_ids)| {
76 *count += 1;
77 provider_ids.push(provider.provider_id.as_str());
78 })
79 .or_insert_with(|| (1, question, vec![provider.provider_id.as_str()]));
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 for (question_id, (count, question, provider_ids)) in questions {
109 if count >= 2 {
111 if question.secret {
113 continue;
114 }
115
116 if is_never_shared(question_id) {
118 continue;
119 }
120
121 shared_questions.push(question.clone());
122 question_providers.insert(
123 question_id.to_string(),
124 provider_ids.into_iter().map(str::to_string).collect(),
125 );
126 }
127 }
128
129 shared_questions.sort_by(|a, b| a.id.cmp(&b.id));
131
132 SharedQuestionsResult {
133 shared_questions,
134 question_providers,
135 }
136}
137
138pub fn prompt_shared_questions(
143 shared: &SharedQuestionsResult,
144 advanced: bool,
145 existing_answers: &Value,
146) -> Result<Value> {
147 if shared.shared_questions.is_empty() {
148 return Ok(Value::Object(JsonMap::new()));
149 }
150
151 let existing_map = existing_answers.as_object();
152
153 let questions_needing_prompt: Vec<_> = shared
155 .shared_questions
156 .iter()
157 .filter(|q| {
158 if HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()) {
160 return false;
161 }
162 if !advanced && !q.required {
164 return false;
165 }
166 if let Some(map) = existing_map
168 && let Some(value) = map.get(&q.id)
169 {
170 if !value.is_null() {
172 if let Some(s) = value.as_str() {
173 return s.is_empty(); }
175 return false; }
177 }
178 true })
180 .collect();
181
182 if questions_needing_prompt.is_empty() {
184 let mut answers = JsonMap::new();
185 if let Some(map) = existing_map {
186 for question in &shared.shared_questions {
187 if let Some(value) = map.get(&question.id) {
188 answers.insert(question.id.clone(), value.clone());
189 }
190 }
191 }
192 return Ok(Value::Object(answers));
193 }
194
195 println!("\n── Shared Configuration ──");
196 println!("The following settings apply to all providers:\n");
197
198 let mut answers = JsonMap::new();
199
200 if let Some(map) = existing_map {
202 for question in &shared.shared_questions {
203 if let Some(value) = map.get(&question.id)
204 && !value.is_null()
205 && !(value.is_string() && value.as_str() == Some(""))
206 {
207 answers.insert(question.id.clone(), value.clone());
208 }
209 }
210 }
211
212 for question in &shared.shared_questions {
213 if HIDDEN_FROM_PROMPTS.contains(&question.id.as_str()) {
215 continue;
216 }
217
218 if answers.contains_key(&question.id) {
220 continue;
221 }
222
223 if !advanced && !question.required {
225 continue;
226 }
227
228 if let Some(provider_ids) = shared.question_providers.get(&question.id) {
230 let providers_str = provider_ids
231 .iter()
232 .map(|id| setup_to_formspec::strip_domain_prefix(id))
233 .collect::<Vec<_>>()
234 .join(", ");
235 println!(" Used by: {providers_str}");
236 }
237
238 if let Some(value) = ask_form_spec_question(question, None)? {
240 answers.insert(question.id.clone(), value);
241 }
242 }
243
244 println!();
245 Ok(Value::Object(answers))
246}
247
248pub fn merge_shared_with_provider_answers(
253 shared: &Value,
254 provider_specific: Option<&Value>,
255) -> Value {
256 let mut merged = JsonMap::new();
257
258 if let Some(shared_map) = shared.as_object() {
260 for (key, value) in shared_map {
261 if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
263 merged.insert(key.clone(), value.clone());
264 }
265 }
266 }
267
268 if let Some(provider_map) = provider_specific.and_then(Value::as_object) {
270 for (key, value) in provider_map {
271 if !merged.contains_key(key) {
273 merged.insert(key.clone(), value.clone());
274 }
275 }
276 }
277
278 Value::Object(merged)
279}
280
281pub fn build_provider_form_specs(
285 providers: &[(std::path::PathBuf, String)], ) -> Vec<ProviderFormSpec> {
287 providers
288 .iter()
289 .filter_map(|(pack_path, provider_id)| {
290 setup_to_formspec::pack_to_form_spec(pack_path, provider_id).map(|form_spec| {
291 ProviderFormSpec {
292 provider_id: provider_id.clone(),
293 form_spec,
294 }
295 })
296 })
297 .collect()
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use qa_spec::QuestionType;
304
305 fn make_provider_form_spec(provider_id: &str, question_ids: &[&str]) -> ProviderFormSpec {
306 let questions = question_ids
307 .iter()
308 .map(|id| QuestionSpec {
309 id: id.to_string(),
310 kind: QuestionType::String,
311 title: format!("{} Question", id),
312 title_i18n: None,
313 description: None,
314 description_i18n: None,
315 required: true,
316 choices: None,
317 default_value: None,
318 secret: false,
319 visible_if: None,
320 constraint: None,
321 list: None,
322 computed: None,
323 policy: Default::default(),
324 computed_overridable: false,
325 })
326 .collect();
327
328 ProviderFormSpec {
329 provider_id: provider_id.to_string(),
330 form_spec: FormSpec {
331 id: format!("{}-setup", provider_id),
332 title: format!("{} Setup", provider_id),
333 version: "1.0.0".into(),
334 description: None,
335 presentation: None,
336 progress_policy: None,
337 secrets_policy: None,
338 store: vec![],
339 validations: vec![],
340 includes: vec![],
341 questions,
342 },
343 }
344 }
345
346 #[test]
347 fn collect_shared_questions_finds_common_questions() {
348 let providers = vec![
349 make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
350 make_provider_form_spec("messaging-slack", &["public_base_url", "slack_token"]),
351 make_provider_form_spec("messaging-teams", &["public_base_url", "teams_app_id"]),
352 ];
353
354 let result = collect_shared_questions(&providers);
355
356 assert_eq!(result.shared_questions.len(), 1);
358 assert_eq!(result.shared_questions[0].id, "public_base_url");
359
360 let providers_for_url = result.question_providers.get("public_base_url").unwrap();
362 assert_eq!(providers_for_url.len(), 3);
363 assert!(providers_for_url.contains(&"messaging-telegram".to_string()));
364 assert!(providers_for_url.contains(&"messaging-slack".to_string()));
365 assert!(providers_for_url.contains(&"messaging-teams".to_string()));
366 }
367
368 #[test]
369 fn collect_shared_questions_excludes_single_provider_questions() {
370 let providers = vec![
371 make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
372 make_provider_form_spec("messaging-slack", &["slack_token"]), ];
374
375 let result = collect_shared_questions(&providers);
376 assert!(result.shared_questions.is_empty());
377 }
378
379 #[test]
380 fn collect_shared_questions_returns_empty_for_single_provider() {
381 let providers = vec![make_provider_form_spec(
382 "messaging-telegram",
383 &["public_base_url", "bot_token"],
384 )];
385
386 let result = collect_shared_questions(&providers);
387 assert!(result.shared_questions.is_empty());
388 }
389
390 #[test]
391 fn collect_shared_questions_finds_non_wellknown_duplicates() {
392 let providers = vec![
393 make_provider_form_spec("provider-a", &["custom_field", "field_a"]),
394 make_provider_form_spec("provider-b", &["custom_field", "field_b"]),
395 ];
396
397 let result = collect_shared_questions(&providers);
398 assert_eq!(result.shared_questions.len(), 1);
399 assert_eq!(result.shared_questions[0].id, "custom_field");
400 }
401
402 #[test]
403 fn collect_shared_questions_deduplicates() {
404 let providers = vec![
405 make_provider_form_spec("provider-a", &["public_base_url"]),
406 make_provider_form_spec("provider-b", &["public_base_url"]),
407 make_provider_form_spec("provider-c", &["public_base_url"]),
408 ];
409
410 let result = collect_shared_questions(&providers);
411 assert_eq!(result.shared_questions.len(), 1);
412 }
413}