use crate::cli::render::{Icons, Theme}; use crate::core::trace::{TraceChain, TraceResult}; /// Parse a path with optional `:line` suffix. /// /// Handles Windows drive letters (e.g. `C:/foo.rs`) by checking that the /// prefix before the colon is not a single ASCII letter. pub fn parse_trace_path(input: &str) -> (String, Option) { if let Some((path, suffix)) = input.rsplit_once(':') && !path.is_empty() && let Ok(line) = suffix.parse::() // Reject Windows drive letters: single ASCII letter before colon && (path.len() > 1 || !path.chars().next().unwrap_or(' ').is_ascii_alphabetic()) { return (path.to_string(), Some(line)); } (input.to_string(), None) } // ── Human output ──────────────────────────────────────────────────────────── pub fn print_trace(result: &TraceResult) { let chain_info = if result.total_chains == 1 { "1 chain".to_string() } else { format!("{} chains", result.total_chains) }; let paths_info = if result.resolved_paths.len() > 1 { format!(", {} paths", result.resolved_paths.len()) } else { String::new() }; println!(); println!( "{}", Theme::bold().render(&format!( "Trace: {} ({}{})", result.path, chain_info, paths_info )) ); // Rename chain if result.renames_followed && result.resolved_paths.len() > 1 { let chain_str: Vec<&str> = result.resolved_paths.iter().map(String::as_str).collect(); println!( " Rename chain: {}", Theme::dim().render(&chain_str.join(" -> ")) ); } // Show searched paths when there are renames but no chains if result.trace_chains.is_empty() { println!( "\n {} {}", Icons::info(), Theme::dim().render("No trace chains found for this file.") ); if !result.renames_followed && result.resolved_paths.len() == 1 { println!( " {} Searched: {}", Icons::info(), Theme::dim().render(&result.resolved_paths[0]) ); } for hint in &result.hints { println!(" {} {}", Icons::info(), Theme::dim().render(hint)); } println!(); return; } println!(); for chain in &result.trace_chains { print_chain(chain); } println!(); } fn print_chain(chain: &TraceChain) { let (icon, state_style) = match chain.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 = chain .merged_at_iso .as_deref() .or(Some(chain.updated_at_iso.as_str())) .unwrap_or("") .split('T') .next() .unwrap_or(""); println!( " {} {} {} {} @{} {} {}", icon, Theme::accent().render(&format!("!{}", chain.mr_iid)), chain.mr_title, state_style.render(&chain.mr_state), chain.mr_author, date, Theme::dim().render(&chain.change_type), ); // Linked issues for issue in &chain.issues { let ref_icon = match issue.reference_type.as_str() { "closes" => Icons::issue_closed(), _ => Icons::issue_opened(), }; println!( " {} #{} {} {} [{}]", ref_icon, issue.iid, issue.title, Theme::dim().render(&issue.state), Theme::dim().render(&issue.reference_type), ); } // Discussions for disc in &chain.discussions { let date = disc.created_at_iso.split('T').next().unwrap_or(""); println!( " {} @{} ({}) [{}]: {}", Icons::note(), disc.author_username, date, Theme::dim().render(&disc.path), disc.body ); } } // ── Robot (JSON) output ───────────────────────────────────────────────────── /// Maximum body length in robot JSON output (token efficiency). const ROBOT_BODY_SNIPPET_LEN: usize = 500; fn truncate_body(body: &str, max: usize) -> String { if body.len() <= max { return body.to_string(); } let boundary = body.floor_char_boundary(max); format!("{}...", &body[..boundary]) } pub fn print_trace_json(result: &TraceResult, elapsed_ms: u64, line_requested: Option) { // Truncate discussion bodies for token efficiency in robot mode let chains: Vec = result .trace_chains .iter() .map(|chain| { let discussions: Vec = chain .discussions .iter() .map(|d| { serde_json::json!({ "discussion_id": d.discussion_id, "mr_iid": d.mr_iid, "author_username": d.author_username, "body_snippet": truncate_body(&d.body, ROBOT_BODY_SNIPPET_LEN), "path": d.path, "created_at_iso": d.created_at_iso, }) }) .collect(); serde_json::json!({ "mr_iid": chain.mr_iid, "mr_title": chain.mr_title, "mr_state": chain.mr_state, "mr_author": chain.mr_author, "change_type": chain.change_type, "merged_at_iso": chain.merged_at_iso, "updated_at_iso": chain.updated_at_iso, "web_url": chain.web_url, "issues": chain.issues, "discussions": discussions, }) }) .collect(); let output = serde_json::json!({ "ok": true, "data": { "path": result.path, "resolved_paths": result.resolved_paths, "trace_chains": chains, }, "meta": { "tier": "api_only", "line_requested": line_requested, "elapsed_ms": elapsed_ms, "total_chains": result.total_chains, "renames_followed": result.renames_followed, "hints": if result.hints.is_empty() { None } else { Some(&result.hints) }, } }); println!("{}", serde_json::to_string(&output).unwrap_or_default()); } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_trace_path_simple() { let (path, line) = parse_trace_path("src/foo.rs"); assert_eq!(path, "src/foo.rs"); assert_eq!(line, None); } #[test] fn test_parse_trace_path_with_line() { let (path, line) = parse_trace_path("src/foo.rs:42"); assert_eq!(path, "src/foo.rs"); assert_eq!(line, Some(42)); } #[test] fn test_parse_trace_path_windows() { let (path, line) = parse_trace_path("C:/foo.rs"); assert_eq!(path, "C:/foo.rs"); assert_eq!(line, None); } #[test] fn test_parse_trace_path_directory() { let (path, line) = parse_trace_path("src/auth/"); assert_eq!(path, "src/auth/"); assert_eq!(line, None); } #[test] fn test_parse_trace_path_with_line_zero() { let (path, line) = parse_trace_path("file.rs:0"); assert_eq!(path, "file.rs"); assert_eq!(line, Some(0)); } }