use tauri::State; use crate::db::models::{ AiConversation, AiMessage, ImageAttachment, Issue, IssueDetail, IssueFilter, IssueSummary, IssueUpdate, LogFile, ResolutionStep, TimelineEvent, }; use crate::state::AppState; #[tauri::command] pub async fn create_issue( title: String, description: String, severity: String, category: String, state: State<'_, AppState>, ) -> Result { let issue = Issue::new(title, description, severity, category); let db = state.db.lock().map_err(|e| e.to_string())?; db.execute( "INSERT INTO issues (id, title, description, severity, status, category, source, created_at, updated_at, resolved_at, assigned_to, tags) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", rusqlite::params![ issue.id, issue.title, issue.description, issue.severity, issue.status, issue.category, issue.source, issue.created_at, issue.updated_at, issue.resolved_at, issue.assigned_to, issue.tags, ], ) .map_err(|e| e.to_string())?; Ok(issue) } #[tauri::command] pub async fn get_issue( issue_id: String, state: State<'_, AppState>, ) -> Result { let db = state.db.lock().map_err(|e| e.to_string())?; // Load issue let mut stmt = db .prepare( "SELECT id, title, description, severity, status, category, source, \ created_at, updated_at, resolved_at, assigned_to, tags \ FROM issues WHERE id = ?1", ) .map_err(|e| e.to_string())?; let issue = stmt .query_row([&issue_id], |row| { Ok(Issue { id: row.get(0)?, title: row.get(1)?, description: row.get(2)?, severity: row.get(3)?, status: row.get(4)?, category: row.get(5)?, source: row.get(6)?, created_at: row.get(7)?, updated_at: row.get(8)?, resolved_at: row.get(9)?, assigned_to: row.get(10)?, tags: row.get(11)?, }) }) .map_err(|e| e.to_string())?; // Load log files let mut lf_stmt = db .prepare( "SELECT id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted \ FROM log_files WHERE issue_id = ?1 ORDER BY uploaded_at ASC", ) .map_err(|e| e.to_string())?; let log_files: Vec = lf_stmt .query_map([&issue_id], |row| { Ok(LogFile { id: row.get(0)?, issue_id: row.get(1)?, file_name: row.get(2)?, file_path: row.get(3)?, file_size: row.get(4)?, mime_type: row.get(5)?, content_hash: row.get(6)?, uploaded_at: row.get(7)?, redacted: row.get::<_, i32>(8)? != 0, }) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); // Load image attachments let mut img_stmt = db .prepare( "SELECT id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste \ FROM image_attachments WHERE issue_id = ?1 ORDER BY uploaded_at ASC", ) .map_err(|e| e.to_string())?; let image_attachments: Vec = img_stmt .query_map([&issue_id], |row| { Ok(ImageAttachment { id: row.get(0)?, issue_id: row.get(1)?, file_name: row.get(2)?, file_path: row.get(3)?, file_size: row.get(4)?, mime_type: row.get(5)?, upload_hash: row.get(6)?, uploaded_at: row.get(7)?, pii_warning_acknowledged: row.get::<_, i32>(8)? != 0, is_paste: row.get::<_, i32>(9)? != 0, }) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); // Load resolution steps (5-whys) let mut rs_stmt = db .prepare( "SELECT id, issue_id, step_order, why_question, answer, evidence, created_at \ FROM resolution_steps WHERE issue_id = ?1 ORDER BY step_order ASC", ) .map_err(|e| e.to_string())?; let resolution_steps: Vec = rs_stmt .query_map([&issue_id], |row| { Ok(ResolutionStep { id: row.get(0)?, issue_id: row.get(1)?, step_order: row.get(2)?, why_question: row.get(3)?, answer: row.get(4)?, evidence: row.get(5)?, created_at: row.get(6)?, }) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); // Load conversations let mut conv_stmt = db .prepare( "SELECT id, issue_id, provider, model, created_at, title \ FROM ai_conversations WHERE issue_id = ?1 ORDER BY created_at ASC", ) .map_err(|e| e.to_string())?; let conversations: Vec = conv_stmt .query_map([&issue_id], |row| { Ok(AiConversation { id: row.get(0)?, issue_id: row.get(1)?, provider: row.get(2)?, model: row.get(3)?, created_at: row.get(4)?, title: row.get(5)?, }) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); // Load timeline events let mut te_stmt = db .prepare( "SELECT id, issue_id, event_type, description, metadata, created_at \ FROM timeline_events WHERE issue_id = ?1 ORDER BY created_at ASC", ) .map_err(|e| e.to_string())?; let timeline_events: Vec = te_stmt .query_map([&issue_id], |row| { Ok(TimelineEvent { id: row.get(0)?, issue_id: row.get(1)?, event_type: row.get(2)?, description: row.get(3)?, metadata: row.get(4)?, created_at: row.get(5)?, }) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); Ok(IssueDetail { issue, log_files, image_attachments, resolution_steps, conversations, timeline_events, }) } #[tauri::command] pub async fn update_issue( issue_id: String, updates: IssueUpdate, state: State<'_, AppState>, ) -> Result { let db = state.db.lock().map_err(|e| e.to_string())?; let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); if let Some(ref title) = updates.title { db.execute( "UPDATE issues SET title = ?1, updated_at = ?2 WHERE id = ?3", rusqlite::params![title, now, issue_id], ) .map_err(|e| e.to_string())?; } if let Some(ref description) = updates.description { db.execute( "UPDATE issues SET description = ?1, updated_at = ?2 WHERE id = ?3", rusqlite::params![description, now, issue_id], ) .map_err(|e| e.to_string())?; } if let Some(ref severity) = updates.severity { db.execute( "UPDATE issues SET severity = ?1, updated_at = ?2 WHERE id = ?3", rusqlite::params![severity, now, issue_id], ) .map_err(|e| e.to_string())?; } if let Some(ref status) = updates.status { db.execute( "UPDATE issues SET status = ?1, updated_at = ?2 WHERE id = ?3", rusqlite::params![status, now, issue_id], ) .map_err(|e| e.to_string())?; if status == "resolved" { db.execute( "UPDATE issues SET resolved_at = ?1 WHERE id = ?2", rusqlite::params![now, issue_id], ) .map_err(|e| e.to_string())?; } } if let Some(ref category) = updates.category { db.execute( "UPDATE issues SET category = ?1, updated_at = ?2 WHERE id = ?3", rusqlite::params![category, now, issue_id], ) .map_err(|e| e.to_string())?; } if let Some(ref assigned_to) = updates.assigned_to { db.execute( "UPDATE issues SET assigned_to = ?1, updated_at = ?2 WHERE id = ?3", rusqlite::params![assigned_to, now, issue_id], ) .map_err(|e| e.to_string())?; } if let Some(ref tags) = updates.tags { db.execute( "UPDATE issues SET tags = ?1, updated_at = ?2 WHERE id = ?3", rusqlite::params![tags, now, issue_id], ) .map_err(|e| e.to_string())?; } // Fetch and return updated issue let mut stmt = db .prepare( "SELECT id, title, description, severity, status, category, source, \ created_at, updated_at, resolved_at, assigned_to, tags \ FROM issues WHERE id = ?1", ) .map_err(|e| e.to_string())?; stmt.query_row([&issue_id], |row| { Ok(Issue { id: row.get(0)?, title: row.get(1)?, description: row.get(2)?, severity: row.get(3)?, status: row.get(4)?, category: row.get(5)?, source: row.get(6)?, created_at: row.get(7)?, updated_at: row.get(8)?, resolved_at: row.get(9)?, assigned_to: row.get(10)?, tags: row.get(11)?, }) }) .map_err(|e| e.to_string()) } #[tauri::command] pub async fn delete_issue(issue_id: String, state: State<'_, AppState>) -> Result<(), String> { let db = state.db.lock().map_err(|e| e.to_string())?; // Delete related records (CASCADE should handle this, but be explicit) db.execute("DELETE FROM ai_messages WHERE conversation_id IN (SELECT id FROM ai_conversations WHERE issue_id = ?1)", [&issue_id]) .map_err(|e| e.to_string())?; db.execute( "DELETE FROM ai_conversations WHERE issue_id = ?1", [&issue_id], ) .map_err(|e| e.to_string())?; db.execute( "DELETE FROM pii_spans WHERE log_file_id IN (SELECT id FROM log_files WHERE issue_id = ?1)", [&issue_id], ) .map_err(|e| e.to_string())?; db.execute("DELETE FROM log_files WHERE issue_id = ?1", [&issue_id]) .map_err(|e| e.to_string())?; db.execute( "DELETE FROM image_attachments WHERE issue_id = ?1", [&issue_id], ) .map_err(|e| e.to_string())?; db.execute( "DELETE FROM resolution_steps WHERE issue_id = ?1", [&issue_id], ) .map_err(|e| e.to_string())?; db.execute( "DELETE FROM timeline_events WHERE issue_id = ?1", [&issue_id], ) .map_err(|e| e.to_string())?; db.execute("DELETE FROM issues WHERE id = ?1", [&issue_id]) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn list_issues( filter: IssueFilter, state: State<'_, AppState>, ) -> Result, String> { let db = state.db.lock().map_err(|e| e.to_string())?; let limit = filter.limit.unwrap_or(50); let offset = filter.offset.unwrap_or(0); let mut sql = String::from( "SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \ (SELECT COUNT(*) FROM log_files lf WHERE lf.issue_id = i.id) as log_count, \ (SELECT COUNT(*) FROM resolution_steps rs WHERE rs.issue_id = i.id) as step_count \ FROM issues i WHERE 1=1", ); let mut params: Vec> = vec![]; if let Some(ref status) = filter.status { sql.push_str(&format!( " AND i.status = ?{index}", index = params.len() + 1 )); params.push(Box::new(status.clone())); } if let Some(ref severity) = filter.severity { sql.push_str(&format!( " AND i.severity = ?{index}", index = params.len() + 1 )); params.push(Box::new(severity.clone())); } if let Some(ref category) = filter.category { sql.push_str(&format!( " AND i.category = ?{index}", index = params.len() + 1 )); params.push(Box::new(category.clone())); } if let Some(ref domain) = filter.domain { sql.push_str(&format!( " AND i.category = ?{index}", index = params.len() + 1 )); params.push(Box::new(domain.clone())); } if let Some(ref search) = filter.search { let pattern = format!("%{search}%"); let idx = params.len() + 1; sql.push_str(&format!( " AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \ OR EXISTS (SELECT 1 FROM ai_messages am \ JOIN ai_conversations ac ON ac.id = am.conversation_id \ WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \ OR EXISTS (SELECT 1 FROM resolution_steps rs \ WHERE rs.issue_id = i.id \ AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \ OR EXISTS (SELECT 1 FROM log_files lf \ WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \ OR EXISTS (SELECT 1 FROM timeline_events te \ WHERE te.issue_id = i.id AND te.description LIKE ?{idx}))", )); params.push(Box::new(pattern)); } sql.push_str(" ORDER BY i.updated_at DESC"); sql.push_str(&format!( " LIMIT ?{limit_index} OFFSET ?{offset_index}", limit_index = params.len() + 1, offset_index = params.len() + 2 )); params.push(Box::new(limit)); params.push(Box::new(offset)); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = db.prepare(&sql).map_err(|e| e.to_string())?; let issues = stmt .query_map(param_refs.as_slice(), |row| { Ok(IssueSummary { id: row.get(0)?, title: row.get(1)?, severity: row.get(2)?, status: row.get(3)?, category: row.get(4)?, created_at: row.get(5)?, updated_at: row.get(6)?, log_count: row.get(7)?, step_count: row.get(8)?, }) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); Ok(issues) } #[tauri::command] pub async fn search_issues( query: String, state: State<'_, AppState>, ) -> Result, String> { let filter = IssueFilter { search: Some(query), limit: Some(50), ..Default::default() }; list_issues(filter, state).await } #[tauri::command] pub async fn get_issue_messages( issue_id: String, state: State<'_, AppState>, ) -> Result, String> { let db = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = db .prepare( "SELECT am.id, am.conversation_id, am.role, am.content, am.token_count, am.created_at \ FROM ai_messages am \ JOIN ai_conversations ac ON ac.id = am.conversation_id \ WHERE ac.issue_id = ?1 \ ORDER BY am.created_at ASC", ) .map_err(|e| e.to_string())?; let messages = stmt .query_map([&issue_id], |row| { Ok(AiMessage { id: row.get(0)?, conversation_id: row.get(1)?, role: row.get(2)?, content: row.get(3)?, token_count: row.get(4)?, created_at: row.get(5)?, }) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); Ok(messages) } #[tauri::command] pub async fn add_five_why( issue_id: String, step_order: i64, why_question: String, answer: String, evidence: String, state: State<'_, AppState>, ) -> Result { let step = ResolutionStep::new(issue_id.clone(), step_order, why_question, answer, evidence); let db = state.db.lock().map_err(|e| e.to_string())?; db.execute( "INSERT INTO resolution_steps (id, issue_id, step_order, why_question, answer, evidence, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![ step.id, step.issue_id, step.step_order, step.why_question, step.answer, step.evidence, step.created_at, ], ) .map_err(|e| e.to_string())?; // Update issue timestamp let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); db.execute( "UPDATE issues SET updated_at = ?1 WHERE id = ?2", rusqlite::params![now, issue_id], ) .map_err(|e| e.to_string())?; Ok(step) } #[tauri::command] pub async fn update_five_why( step_id: String, answer: String, evidence: Option, state: State<'_, AppState>, ) -> Result<(), String> { let db = state.db.lock().map_err(|e| e.to_string())?; if let Some(ref ev) = evidence { db.execute( "UPDATE resolution_steps SET answer = ?1, evidence = ?2 WHERE id = ?3", rusqlite::params![answer, ev, step_id], ) .map_err(|e| e.to_string())?; } else { db.execute( "UPDATE resolution_steps SET answer = ?1 WHERE id = ?2", rusqlite::params![answer, step_id], ) .map_err(|e| e.to_string())?; } Ok(()) } const VALID_EVENT_TYPES: &[&str] = &[ "triage_started", "log_uploaded", "why_level_advanced", "root_cause_identified", "rca_generated", "postmortem_generated", "document_exported", ]; #[tauri::command] pub async fn add_timeline_event( issue_id: String, event_type: String, description: String, metadata: Option, state: State<'_, AppState>, ) -> Result { if !VALID_EVENT_TYPES.contains(&event_type.as_str()) { return Err(format!("Invalid event_type: {event_type}")); } let meta = metadata.unwrap_or_else(|| "{}".to_string()); if meta.len() > 10240 { return Err("metadata exceeds maximum size of 10KB".to_string()); } serde_json::from_str::(&meta) .map_err(|_| "metadata must be valid JSON".to_string())?; let event = TimelineEvent::new( issue_id.clone(), event_type.clone(), description.clone(), meta, ); let mut db = state.db.lock().map_err(|e| e.to_string())?; let tx = db.transaction().map_err(|e| e.to_string())?; tx.execute( "INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ event.id, event.issue_id, event.event_type, event.description, event.metadata, event.created_at, ], ) .map_err(|e| e.to_string())?; crate::audit::log::write_audit_event( &tx, &event_type, "issue", &issue_id, &serde_json::json!({ "description": description, "metadata": event.metadata }).to_string(), ) .map_err(|_| "Failed to write security audit entry".to_string())?; let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); tx.execute( "UPDATE issues SET updated_at = ?1 WHERE id = ?2", rusqlite::params![now, issue_id], ) .map_err(|e| e.to_string())?; tx.commit().map_err(|e| e.to_string())?; Ok(event) } #[tauri::command] pub async fn get_timeline_events( issue_id: String, state: State<'_, AppState>, ) -> Result, String> { let db = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = db .prepare( "SELECT id, issue_id, event_type, description, metadata, created_at \ FROM timeline_events WHERE issue_id = ?1 ORDER BY created_at ASC", ) .map_err(|e| e.to_string())?; let events = stmt .query_map([&issue_id], |row| { Ok(TimelineEvent { id: row.get(0)?, issue_id: row.get(1)?, event_type: row.get(2)?, description: row.get(3)?, metadata: row.get(4)?, created_at: row.get(5)?, }) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); Ok(events) } #[cfg(test)] mod tests { use rusqlite::Connection; fn setup_test_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); crate::db::migrations::run_migrations(&conn).unwrap(); conn } fn insert_issue(conn: &Connection, id: &str, title: &str, description: &str) { let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); conn.execute( "INSERT INTO issues (id, title, description, severity, status, category, source, created_at, updated_at, assigned_to, tags) \ VALUES (?1, ?2, ?3, 'medium', 'open', 'general', 'manual', ?4, ?5, '', '[]')", rusqlite::params![id, title, description, &now, &now], ) .unwrap(); } fn run_search_query(conn: &Connection, search: &str) -> Vec { let pattern = format!("%{search}%"); let idx = 1; let sql = format!( "SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \ (SELECT COUNT(*) FROM log_files lf2 WHERE lf2.issue_id = i.id) as log_count, \ (SELECT COUNT(*) FROM resolution_steps rs2 WHERE rs2.issue_id = i.id) as step_count \ FROM issues i WHERE 1=1 \ AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \ OR EXISTS (SELECT 1 FROM ai_messages am \ JOIN ai_conversations ac ON ac.id = am.conversation_id \ WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \ OR EXISTS (SELECT 1 FROM resolution_steps rs \ WHERE rs.issue_id = i.id \ AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \ OR EXISTS (SELECT 1 FROM log_files lf \ WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \ OR EXISTS (SELECT 1 FROM timeline_events te \ WHERE te.issue_id = i.id AND te.description LIKE ?{idx})) \ ORDER BY i.updated_at DESC" ); let mut stmt = conn.prepare(&sql).unwrap(); stmt.query_map([&pattern], |row| row.get::<_, String>(0)) .unwrap() .filter_map(|r| r.ok()) .collect() } #[test] fn test_search_finds_by_title() { let conn = setup_test_db(); insert_issue(&conn, "issue-1", "Kubernetes OOM crash", "No details"); insert_issue(&conn, "issue-2", "Network timeout", "No details"); let results = run_search_query(&conn, "Kubernetes"); assert_eq!(results, vec!["issue-1"]); } #[test] fn test_search_finds_by_description() { let conn = setup_test_db(); insert_issue( &conn, "issue-1", "Generic title", "The pod was killed due to memory pressure", ); insert_issue(&conn, "issue-2", "Other", "All fine"); let results = run_search_query(&conn, "memory pressure"); assert_eq!(results, vec!["issue-1"]); } #[test] fn test_search_finds_by_ai_message_content() { let conn = setup_test_db(); insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc"); let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); conn.execute( "INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"], ).unwrap(); conn.execute( "INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params!["msg-1", "conv-1", "assistant", "The root cause is a deadlock in PostgreSQL", 10, &now], ).unwrap(); let results = run_search_query(&conn, "deadlock in PostgreSQL"); assert_eq!(results, vec!["issue-1"]); } #[test] fn test_search_finds_by_resolution_step_answer() { let conn = setup_test_db(); insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc"); let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); conn.execute( "INSERT INTO resolution_steps (id, issue_id, step_order, why_question, answer, evidence, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params!["step-1", "issue-1", 1, "Why did it fail?", "Connection pool exhausted", "metrics dashboard", &now], ).unwrap(); let results = run_search_query(&conn, "pool exhausted"); assert_eq!(results, vec!["issue-1"]); } #[test] fn test_search_finds_by_log_file_name() { let conn = setup_test_db(); insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc"); let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); conn.execute( "INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", rusqlite::params!["lf-1", "issue-1", "nginx-error-2025.log", "/tmp/nginx-error-2025.log", 1024, "text/plain", "abc", &now, 0], ).unwrap(); let results = run_search_query(&conn, "nginx-error"); assert_eq!(results, vec!["issue-1"]); } #[test] fn test_search_finds_by_timeline_description() { let conn = setup_test_db(); insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc"); conn.execute( "INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params!["te-1", "issue-1", "triage_started", "Engineer started investigating DNS resolution failure", "{}", "2025-01-15 10:00:00"], ).unwrap(); let results = run_search_query(&conn, "DNS resolution"); assert_eq!(results, vec!["issue-1"]); } #[test] fn test_search_no_duplicates_for_multiple_matches() { let conn = setup_test_db(); insert_issue( &conn, "issue-1", "Kubernetes crash", "Kubernetes pod killed", ); let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); conn.execute( "INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"], ).unwrap(); conn.execute( "INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params!["msg-1", "conv-1", "assistant", "Kubernetes OOM detected", 5, &now], ).unwrap(); let results = run_search_query(&conn, "Kubernetes"); assert_eq!(results.len(), 1); assert_eq!(results[0], "issue-1"); } }