fix(explain): align human output with render module conventions

The explain command's human-mode output was hand-rolled with raw
println! formatting that didn't use any of the shared render.rs
infrastructure. This made it visually inconsistent with every other
command (me, who, search, timeline).

Changes to print_explain():
- Section headers now use render::section_divider() with counts,
  producing the same box-drawing divider lines as the me command
- Entity refs use Theme::issue_ref()/mr_ref() color styling
- Entity state uses Theme::state_opened/closed/merged() styling
- Authors/usernames use Theme::username() with @ prefix
- Project paths use Theme::muted()
- Timestamps use format_relative_time() for recency fields (created,
  first/last event, last note) and format_date() for point-in-time
  fields (key decisions, timeline events), matching the conventions
  in me, who, and timeline respectively
- Note excerpts use render::truncate() instead of manual byte slicing
- Related entity titles are truncated via render::truncate()
- Indentation aligned to 4-space content under section dividers

Robot JSON output is unchanged -- it continues to use ms_to_iso() for
all timestamp fields, consistent with the rest of the robot API.
This commit is contained in:
teernisse
2026-03-13 09:59:08 -04:00
parent 796b6b7289
commit 1bbdcb70ef

View File

@@ -3,7 +3,7 @@ use serde::Serialize;
use crate::core::error::{LoreError, Result}; use crate::core::error::{LoreError, Result};
use crate::core::project::resolve_project; use crate::core::project::resolve_project;
use crate::core::time::ms_to_iso; use crate::core::time::{iso_to_ms, ms_to_iso};
use crate::timeline::collect::collect_events; use crate::timeline::collect::collect_events;
use crate::timeline::seed::seed_timeline_direct; use crate::timeline::seed::seed_timeline_direct;
@@ -1127,41 +1127,71 @@ pub fn print_explain_json(result: &ExplainResult, elapsed_ms: u64) -> Result<()>
} }
pub fn print_explain(result: &ExplainResult) { pub fn print_explain(result: &ExplainResult) {
use crate::cli::render::{Icons, Theme}; use crate::cli::render::{self, Icons, Theme};
let to_relative = |iso: &str| -> String {
iso_to_ms(iso)
.map(render::format_relative_time)
.unwrap_or_else(|| iso.to_string())
};
let to_date = |iso: &str| -> String {
iso_to_ms(iso)
.map(render::format_date)
.unwrap_or_else(|| iso.to_string())
};
// Entity header // Entity header
let type_label = match result.entity.entity_type.as_str() { let (type_label, ref_style, ref_str) = match result.entity.entity_type.as_str() {
"issue" => "Issue", "issue" => ("Issue", Theme::issue_ref(), format!("#{}", result.entity.iid)),
"merge_request" => "MR", "merge_request" => ("MR", Theme::mr_ref(), format!("!{}", result.entity.iid)),
_ => &result.entity.entity_type, _ => (
result.entity.entity_type.as_str(),
Theme::info(),
format!("#{}", result.entity.iid),
),
};
let state_style = match result.entity.state.as_str() {
"opened" => Theme::state_opened(),
"closed" => Theme::state_closed(),
"merged" => Theme::state_merged(),
_ => Theme::dim(),
}; };
println!( println!(
"{} {} #{}{}", "{} {} {}{}",
Icons::info(), Icons::info(),
Theme::bold().render(type_label), Theme::bold().render(type_label),
result.entity.iid, ref_style.render(&ref_str),
Theme::bold().render(&result.entity.title) Theme::bold().render(&result.entity.title)
); );
println!( println!(
" Project: {} State: {} Author: {} Created: {}", " {} {} {} {}",
result.entity.project_path, Theme::muted().render(&result.entity.project_path),
result.entity.state, state_style.render(&result.entity.state),
result.entity.author, Theme::username().render(&format!("@{}", result.entity.author)),
result.entity.created_at Theme::dim().render(&to_relative(&result.entity.created_at)),
); );
if !result.entity.assignees.is_empty() { if !result.entity.assignees.is_empty() {
println!(" Assignees: {}", result.entity.assignees.join(", ")); let styled: Vec<String> = result
.entity
.assignees
.iter()
.map(|a| Theme::username().render(&format!("@{a}")))
.collect();
println!(" Assignees: {}", styled.join(", "));
} }
if !result.entity.labels.is_empty() { if !result.entity.labels.is_empty() {
println!(" Labels: {}", result.entity.labels.join(", ")); println!(
" Labels: {}",
Theme::dim().render(&result.entity.labels.join(", "))
);
} }
if let Some(ref url) = result.entity.url { if let Some(ref url) = result.entity.url {
println!(" URL: {url}"); println!(" {}", Theme::dim().render(url));
} }
// Description // Description
if let Some(ref desc) = result.description_excerpt { if let Some(ref desc) = result.description_excerpt {
println!("\n{}", Theme::bold().render("Description")); println!("{}", render::section_divider("Description"));
for line in desc.lines() { for line in desc.lines() {
println!(" {line}"); println!(" {line}");
} }
@@ -1172,15 +1202,14 @@ pub fn print_explain(result: &ExplainResult) {
&& !decisions.is_empty() && !decisions.is_empty()
{ {
println!( println!(
"\n{} {}", "{}",
Icons::info(), render::section_divider(&format!("Key Decisions ({})", decisions.len()))
Theme::bold().render("Key Decisions")
); );
for d in decisions { for d in decisions {
println!( println!(
" {} {}{}", " {} {}{}",
Theme::muted().render(&d.timestamp), Theme::muted().render(&to_date(&d.timestamp)),
Theme::bold().render(&d.actor), Theme::username().render(&format!("@{}", d.actor)),
d.action, d.action,
); );
for line in d.context_note.lines() { for line in d.context_note.lines() {
@@ -1191,16 +1220,22 @@ pub fn print_explain(result: &ExplainResult) {
// Activity // Activity
if let Some(ref act) = result.activity { if let Some(ref act) = result.activity {
println!("\n{}", Theme::bold().render("Activity")); println!("{}", render::section_divider("Activity"));
println!( println!(
" {} state changes, {} label changes, {} notes", " {} state changes, {} label changes, {} notes",
act.state_changes, act.label_changes, act.notes act.state_changes, act.label_changes, act.notes
); );
if let Some(ref first) = act.first_event { if let Some(ref first) = act.first_event {
println!(" First event: {first}"); println!(
" First event: {}",
Theme::dim().render(&to_relative(first))
);
} }
if let Some(ref last) = act.last_event { if let Some(ref last) = act.last_event {
println!(" Last event: {last}"); println!(
" Last event: {}",
Theme::dim().render(&to_relative(last))
);
} }
} }
@@ -1209,26 +1244,20 @@ pub fn print_explain(result: &ExplainResult) {
&& !threads.is_empty() && !threads.is_empty()
{ {
println!( println!(
"\n{} {} ({})", "{}",
Icons::warning(), render::section_divider(&format!("Open Threads ({})", threads.len()))
Theme::bold().render("Open Threads"),
threads.len()
); );
for t in threads { for t in threads {
println!( println!(
" {} by {} ({} notes, last: {})", " {} by {} ({} notes, last: {})",
t.discussion_id, Theme::dim().render(&t.discussion_id),
t.started_by.as_deref().unwrap_or("unknown"), Theme::username()
.render(&format!("@{}", t.started_by.as_deref().unwrap_or("unknown"))),
t.note_count, t.note_count,
t.last_note_at Theme::dim().render(&to_relative(&t.last_note_at))
); );
if let Some(ref excerpt) = t.first_note_excerpt { if let Some(ref excerpt) = t.first_note_excerpt {
let preview = if excerpt.len() > 100 { let preview = render::truncate(excerpt, 100);
let b = excerpt.floor_char_boundary(100);
format!("{}...", &excerpt[..b])
} else {
excerpt.clone()
};
// Show first line only in human output // Show first line only in human output
if let Some(line) = preview.lines().next() { if let Some(line) = preview.lines().next() {
println!(" {}", Theme::muted().render(line)); println!(" {}", Theme::muted().render(line));
@@ -1241,14 +1270,21 @@ pub fn print_explain(result: &ExplainResult) {
if let Some(ref related) = result.related if let Some(ref related) = result.related
&& (!related.closing_mrs.is_empty() || !related.related_issues.is_empty()) && (!related.closing_mrs.is_empty() || !related.related_issues.is_empty())
{ {
println!("\n{}", Theme::bold().render("Related")); let total = related.closing_mrs.len() + related.related_issues.len();
println!("{}", render::section_divider(&format!("Related ({total})")));
for mr in &related.closing_mrs { for mr in &related.closing_mrs {
let mr_state = match mr.state.as_str() {
"merged" => Theme::state_merged(),
"closed" => Theme::state_closed(),
"opened" => Theme::state_opened(),
_ => Theme::dim(),
};
println!( println!(
" {} MR !{}{} [{}]", " {} {}{} {}",
Icons::success(), Icons::success(),
mr.iid, Theme::mr_ref().render(&format!("!{}", mr.iid)),
mr.title, render::truncate(&mr.title, 60),
mr.state mr_state.render(&format!("[{}]", mr.state))
); );
} }
for ri in &related.related_issues { for ri in &related.related_issues {
@@ -1261,13 +1297,16 @@ pub fn print_explain(result: &ExplainResult) {
} else { } else {
"->" "->"
}; };
let (ref_style, ref_prefix) = match ri.entity_type.as_str() {
"issue" => (Theme::issue_ref(), "#"),
"merge_request" => (Theme::mr_ref(), "!"),
_ => (Theme::info(), "#"),
};
println!( println!(
" {} {arrow} {} #{} {}{state_str} ({})", " {arrow} {} {}{state_str} ({})",
Icons::info(), ref_style.render(&format!("{ref_prefix}{}", ri.iid)),
ri.entity_type, render::truncate(ri.title.as_deref().unwrap_or("(untitled)"), 50),
ri.iid, Theme::dim().render(&ri.reference_type)
ri.title.as_deref().unwrap_or("(untitled)"),
ri.reference_type
); );
} }
} }
@@ -1276,26 +1315,25 @@ pub fn print_explain(result: &ExplainResult) {
if let Some(ref excerpt) = result.timeline_excerpt if let Some(ref excerpt) = result.timeline_excerpt
&& !excerpt.events.is_empty() && !excerpt.events.is_empty()
{ {
let truncation_note = if excerpt.truncated { let title = if excerpt.truncated {
format!( format!(
" (showing {} of {})", "Timeline (showing {} of {})",
excerpt.events.len(), excerpt.events.len(),
excerpt.total_events excerpt.total_events
) )
} else { } else {
String::new() format!("Timeline ({})", excerpt.total_events)
}; };
println!( println!("{}", render::section_divider(&title));
"\n{} {}{}",
Icons::info(),
Theme::bold().render("Timeline"),
truncation_note
);
for e in &excerpt.events { for e in &excerpt.events {
let actor_str = e.actor.as_deref().unwrap_or(""); let actor_str = e
.actor
.as_deref()
.map(|a| Theme::username().render(&format!("@{a}")))
.unwrap_or_default();
println!( println!(
" {} {} {} {}", " {} {} {} {}",
Theme::muted().render(&e.timestamp), Theme::muted().render(&to_date(&e.timestamp)),
e.event_type, e.event_type,
actor_str, actor_str,
e.summary e.summary