use std::collections::HashMap; use crate::cli::render::{self, Theme}; use serde::Serialize; use crate::Config; use crate::core::db::create_connection; use crate::core::error::{LoreError, Result}; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; use crate::core::time::{ms_to_iso, parse_since}; use crate::documents::SourceType; use crate::embedding::ollama::{OllamaClient, OllamaConfig}; use crate::search::{ FtsQueryMode, HybridResult, PathFilter, SearchFilters, SearchMode, get_result_snippet, search_fts, search_hybrid, }; #[derive(Debug, Serialize)] pub struct SearchResultDisplay { pub document_id: i64, pub source_type: String, #[serde(skip_serializing_if = "Option::is_none")] pub source_entity_iid: Option, pub title: String, pub url: Option, pub author: Option, pub created_at: Option, pub updated_at: Option, /// Raw epoch ms for human rendering; not serialized to JSON. #[serde(skip)] pub updated_at_ms: Option, pub project_path: String, pub labels: Vec, pub paths: Vec, pub snippet: String, pub score: f64, #[serde(skip_serializing_if = "Option::is_none")] pub explain: Option, } #[derive(Debug, Serialize)] pub struct ExplainData { pub vector_rank: Option, pub fts_rank: Option, pub rrf_score: f64, } #[derive(Debug, Serialize)] pub struct SearchResponse { pub query: String, pub mode: String, pub total_results: usize, pub results: Vec, pub warnings: Vec, } pub struct SearchCliFilters { pub source_type: Option, pub author: Option, pub project: Option, pub labels: Vec, pub path: Option, pub since: Option, pub updated_since: Option, pub limit: usize, } pub async fn run_search( config: &Config, query: &str, cli_filters: SearchCliFilters, fts_mode: FtsQueryMode, requested_mode: &str, explain: bool, ) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; let mut warnings: Vec = Vec::new(); let actual_mode = SearchMode::parse(requested_mode).unwrap_or(SearchMode::Hybrid); let client = if actual_mode != SearchMode::Lexical { let ollama_cfg = &config.embedding; Some(OllamaClient::new(OllamaConfig { base_url: ollama_cfg.base_url.clone(), model: ollama_cfg.model.clone(), ..OllamaConfig::default() })) } else { None }; let doc_count: i64 = conn .query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0)) .unwrap_or(0); if doc_count == 0 { warnings.push("No documents indexed. Run 'lore generate-docs' first.".to_string()); return Ok(SearchResponse { query: query.to_string(), mode: actual_mode.as_str().to_string(), total_results: 0, results: vec![], warnings, }); } let source_type = cli_filters .source_type .as_deref() .and_then(SourceType::parse); let project_id = cli_filters .project .as_deref() .map(|p| resolve_project(&conn, p)) .transpose()?; let since = cli_filters .since .as_deref() .map(|s| { parse_since(s).ok_or_else(|| { LoreError::Other(format!( "Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", s )) }) }) .transpose()?; let updated_since = cli_filters .updated_since .as_deref() .map(|s| { parse_since(s).ok_or_else(|| { LoreError::Other(format!( "Invalid --updated-since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", s )) }) }) .transpose()?; let path = cli_filters.path.as_deref().map(|p| { if p.ends_with('/') { PathFilter::Prefix(p.to_string()) } else { PathFilter::Exact(p.to_string()) } }); let filters = SearchFilters { source_type, author: cli_filters.author, project_id, since, updated_since, labels: cli_filters.labels, path, limit: cli_filters.limit, }; // Run FTS separately for snippet extraction (search_hybrid doesn't return snippets). let snippet_top_k = filters .clamp_limit() .checked_mul(10) .unwrap_or(500) .clamp(50, 1500); let fts_results = search_fts(&conn, query, snippet_top_k, fts_mode)?; let snippet_map: HashMap = fts_results .iter() .map(|r| (r.document_id, r.snippet.clone())) .collect(); // search_hybrid handles recall sizing, RRF ranking, and filter application internally. let (hybrid_results, mut hybrid_warnings) = search_hybrid( &conn, client.as_ref(), query, actual_mode, &filters, fts_mode, ) .await?; warnings.append(&mut hybrid_warnings); if hybrid_results.is_empty() { return Ok(SearchResponse { query: query.to_string(), mode: actual_mode.as_str().to_string(), total_results: 0, results: vec![], warnings, }); } let ranked_ids: Vec = hybrid_results.iter().map(|r| r.document_id).collect(); let hydrated = hydrate_results(&conn, &ranked_ids)?; let hybrid_map: HashMap = hybrid_results.iter().map(|r| (r.document_id, r)).collect(); let mut results: Vec = Vec::with_capacity(hydrated.len()); for row in &hydrated { let hr = hybrid_map.get(&row.document_id); let fts_snippet = snippet_map.get(&row.document_id).map(|s| s.as_str()); let snippet = get_result_snippet(fts_snippet, &row.content_text); let explain_data = if explain { hr.map(|r| ExplainData { vector_rank: r.vector_rank, fts_rank: r.fts_rank, rrf_score: r.rrf_score, }) } else { None }; results.push(SearchResultDisplay { document_id: row.document_id, source_type: row.source_type.clone(), source_entity_iid: row.source_entity_iid, title: row.title.clone().unwrap_or_default(), url: row.url.clone(), author: row.author.clone(), created_at: row.created_at.map(ms_to_iso), updated_at: row.updated_at.map(ms_to_iso), updated_at_ms: row.updated_at, project_path: row.project_path.clone(), labels: row.labels.clone(), paths: row.paths.clone(), snippet, score: hr.map(|r| r.score).unwrap_or(0.0), explain: explain_data, }); } Ok(SearchResponse { query: query.to_string(), mode: actual_mode.as_str().to_string(), total_results: results.len(), results, warnings, }) } struct HydratedRow { document_id: i64, source_type: String, source_entity_iid: Option, title: Option, url: Option, author: Option, created_at: Option, updated_at: Option, content_text: String, project_path: String, labels: Vec, paths: Vec, } fn hydrate_results(conn: &rusqlite::Connection, document_ids: &[i64]) -> Result> { if document_ids.is_empty() { return Ok(Vec::new()); } let ids_json = serde_json::to_string(document_ids).map_err(|e| LoreError::Other(e.to_string()))?; let sql = r#" SELECT d.id, d.source_type, d.title, d.url, d.author_username, d.created_at, d.updated_at, d.content_text, p.path_with_namespace AS project_path, (SELECT json_group_array(dl.label_name) FROM document_labels dl WHERE dl.document_id = d.id) AS labels_json, (SELECT json_group_array(dp.path) FROM document_paths dp WHERE dp.document_id = d.id) AS paths_json, CASE d.source_type WHEN 'issue' THEN (SELECT i.iid FROM issues i WHERE i.id = d.source_id) WHEN 'merge_request' THEN (SELECT m.iid FROM merge_requests m WHERE m.id = d.source_id) WHEN 'discussion' THEN (SELECT COALESCE( (SELECT i.iid FROM issues i WHERE i.id = disc.issue_id), (SELECT m.iid FROM merge_requests m WHERE m.id = disc.merge_request_id) ) FROM discussions disc WHERE disc.id = d.source_id) WHEN 'note' THEN (SELECT COALESCE( (SELECT i.iid FROM issues i WHERE i.id = disc.issue_id), (SELECT m.iid FROM merge_requests m WHERE m.id = disc.merge_request_id) ) FROM notes n JOIN discussions disc ON disc.id = n.discussion_id WHERE n.id = d.source_id) ELSE NULL END AS source_entity_iid FROM json_each(?1) AS j JOIN documents d ON d.id = j.value JOIN projects p ON p.id = d.project_id ORDER BY j.key "#; let mut stmt = conn.prepare(sql)?; let rows = stmt .query_map([ids_json], |row| { let labels_json: String = row.get(9)?; let paths_json: String = row.get(10)?; Ok(HydratedRow { document_id: row.get(0)?, source_type: row.get(1)?, title: row.get(2)?, url: row.get(3)?, author: row.get(4)?, created_at: row.get(5)?, updated_at: row.get(6)?, content_text: row.get(7)?, project_path: row.get(8)?, labels: parse_json_array(&labels_json), paths: parse_json_array(&paths_json), source_entity_iid: row.get(11)?, }) })? .collect::, _>>()?; Ok(rows) } fn parse_json_array(json: &str) -> Vec { serde_json::from_str::>(json) .unwrap_or_default() .into_iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .filter(|s| !s.is_empty()) .collect() } /// Render FTS snippet with `` tags as terminal highlight style. fn render_snippet(snippet: &str) -> String { let mut result = String::new(); let mut remaining = snippet; while let Some(start) = remaining.find("") { result.push_str(&Theme::muted().render(&remaining[..start])); remaining = &remaining[start + 6..]; if let Some(end) = remaining.find("") { let highlighted = &remaining[..end]; result.push_str(&Theme::highlight().render(highlighted)); remaining = &remaining[end + 7..]; } } result.push_str(&Theme::muted().render(remaining)); result } pub fn print_search_results(response: &SearchResponse, explain: bool) { if !response.warnings.is_empty() { for w in &response.warnings { eprintln!("{} {}", Theme::warning().render("Warning:"), w); } } if response.results.is_empty() { println!( "No results found for '{}'", Theme::bold().render(&response.query) ); return; } // Phase 6: section divider header println!( "{}", render::section_divider(&format!( "{} results for '{}' {}", response.total_results, response.query, response.mode )) ); for (i, result) in response.results.iter().enumerate() { println!(); let type_badge = match result.source_type.as_str() { "issue" => Theme::issue_ref().render("issue"), "merge_request" => Theme::mr_ref().render(" mr "), "discussion" => Theme::info().render(" disc"), "note" => Theme::muted().render(" note"), _ => Theme::muted().render(&format!("{:>5}", &result.source_type)), }; // Phase 1: entity ref (e.g. #42 or !99) let entity_ref = result .source_entity_iid .map(|iid| match result.source_type.as_str() { "issue" | "discussion" | "note" => Theme::issue_ref().render(&format!("#{iid}")), "merge_request" => Theme::mr_ref().render(&format!("!{iid}")), _ => String::new(), }); // Phase 3: relative time let time_str = result .updated_at_ms .map(|ms| Theme::dim().render(&render::format_relative_time_compact(ms))); // Phase 2: build prefix, compute indent from its visible width let prefix = format!(" {:>3}. {} ", i + 1, type_badge); let indent = " ".repeat(render::visible_width(&prefix)); // Title line: rank, type badge, entity ref, title, relative time let mut title_line = prefix; if let Some(ref eref) = entity_ref { title_line.push_str(eref); title_line.push_str(" "); } title_line.push_str(&Theme::bold().render(&result.title)); if let Some(ref time) = time_str { title_line.push_str(" "); title_line.push_str(time); } println!("{title_line}"); // Metadata: project, author — compact middle-dot line let sep = Theme::muted().render(" \u{b7} "); let mut meta_parts: Vec = Vec::new(); meta_parts.push(Theme::muted().render(&result.project_path)); if let Some(ref author) = result.author { meta_parts.push(Theme::username().render(&format!("@{author}"))); } println!("{indent}{}", meta_parts.join(&sep)); // Phase 5: limit snippet to ~2 terminal lines let max_snippet_width = render::terminal_width().saturating_sub(render::visible_width(&indent)); let max_snippet_chars = max_snippet_width.saturating_mul(2); let snippet = if result.snippet.chars().count() > max_snippet_chars && max_snippet_chars > 3 { let truncated: String = result.snippet.chars().take(max_snippet_chars - 3).collect(); format!("{truncated}...") } else { result.snippet.clone() }; let rendered = render_snippet(&snippet); println!("{indent}{rendered}"); if let Some(ref explain_data) = result.explain { let mut explain_line = format!( "{indent}{} vec={} fts={} rrf={:.4}", Theme::accent().render("explain"), explain_data .vector_rank .map(|r| r.to_string()) .unwrap_or_else(|| "-".into()), explain_data .fts_rank .map(|r| r.to_string()) .unwrap_or_else(|| "-".into()), explain_data.rrf_score ); // Phase 5: labels shown only in explain mode if explain && !result.labels.is_empty() { let label_str = if result.labels.len() <= 3 { result.labels.join(", ") } else { format!( "{} +{}", result.labels[..2].join(", "), result.labels.len() - 2 ) }; explain_line.push_str(&format!(" {}", Theme::muted().render(&label_str))); } println!("{explain_line}"); } } // Phase 4: drill-down hint footer if let Some(first) = response.results.first() && let Some(iid) = first.source_entity_iid { let cmd = match first.source_type.as_str() { "issue" | "discussion" | "note" => Some(format!("lore issues {iid}")), "merge_request" => Some(format!("lore mrs {iid}")), _ => None, }; if let Some(cmd) = cmd { println!( "\n {} {}", Theme::dim().render("Tip:"), Theme::dim().render(&format!("{cmd} for details")) ); } } println!(); } #[derive(Serialize)] struct SearchJsonOutput<'a> { ok: bool, data: &'a SearchResponse, meta: SearchMeta, } #[derive(Serialize)] struct SearchMeta { elapsed_ms: u64, } pub fn print_search_results_json( response: &SearchResponse, elapsed_ms: u64, fields: Option<&[String]>, ) { let output = SearchJsonOutput { ok: true, data: response, meta: SearchMeta { elapsed_ms }, }; let mut value = serde_json::to_value(&output).unwrap(); if let Some(f) = fields { let expanded = crate::cli::robot::expand_fields_preset(f, "search"); crate::cli::robot::filter_fields(&mut value, "results", &expanded); } match serde_json::to_string(&value) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } }