use std::collections::BTreeSet;
use crate::config::schema::{
IngestInstance, InstanceConfig, InstanceSpec, McpInstance, SourceConnection, SourceType,
};
use super::{
env_var_stem_from_name, parse_duration, prompt_credential_env_var, prompt_unique_name,
};
pub async fn prompt_instances(
connections: &[SourceConnection],
) -> anyhow::Result<Vec<InstanceConfig>> {
println!("\n=== Instances ===");
println!(
"An instance is one process you'll run somewhere. Two kinds:\n \
- ingest: pulls from one or more source connections, writes to Cosmos.\n \
- mcp: serves the agent-facing MCP API, queries Cosmos + AI Search.\n\
A typical setup has one of each. Add as many as you need.\n"
);
let mut instances: Vec<InstanceConfig> = Vec::new();
let mut seen_names: BTreeSet<String> = BTreeSet::new();
loop {
let must_add = instances.is_empty();
let prompt = if must_add {
"Add an instance:"
} else {
"Add another instance?"
};
let mut options = vec!["Ingest", "MCP"];
if !must_add {
options.push("Done (no more instances)");
}
let idx = inquire::Select::new(prompt, options.clone())
.with_starting_cursor(0)
.raw_prompt()?
.index;
if idx == 2 {
break;
}
let inst = if idx == 0 {
prompt_ingest_instance(&seen_names, connections)?
} else {
prompt_mcp_instance(&seen_names, connections)?
};
seen_names.insert(inst.name.clone());
instances.push(inst);
}
Ok(instances)
}
fn prompt_ingest_instance(
seen: &BTreeSet<String>,
connections: &[SourceConnection],
) -> anyhow::Result<InstanceConfig> {
println!("\n --- Ingest instance ---");
if connections.is_empty() {
anyhow::bail!(
"an ingest instance needs at least one source connection — go back \
and add one first (this should not happen via the wizard)"
);
}
let name = prompt_unique_name(
seen,
" Instance name (used by `quelch ingest --instance ...`):",
"ingest-main",
)?;
let labels: Vec<String> = connections
.iter()
.map(|c| format!("{} ({:?}, {})", c.name, c.source_type, c.base_url))
.collect();
let chosen_indices =
inquire::MultiSelect::new(" Connections this instance should ingest from:", labels)
.with_default(&[0])
.raw_prompt()?
.into_iter()
.map(|s| s.index)
.collect::<Vec<_>>();
if chosen_indices.is_empty() {
anyhow::bail!("an ingest instance must reference at least one connection");
}
let chosen_connections: Vec<String> = chosen_indices
.iter()
.map(|i| connections[*i].name.clone())
.collect();
let interval_str: String = inquire::Text::new(" Cycle interval (e.g. 5m, 30s, 1h):")
.with_initial_value("5m")
.prompt()?;
let cycle_interval = parse_duration(&interval_str)?;
Ok(InstanceConfig {
name,
spec: InstanceSpec::Ingest(IngestInstance {
connections: chosen_connections,
cycle_interval,
}),
})
}
fn prompt_mcp_instance(
seen: &BTreeSet<String>,
connections: &[SourceConnection],
) -> anyhow::Result<InstanceConfig> {
println!("\n --- MCP instance ---");
let name = prompt_unique_name(
seen,
" Instance name (used by `quelch mcp --instance ...`):",
"mcp-prod",
)?;
let available = available_data_sources(connections);
if available.is_empty() {
println!(
" (no source connections declared — the MCP instance needs at least one\n \
data source. Add a connection first, then re-run init.)"
);
anyhow::bail!("MCP instance needs at least one source connection to expose");
}
let chosen_indices = inquire::MultiSelect::new(" Data sources to expose:", available.clone())
.with_default(&(0..available.len()).collect::<Vec<_>>())
.raw_prompt()?
.into_iter()
.map(|s| s.index)
.collect::<Vec<_>>();
if chosen_indices.is_empty() {
anyhow::bail!("an MCP instance must expose at least one data source");
}
let expose: Vec<String> = chosen_indices
.iter()
.map(|i| available[*i].clone())
.collect();
let env_stem = env_var_stem_from_name(&name);
let api_key_var =
prompt_credential_env_var("mcp", &format!("{env_stem}_API_KEY"), "MCP API key")?;
let api_key = format!("${{{api_key_var}}}");
let listen: String = inquire::Text::new(" Listen address:")
.with_initial_value("0.0.0.0:8080")
.prompt()?;
let knowledge_base: String = inquire::Text::new(" Knowledge Base name (rigg + AI Search):")
.with_initial_value("quelch-prod-kb")
.prompt()?;
Ok(InstanceConfig {
name,
spec: InstanceSpec::Mcp(McpInstance {
expose,
api_key,
knowledge_base,
listen,
}),
})
}
fn available_data_sources(connections: &[SourceConnection]) -> Vec<String> {
let mut has_jira = false;
let mut has_confluence = false;
for c in connections {
match c.source_type {
SourceType::Jira => has_jira = true,
SourceType::Confluence => has_confluence = true,
}
}
let mut out = Vec::new();
if has_jira {
out.extend([
"jira_issues".to_string(),
"jira_sprints".to_string(),
"jira_fix_versions".to_string(),
"jira_projects".to_string(),
]);
}
if has_confluence {
out.extend([
"confluence_pages".to_string(),
"confluence_spaces".to_string(),
]);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::SourceAuth;
#[test]
fn available_data_sources_picks_jira_only_when_only_jira_present() {
let conns = vec![SourceConnection {
name: "j".to_string(),
source_type: SourceType::Jira,
base_url: "https://j".to_string(),
auth: SourceAuth::Pat {
token: "T".to_string(),
},
projects: vec!["X".to_string()],
spaces: vec![],
}];
let avail = available_data_sources(&conns);
assert!(avail.contains(&"jira_issues".to_string()));
assert!(avail.contains(&"jira_projects".to_string()));
assert!(!avail.iter().any(|d| d.starts_with("confluence")));
}
#[test]
fn available_data_sources_returns_empty_when_no_connections() {
let avail = available_data_sources(&[]);
assert!(avail.is_empty());
}
}