Gate 5 Code Trace - Tier 1 (API-only, no git blame). Answers 'Why was this code introduced?' by building file -> MR -> issue -> discussion chains. New files: - src/core/trace.rs: run_trace() query logic with rename-aware path resolution, entity_reference-based issue linking, and DiffNote discussion extraction - src/core/trace_tests.rs: 7 unit tests for query logic - src/cli/commands/trace.rs: CLI command with human output, robot JSON output, and :line suffix parsing (5 tests) Human output shows full content (no truncation). Robot JSON truncates discussion bodies to 500 chars for token efficiency. Wiring: - TraceArgs + Commands::Trace in cli/mod.rs - handle_trace in main.rs - VALID_COMMANDS + robot-docs manifest entry - COMMAND_FLAGS autocorrect registry entry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
243 lines
7.2 KiB
Rust
243 lines
7.2 KiB
Rust
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<u32>) {
|
|
if let Some((path, suffix)) = input.rsplit_once(':')
|
|
&& !path.is_empty()
|
|
&& let Ok(line) = suffix.parse::<u32>()
|
|
// 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(" -> "))
|
|
);
|
|
}
|
|
|
|
if result.trace_chains.is_empty() {
|
|
println!(
|
|
"\n {} {}",
|
|
Icons::info(),
|
|
Theme::dim().render("No trace chains found for this file.")
|
|
);
|
|
println!(
|
|
" {}",
|
|
Theme::dim()
|
|
.render("Hint: Run 'lore sync' to fetch MR file changes and cross-references.")
|
|
);
|
|
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<u32>) {
|
|
// Truncate discussion bodies for token efficiency in robot mode
|
|
let chains: Vec<serde_json::Value> = result
|
|
.trace_chains
|
|
.iter()
|
|
.map(|chain| {
|
|
let discussions: Vec<serde_json::Value> = 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,
|
|
}
|
|
});
|
|
|
|
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));
|
|
}
|
|
}
|