use ratatui::style::Color;
use serde::Deserialize;
use crate::theme;
#[derive(Deserialize, Debug)]
struct AiResponse {
suggestions: Vec<JsonSuggestion>,
}
#[derive(Deserialize, Debug)]
struct JsonSuggestion {
#[serde(rename = "type")]
suggestion_type: String,
query: String,
details: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuggestionType {
Fix,
Optimize,
Next,
}
impl SuggestionType {
pub fn color(&self) -> Color {
match self {
SuggestionType::Fix => theme::ai::SUGGESTION_FIX,
SuggestionType::Optimize => theme::ai::SUGGESTION_OPTIMIZE,
SuggestionType::Next => theme::ai::SUGGESTION_NEXT,
}
}
pub fn parse_type(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"fix" => Some(SuggestionType::Fix),
"optimize" => Some(SuggestionType::Optimize),
"next" => Some(SuggestionType::Next),
_ => None,
}
}
pub fn label(&self) -> &'static str {
match self {
SuggestionType::Fix => "[Fix]",
SuggestionType::Optimize => "[Optimize]",
SuggestionType::Next => "[Next]",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Suggestion {
pub query: String,
pub description: String,
pub suggestion_type: SuggestionType,
}
pub fn parse_suggestions(response: &str) -> Vec<Suggestion> {
let cleaned_response = strip_markdown_fences(response);
if let Ok(parsed) = parse_suggestions_json(&cleaned_response)
&& !parsed.is_empty()
{
return parsed;
}
parse_suggestions_text(response)
}
fn strip_markdown_fences(response: &str) -> String {
let trimmed = response.trim();
if let Some(first_newline) = trimmed.find('\n') {
let first_line = &trimmed[..first_newline];
if first_line.trim().starts_with("```") {
let content_start = first_newline + 1;
if let Some(closing_fence_pos) = trimmed[content_start..].rfind("\n```") {
let content_end = content_start + closing_fence_pos;
return trimmed[content_start..content_end].trim().to_string();
}
}
}
response.to_string()
}
fn parse_suggestions_json(response: &str) -> Result<Vec<Suggestion>, serde_json::Error> {
let ai_response: AiResponse = serde_json::from_str(response)?;
let suggestions = ai_response
.suggestions
.into_iter()
.filter_map(|json_sugg| {
let suggestion_type = SuggestionType::parse_type(&json_sugg.suggestion_type)?;
Some(Suggestion {
query: json_sugg.query,
description: json_sugg.details,
suggestion_type,
})
})
.collect();
Ok(suggestions)
}
fn parse_suggestions_text(response: &str) -> Vec<Suggestion> {
let mut suggestions = Vec::new();
let lines: Vec<&str> = response.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
if let Some(suggestion) = parse_suggestion_line(line, &lines[i + 1..]) {
suggestions.push(suggestion.0);
i += suggestion.1; } else {
i += 1;
}
}
suggestions
}
fn parse_suggestion_line(line: &str, remaining_lines: &[&str]) -> Option<(Suggestion, usize)> {
let line = line.trim();
let dot_pos = line.find(". [")?;
let num_str = &line[..dot_pos];
if !num_str.chars().all(|c| c.is_ascii_digit()) || num_str.is_empty() {
return None;
}
let type_start = dot_pos + 3; let type_end = line[type_start..].find(']')? + type_start;
let type_str = &line[type_start..type_end];
let suggestion_type = SuggestionType::parse_type(type_str)?;
let query_start = type_end + 1;
let mut query = line[query_start..].trim();
if query.starts_with('`') && query.ends_with('`') && query.len() > 2 {
query = &query[1..query.len() - 1];
}
let query = query.to_string();
if query.is_empty() {
return None;
}
let mut description_lines = Vec::new();
let mut lines_consumed = 1;
for remaining_line in remaining_lines {
let trimmed = remaining_line.trim();
if trimmed.is_empty() {
lines_consumed += 1;
break;
}
if let Some(dot_pos) = trimmed.find(". [") {
let num_part = &trimmed[..dot_pos];
if num_part.chars().all(|c| c.is_ascii_digit()) && !num_part.is_empty() {
break;
}
}
description_lines.push(trimmed);
lines_consumed += 1;
}
let description = description_lines.join(" ");
Some((
Suggestion {
query,
description,
suggestion_type,
},
lines_consumed,
))
}
#[cfg(test)]
#[path = "parser_tests.rs"]
mod parser_tests;