use crate::bluesky::did::DidResolver;
use crate::bluesky::provider::RepositoryProvider;
use crate::bluesky::records::PostRecord;
use crate::cli::SearchArgs;
use crate::error::{normalize_text, validate_account, validate_query, AppError};
use crate::mcp::{McpResponse, ToolResult};
use anyhow::Result;
use serde_json::Value;
use tokio::time::{timeout, Duration};
use tracing::{debug, info};
pub async fn handle_search(id: Option<Value>, args: Value) -> McpResponse {
match timeout(Duration::from_secs(120), handle_search_impl(args)).await {
Ok(result) => match result {
Ok(content) => McpResponse::success(id, serde_json::to_value(content).unwrap()),
Err(e) => McpResponse::error(id, e.error_code(), &e.message()),
},
Err(_) => McpResponse::error(id, "timeout", "Search request exceeded 120 second timeout"),
}
}
async fn handle_search_impl(args: Value) -> Result<ToolResult, AppError> {
let search_args: SearchArgs = serde_json::from_value(args)
.map_err(|e| AppError::InvalidInput(format!("Invalid arguments: {}", e)))?;
execute_search(search_args).await
}
pub async fn execute_search(search_args: SearchArgs) -> Result<ToolResult, AppError> {
validate_account(&search_args.account)?;
validate_query(&search_args.query)?;
info!(
"Search request for account: {}, query: '{}'",
search_args.account, search_args.query
);
let normalized_query = normalize_text(&search_args.query);
if normalized_query.is_empty() {
return Err(AppError::InvalidInput("Query is empty after normalization".to_string()));
}
let resolver = DidResolver::new();
let did = resolver.resolve_handle(&search_args.account).await?;
let display_handle = if search_args.account.starts_with("did:plc:") {
search_args.account.clone()
} else {
search_args.account.strip_prefix('@').unwrap_or(&search_args.account).to_string()
};
debug!("Resolved {} to DID: {:?}", search_args.account, did);
let provider = RepositoryProvider::new()?;
let records = provider.records(did.as_ref().ok_or_else(|| AppError::DidResolveFailed("DID resolution failed".to_string()))?).await?;
let posts: Vec<PostRecord> = records
.filter_map(|record_result| {
let (record_type, cbor_data) = record_result.ok()?;
if record_type != "app.bsky.feed.post" {
return None;
}
if let Ok(serde_cbor::Value::Map(post_map)) = serde_cbor::from_slice::<serde_cbor::Value>(&cbor_data) {
let text = match post_map.get(&serde_cbor::Value::Text("text".to_string())) {
Some(serde_cbor::Value::Text(t)) => t.clone(),
_ => return None,
};
let created_at = match post_map.get(&serde_cbor::Value::Text("createdAt".to_string())) {
Some(serde_cbor::Value::Text(t)) => t.clone(),
_ => return None,
};
Some(PostRecord {
uri: format!("at://{}/app.bsky.feed.post/unknown", did.as_deref().unwrap_or("unknown")),
cid: "unknown".to_string(), text,
created_at,
embeds: Vec::new(), facets: Vec::new(), })
} else {
None
}
})
.collect();
debug!("Extracted {} post records using streaming iterator", posts.len());
let mut matching_posts = search_posts(&posts, &normalized_query);
matching_posts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let limit = search_args.limit.unwrap_or(50).clamp(1, 200);
if matching_posts.len() > limit { matching_posts.truncate(limit); }
if matching_posts.is_empty() {
return Err(AppError::NotFound(format!(
"No posts found matching query '{}' for account {}",
search_args.query, search_args.account
)));
}
info!(
"Found {} matching posts for query: '{}'",
matching_posts.len(),
search_args.query
);
let enriched: Vec<PostRecord> = matching_posts
.into_iter()
.map(|p| {
let mut pr = p.clone();
if !pr.uri.is_empty() && !pr.uri.starts_with("at://") {
pr.uri = format!("at://{}/app.bsky.feed.post/{}", did.as_deref().unwrap_or("unknown"), pr.uri);
}
pr
})
.collect();
let enriched_refs: Vec<&PostRecord> = enriched.iter().collect();
let markdown = format_search_results(&enriched_refs, &display_handle, &search_args.query);
Ok(ToolResult::text(markdown))
}
fn search_posts<'a>(posts: &'a [PostRecord], query: &str) -> Vec<&'a PostRecord> {
let lower_query = query.to_lowercase();
posts
.iter()
.filter(|post| {
post.get_searchable_text()
.iter()
.any(|text| normalize_text(text).to_lowercase().contains(&lower_query))
})
.collect()
}
fn format_search_results(posts: &[&PostRecord], handle: &str, query: &str) -> String {
let mut markdown = format!("# Search Results for \"{}\" in @{}\n\n", query, handle);
for (i, post) in posts.iter().enumerate() {
markdown.push_str(&format!("## Post {}\n", i + 1));
markdown.push_str(&post.to_markdown(handle, query));
if i < posts.len() - 1 {
markdown.push_str("---\n\n");
}
}
let summary = if posts.len() == 1 {
"Found 1 matching post".to_string()
} else {
format!("Found {} matching posts", posts.len())
};
markdown.push_str(&format!("\n---\n\n*{}*\n", summary));
markdown
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bluesky::records::PostRecord;
use serde_json::json;
#[tokio::test]
async fn test_search_args_parsing() {
let args = json!({
"account": "test.bsky.social",
"query": "hello world"
});
let parsed: SearchArgs = serde_json::from_value(args).unwrap();
assert_eq!(parsed.account, "test.bsky.social");
assert_eq!(parsed.query, "hello world");
}
#[test]
fn test_search_posts() {
let posts = vec![
PostRecord {
uri: "at://test/app.bsky.feed.post/1".to_string(),
cid: "cid1".to_string(),
text: "Hello world, this is a test post".to_string(),
created_at: "2024-01-01T00:00:00Z".to_string(),
embeds: vec![],
facets: vec![],
},
PostRecord {
uri: "at://test/app.bsky.feed.post/2".to_string(),
cid: "cid2".to_string(),
text: "This is another post about programming".to_string(),
created_at: "2024-01-02T00:00:00Z".to_string(),
embeds: vec![],
facets: vec![],
},
PostRecord {
uri: "at://test/app.bsky.feed.post/3".to_string(),
cid: "cid3".to_string(),
text: "Hello everyone, how are you doing?".to_string(),
created_at: "2024-01-03T00:00:00Z".to_string(),
embeds: vec![],
facets: vec![],
},
];
let results = search_posts(&posts, "hello");
assert_eq!(results.len(), 2);
assert!(results.iter().any(|p| p.text.contains("Hello world")));
assert!(results.iter().any(|p| p.text.contains("Hello everyone")));
let results = search_posts(&posts, "programming");
assert_eq!(results.len(), 1);
assert!(results[0].text.contains("programming"));
let results = search_posts(&posts, "nonexistent");
assert_eq!(results.len(), 0);
}
#[test]
fn test_format_search_results() {
let post = PostRecord {
uri: "at://test/app.bsky.feed.post/1".to_string(),
cid: "cid1".to_string(),
text: "Hello world, this is a test".to_string(),
created_at: "2024-01-01T00:00:00Z".to_string(),
embeds: vec![],
facets: vec![],
};
let posts = vec![&post];
let markdown = format_search_results(&posts, "test.bsky.social", "hello");
assert!(markdown.contains("# Search Results for \"hello\" in @test.bsky.social"));
assert!(markdown.contains("## Post 1"));
assert!(markdown.contains("Found 1 matching post"));
}
}