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::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::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) {
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
let type_label = match result.entity.entity_type.as_str() {
"issue" => "Issue",
"merge_request" => "MR",
_ => &result.entity.entity_type,
let (type_label, ref_style, ref_str) = match result.entity.entity_type.as_str() {
"issue" => ("Issue", Theme::issue_ref(), format!("#{}", result.entity.iid)),
"merge_request" => ("MR", Theme::mr_ref(), format!("!{}", result.entity.iid)),
_ => (
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!(
"{} {} #{}{}",
"{} {} {}{}",
Icons::info(),
Theme::bold().render(type_label),
result.entity.iid,
ref_style.render(&ref_str),
Theme::bold().render(&result.entity.title)
);
println!(
" Project: {} State: {} Author: {} Created: {}",
result.entity.project_path,
result.entity.state,
result.entity.author,
result.entity.created_at
" {} {} {} {}",
Theme::muted().render(&result.entity.project_path),
state_style.render(&result.entity.state),
Theme::username().render(&format!("@{}", result.entity.author)),
Theme::dim().render(&to_relative(&result.entity.created_at)),
);
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() {
println!(" Labels: {}", result.entity.labels.join(", "));
println!(
" Labels: {}",
Theme::dim().render(&result.entity.labels.join(", "))
);
}
if let Some(ref url) = result.entity.url {
println!(" URL: {url}");
println!(" {}", Theme::dim().render(url));
}
// Description
if let Some(ref desc) = result.description_excerpt {
println!("\n{}", Theme::bold().render("Description"));
println!("{}", render::section_divider("Description"));
for line in desc.lines() {
println!(" {line}");
}
@@ -1172,15 +1202,14 @@ pub fn print_explain(result: &ExplainResult) {
&& !decisions.is_empty()
{
println!(
"\n{} {}",
Icons::info(),
Theme::bold().render("Key Decisions")
"{}",
render::section_divider(&format!("Key Decisions ({})", decisions.len()))
);
for d in decisions {
println!(
" {} {}{}",
Theme::muted().render(&d.timestamp),
Theme::bold().render(&d.actor),
Theme::muted().render(&to_date(&d.timestamp)),
Theme::username().render(&format!("@{}", d.actor)),
d.action,
);
for line in d.context_note.lines() {
@@ -1191,16 +1220,22 @@ pub fn print_explain(result: &ExplainResult) {
// Activity
if let Some(ref act) = result.activity {
println!("\n{}", Theme::bold().render("Activity"));
println!("{}", render::section_divider("Activity"));
println!(
" {} state changes, {} label changes, {} notes",
act.state_changes, act.label_changes, act.notes
);
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 {
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()
{
println!(
"\n{} {} ({})",
Icons::warning(),
Theme::bold().render("Open Threads"),
threads.len()
"{}",
render::section_divider(&format!("Open Threads ({})", threads.len()))
);
for t in threads {
println!(
" {} by {} ({} notes, last: {})",
t.discussion_id,
t.started_by.as_deref().unwrap_or("unknown"),
Theme::dim().render(&t.discussion_id),
Theme::username()
.render(&format!("@{}", t.started_by.as_deref().unwrap_or("unknown"))),
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 {
let preview = if excerpt.len() > 100 {
let b = excerpt.floor_char_boundary(100);
format!("{}...", &excerpt[..b])
} else {
excerpt.clone()
};
let preview = render::truncate(excerpt, 100);
// Show first line only in human output
if let Some(line) = preview.lines().next() {
println!(" {}", Theme::muted().render(line));
@@ -1241,14 +1270,21 @@ pub fn print_explain(result: &ExplainResult) {
if let Some(ref related) = result.related
&& (!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 {
let mr_state = match mr.state.as_str() {
"merged" => Theme::state_merged(),
"closed" => Theme::state_closed(),
"opened" => Theme::state_opened(),
_ => Theme::dim(),
};
println!(
" {} MR !{}{} [{}]",
" {} {}{} {}",
Icons::success(),
mr.iid,
mr.title,
mr.state
Theme::mr_ref().render(&format!("!{}", mr.iid)),
render::truncate(&mr.title, 60),
mr_state.render(&format!("[{}]", mr.state))
);
}
for ri in &related.related_issues {
@@ -1261,13 +1297,16 @@ pub fn print_explain(result: &ExplainResult) {
} else {
"->"
};
let (ref_style, ref_prefix) = match ri.entity_type.as_str() {
"issue" => (Theme::issue_ref(), "#"),
"merge_request" => (Theme::mr_ref(), "!"),
_ => (Theme::info(), "#"),
};
println!(
" {} {arrow} {} #{} {}{state_str} ({})",
Icons::info(),
ri.entity_type,
ri.iid,
ri.title.as_deref().unwrap_or("(untitled)"),
ri.reference_type
" {arrow} {} {}{state_str} ({})",
ref_style.render(&format!("{ref_prefix}{}", ri.iid)),
render::truncate(ri.title.as_deref().unwrap_or("(untitled)"), 50),
Theme::dim().render(&ri.reference_type)
);
}
}
@@ -1276,26 +1315,25 @@ pub fn print_explain(result: &ExplainResult) {
if let Some(ref excerpt) = result.timeline_excerpt
&& !excerpt.events.is_empty()
{
let truncation_note = if excerpt.truncated {
let title = if excerpt.truncated {
format!(
" (showing {} of {})",
"Timeline (showing {} of {})",
excerpt.events.len(),
excerpt.total_events
)
} else {
String::new()
format!("Timeline ({})", excerpt.total_events)
};
println!(
"\n{} {}{}",
Icons::info(),
Theme::bold().render("Timeline"),
truncation_note
);
println!("{}", render::section_divider(&title));
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!(
" {} {} {} {}",
Theme::muted().render(&e.timestamp),
Theme::muted().render(&to_date(&e.timestamp)),
e.event_type,
actor_str,
e.summary