use clap::{Arg, ArgMatches, Command};
use serde::Serialize;
use std::collections::BTreeMap;
use crate::domain::model::status::RollupHistogram;
use crate::domain::usecases::issue::tree_view::{build_issue_tree_view, TreeRollup, TreeViewNode};
use crate::domain::usecases::issue::{IssueRepository, TagRollupValue};
use crate::infra::driving::cli::errors::{die1, CliError};
use crate::infra::driving::cli::theme;
use crate::infra::driving::cli::{render_structured, Context};
pub(super) fn subcommand() -> Command {
Command::new("tree")
.about("List issues as a parent/child tree")
.arg(
Arg::new("status")
.long("status")
.help("Filter by status (open, in-progress, closed)")
.value_name("STATUS"),
)
.arg(
Arg::new("active")
.long("active")
.help("Show only active issues (open or in-progress)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("tag")
.long("tag")
.help(
"Filter by tag (repeatable; AND across filters). \
Forms: `name`, `key:value`, or `key:` to match any value of that key.",
)
.value_name("PATTERN")
.action(clap::ArgAction::Append),
)
.arg(
Arg::new("with-rollup")
.long("with-rollup")
.help(
"Surface the derived status rollup of composite issues \
next to each parent node.",
)
.action(clap::ArgAction::SetTrue),
)
}
pub(super) fn execute(sub: &ArgMatches, ctx: &Context<'_>) {
let statuses = ctx.issues_statuses;
let output_fmt = ctx.output_fmt;
let active_only = sub.get_flag("active");
let with_rollup = sub.get_flag("with-rollup");
let status_filter = if active_only {
None
} else {
sub.get_one::<String>("status").map(|s| {
statuses.resolve(s).unwrap_or_else(|_| {
let known: Vec<&str> = statuses.status_names().collect();
die1(
CliError::new(format!("unknown status '{s}'"))
.kind("validation")
.hint(format!("Known statuses: {}", known.join(", "))),
output_fmt,
);
})
})
};
let tag_filters: Vec<crate::domain::model::tag_filter::TagFilter> = sub
.get_many::<String>("tag")
.unwrap_or_default()
.map(|s| {
crate::domain::model::tag_filter::TagFilter::parse(s).unwrap_or_else(|e| {
die1(
CliError::new(format!("invalid tag filter '{s}': {e}")).kind("validation"),
output_fmt,
);
})
})
.collect();
let repo = ctx.issue_repository();
let issues = repo
.list()
.unwrap_or_else(|e| die1(CliError::new(e.to_string()), output_fmt))
.into_vec();
let filter = crate::domain::model::issue::IssueFilter {
status: status_filter.as_ref(),
active: active_only,
tags: &tag_filters,
};
let descriptors = ctx.config().tag_descriptors_for("issues");
let forest = build_issue_tree_view(&issues, &filter, &descriptors);
if output_fmt.is_structured() {
let view: Vec<NodeView<'_>> = forest.iter().map(NodeView::from).collect();
render_structured(&view, output_fmt);
return;
}
if forest.is_empty() {
println!("No issues found");
return;
}
for (i, root) in forest.iter().enumerate() {
print_node(root, "", i == forest.len() - 1, true, with_rollup);
}
}
fn print_node(node: &TreeViewNode, prefix: &str, last: bool, is_root: bool, with_rollup: bool) {
let connector = if is_root {
""
} else if last {
"└── "
} else {
"├── "
};
let id = theme::id(&node.issue.id.to_string());
let status = theme::status(&node.issue.status.label, node.issue.status.category);
let title = node.issue.title.to_string();
let rollup_suffix = if with_rollup {
node.rollup.as_ref().map(rollup_suffix).unwrap_or_default()
} else {
String::new()
};
let line = format!("{prefix}{connector}{id} {status} {title}{rollup_suffix}");
if node.matched {
println!("{line}");
} else {
println!("{}", theme::noop(&line));
}
let child_prefix = if is_root {
String::new()
} else if last {
format!("{prefix} ")
} else {
format!("{prefix}│ ")
};
for (i, child) in node.children.iter().enumerate() {
print_node(
child,
&child_prefix,
i == node.children.len() - 1,
false,
with_rollup,
);
}
}
fn rollup_suffix(r: &TreeRollup) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(h) = &r.status {
parts.push(theme::status(h.category().as_str(), h.category()));
}
for (key, rollup) in &r.tags {
parts.push(format!("{key}:{}", rollup.value));
}
if parts.is_empty() {
String::new()
} else {
format!(" [{}]", parts.join(", "))
}
}
#[derive(Serialize)]
struct NodeView<'a> {
id: String,
title: &'a str,
status: &'a str,
matched: bool,
#[serde(skip_serializing_if = "Option::is_none")]
rollup: Option<NodeRollupView>,
children: Vec<NodeView<'a>>,
}
#[derive(Serialize)]
struct NodeRollupView {
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<StatusRollupView>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
tags: BTreeMap<String, TagRollupValue>,
}
#[derive(Serialize)]
struct StatusRollupView {
category: String,
histogram: RollupHistogram,
}
impl<'a> From<&'a TreeViewNode> for NodeView<'a> {
fn from(n: &'a TreeViewNode) -> Self {
let rollup = n.rollup.as_ref().map(|r| NodeRollupView {
status: r.status.map(|h| StatusRollupView {
category: h.category().as_str().to_string(),
histogram: h,
}),
tags: r.tags.clone(),
});
NodeView {
id: n.issue.id.to_string(),
title: n.issue.title.as_str(),
status: n.issue.status.label.as_str(),
matched: n.matched,
rollup,
children: n.children.iter().map(NodeView::from).collect(),
}
}
}