#![allow(private_interfaces)]
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::search::HybridSearch;
use crate::types::SearchResult;
use super::super::{internal_error, open_vector_store, AppState, ErrorBody};
use super::helpers::*;
#[derive(Deserialize)]
pub(crate) struct SearchBeadsParams {
q: String,
priority: Option<i32>,
status: Option<String>,
assignee: Option<String>,
rig: Option<String>,
issue_type: Option<String>,
label: Option<String>,
limit: Option<usize>,
enrich: Option<bool>,
compact: Option<bool>,
}
#[derive(Serialize)]
pub(crate) struct SearchBeadsResponse {
query: String,
count: usize,
results: Vec<BeadResultItem>,
}
#[derive(Serialize)]
pub(crate) struct BeadResultItem {
bead_id: String,
title: String,
priority: String,
status: String,
issue_type: String,
assignee: String,
owner: String,
rig: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
labels: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
created_at: Option<String>,
relevance_score: f32,
match_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
snippet: Option<String>,
}
pub(crate) async fn search_beads(
State(state): State<Arc<AppState>>,
Query(params): Query<SearchBeadsParams>,
) -> Result<Json<SearchBeadsResponse>, (StatusCode, Json<ErrorBody>)> {
let limit = params.limit.unwrap_or(10);
let should_enrich = params.enrich.unwrap_or(true);
let compact = params.compact.unwrap_or(true);
let vector_store = open_vector_store(&state).await.map_err(internal_error)?;
let embedder = state.get_embedder().await.map_err(internal_error)?.clone();
let mut search = HybridSearch::new(embedder, vector_store, state.config.search.semantic_weight);
let search_results = search
.search_filtered(¶ms.q, limit, None, Some("chunk_type = 'issue'"))
.await
.map_err(|e| internal_error(e.into()))?;
let mut filtered: Vec<SearchResult> = search_results.into_iter().collect();
if let Some(ref rig) = params.rig {
let prefix = format!("beads:{}:", rig);
filtered.retain(|r| r.chunk.file_path.starts_with(&prefix));
}
let live_metadata = if should_enrich && state.config.beads.enabled {
let bead_ids: Vec<(String, String)> = filtered
.iter()
.filter_map(|r| {
let parts: Vec<&str> = r.chunk.file_path.splitn(3, ':').collect();
if parts.len() == 3 {
Some((parts[1].to_string(), parts[2].to_string()))
} else {
None
}
})
.collect();
crate::index::beads::fetch_bead_metadata(&state.config.beads, &bead_ids)
.await
.map_err(internal_error)?
} else {
std::collections::HashMap::new()
};
let has_filters = params.status.is_some()
|| params.priority.is_some()
|| params.assignee.is_some()
|| params.issue_type.is_some()
|| params.label.is_some();
if has_filters {
filtered.retain(|r| {
let bead_id = r.chunk.file_path.split(':').nth(2).unwrap_or("");
if let Some(meta) = live_metadata.get(bead_id) {
if let Some(ref status) = params.status {
if meta.status != *status {
return false;
}
}
if let Some(priority) = params.priority {
if meta.priority != priority {
return false;
}
}
if let Some(ref assignee) = params.assignee {
let meta_assignee = meta.assignee.as_deref().unwrap_or("unassigned");
if !meta_assignee.contains(assignee.as_str()) {
return false;
}
}
if let Some(ref issue_type) = params.issue_type {
if meta.issue_type != *issue_type {
return false;
}
}
if let Some(ref label) = params.label {
if !meta.labels.iter().any(|l| l.contains(label.as_str())) {
return false;
}
}
true
} else {
let content = &r.chunk.content;
if let Some(ref status) = params.status {
if !content.contains(&format!("Status: {}", status)) {
return false;
}
}
if let Some(priority) = params.priority {
if !content.contains(&format!("Priority: P{}", priority)) {
return false;
}
}
if let Some(ref assignee) = params.assignee {
if !content.contains(&format!("Assignee: {}", assignee)) {
return false;
}
}
true
}
});
}
let query_lower = params.q.to_lowercase();
let query_terms: Vec<&str> = query_lower.split_whitespace().collect();
for result in &mut filtered {
let mut boost: f32 = 1.0;
if let Some(ref name) = result.chunk.name {
let title_lower = name.to_lowercase();
let matching_terms = query_terms
.iter()
.filter(|t| title_lower.contains(**t))
.count();
if matching_terms > 0 {
boost += 0.3 * (matching_terms as f32 / query_terms.len().max(1) as f32);
}
}
let bead_id = result.chunk.file_path.split(':').nth(2).unwrap_or("");
if let Some(meta) = live_metadata.get(bead_id) {
match meta.status.as_str() {
"in_progress" | "hooked" => boost += 0.15,
"open" | "blocked" => boost += 0.1,
"closed" => boost -= 0.1,
_ => {}
}
}
result.score = (result.score * boost).min(1.0);
}
filtered.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
filtered.truncate(limit);
let results: Vec<BeadResultItem> = filtered
.iter()
.map(|r| {
let parts: Vec<&str> = r.chunk.file_path.splitn(3, ':').collect();
let rig = if parts.len() >= 2 { parts[1] } else { "" };
let bead_id = if parts.len() == 3 {
parts[2]
} else {
&r.chunk.file_path
};
let match_type = r
.match_type
.as_ref()
.map(|mt| format!("{:?}", mt).to_lowercase())
.unwrap_or_else(|| "hybrid".to_string());
if let Some(meta) = live_metadata.get(bead_id) {
let snippet = if compact {
None
} else {
Some(clean_bead_snippet(&r.chunk.content, 200))
};
BeadResultItem {
bead_id: bead_id.to_string(),
title: meta.title.clone(),
priority: format!("P{}", meta.priority),
status: meta.status.clone(),
issue_type: meta.issue_type.clone(),
assignee: meta
.assignee
.clone()
.unwrap_or_else(|| "unassigned".to_string()),
owner: meta.owner.clone(),
rig: rig.to_string(),
labels: meta.labels.clone(),
created_at: meta.created_at.clone(),
relevance_score: r.score,
match_type,
snippet,
}
} else {
let content = &r.chunk.content;
let snippet = if compact {
None
} else {
Some(clean_bead_snippet(content, 200))
};
BeadResultItem {
bead_id: bead_id.to_string(),
title: r.chunk.name.clone().unwrap_or_default(),
priority: extract_bead_field(content, "Priority: "),
status: extract_bead_field(content, "Status: "),
issue_type: "task".to_string(),
assignee: extract_bead_field(content, "Assignee: "),
owner: String::new(),
rig: rig.to_string(),
labels: Vec::new(),
created_at: None,
relevance_score: r.score,
match_type,
snippet,
}
}
})
.collect();
Ok(Json(SearchBeadsResponse {
query: params.q,
count: results.len(),
results,
}))
}