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 PUBLIC_URL_FILLER: &str = "http://localhost:8080";
34
35pub fn is_public_url_question(question_id: &str) -> bool {
40 question_id == "public_base_url"
41}
42
43pub fn fill_public_url_placeholders<'a>(
51 question_ids: impl IntoIterator<Item = &'a str>,
52 answers: &mut JsonMap<String, Value>,
53) {
54 for id in question_ids {
55 if !is_public_url_question(id) {
56 continue;
57 }
58 let has_value = answers
59 .get(id)
60 .is_some_and(|v| !v.is_null() && v.as_str() != Some(""));
61 if !has_value {
62 answers.insert(id.to_string(), Value::String(PUBLIC_URL_FILLER.to_string()));
63 }
64 }
65}
66
67pub const HIDDEN_FROM_PROMPTS: &[&str] = &[];
75
76#[derive(Clone)]
78pub struct ProviderFormSpec {
79 pub provider_id: String,
81 pub form_spec: FormSpec,
83}
84
85#[derive(Clone, Default)]
87pub struct SharedQuestionsResult {
88 pub shared_questions: Vec<QuestionSpec>,
91 pub question_providers: HashMap<String, Vec<String>>,
93}
94
95pub fn collect_shared_questions(providers: &[ProviderFormSpec]) -> SharedQuestionsResult {
104 if providers.len() <= 1 {
105 return SharedQuestionsResult::default();
106 }
107
108 let mut questions: HashMap<&str, (usize, &QuestionSpec, Vec<&str>)> = HashMap::new();
111
112 for provider in providers {
113 for question in &provider.form_spec.questions {
114 if question.id.is_empty() {
115 continue;
116 }
117 questions
118 .entry(question.id.as_str())
119 .and_modify(|(count, _, provider_ids)| {
120 *count += 1;
121 provider_ids.push(provider.provider_id.as_str());
122 })
123 .or_insert_with(|| (1, question, vec![provider.provider_id.as_str()]));
124 }
125 }
126
127 let mut shared_questions = Vec::new();
134 let mut question_providers = HashMap::new();
135
136 fn is_never_shared(question_id: &str) -> bool {
137 matches!(
138 question_id,
139 "api_base_url"
140 | "bot_token"
141 | "access_token"
142 | "token"
143 | "app_id"
144 | "app_secret"
145 | "client_id"
146 | "client_secret"
147 | "webhook_secret"
148 | "signing_secret"
149 )
150 }
151
152 for (question_id, (count, question, provider_ids)) in questions {
153 if count >= 2 {
155 if question.secret {
157 continue;
158 }
159
160 if is_never_shared(question_id) {
162 continue;
163 }
164
165 shared_questions.push(question.clone());
166 question_providers.insert(
167 question_id.to_string(),
168 provider_ids.into_iter().map(str::to_string).collect(),
169 );
170 }
171 }
172
173 shared_questions.sort_by(|a, b| a.id.cmp(&b.id));
175
176 SharedQuestionsResult {
177 shared_questions,
178 question_providers,
179 }
180}
181
182pub fn prompt_shared_questions(
187 shared: &SharedQuestionsResult,
188 advanced: bool,
189 existing_answers: &Value,
190) -> Result<Value> {
191 if shared.shared_questions.is_empty() {
192 return Ok(Value::Object(JsonMap::new()));
193 }
194
195 let existing_map = existing_answers.as_object();
196
197 let questions_needing_prompt: Vec<_> = shared
199 .shared_questions
200 .iter()
201 .filter(|q| {
202 if HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()) {
204 return false;
205 }
206 if is_public_url_question(&q.id) {
209 return false;
210 }
211 if !advanced && !q.required {
213 return false;
214 }
215 if let Some(map) = existing_map
217 && let Some(value) = map.get(&q.id)
218 {
219 if !value.is_null() {
221 if let Some(s) = value.as_str() {
222 return s.is_empty(); }
224 return false; }
226 }
227 true })
229 .collect();
230
231 if questions_needing_prompt.is_empty() {
233 let mut answers = JsonMap::new();
234 if let Some(map) = existing_map {
235 for question in &shared.shared_questions {
236 if let Some(value) = map.get(&question.id) {
237 answers.insert(question.id.clone(), value.clone());
238 }
239 }
240 }
241 fill_public_url_placeholders(
242 shared.shared_questions.iter().map(|q| q.id.as_str()),
243 &mut answers,
244 );
245 return Ok(Value::Object(answers));
246 }
247
248 println!("\n── Shared Configuration ──");
249 println!("The following settings apply to all providers:\n");
250
251 let mut answers = JsonMap::new();
252
253 if let Some(map) = existing_map {
255 for question in &shared.shared_questions {
256 if let Some(value) = map.get(&question.id)
257 && !value.is_null()
258 && !(value.is_string() && value.as_str() == Some(""))
259 {
260 answers.insert(question.id.clone(), value.clone());
261 }
262 }
263 }
264
265 for question in &shared.shared_questions {
266 if HIDDEN_FROM_PROMPTS.contains(&question.id.as_str()) {
268 continue;
269 }
270
271 if is_public_url_question(&question.id) {
273 continue;
274 }
275
276 if answers.contains_key(&question.id) {
278 continue;
279 }
280
281 if !advanced && !question.required {
283 continue;
284 }
285
286 if let Some(provider_ids) = shared.question_providers.get(&question.id) {
288 let providers_str = provider_ids
289 .iter()
290 .map(|id| setup_to_formspec::strip_domain_prefix(id))
291 .collect::<Vec<_>>()
292 .join(", ");
293 println!(" Used by: {providers_str}");
294 }
295
296 if let Some(value) = ask_form_spec_question(question, None)? {
298 answers.insert(question.id.clone(), value);
299 }
300 }
301
302 fill_public_url_placeholders(
303 shared.shared_questions.iter().map(|q| q.id.as_str()),
304 &mut answers,
305 );
306
307 println!();
308 Ok(Value::Object(answers))
309}
310
311pub fn merge_shared_with_provider_answers(
316 shared: &Value,
317 provider_specific: Option<&Value>,
318) -> Value {
319 let mut merged = JsonMap::new();
320
321 if let Some(shared_map) = shared.as_object() {
323 for (key, value) in shared_map {
324 if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
326 merged.insert(key.clone(), value.clone());
327 }
328 }
329 }
330
331 if let Some(provider_map) = provider_specific.and_then(Value::as_object) {
333 for (key, value) in provider_map {
334 if !merged.contains_key(key) {
336 merged.insert(key.clone(), value.clone());
337 }
338 }
339 }
340
341 Value::Object(merged)
342}
343
344pub fn build_provider_form_specs(
348 providers: &[(std::path::PathBuf, String)], ) -> Vec<ProviderFormSpec> {
350 providers
351 .iter()
352 .filter_map(|(pack_path, provider_id)| {
353 setup_to_formspec::pack_to_form_spec(pack_path, provider_id).map(|form_spec| {
354 ProviderFormSpec {
355 provider_id: provider_id.clone(),
356 form_spec,
357 }
358 })
359 })
360 .collect()
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use qa_spec::QuestionType;
367
368 fn make_provider_form_spec(provider_id: &str, question_ids: &[&str]) -> ProviderFormSpec {
369 let questions = question_ids
370 .iter()
371 .map(|id| QuestionSpec {
372 id: id.to_string(),
373 kind: QuestionType::String,
374 title: format!("{} Question", id),
375 title_i18n: None,
376 description: None,
377 description_i18n: None,
378 required: true,
379 choices: None,
380 default_value: None,
381 secret: false,
382 visible_if: None,
383 constraint: None,
384 list: None,
385 computed: None,
386 policy: Default::default(),
387 computed_overridable: false,
388 })
389 .collect();
390
391 ProviderFormSpec {
392 provider_id: provider_id.to_string(),
393 form_spec: FormSpec {
394 id: format!("{}-setup", provider_id),
395 title: format!("{} Setup", provider_id),
396 version: "1.0.0".into(),
397 description: None,
398 presentation: None,
399 progress_policy: None,
400 secrets_policy: None,
401 store: vec![],
402 validations: vec![],
403 includes: vec![],
404 questions,
405 },
406 }
407 }
408
409 #[test]
410 fn collect_shared_questions_finds_common_questions() {
411 let providers = vec![
412 make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
413 make_provider_form_spec("messaging-slack", &["public_base_url", "slack_token"]),
414 make_provider_form_spec("messaging-teams", &["public_base_url", "teams_app_id"]),
415 ];
416
417 let result = collect_shared_questions(&providers);
418
419 assert_eq!(result.shared_questions.len(), 1);
421 assert_eq!(result.shared_questions[0].id, "public_base_url");
422
423 let providers_for_url = result.question_providers.get("public_base_url").unwrap();
425 assert_eq!(providers_for_url.len(), 3);
426 assert!(providers_for_url.contains(&"messaging-telegram".to_string()));
427 assert!(providers_for_url.contains(&"messaging-slack".to_string()));
428 assert!(providers_for_url.contains(&"messaging-teams".to_string()));
429 }
430
431 #[test]
432 fn collect_shared_questions_excludes_single_provider_questions() {
433 let providers = vec![
434 make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
435 make_provider_form_spec("messaging-slack", &["slack_token"]), ];
437
438 let result = collect_shared_questions(&providers);
439 assert!(result.shared_questions.is_empty());
440 }
441
442 #[test]
443 fn collect_shared_questions_returns_empty_for_single_provider() {
444 let providers = vec![make_provider_form_spec(
445 "messaging-telegram",
446 &["public_base_url", "bot_token"],
447 )];
448
449 let result = collect_shared_questions(&providers);
450 assert!(result.shared_questions.is_empty());
451 }
452
453 #[test]
454 fn collect_shared_questions_finds_non_wellknown_duplicates() {
455 let providers = vec![
456 make_provider_form_spec("provider-a", &["custom_field", "field_a"]),
457 make_provider_form_spec("provider-b", &["custom_field", "field_b"]),
458 ];
459
460 let result = collect_shared_questions(&providers);
461 assert_eq!(result.shared_questions.len(), 1);
462 assert_eq!(result.shared_questions[0].id, "custom_field");
463 }
464
465 #[test]
466 fn collect_shared_questions_deduplicates() {
467 let providers = vec![
468 make_provider_form_spec("provider-a", &["public_base_url"]),
469 make_provider_form_spec("provider-b", &["public_base_url"]),
470 make_provider_form_spec("provider-c", &["public_base_url"]),
471 ];
472
473 let result = collect_shared_questions(&providers);
474 assert_eq!(result.shared_questions.len(), 1);
475 }
476
477 #[test]
478 fn public_url_question_is_recognized() {
479 assert!(is_public_url_question("public_base_url"));
480 assert!(!is_public_url_question("api_base_url"));
481 assert!(!is_public_url_question("bot_token"));
482 }
483
484 #[test]
485 fn fill_public_url_placeholders_injects_filler_when_absent() {
486 let form = make_provider_form_spec("p", &["public_base_url", "bot_token"]);
487 let mut answers = JsonMap::new();
488 fill_public_url_placeholders(
489 form.form_spec.questions.iter().map(|q| q.id.as_str()),
490 &mut answers,
491 );
492 assert_eq!(
493 answers.get("public_base_url"),
494 Some(&Value::String(PUBLIC_URL_FILLER.to_string()))
495 );
496 assert!(answers.get("bot_token").is_none());
498 }
499
500 #[test]
501 fn fill_public_url_placeholders_keeps_existing_value() {
502 let form = make_provider_form_spec("p", &["public_base_url"]);
503 let mut answers = JsonMap::new();
504 answers.insert(
505 "public_base_url".to_string(),
506 Value::String("https://real.example.com".to_string()),
507 );
508 fill_public_url_placeholders(
509 form.form_spec.questions.iter().map(|q| q.id.as_str()),
510 &mut answers,
511 );
512 assert_eq!(
513 answers.get("public_base_url"),
514 Some(&Value::String("https://real.example.com".to_string()))
515 );
516 }
517
518 #[test]
519 fn prompt_shared_questions_fills_public_url_without_prompting() {
520 let providers = vec![
523 make_provider_form_spec("provider-a", &["public_base_url"]),
524 make_provider_form_spec("provider-b", &["public_base_url"]),
525 ];
526 let shared = collect_shared_questions(&providers);
527 let answers =
528 prompt_shared_questions(&shared, false, &Value::Object(JsonMap::new())).unwrap();
529 assert_eq!(
530 answers.get("public_base_url"),
531 Some(&Value::String(PUBLIC_URL_FILLER.to_string()))
532 );
533 }
534}