Three robot-mode print functions used `serde_json::to_string().unwrap_or_default()` which silently outputs an empty string on failure (exit 0, no error). This diverged from the codebase standard in handlers.rs which uses `?` propagation. Changed to return Result<()> with proper LoreError::Other mapping: - explain.rs: print_explain_json() - file_history.rs: print_file_history_json() - trace.rs: print_trace_json() Updated callers in handlers.rs and explain.rs to propagate with `?`. While serde_json::to_string on a json!() Value is unlikely to fail in practice (only non-finite floats trigger it), the unwrap_or_default pattern violates the robot mode contract: callers expect either valid JSON on stdout or a structured error on stderr with a non-zero exit code, never empty output with exit 0.
260 lines
7.8 KiB
Rust
260 lines
7.8 KiB
Rust
use crate::cli::render::{Icons, Theme};
|
|
use crate::core::error::{LoreError, Result};
|
|
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(" -> "))
|
|
);
|
|
}
|
|
|
|
// 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<u32>,
|
|
) -> Result<()> {
|
|
// 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,
|
|
"hints": if result.hints.is_empty() { None } else { Some(&result.hints) },
|
|
}
|
|
});
|
|
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&output)
|
|
.map_err(|e| LoreError::Other(format!("JSON serialization failed: {e}")))?
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
}
|