use anyhow::Result;
use crate::models::FileGroupedResult;
use super::providers::LlmProvider;
const MAX_MATCHES_IN_PROMPT: usize = 50;
const MAX_PREVIEW_LENGTH: usize = 200;
pub async fn generate_answer(
question: &str,
results: &[FileGroupedResult],
total_count: usize,
gathered_context: Option<&str>,
codebase_context: Option<&str>,
provider: &dyn LlmProvider,
) -> Result<String> {
if results.is_empty() {
if let Some(context) = gathered_context {
if !context.is_empty() {
let prompt = build_context_only_prompt(question, context);
log::debug!("Generating answer from gathered context ({} chars)", prompt.len());
let answer = provider.complete(&prompt, false).await?;
let cleaned = strip_markdown_fences(&answer);
return Ok(cleaned.to_string());
}
}
if let Some(context) = codebase_context {
if !context.is_empty() {
let prompt = build_codebase_context_prompt(question, context);
log::debug!("Generating answer from codebase context ({} chars)", prompt.len());
let answer = provider.complete(&prompt, false).await?;
let cleaned = strip_markdown_fences(&answer);
return Ok(cleaned.to_string());
}
}
return Ok(format!("No results found for: {}", question));
}
let prompt = build_answer_prompt(question, results, total_count, gathered_context);
log::debug!("Generating answer with prompt ({} chars)", prompt.len());
let answer = provider.complete(&prompt, false).await?;
let cleaned = strip_markdown_fences(&answer);
Ok(cleaned.to_string())
}
fn build_answer_prompt(
question: &str,
results: &[FileGroupedResult],
total_count: usize,
gathered_context: Option<&str>,
) -> String {
let mut prompt = String::new();
prompt.push_str("You are analyzing code search results to answer a developer's question.\n\n");
prompt.push_str("IMPORTANT: Provide ONLY the answer text, without any markdown formatting, code fences, or explanatory prefixes.\n\n");
prompt.push_str(&format!("Question: {}\n\n", question));
if let Some(context) = gathered_context {
if !context.is_empty() {
prompt.push_str("Additional Context (from documentation and codebase analysis):\n");
prompt.push_str("====================================================================\n\n");
prompt.push_str(context);
prompt.push_str("\n\n");
}
}
prompt.push_str(&format!("Found {} total matches across {} files.\n\n", total_count, results.len()));
prompt.push_str("Code Search Results:\n");
prompt.push_str("====================\n\n");
let mut match_count = 0;
for file_group in results {
if match_count >= MAX_MATCHES_IN_PROMPT {
prompt.push_str(&format!("\n... and {} more matches not shown\n", total_count - match_count));
break;
}
prompt.push_str(&format!("File: {}\n", file_group.path));
for match_result in &file_group.matches {
if match_count >= MAX_MATCHES_IN_PROMPT {
break;
}
log::debug!("Formatting match at {}:{} - context_before: {}, context_after: {}",
file_group.path, match_result.span.start_line,
match_result.context_before.len(), match_result.context_after.len());
for (idx, line) in match_result.context_before.iter().enumerate() {
let line_num = match_result.span.start_line.saturating_sub(match_result.context_before.len() - idx);
let truncated = if line.len() > MAX_PREVIEW_LENGTH {
format!("{}...", &line[..MAX_PREVIEW_LENGTH])
} else {
line.clone()
};
prompt.push_str(&format!(" Line {}: {}\n", line_num, truncated.trim()));
}
let preview = if match_result.preview.len() > MAX_PREVIEW_LENGTH {
format!("{}...", &match_result.preview[..MAX_PREVIEW_LENGTH])
} else {
match_result.preview.clone()
};
prompt.push_str(&format!(
" Line {}-{}: {}\n",
match_result.span.start_line,
match_result.span.end_line,
preview.trim()
));
for (idx, line) in match_result.context_after.iter().enumerate() {
let line_num = match_result.span.start_line + idx + 1;
let truncated = if line.len() > MAX_PREVIEW_LENGTH {
format!("{}...", &line[..MAX_PREVIEW_LENGTH])
} else {
line.clone()
};
prompt.push_str(&format!(" Line {}: {}\n", line_num, truncated.trim()));
}
match_count += 1;
}
prompt.push_str("\n");
}
prompt.push_str("\nProvide a conversational answer that:\n");
prompt.push_str("1. Directly answers the question based on the search results\n");
prompt.push_str("2. References specific files and line numbers where relevant\n");
prompt.push_str("3. Summarizes patterns or common approaches if multiple results are similar\n");
prompt.push_str("4. Is concise but informative (typically 2-4 sentences)\n");
prompt.push_str("5. Only mentions information that appears in the search results above\n\n");
prompt.push_str("Answer (plain text only, no markdown):\n");
prompt
}
fn build_context_only_prompt(question: &str, gathered_context: &str) -> String {
let mut prompt = String::new();
prompt.push_str("You are answering a developer's question using documentation and codebase context.\n\n");
prompt.push_str("IMPORTANT: Provide ONLY the answer text, without any markdown formatting, code fences, or explanatory prefixes.\n\n");
prompt.push_str(&format!("Question: {}\n\n", question));
prompt.push_str("Available Context (from documentation and codebase analysis):\n");
prompt.push_str("================================================================\n\n");
prompt.push_str(gathered_context);
prompt.push_str("\n\n");
prompt.push_str("Provide a conversational answer that:\n");
prompt.push_str("1. Directly answers the question based on the context above\n");
prompt.push_str("2. References documentation sections or files where relevant\n");
prompt.push_str("3. Is concise but informative (typically 2-4 sentences)\n");
prompt.push_str("4. Only mentions information that appears in the context above\n\n");
prompt.push_str("Answer (plain text only, no markdown):\n");
prompt
}
fn build_codebase_context_prompt(question: &str, codebase_context: &str) -> String {
let mut prompt = String::new();
prompt.push_str("You are answering a developer's question using codebase metadata.\n\n");
prompt.push_str("IMPORTANT: Provide ONLY the answer text, without any markdown formatting, code fences, or explanatory prefixes.\n\n");
prompt.push_str(&format!("Question: {}\n\n", question));
prompt.push_str("Codebase Metadata:\n");
prompt.push_str("==================\n\n");
prompt.push_str(codebase_context);
prompt.push_str("\n\n");
prompt.push_str("Provide a conversational answer that:\n");
prompt.push_str("1. Directly answers the question using the metadata above\n");
prompt.push_str("2. Uses specific numbers and percentages from the metadata\n");
prompt.push_str("3. Is concise but informative (typically 1-2 sentences)\n");
prompt.push_str("4. Only mentions information that appears in the metadata above\n\n");
prompt.push_str("Answer (plain text only, no markdown):\n");
prompt
}
fn strip_markdown_fences(text: &str) -> &str {
let trimmed = text.trim();
if trimmed.starts_with("```") && trimmed.ends_with("```") {
let without_start = if let Some(rest) = trimmed.strip_prefix("```markdown") {
rest
} else if let Some(rest) = trimmed.strip_prefix("```text") {
rest
} else if let Some(rest) = trimmed.strip_prefix("```") {
rest
} else {
return trimmed;
};
let without_end = without_start.strip_suffix("```")
.unwrap_or(without_start);
without_end.trim()
} else {
trimmed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_markdown_fences() {
let input = "```\nThis is the answer\n```";
assert_eq!(strip_markdown_fences(input), "This is the answer");
}
#[test]
fn test_strip_markdown_fences_with_language() {
let input = "```text\nThis is the answer\n```";
assert_eq!(strip_markdown_fences(input), "This is the answer");
}
#[test]
fn test_strip_markdown_fences_no_fences() {
let input = "This is the answer";
assert_eq!(strip_markdown_fences(input), "This is the answer");
}
#[test]
fn test_build_answer_prompt_empty_results() {
let results: Vec<FileGroupedResult> = vec![];
let prompt = build_answer_prompt("Find TODOs", &results, 0, None);
assert!(prompt.contains("Found 0 total matches"));
assert!(prompt.contains("Question: Find TODOs"));
}
}