use anyhow::Result;
use qa_spec::{FormSpec, QuestionSpec};
use serde_json::{Map as JsonMap, Value};
use std::collections::{HashMap, HashSet};
use crate::qa::prompts::ask_form_spec_question;
use crate::setup_to_formspec;
pub const SHARED_QUESTION_IDS: &[&str] = &[
"public_base_url",
];
pub const HIDDEN_FROM_PROMPTS: &[&str] = &["public_base_url"];
#[derive(Clone)]
pub struct ProviderFormSpec {
pub provider_id: String,
pub form_spec: FormSpec,
}
#[derive(Clone, Default)]
pub struct SharedQuestionsResult {
pub shared_questions: Vec<QuestionSpec>,
pub question_providers: HashMap<String, Vec<String>>,
}
pub fn collect_shared_questions(providers: &[ProviderFormSpec]) -> SharedQuestionsResult {
if providers.len() <= 1 {
return SharedQuestionsResult::default();
}
let mut question_count: HashMap<String, usize> = HashMap::new();
let mut first_question: HashMap<String, QuestionSpec> = HashMap::new();
for provider in providers {
for question in &provider.form_spec.questions {
if question.id.is_empty() {
continue;
}
*question_count.entry(question.id.clone()).or_insert(0) += 1;
first_question
.entry(question.id.clone())
.or_insert_with(|| question.clone());
}
}
let mut shared_questions = Vec::new();
let mut question_providers = HashMap::new();
fn is_never_shared(question_id: &str) -> bool {
matches!(
question_id,
"api_base_url"
| "bot_token"
| "access_token"
| "token"
| "app_id"
| "app_secret"
| "client_id"
| "client_secret"
| "webhook_secret"
| "signing_secret"
)
}
let mut shared_ids = HashSet::new();
for (question_id, count) in &question_count {
if *count >= 2
&& let Some(question) = first_question.get(question_id)
{
if question.secret {
continue;
}
if is_never_shared(question_id) {
continue;
}
shared_questions.push(question.clone());
question_providers.insert(question_id.clone(), Vec::new());
shared_ids.insert(question_id.clone());
}
}
if !shared_ids.is_empty() {
for provider in providers {
for question in &provider.form_spec.questions {
if shared_ids.contains(&question.id)
&& let Some(provider_ids) = question_providers.get_mut(&question.id)
{
provider_ids.push(provider.provider_id.clone());
}
}
}
}
shared_questions.sort_by(|a, b| a.id.cmp(&b.id));
SharedQuestionsResult {
shared_questions,
question_providers,
}
}
pub fn prompt_shared_questions(
shared: &SharedQuestionsResult,
advanced: bool,
existing_answers: &Value,
) -> Result<Value> {
if shared.shared_questions.is_empty() {
return Ok(Value::Object(JsonMap::new()));
}
let existing_map = existing_answers.as_object();
let questions_needing_prompt: Vec<_> = shared
.shared_questions
.iter()
.filter(|q| {
if HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()) {
return false;
}
if !advanced && !q.required {
return false;
}
if let Some(map) = existing_map
&& let Some(value) = map.get(&q.id)
{
if !value.is_null() {
if let Some(s) = value.as_str() {
return s.is_empty(); }
return false; }
}
true })
.collect();
if questions_needing_prompt.is_empty() {
let mut answers = JsonMap::new();
if let Some(map) = existing_map {
for question in &shared.shared_questions {
if let Some(value) = map.get(&question.id) {
answers.insert(question.id.clone(), value.clone());
}
}
}
return Ok(Value::Object(answers));
}
println!("\n── Shared Configuration ──");
println!("The following settings apply to all providers:\n");
let mut answers = JsonMap::new();
if let Some(map) = existing_map {
for question in &shared.shared_questions {
if let Some(value) = map.get(&question.id)
&& !value.is_null()
&& !(value.is_string() && value.as_str() == Some(""))
{
answers.insert(question.id.clone(), value.clone());
}
}
}
for question in &shared.shared_questions {
if HIDDEN_FROM_PROMPTS.contains(&question.id.as_str()) {
continue;
}
if answers.contains_key(&question.id) {
continue;
}
if !advanced && !question.required {
continue;
}
if let Some(provider_ids) = shared.question_providers.get(&question.id) {
let providers_str = provider_ids
.iter()
.map(|id| setup_to_formspec::strip_domain_prefix(id))
.collect::<Vec<_>>()
.join(", ");
println!(" Used by: {providers_str}");
}
if let Some(value) = ask_form_spec_question(question)? {
answers.insert(question.id.clone(), value);
}
}
println!();
Ok(Value::Object(answers))
}
pub fn merge_shared_with_provider_answers(
shared: &Value,
provider_specific: Option<&Value>,
) -> Value {
let mut merged = JsonMap::new();
if let Some(shared_map) = shared.as_object() {
for (key, value) in shared_map {
if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
merged.insert(key.clone(), value.clone());
}
}
}
if let Some(provider_map) = provider_specific.and_then(Value::as_object) {
for (key, value) in provider_map {
if !merged.contains_key(key) {
merged.insert(key.clone(), value.clone());
}
}
}
Value::Object(merged)
}
pub fn build_provider_form_specs(
providers: &[(std::path::PathBuf, String)], ) -> Vec<ProviderFormSpec> {
providers
.iter()
.filter_map(|(pack_path, provider_id)| {
setup_to_formspec::pack_to_form_spec(pack_path, provider_id).map(|form_spec| {
ProviderFormSpec {
provider_id: provider_id.clone(),
form_spec,
}
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use qa_spec::QuestionType;
fn make_provider_form_spec(provider_id: &str, question_ids: &[&str]) -> ProviderFormSpec {
let questions = question_ids
.iter()
.map(|id| QuestionSpec {
id: id.to_string(),
kind: QuestionType::String,
title: format!("{} Question", id),
title_i18n: None,
description: None,
description_i18n: None,
required: true,
choices: None,
default_value: None,
secret: false,
visible_if: None,
constraint: None,
list: None,
computed: None,
policy: Default::default(),
computed_overridable: false,
})
.collect();
ProviderFormSpec {
provider_id: provider_id.to_string(),
form_spec: FormSpec {
id: format!("{}-setup", provider_id),
title: format!("{} Setup", provider_id),
version: "1.0.0".into(),
description: None,
presentation: None,
progress_policy: None,
secrets_policy: None,
store: vec![],
validations: vec![],
includes: vec![],
questions,
},
}
}
#[test]
fn collect_shared_questions_finds_common_questions() {
let providers = vec![
make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
make_provider_form_spec("messaging-slack", &["public_base_url", "slack_token"]),
make_provider_form_spec("messaging-teams", &["public_base_url", "teams_app_id"]),
];
let result = collect_shared_questions(&providers);
assert_eq!(result.shared_questions.len(), 1);
assert_eq!(result.shared_questions[0].id, "public_base_url");
let providers_for_url = result.question_providers.get("public_base_url").unwrap();
assert_eq!(providers_for_url.len(), 3);
assert!(providers_for_url.contains(&"messaging-telegram".to_string()));
assert!(providers_for_url.contains(&"messaging-slack".to_string()));
assert!(providers_for_url.contains(&"messaging-teams".to_string()));
}
#[test]
fn collect_shared_questions_excludes_single_provider_questions() {
let providers = vec![
make_provider_form_spec("messaging-telegram", &["public_base_url", "bot_token"]),
make_provider_form_spec("messaging-slack", &["slack_token"]), ];
let result = collect_shared_questions(&providers);
assert!(result.shared_questions.is_empty());
}
#[test]
fn collect_shared_questions_returns_empty_for_single_provider() {
let providers = vec![make_provider_form_spec(
"messaging-telegram",
&["public_base_url", "bot_token"],
)];
let result = collect_shared_questions(&providers);
assert!(result.shared_questions.is_empty());
}
#[test]
fn collect_shared_questions_finds_non_wellknown_duplicates() {
let providers = vec![
make_provider_form_spec("provider-a", &["custom_field", "field_a"]),
make_provider_form_spec("provider-b", &["custom_field", "field_b"]),
];
let result = collect_shared_questions(&providers);
assert_eq!(result.shared_questions.len(), 1);
assert_eq!(result.shared_questions[0].id, "custom_field");
}
#[test]
fn collect_shared_questions_deduplicates() {
let providers = vec![
make_provider_form_spec("provider-a", &["public_base_url"]),
make_provider_form_spec("provider-b", &["public_base_url"]),
make_provider_form_spec("provider-c", &["public_base_url"]),
];
let result = collect_shared_questions(&providers);
assert_eq!(result.shared_questions.len(), 1);
}
}