use clap::{Arg, ArgMatches, Command};
use crate::domain::usecases::clock::Clock;
use crate::domain::usecases::issue::stats::{compute_distribution_by_tag, TagDistribution};
use crate::domain::usecases::issue::IssueRepository;
use crate::infra::driven::clock::SystemClock;
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("distribution")
.about("Throughput distribution grouped by a structured-tag key")
.arg(
Arg::new("by")
.long("by")
.help("Tag key to group by (any key declared under `[tags.<key>]` with `applies_to = [\"issues\"]`)")
.value_name("KEY")
.required(true),
)
.arg(
Arg::new("weeks")
.long("weeks")
.help("Window size in weeks (default: 8)")
.value_name("N")
.value_parser(clap::value_parser!(u32)),
)
.arg(
Arg::new("include-unset")
.long("include-unset")
.help("Bucket issues without that key under an empty value")
.action(clap::ArgAction::SetTrue),
)
}
pub(super) fn execute(sub: &ArgMatches, ctx: &Context<'_>) {
let key = crate::infra::driving::cli::helpers::required_str(sub, "by");
let weeks = sub.get_one::<u32>("weeks").copied().unwrap_or(8);
let include_unset = sub.get_flag("include-unset");
let output_fmt = ctx.output_fmt;
let config = ctx.config();
let issue_descriptors = config.tag_descriptors_for("issues");
let descriptor = issue_descriptors.get(key).unwrap_or_else(|| {
die1(
CliError::new(format!("--by {key:?}: no descriptor for that key"))
.kind("validation")
.hint(format!(
"Declare `[tags.{key}]` with `applies_to = [\"issues\"]` in cartulary.toml."
)),
output_fmt,
);
});
let repo = ctx.issue_repository();
let issues = repo.list().unwrap_or_else(|e| {
die1(CliError::new(e.to_string()), output_fmt);
});
let today = SystemClock.today_local();
let view = issues.view();
let dist = compute_distribution_by_tag(
&view,
ctx.issues_statuses,
&today,
weeks,
key,
Some(descriptor),
include_unset,
);
if output_fmt.is_structured() {
render_structured(&SerializableDistribution::from(&dist), output_fmt);
} else {
print_human(&dist);
}
}
fn print_human(dist: &TagDistribution) {
println!(
"{} [last {} weeks, {} items closed, --by {}]:",
theme::section("Distribution"),
dist.weeks,
dist.total_closed,
dist.key,
);
if dist.by_value.is_empty() {
println!(" (no data)");
return;
}
let max = dist.by_value.iter().map(|(_, c)| *c).max().unwrap_or(1) as f64;
for (value, count) in &dist.by_value {
let bar_len = (*count as f64 / max * 20.0).round() as usize;
let bar = "█".repeat(bar_len);
let pct = if dist.total_closed > 0 {
*count as f64 / dist.total_closed as f64 * 100.0
} else {
0.0
};
let label = if value.is_empty() { "(unset)" } else { value };
println!(
" {:<14} {:<20} {:>3} ({:.0}%)",
theme::label(label),
bar,
count,
pct
);
}
}
#[derive(serde::Serialize)]
struct SerializableDistribution<'a> {
key: &'a str,
weeks: u32,
total_closed: u32,
#[serde(serialize_with = "serialize_by_value")]
by_value: &'a [(String, u32)],
}
impl<'a> From<&'a TagDistribution> for SerializableDistribution<'a> {
fn from(d: &'a TagDistribution) -> Self {
SerializableDistribution {
key: &d.key,
weeks: d.weeks,
total_closed: d.total_closed,
by_value: &d.by_value,
}
}
}
fn serialize_by_value<S>(pairs: &&[(String, u32)], s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeMap;
let mut map = s.serialize_map(Some(pairs.len()))?;
for (value, count) in pairs.iter() {
let key = if value.is_empty() { "(unset)" } else { value };
map.serialize_entry(key, count)?;
}
map.end()
}