pub mod config;
pub mod configure;
pub mod context;
pub mod executor;
pub mod prompt;
pub mod providers;
pub mod schema;
pub mod answer;
pub mod schema_agentic;
pub mod agentic;
pub mod tools;
pub mod evaluator;
pub mod prompt_agentic;
pub mod reporter;
pub mod chat_session;
pub mod chat_tui;
pub use configure::run_configure_wizard;
pub use executor::{execute_queries, parse_command, ParsedCommand};
pub use schema::{QueryCommand, QueryResponse as SemanticQueryResponse, AgenticQueryResponse};
pub use agentic::{run_agentic_loop, AgenticConfig};
pub use reporter::{AgenticReporter, ConsoleReporter, QuietReporter};
pub use answer::generate_answer;
pub use chat_tui::run_chat_mode;
pub use config::{save_user_provider, is_any_api_key_configured};
use anyhow::{Context, Result};
use crate::cache::CacheManager;
pub async fn ask_question(
question: &str,
cache: &CacheManager,
provider_override: Option<String>,
additional_context: Option<String>,
debug: bool,
) -> Result<schema::QueryResponse> {
let mut config = config::load_config(cache.path())?;
if let Some(provider) = provider_override {
config.provider = provider;
}
let api_key = config::get_api_key(&config.provider)?;
let model = config::resolve_model(&config, None);
let provider = providers::create_provider(
&config.provider,
api_key,
model,
config::get_provider_options(&config.provider),
)?;
log::info!("Using provider: {} (model: {})", provider.name(), provider.default_model());
let prompt = prompt::build_prompt(question, cache, additional_context.as_deref())?;
log::debug!("Generated prompt ({} chars)", prompt.len());
if debug {
eprintln!("\n{}", "=".repeat(80));
eprintln!("DEBUG: Full LLM Prompt (Standard Mode)");
eprintln!("{}", "=".repeat(80));
eprintln!("{}", prompt);
eprintln!("{}\n", "=".repeat(80));
}
let json_response = call_with_retry(&*provider, &prompt, 2, validate_query_response).await?;
log::debug!("Received response ({} chars)", json_response.len());
let response: schema::QueryResponse = serde_json::from_str(&json_response)
.context("Failed to parse LLM response as JSON. The LLM may have returned invalid JSON.")?;
if response.queries.is_empty() {
anyhow::bail!("LLM returned no queries");
}
log::info!("Generated {} quer{}", response.queries.len(), if response.queries.len() == 1 { "y" } else { "ies" });
Ok(response)
}
pub(crate) fn extract_json(text: &str) -> &str {
let trimmed = text.trim();
if trimmed.starts_with("```") && trimmed.ends_with("```") {
let after_backticks = &trimmed[3..];
let content_start = after_backticks.find('\n')
.map(|i| 3 + i + 1)
.unwrap_or(3);
let content = &trimmed[content_start..trimmed.len() - 3];
return content.trim();
}
if !trimmed.starts_with('{') {
if let Some(start) = trimmed.find('{') {
let bytes = trimmed.as_bytes();
let mut depth = 0i32;
let mut last_close = start;
let mut in_string = false;
let mut escape_next = false;
for (i, &b) in bytes[start..].iter().enumerate() {
if escape_next {
escape_next = false;
continue;
}
match b {
b'\\' if in_string => escape_next = true,
b'"' => in_string = !in_string,
b'{' if !in_string => depth += 1,
b'}' if !in_string => {
depth -= 1;
if depth == 0 {
last_close = start + i;
break;
}
}
_ => {}
}
}
if depth == 0 && last_close > start {
return trimmed[start..=last_close].trim();
}
}
}
trimmed
}
fn coerce_string_values(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(s) => {
match s.as_str() {
"true" => *value = serde_json::Value::Bool(true),
"false" => *value = serde_json::Value::Bool(false),
_ => {}
}
}
serde_json::Value::Object(map) => {
for v in map.values_mut() {
coerce_string_values(v);
}
}
serde_json::Value::Array(arr) => {
for v in arr.iter_mut() {
coerce_string_values(v);
}
}
_ => {}
}
}
pub(crate) async fn call_with_retry(
provider: &dyn providers::LlmProvider,
prompt: &str,
max_retries: usize,
validator: impl Fn(&str) -> Result<(), String>,
) -> Result<String> {
let mut last_error = None;
for attempt in 0..=max_retries {
if attempt > 0 {
log::warn!("Retrying LLM call (attempt {}/{})", attempt + 1, max_retries + 1);
}
match provider.complete(prompt, true).await { Ok(response) => {
let cleaned_response = extract_json(&response);
let cleaned_response = match serde_json::from_str::<serde_json::Value>(cleaned_response) {
Ok(mut value) => {
coerce_string_values(&mut value);
value.to_string()
}
Err(_) => cleaned_response.to_string(), };
match validator(&cleaned_response) {
Ok(()) => {
return Ok(cleaned_response);
}
Err(e) => {
if attempt < max_retries {
log::warn!(
"Invalid JSON response from LLM, retrying ({}/{}): {}",
attempt + 1,
max_retries,
e
);
last_error = Some(anyhow::anyhow!(
"Invalid JSON format: {}. Response: {}",
e,
cleaned_response
));
let delay_ms = 500 * (attempt as u64 + 1);
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
continue;
} else {
last_error = Some(anyhow::anyhow!(
"Invalid JSON format after {} attempts: {}. Response: {}",
max_retries + 1,
e,
cleaned_response
));
}
}
}
}
Err(e) => {
if attempt < max_retries {
log::warn!(
"LLM API call failed, retrying ({}/{}): {}",
attempt + 1,
max_retries,
e
);
let delay_ms = 500 * (attempt as u64 + 1);
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
}
last_error = Some(e);
}
}
}
Err(last_error.unwrap())
}
pub(crate) fn validate_query_response(json: &str) -> Result<(), String> {
serde_json::from_str::<schema::QueryResponse>(json)
.map(|_| ())
.map_err(|e| e.to_string())
}
pub(crate) fn validate_agentic_response(json: &str) -> Result<(), String> {
serde_json::from_str::<schema_agentic::AgenticResponse>(json)
.map(|_| ())
.map_err(|e| e.to_string())
}
pub(crate) fn validate_agentic_or_query_response(json: &str) -> Result<(), String> {
if serde_json::from_str::<schema_agentic::AgenticResponse>(json).is_ok() {
return Ok(());
}
serde_json::from_str::<schema::QueryResponse>(json)
.map(|_| ())
.map_err(|e| format!("Neither AgenticResponse nor QueryResponse: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_json_with_json_label() {
let input = r#"```json
{
"queries": [
{
"command": "query \"User\" --symbols --kind class --lang php",
"order": 1,
"merge": true
}
]
}
```"#;
let expected = r#"{
"queries": [
{
"command": "query \"User\" --symbols --kind class --lang php",
"order": 1,
"merge": true
}
]
}"#;
assert_eq!(extract_json(input), expected);
}
#[test]
fn test_extract_json_without_json_label() {
let input = r#"```
{"queries": []}
```"#;
let expected = r#"{"queries": []}"#;
assert_eq!(extract_json(input), expected);
}
#[test]
fn test_extract_json_no_fences() {
let input = r#"{"queries": []}"#;
assert_eq!(extract_json(input), input);
}
#[test]
fn test_extract_json_with_whitespace() {
let input = r#" ```json
{"queries": []}
``` "#;
let expected = r#"{"queries": []}"#;
assert_eq!(extract_json(input), expected);
}
#[test]
fn test_extract_json_case_insensitive_tag() {
let input = r#"```JSON
{"queries": []}
```"#;
let expected = r#"{"queries": []}"#;
assert_eq!(extract_json(input), expected);
}
#[test]
fn test_extract_json_embedded_in_text() {
let input = r#"Here is the response:
{"phase": "assessment", "reasoning": "need context", "needs_context": true, "tool_calls": []}
Some trailing text here."#;
let expected = r#"{"phase": "assessment", "reasoning": "need context", "needs_context": true, "tool_calls": []}"#;
assert_eq!(extract_json(input), expected);
}
#[test]
fn test_extract_json_with_nested_braces_in_strings() {
let input = r#"Result: {"reasoning": "found {braces} in code", "phase": "final"} done"#;
let expected = r#"{"reasoning": "found {braces} in code", "phase": "final"}"#;
assert_eq!(extract_json(input), expected);
}
#[test]
fn test_validate_query_response() {
assert!(validate_query_response(r#"{"queries": []}"#).is_ok());
assert!(validate_query_response(r#"{"phase": "assessment"}"#).is_err());
}
#[test]
fn test_validate_agentic_response() {
let valid = r#"{"phase": "assessment", "reasoning": "test"}"#;
assert!(validate_agentic_response(valid).is_ok());
assert!(validate_agentic_response(r#"{"queries": []}"#).is_err());
}
#[test]
fn test_validate_agentic_or_query_response() {
assert!(validate_agentic_or_query_response(r#"{"queries": []}"#).is_ok());
assert!(validate_agentic_or_query_response(
r#"{"phase": "final", "reasoning": "done"}"#
).is_ok());
assert!(validate_agentic_or_query_response(r#"{"bad": true}"#).is_err());
}
#[test]
fn test_module_structure() {
assert!(true);
}
#[test]
fn test_coerce_string_values_booleans() {
let mut value = serde_json::json!({
"needs_context": "true",
"structure": "false",
"reasoning": "some text"
});
coerce_string_values(&mut value);
assert_eq!(value["needs_context"], serde_json::json!(true));
assert_eq!(value["structure"], serde_json::json!(false));
assert_eq!(value["reasoning"], serde_json::json!("some text"));
}
#[test]
fn test_coerce_string_values_nested() {
let mut value = serde_json::json!({
"tool_calls": [
{
"expand": "true",
"symbols": "false",
"query": "search term"
}
],
"outer": {
"inner_bool": "true"
}
});
coerce_string_values(&mut value);
assert_eq!(value["tool_calls"][0]["expand"], serde_json::json!(true));
assert_eq!(value["tool_calls"][0]["symbols"], serde_json::json!(false));
assert_eq!(value["tool_calls"][0]["query"], serde_json::json!("search term"));
assert_eq!(value["outer"]["inner_bool"], serde_json::json!(true));
}
#[test]
fn test_coerce_string_values_preserves_strings() {
let mut value = serde_json::json!({
"reasoning": "this is true and false",
"description": "truly amazing",
"phase": "assessment",
"command": "query \"true\" --symbols"
});
let original = value.clone();
coerce_string_values(&mut value);
assert_eq!(value, original);
}
#[test]
fn test_extract_json_and_coerce_integration() {
let llm_output = r#"```json
{
"phase": "assessment",
"reasoning": "need to find relevant files",
"needs_context": "true",
"tool_calls": [
{
"tool": "search_code",
"query": "trigram",
"symbols": "false",
"expand": "true"
}
]
}
```"#;
let extracted = extract_json(llm_output);
let mut value: serde_json::Value = serde_json::from_str(extracted).unwrap();
coerce_string_values(&mut value);
assert_eq!(value["needs_context"], serde_json::json!(true));
assert_eq!(value["tool_calls"][0]["symbols"], serde_json::json!(false));
assert_eq!(value["tool_calls"][0]["expand"], serde_json::json!(true));
assert_eq!(value["phase"], serde_json::json!("assessment"));
assert_eq!(value["reasoning"], serde_json::json!("need to find relevant files"));
assert_eq!(value["tool_calls"][0]["query"], serde_json::json!("trigram"));
}
}