use serde_json::Value;
use crate::config::{Config, DeploymentRole};
use crate::cosmos::factory::build_cosmos_backend;
use crate::mcp::expose::ExposeResolver;
use crate::mcp::schema::SchemaCatalog;
use crate::mcp::tools::search::{self, IncludeContent, SearchRequest, SearchToolConfig};
use crate::mcp::tools::search_api::AzureSearchAdapter;
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum IncludeContentArg {
Snippet,
Full,
AgenticAnswer,
}
impl From<IncludeContentArg> for IncludeContent {
fn from(a: IncludeContentArg) -> Self {
match a {
IncludeContentArg::Snippet => IncludeContent::Snippet,
IncludeContentArg::Full => IncludeContent::Full,
IncludeContentArg::AgenticAnswer => IncludeContent::AgenticAnswer,
}
}
}
#[derive(Debug)]
pub struct SearchOptions {
pub query: String,
pub data_sources: Option<Vec<String>>,
pub where_: Option<Value>,
pub top: usize,
pub cursor: Option<String>,
pub include_content: IncludeContentArg,
pub include_deleted: bool,
pub json: bool,
}
pub async fn run(config: &Config, options: SearchOptions) -> anyhow::Result<()> {
let cosmos = build_cosmos_backend(config).await?;
let deployment_name = config
.deployments
.iter()
.find(|d| d.role == DeploymentRole::Mcp)
.map(|d| d.name.clone())
.ok_or_else(|| {
anyhow::anyhow!("no MCP deployment in config; `quelch search` requires one")
})?;
let sliced = crate::config::slice::for_deployment(config, &deployment_name)?;
let expose = ExposeResolver::from_sliced(&sliced, &deployment_name)
.map_err(|e| anyhow::anyhow!("expose resolver: {e}"))?;
let schema = SchemaCatalog::default();
let search_config = SearchToolConfig {
disable_agentic: sliced
.mcp
.search
.as_ref()
.map(|s| s.disable_agentic)
.unwrap_or(false),
knowledge_base_name: sliced
.mcp
.search
.as_ref()
.and_then(|s| s.knowledge_base.clone())
.unwrap_or_else(|| "quelch-prod-kb".into()),
default_top: sliced.mcp.default_top as usize,
max_top: sliced.mcp.max_top as usize,
};
let search_service = sliced
.search
.service
.as_deref()
.unwrap_or("quelch-prod-search");
let search_endpoint = format!("https://{search_service}.search.windows.net");
let api_version = "2025-11-01-preview".to_string();
let search_api = AzureSearchAdapter::new(search_endpoint, api_version)
.map_err(|e| anyhow::anyhow!("search adapter init failed: {e}\nHint: run `az login` or set AZURE_CLIENT_ID / AZURE_CLIENT_SECRET env vars"))?;
let req = SearchRequest {
query: options.query,
data_sources: options.data_sources,
r#where: options.where_,
top: options.top,
cursor: options.cursor,
include_deleted: options.include_deleted,
include_content: options.include_content.into(),
};
let resp = search::run(&search_api, &expose, &schema, &search_config, req)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
if options.json {
println!("{}", serde_json::to_string_pretty(&resp)?);
} else {
println!("Results: {} (estimated)", resp.total_estimate);
if let Some(answer) = &resp.answer {
println!();
println!("Answer: {answer}");
}
if !resp.items.is_empty() {
println!();
for item in &resp.items {
println!(
"• [{:.2}] {} — {}",
item.score,
item.source_link,
item.snippet.as_deref().unwrap_or("—")
);
}
}
if let Some(cursor) = &resp.next_cursor {
println!();
println!("More results available. Continue with --cursor {cursor}");
}
}
drop(cosmos);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::schema::SchemaCatalog;
use crate::mcp::tools::search::SearchToolConfig;
use crate::mcp::tools::search_api::mock::MockSearchApi;
use crate::mcp::tools::test_helpers::build_expose;
#[tokio::test]
async fn search_dispatches_to_search_tool_with_mock() {
let api = MockSearchApi::new();
let expose = build_expose(&[("jira_issues", "jira_issue", "jira-issues")]);
let schema = SchemaCatalog::default();
let config = SearchToolConfig {
disable_agentic: true,
..Default::default()
};
let req = SearchRequest {
query: "open bugs".to_string(),
data_sources: Some(vec!["jira_issues".to_string()]),
r#where: None,
top: 10,
cursor: None,
include_deleted: false,
include_content: IncludeContent::Snippet,
};
let resp = search::run(&api, &expose, &schema, &config, req)
.await
.unwrap();
assert_eq!(resp.total_estimate, 1);
}
#[test]
fn include_content_arg_converts_correctly() {
assert!(matches!(
IncludeContent::from(IncludeContentArg::Snippet),
IncludeContent::Snippet
));
assert!(matches!(
IncludeContent::from(IncludeContentArg::Full),
IncludeContent::Full
));
assert!(matches!(
IncludeContent::from(IncludeContentArg::AgenticAnswer),
IncludeContent::AgenticAnswer
));
}
}