use clap::{Arg, ArgMatches, Command};
use crate::domain::usecases::issue::{list_issues, ListedIssue};
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("list")
.about("List issues")
.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("sort")
.long("sort")
.help(
"Sort by an ordered tag descriptor. The key must be declared as \
`[tags.<key>]` with `ordered = true` and \
`applies_to = [\"issues\"]`.",
)
.value_name("KEY"),
)
.arg(
Arg::new("with-rollup")
.long("with-rollup")
.help(
"Surface the derived status rollup of composite issues \
as an extra column. Off by default to keep the listing \
narrow.",
)
.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 descriptors = ctx.config().tag_descriptors_for("issues");
let sort_descriptor = sub.get_one::<String>("sort").map(|key| {
let descriptor = descriptors.get(key).unwrap_or_else(|| {
die1(
CliError::new(format!("--sort {key:?}: no descriptor for that key"))
.kind("validation")
.hint(format!(
"Declare `[tags.{key}]` with `applies_to = [\"issues\"]` in cartulary.toml."
)),
output_fmt,
);
});
if !descriptor.ordered {
die1(
CliError::new(format!("--sort {key:?}: not an ordered tag descriptor"))
.kind("validation")
.hint(format!("Add `ordered = true` to `[tags.{key}]`.")),
output_fmt,
);
}
descriptor.clone()
});
let filter = crate::domain::model::issue::IssueFilter {
status: status_filter.as_ref(),
active: active_only,
tags: &tag_filters,
};
let repo = ctx.issue_repository();
let listed = list_issues(
&repo,
&filter,
sort_descriptor.as_ref(),
&descriptors,
with_rollup,
)
.unwrap_or_else(|e| die1(CliError::new(e.to_string()), output_fmt));
if output_fmt.is_structured() {
use crate::infra::driving::cli::issue_view::IssueView;
let views: Vec<IssueView> = listed
.iter()
.map(|l| {
let mut v = IssueView::from_issue(&l.issue);
if let Some(rollup) = &l.rollup {
v = v.with_rollups(rollup.status, rollup.tags.clone());
}
v
})
.collect();
render_structured(&views, output_fmt);
return;
}
render_table(&listed, with_rollup);
}
fn render_table(listed: &[ListedIssue], with_rollup: bool) {
if listed.is_empty() {
println!("No issues found");
return;
}
use crate::infra::driving::cli::table::{terminal_width, Cell, Table};
let mut table = Table::new(terminal_width());
for l in listed {
let mut cells = vec![
Cell::new(theme::id(&l.issue.id.to_string())),
Cell::new(theme::status(
&l.issue.status.label,
l.issue.status.category,
)),
];
if with_rollup {
cells.push(Cell::new(format_rollup_cell(l.rollup.as_ref())));
}
let title = if matches!(
l.issue.origin,
crate::domain::model::entry_origin::EntryOrigin::Union { .. }
) {
format!("{} [union]", l.issue.title)
} else {
l.issue.title.to_string()
};
cells.push(Cell::new(title));
table.push(cells);
}
table.print();
}
fn format_rollup_cell(
rollup: Option<&crate::domain::usecases::issue::tree_view::TreeRollup>,
) -> String {
let Some(rollup) = rollup else {
return String::new();
};
let mut parts: Vec<String> = Vec::new();
if let Some(h) = &rollup.status {
parts.push(theme::status(h.category().as_str(), h.category()));
}
for (key, tag) in &rollup.tags {
parts.push(format!("{key}:{}", tag.value));
}
parts.join(" ")
}