use clap::{Arg, ArgMatches, Command};
use crate::domain::model::issue::Issue;
use crate::domain::usecases::issue::read_companion::{read_companion, ReadCompanionOutcome};
use crate::domain::usecases::issue::{show_issue_with_family_and_tags, IssueFamily};
use crate::infra::driving::cli::errors::{die1, CliError};
use crate::infra::driving::cli::helpers::required_str;
use crate::infra::driving::cli::id_parsing::resolve_issue_id;
use crate::infra::driving::cli::theme;
use crate::infra::driving::cli::{render_structured, Context};
pub(super) fn subcommand() -> Command {
Command::new("show")
.about("Show issue details")
.arg(
Arg::new("id")
.required(true)
.value_name("ID")
.help("Issue ID (e.g. ISSUE-01H8MSQGXYZ12)"),
)
.arg(
Arg::new("history")
.long("history")
.help("Show event history")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("companion")
.long("companion")
.value_name("NAME")
.help(
"Render a companion by canonical filename, stem, or unique prefix \
(e.g. `plan`, `p`, `mockup`)",
),
)
}
pub(super) fn execute(sub: &ArgMatches, ctx: &Context<'_>) {
let _statuses = ctx.issues_statuses;
let output_fmt = ctx.output_fmt;
let id_str = required_str(sub, "id");
let show_history = sub.get_flag("history");
let companion_arg = sub.get_one::<String>("companion").cloned();
let repo = ctx.issue_repository();
use crate::domain::usecases::issue::IssueRepository;
let id = resolve_issue_id(
&repo as &dyn IssueRepository,
id_str,
ctx.issues_id_prefix(),
output_fmt,
);
if let Some(name) = companion_arg.as_deref() {
render_companion(&repo, &id, name, output_fmt);
return;
}
let descriptors = &ctx.config().tag_descriptors_for("issues");
match show_issue_with_family_and_tags(&repo, &id, descriptors) {
Ok(Some((issue, family))) => {
if output_fmt.is_structured() {
render_structured(
&crate::infra::driving::cli::issue_view::IssueView::from_issue(&issue)
.with_family(&family),
output_fmt,
);
return;
}
if show_history {
print_issue_history(&issue);
} else {
print_issue_detail(&issue, &family);
}
print_companions_block(&repo, &id);
}
Ok(None) => {
die1(
CliError::not_found(format!("issue {id}"), "issue list"),
output_fmt,
);
}
Err(e) => {
die1(CliError::new(e.to_string()), output_fmt);
}
}
}
fn render_companion(
repo: &dyn crate::domain::usecases::issue::IssueRepository,
id: &crate::domain::model::record_ref::IssueRef,
name: &str,
output_fmt: crate::infra::driving::cli::OutputFormat,
) {
match read_companion(repo, id, name) {
Ok(ReadCompanionOutcome::Found { body, .. }) => println!("{}", body.as_str()),
Ok(ReadCompanionOutcome::Absent { filename }) => println!("no {filename} in {id}"),
Ok(ReadCompanionOutcome::Unmanaged { identifier }) => die1(
CliError::new(format!("companion '{identifier}' is not addressable"))
.kind("validation"),
output_fmt,
),
Ok(ReadCompanionOutcome::AmbiguousId { candidates }) => {
let list: Vec<&str> = candidates.iter().map(|c| c.as_str()).collect();
die1(
CliError::new(format!("companion '{name}' is ambiguous"))
.kind("validation")
.hint(format!("Candidates: {}", list.join(", "))),
output_fmt,
);
}
Ok(ReadCompanionOutcome::Inexistant) => die1(
CliError::new(format!("no companion matching '{name}' in {id}")).kind("validation"),
output_fmt,
),
Err(e) => die1(CliError::new(format!("{e}")).kind("io"), output_fmt),
}
}
fn print_issue_history(issue: &Issue) {
let id_display = issue.id.to_string();
println!(
"{} {}",
theme::id(&id_display),
theme::status(&issue.status.label, issue.status.category),
);
println!("{}", theme::separator(50));
println!("{}", issue.title);
println!();
println!("{}", theme::section("Event History:"));
if issue.events.is_empty() {
println!(" No events recorded");
return;
}
for event in &issue.events {
use crate::domain::model::event::EventAction;
println!("{} - {}", event.timestamp.format_local(), event.action);
match &event.action {
EventAction::Created { state } => println!(" Status: {state}"),
EventAction::StatusChanged { from, to } => {
println!(" From: {from}");
println!(" To: {to}");
}
}
println!();
}
}
fn print_companions_block(
repo: &dyn crate::domain::usecases::issue::IssueRepository,
id: &crate::domain::model::record_ref::IssueRef,
) {
let companions = repo.issue_companions(id).unwrap_or_default();
if companions.is_empty() {
return;
}
println!();
let names: Vec<&str> = companions.iter().map(|c| c.identifier.as_str()).collect();
println!("{} {}", theme::section("Companions:"), names.join(", "));
}
fn print_issue_detail(issue: &Issue, family: &IssueFamily) {
let id_display = issue.id.to_string();
println!(
"{} {}",
theme::id(&id_display),
theme::status(&issue.status.label, issue.status.category),
);
println!("{}", theme::separator(50));
println!("{}", issue.title);
println!("{} {}", theme::label("Date:"), issue.date);
if let crate::domain::model::entry_origin::EntryOrigin::Union { name } = &issue.origin {
println!(
"{} read-only, from union source '{name}'",
theme::label("Origin:"),
);
}
if !issue.tags.is_empty() {
let tags_str: Vec<&str> = issue.tags.iter().map(|t| t.as_str()).collect();
println!("{} {}", theme::label("Tags:"), tags_str.join(", "));
}
if let Some(parent) = &family.parent {
println!("{} {}", theme::label("Parent:"), parent);
}
if !family.children.is_empty() {
println!();
println!("{}", theme::label("Children:"));
for child in &family.children {
println!(" - {child}");
}
}
print_rollup_block(family);
let peer_links: Vec<_> = issue
.links
.iter()
.filter(|l| !l.relationship.is_hierarchical())
.collect();
if !peer_links.is_empty() {
println!();
println!("{}", theme::label("Links:"));
for link in peer_links {
let rel = link.relationship.as_str();
let label = {
let mut c = rel.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
};
println!(" {}: {}", theme::label(&label), link.target);
}
}
println!();
println!("{}", issue.content);
}
fn print_rollup_block(family: &IssueFamily) {
let has_status = family.rollup.is_some();
let has_tags = !family.tag_rollups.is_empty();
if !has_status && !has_tags {
return;
}
println!();
let header = match family.rollup.map(|h| h.total()) {
Some(n) => format!("Rollup (from {n} direct children)"),
None => "Rollup".to_string(),
};
println!("{}", theme::section(&header));
if let Some(h) = &family.rollup {
println!(
" {:<12} {} [Q:{} A:{} S:{} R:{} C:{}]",
theme::label("status:"),
theme::status(h.category().as_str(), h.category()),
h.queued,
h.active,
h.stalled,
h.resolved,
h.cancelled,
);
}
for (key, rollup) in &family.tag_rollups {
let provenance = if rollup.count == 1 {
"1 child".to_string()
} else {
format!("{} children", rollup.count)
};
println!(
" {:<12} {} ({provenance})",
theme::label(&format!("{key}:")),
rollup.value,
);
}
}