use serde::Serialize; use tracing::info; use crate::Config; use crate::cli::render::{self, Icons, Theme}; use crate::core::db::create_connection; use crate::core::error::Result; use crate::core::file_history::resolve_rename_chain; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; use crate::core::time::ms_to_iso; /// Maximum rename chain BFS depth. const MAX_RENAME_HOPS: usize = 10; /// A single MR that touched the file. #[derive(Debug, Serialize)] pub struct FileHistoryMr { pub iid: i64, pub title: String, pub state: String, pub author_username: String, pub change_type: String, pub merged_at_iso: Option, pub updated_at_iso: String, pub merge_commit_sha: Option, pub web_url: Option, } /// A DiffNote discussion snippet on the file. #[derive(Debug, Serialize)] pub struct FileDiscussion { pub discussion_id: String, pub author_username: String, pub body_snippet: String, pub path: String, pub created_at_iso: String, } /// Full result of a file-history query. #[derive(Debug, Serialize)] pub struct FileHistoryResult { pub path: String, pub rename_chain: Vec, pub renames_followed: bool, pub merge_requests: Vec, pub discussions: Vec, pub total_mrs: usize, pub paths_searched: usize, /// Diagnostic hints explaining why results may be empty. #[serde(skip_serializing_if = "Vec::is_empty")] pub hints: Vec, } /// Run the file-history query. pub fn run_file_history( config: &Config, path: &str, project: Option<&str>, no_follow_renames: bool, merged_only: bool, include_discussions: bool, limit: usize, ) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; let project_id = project.map(|p| resolve_project(&conn, p)).transpose()?; // Resolve rename chain unless disabled let (all_paths, renames_followed) = if no_follow_renames { (vec![path.to_string()], false) } else if let Some(pid) = project_id { let chain = resolve_rename_chain(&conn, pid, path, MAX_RENAME_HOPS)?; let followed = chain.len() > 1; (chain, followed) } else { // Without a project scope, can't resolve renames (need project_id) (vec![path.to_string()], false) }; let paths_searched = all_paths.len(); info!( paths = paths_searched, renames_followed, "file-history: resolved {} path(s) for '{}'", paths_searched, path ); // Build placeholders for IN clause let placeholders: Vec = (0..all_paths.len()) .map(|i| format!("?{}", i + 2)) .collect(); let in_clause = placeholders.join(", "); let merged_filter = if merged_only { " AND mr.state = 'merged'" } else { "" }; let project_filter = if project_id.is_some() { "AND mfc.project_id = ?1" } else { "" }; let sql = format!( "SELECT DISTINCT \ mr.iid, mr.title, mr.state, mr.author_username, \ mfc.change_type, mr.merged_at, mr.updated_at, mr.merge_commit_sha, mr.web_url \ FROM mr_file_changes mfc \ JOIN merge_requests mr ON mr.id = mfc.merge_request_id \ WHERE mfc.new_path IN ({in_clause}) {project_filter} {merged_filter} \ ORDER BY COALESCE(mr.merged_at, mr.updated_at) DESC \ LIMIT ?{}", all_paths.len() + 2 ); let mut stmt = conn.prepare(&sql)?; // Bind parameters: ?1 = project_id (or 0 placeholder), ?2..?N+1 = paths, ?N+2 = limit let mut params: Vec> = Vec::new(); params.push(Box::new(project_id.unwrap_or(0))); for p in &all_paths { params.push(Box::new(p.clone())); } params.push(Box::new(limit as i64)); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let merge_requests: Vec = stmt .query_map(param_refs.as_slice(), |row| { let merged_at: Option = row.get(5)?; let updated_at: i64 = row.get(6)?; Ok(FileHistoryMr { iid: row.get(0)?, title: row.get(1)?, state: row.get(2)?, author_username: row.get(3)?, change_type: row.get(4)?, merged_at_iso: merged_at.map(ms_to_iso), updated_at_iso: ms_to_iso(updated_at), merge_commit_sha: row.get(7)?, web_url: row.get(8)?, }) })? .collect::, _>>()?; let total_mrs = merge_requests.len(); info!( mr_count = total_mrs, "file-history: found {} MR(s) touching '{}'", total_mrs, path ); // Optionally fetch DiffNote discussions on this file let discussions = if include_discussions && !merge_requests.is_empty() { let discs = fetch_file_discussions(&conn, &all_paths, project_id)?; info!( discussion_count = discs.len(), "file-history: found {} discussion(s)", discs.len() ); discs } else { Vec::new() }; // Build diagnostic hints when no results found let hints = if total_mrs == 0 { build_file_history_hints(&conn, project_id, &all_paths)? } else { Vec::new() }; Ok(FileHistoryResult { path: path.to_string(), rename_chain: all_paths, renames_followed, merge_requests, discussions, total_mrs, paths_searched, hints, }) } /// Fetch DiffNote discussions that reference the given file paths. fn fetch_file_discussions( conn: &rusqlite::Connection, paths: &[String], project_id: Option, ) -> Result> { let placeholders: Vec = (0..paths.len()).map(|i| format!("?{}", i + 2)).collect(); let in_clause = placeholders.join(", "); let project_filter = if project_id.is_some() { "AND d.project_id = ?1" } else { "" }; let sql = format!( "SELECT d.gitlab_discussion_id, n.author_username, n.body, n.position_new_path, n.created_at \ FROM notes n \ JOIN discussions d ON d.id = n.discussion_id \ WHERE n.position_new_path IN ({in_clause}) {project_filter} \ AND n.is_system = 0 \ ORDER BY n.created_at DESC" ); let mut stmt = conn.prepare(&sql)?; let mut params: Vec> = Vec::new(); params.push(Box::new(project_id.unwrap_or(0))); for p in paths { params.push(Box::new(p.clone())); } let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let discussions: Vec = stmt .query_map(param_refs.as_slice(), |row| { let body: String = row.get(2)?; let snippet = if body.len() > 200 { format!("{}...", &body[..body.floor_char_boundary(200)]) } else { body }; let created_at: i64 = row.get(4)?; Ok(FileDiscussion { discussion_id: row.get(0)?, author_username: row.get(1)?, body_snippet: snippet, path: row.get(3)?, created_at_iso: ms_to_iso(created_at), }) })? .collect::, _>>()?; Ok(discussions) } /// Build diagnostic hints explaining why a file-history query returned no results. fn build_file_history_hints( conn: &rusqlite::Connection, project_id: Option, paths: &[String], ) -> Result> { let mut hints = Vec::new(); // Check if mr_file_changes has ANY rows for this project let has_file_changes: bool = if let Some(pid) = project_id { conn.query_row( "SELECT EXISTS(SELECT 1 FROM mr_file_changes WHERE project_id = ?1 LIMIT 1)", rusqlite::params![pid], |row| row.get(0), )? } else { conn.query_row( "SELECT EXISTS(SELECT 1 FROM mr_file_changes LIMIT 1)", [], |row| row.get(0), )? }; if !has_file_changes { hints.push( "No MR file changes have been synced yet. Run 'lore sync' to fetch file change data." .to_string(), ); return Ok(hints); } // File changes exist but none match these paths let path_list = paths .iter() .map(|p| format!("'{p}'")) .collect::>() .join(", "); hints.push(format!( "Searched paths [{}] were not found in MR file changes. \ The file may predate the sync window or use a different path.", path_list )); Ok(hints) } // ── Human output ──────────────────────────────────────────────────────────── pub fn print_file_history(result: &FileHistoryResult) { // Header let paths_info = if result.paths_searched > 1 { format!( " (via {} paths, {} MRs)", result.paths_searched, result.total_mrs ) } else { format!(" ({} MRs)", result.total_mrs) }; println!(); println!( "{}", Theme::bold().render(&format!("File History: {}{}", result.path, paths_info)) ); // Rename chain if result.renames_followed && result.rename_chain.len() > 1 { let chain_str: Vec<&str> = result.rename_chain.iter().map(String::as_str).collect(); println!( " Rename chain: {}", Theme::dim().render(&chain_str.join(" -> ")) ); } if result.merge_requests.is_empty() { println!( "\n {} {}", Icons::info(), Theme::dim().render("No merge requests found touching this file.") ); if !result.renames_followed && result.rename_chain.len() == 1 { println!( " {} Searched: {}", Icons::info(), Theme::dim().render(&result.rename_chain[0]) ); } for hint in &result.hints { println!(" {} {}", Icons::info(), Theme::dim().render(hint)); } println!(); return; } println!(); for mr in &result.merge_requests { let (icon, state_style) = match mr.state.as_str() { "merged" => (Icons::mr_merged(), Theme::accent()), "opened" => (Icons::mr_opened(), Theme::success()), "closed" => (Icons::mr_closed(), Theme::warning()), _ => (Icons::mr_opened(), Theme::dim()), }; let date = mr .merged_at_iso .as_deref() .or(Some(mr.updated_at_iso.as_str())) .unwrap_or("") .split('T') .next() .unwrap_or(""); println!( " {} {} {} {} @{} {} {}", icon, Theme::accent().render(&format!("!{}", mr.iid)), render::truncate(&mr.title, 50), state_style.render(&mr.state), mr.author_username, date, Theme::dim().render(&mr.change_type), ); } // Discussions if !result.discussions.is_empty() { println!( "\n {} File discussions ({}):", Icons::note(), result.discussions.len() ); for d in &result.discussions { let date = d.created_at_iso.split('T').next().unwrap_or(""); println!( " @{} ({}) [{}]: {}", d.author_username, date, Theme::dim().render(&d.path), d.body_snippet ); } } println!(); } // ── Robot (JSON) output ───────────────────────────────────────────────────── pub fn print_file_history_json(result: &FileHistoryResult, elapsed_ms: u64) { let output = serde_json::json!({ "ok": true, "data": { "path": result.path, "rename_chain": if result.renames_followed { Some(&result.rename_chain) } else { None }, "merge_requests": result.merge_requests, "discussions": if result.discussions.is_empty() { None } else { Some(&result.discussions) }, }, "meta": { "elapsed_ms": elapsed_ms, "total_mrs": result.total_mrs, "renames_followed": result.renames_followed, "paths_searched": result.paths_searched, "hints": if result.hints.is_empty() { None } else { Some(&result.hints) }, } }); println!("{}", serde_json::to_string(&output).unwrap_or_default()); }