use clap::{Arg, ArgMatches, Command};
use crate::domain::usecases::clock::Clock;
use crate::domain::usecases::issue::{
compute_issue_stats, forecast_items, forecast_weeks, IssueRepository, IssueStats,
ItemsForecast, StabilityLevel, WeeksForecast,
};
use crate::infra::driven::clock::SystemClock;
use crate::infra::driving::cli::errors::{die1, CliError};
use crate::infra::driving::cli::helpers::capitalise;
use crate::infra::driving::cli::theme;
use crate::infra::driving::cli::{render_structured, Context};
pub(super) fn summary_subcommand() -> Command {
Command::new("summary")
.about("Aggregate counts, lead time, and throughput stability")
.arg(
Arg::new("status")
.long("status")
.help("Filter by status")
.value_name("STATUS"),
)
.arg(
Arg::new("tag")
.long("tag")
.help("Filter by tag (repeatable; AND semantics)")
.value_name("TAG")
.action(clap::ArgAction::Append),
)
.arg(
Arg::new("detailed")
.long("detailed")
.help("Show additional metrics (cadence, tag distributions, historical throughput)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("history")
.long("history")
.help("Historical window in weeks for cadence and throughput metrics (default: 8)")
.value_name("N")
.value_parser(clap::value_parser!(u32)),
)
.arg(
Arg::new("stale-after")
.long("stale-after")
.help("Mark WIP items as stale after this many days without activity (default: 3)")
.value_name("N")
.value_parser(clap::value_parser!(u32)),
)
}
pub(super) fn forecast_subcommand() -> Command {
Command::new("forecast")
.about("Monte Carlo probabilistic forecast based on throughput history")
.arg(
Arg::new("items")
.long("items")
.help("Forecast: how many weeks to deliver N items?")
.value_name("N")
.value_parser(clap::value_parser!(u32))
.conflicts_with("weeks"),
)
.arg(
Arg::new("weeks")
.long("weeks")
.help("Forecast: how many items delivered in N weeks?")
.value_name("N")
.value_parser(clap::value_parser!(u32))
.conflicts_with("items"),
)
.arg(
Arg::new("history")
.long("history")
.help("Historical window in weeks for throughput sampling (default: 8)")
.value_name("N")
.value_parser(clap::value_parser!(u32)),
)
.arg(
Arg::new("simulations")
.long("simulations")
.help("Number of Monte Carlo simulations (default: 10000)")
.value_name("N")
.value_parser(clap::value_parser!(u32)),
)
.arg(
Arg::new("seed")
.long("seed")
.help("Random seed for deterministic output (testing)")
.value_name("N")
.value_parser(clap::value_parser!(u64))
.hide(true),
)
}
pub(super) fn execute_summary(sub: &ArgMatches, ctx: &Context<'_>) {
let statuses = ctx.issues_statuses;
let output_fmt = ctx.output_fmt;
let status_filter = sub
.get_one::<String>("status")
.and_then(|s| statuses.resolve(s).ok());
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 detailed = sub.get_flag("detailed");
let weeks = sub.get_one::<u32>("history").copied().unwrap_or(8);
let repo = ctx.issue_repository();
let issues = repo
.list()
.unwrap_or_else(|e| {
die1(CliError::new(e.to_string()), output_fmt);
})
.into_vec();
let today = SystemClock.today_local();
let stale_after = sub.get_one::<u32>("stale-after").copied().unwrap_or(3);
let filter = crate::domain::usecases::issue::filter::IssueFilter {
status: status_filter.as_ref(),
active: false,
tags: &tag_filters,
};
let stats = compute_issue_stats(
&issues,
ctx.issues_statuses,
&filter,
&today,
detailed,
weeks,
stale_after,
);
if output_fmt.is_structured() {
render_structured(&stats, output_fmt);
} else {
print_issue_stats(&stats, detailed);
}
}
pub(super) fn execute_forecast(sub: &ArgMatches, ctx: &Context<'_>) {
let output_fmt = ctx.output_fmt;
let target_items = sub.get_one::<u32>("items").copied();
let target_weeks = sub.get_one::<u32>("weeks").copied();
let history = sub.get_one::<u32>("history").copied().unwrap_or(8);
let simulations = sub.get_one::<u32>("simulations").copied().unwrap_or(10_000);
let seed = sub
.get_one::<u64>("seed")
.copied()
.unwrap_or_else(rand::random::<u64>);
match (target_items, target_weeks) {
(None, None) => {
die1(
CliError::new("one of --items or --weeks is required").kind("validation"),
output_fmt,
);
}
(Some(_), Some(_)) => {
die1(
CliError::new("--items and --weeks are mutually exclusive").kind("validation"),
output_fmt,
);
}
_ => {}
}
let repo = ctx.issue_repository();
let issues = repo
.list()
.unwrap_or_else(|e| {
die1(CliError::new(e.to_string()), output_fmt);
})
.into_vec();
let today = SystemClock.today_local();
if let Some(n) = target_items {
let mut sampler = crate::infra::driven::sampler::SeededIndexSampler::from_seed(seed);
match forecast_items(
&issues,
ctx.issues_statuses,
&today,
n,
history,
simulations,
&mut sampler,
) {
Ok(fc) => {
if output_fmt.is_structured() {
render_structured(&fc, output_fmt);
} else {
print_items_forecast(&fc);
}
}
Err(e) => {
die1(CliError::new(e.to_string()), output_fmt);
}
}
} else if let Some(w) = target_weeks {
let mut sampler = crate::infra::driven::sampler::SeededIndexSampler::from_seed(seed);
match forecast_weeks(
&issues,
ctx.issues_statuses,
&today,
w,
history,
simulations,
&mut sampler,
) {
Ok(fc) => {
if output_fmt.is_structured() {
render_structured(&fc, output_fmt);
} else {
print_weeks_forecast(&fc);
}
}
Err(e) => {
die1(CliError::new(e.to_string()), output_fmt);
}
}
}
}
fn print_issue_stats(stats: &IssueStats, detailed: bool) {
println!("{}", theme::section("ISSUE STATISTICS"));
println!("{}", theme::separator(40));
println!();
print_overview(stats);
print_flow_load(stats, detailed);
print_flow_metrics(stats, detailed);
if !detailed {
return;
}
print_cadence(stats);
print_distributions(stats);
print_historical(stats);
}
fn print_overview(stats: &IssueStats) {
println!("{}", theme::section("Overview:"));
println!(
" {} {}",
theme::label("Total:"),
theme::number(&stats.total.to_string())
);
for (status, count) in &stats.by_status {
let pct = pct_of(stats.total, *count);
println!(
" {}: {} ({:.0}%)",
capitalise(status.as_str()),
theme::number(&count.to_string()),
pct
);
}
println!();
}
fn print_flow_load(stats: &IssueStats, detailed: bool) {
println!(
"{}",
theme::section("Flow Load / WIP (started, not closed):")
);
println!(
" {} {}",
theme::label("Total:"),
theme::number(&stats.flow_load.total.to_string())
);
if detailed {
if !stats.flow_load.by_status.is_empty() {
for (status, count) in &stats.flow_load.by_status {
println!(
" {}: {}",
capitalise(status.as_str()),
theme::number(&count.to_string())
);
}
}
let p85_label = stats
.cycle_time
.as_ref()
.or(stats.lead_time.as_ref())
.map(|p| format!(" (! = age exceeds cycle time p85 of {:.1}d)", p.p85))
.unwrap_or_default();
if !stats.flow_load.items.is_empty() {
println!();
println!(" {}{}:", theme::label("Item detail"), p85_label);
for item in &stats.flow_load.items {
let stale_marker = if item.stale { " ~" } else { "" };
let risk_marker = if item.at_risk { " !" } else { "" };
println!(
" {} {:<15} age: {:>3}d last activity: {:>3}d ago{}{}",
theme::id(&item.id.to_string()),
item.status.as_str(),
item.age_days,
item.last_activity_days,
stale_marker,
risk_marker
);
}
}
}
println!();
}
fn print_flow_metrics(stats: &IssueStats, detailed: bool) {
println!("{}", theme::section("Flow Metrics:"));
match &stats.lead_time {
Some(p) => println!(
" {:<45} p50: {:>5.1}d p85: {:>5.1}d p95: {:>5.1}d",
theme::label("Lead Time (Flow Time):"),
p.p50,
p.p85,
p.p95
),
None => println!(
" {} N/A (no closed issues)",
theme::label("Lead Time (Flow Time):")
),
}
if detailed {
match &stats.cycle_time {
Some(p) => println!(
" {:<45} p50: {:>5.1}d p85: {:>5.1}d p95: {:>5.1}d",
theme::label("Cycle Time:"),
p.p50,
p.p85,
p.p95
),
None => println!(
" {} N/A (no closed issues with Active transitions)",
theme::label("Cycle Time:")
),
}
match &stats.queue_time {
Some(p) => println!(
" {:<45} p50: {:>5.1}d p85: {:>5.1}d p95: {:>5.1}d",
theme::label("Queue Time (Queued window):"),
p.p50,
p.p85,
p.p95
),
None => println!(
" {} N/A (no issues reached Active)",
theme::label("Queue Time (Queued window):")
),
}
}
match stats.flow_efficiency_pct {
Some(e) => println!(
" {:<45} {:.1}% (Active / (Active + Stalled); industry avg ~15%)",
theme::label("Flow Efficiency:"),
e
),
None => println!(" {} N/A", theme::label("Flow Efficiency:")),
}
match stats.throughput_stability_pct {
Some(cv) => println!(
" {:<45} {:.1}% (target < 25%)",
theme::label("Throughput Stability (Flow Velocity):"),
cv
),
None => println!(
" {} N/A (need ≥ 2 weeks of data)",
theme::label("Throughput Stability (Flow Velocity):")
),
}
}
fn print_cadence(stats: &IssueStats) {
let Some(c) = &stats.cadence else { return };
println!();
println!(
"{} [last {} weeks]:",
theme::section("Cadence / Flow Velocity"),
c.weeks
);
println!(" {:<32} {:>6} {:>7}", "", "/day", "/week");
println!(
" {:<32} {:>6.1} {:>6.1}",
theme::label("Throughput:"),
c.throughput_per_day,
c.throughput_per_week
);
println!(
" {:<32} {:>6.1} {:>6.1}",
theme::label("Arrivals:"),
c.arrivals_per_day,
c.arrivals_per_week
);
match c.net_flow_per_week {
Some(nf) => println!(
" {:<32} {:>6} {:>+6.1} (+ = finishing more)",
theme::label("Net Flow:"),
"—",
nf
),
None => println!(" {} N/A", theme::label("Net Flow:")),
}
match c.avg_wip {
Some(w) => println!(
" {:<32} {:>6} {:>6.1}",
theme::label("Avg WIP (Flow Load):"),
"—",
w
),
None => println!(" {} N/A", theme::label("Avg WIP (Flow Load):")),
}
match c.avg_lead_time_days {
Some(lt) => println!(
" {:<32} {:>6} {:>5.1}d",
theme::label("Avg Lead Time:"),
"—",
lt
),
None => println!(" {} N/A", theme::label("Avg Lead Time:")),
}
}
fn print_distributions(stats: &IssueStats) {
let Some(dist) = &stats.distributions else {
return;
};
println!();
println!("{}", theme::section("Distributions:"));
print_histogram("Lead Time (Flow Time)", &dist.lead_time);
println!();
print_histogram("Cycle Time", &dist.cycle_time);
}
fn print_histogram(title: &str, buckets: &[crate::domain::usecases::issue::HistoBucket]) {
let max = buckets.iter().map(|b| b.count).max().unwrap_or(1) as f64;
println!(" {}:", theme::section(title));
for bucket in buckets {
let bar_len = if max > 0.0 {
(bucket.count as f64 / max * 20.0).round() as usize
} else {
0
};
let bar = "█".repeat(bar_len);
println!(
" {:<12} {:<20} {:>3}",
theme::label(bucket.label),
bar,
bucket.count
);
}
}
fn print_historical(stats: &IssueStats) {
println!();
println!("{}", theme::section("Historical:"));
println!(" {}:", theme::label("Age Distribution (open issues)"));
println!(
" {} {}",
theme::label(" 0-7 days:"),
theme::number(&stats.age_buckets.days_0_7.to_string())
);
println!(
" {} {}",
theme::label(" 8-30 days:"),
theme::number(&stats.age_buckets.days_8_30.to_string())
);
println!(
" {} {}",
theme::label("31-90 days:"),
theme::number(&stats.age_buckets.days_31_90.to_string())
);
println!(
" {} {}",
theme::label(" 90+ days: "),
theme::number(&stats.age_buckets.days_91_plus.to_string())
);
println!();
println!(" {}:", theme::label("Created by Month (last 6 months)"));
for mc in &stats.by_month {
println!(
" {}: {}",
theme::label(&mc.month),
theme::number(&mc.count.to_string())
);
}
println!();
println!(
" {}:",
theme::label(&format!("Throughput by Week (last {} weeks)", stats.weeks))
);
if stats.throughput.is_empty() {
println!(" N/A");
} else {
for wc in &stats.throughput {
println!(
" {}: {}",
theme::label(&wc.week),
theme::number(&wc.count.to_string())
);
}
}
}
fn pct_of(total: u32, count: u32) -> f64 {
if total > 0 {
count as f64 / total as f64 * 100.0
} else {
0.0
}
}
fn print_stability(cv: f64, stability: &StabilityLevel) {
println!(
" {} {:.0}% ({})",
theme::label("Throughput stability:"),
cv,
stability.as_str()
);
}
fn print_items_forecast(fc: &ItemsForecast) {
println!(
"{}",
theme::section(&format!(
"Forecast: {} items [based on last {} weeks of throughput, {} simulations]",
fc.items, fc.history_weeks, fc.simulations
))
);
print_stability(fc.throughput_cv_pct, &fc.stability);
println!();
println!(
" {:<12} {}",
theme::label("Likelihood"),
theme::label("Weeks to complete")
);
println!(" {}", "-".repeat(32));
for (pct, weeks) in [
("50%", fc.weeks_to_complete.p50),
("70%", fc.weeks_to_complete.p70),
("85%", fc.weeks_to_complete.p85),
("95%", fc.weeks_to_complete.p95),
] {
println!(" {:<12} {:.1}", theme::number(pct), weeks);
}
}
fn print_weeks_forecast(fc: &WeeksForecast) {
println!(
"{}",
theme::section(&format!(
"Forecast: {} weeks [based on last {} weeks of throughput, {} simulations]",
fc.weeks, fc.history_weeks, fc.simulations
))
);
print_stability(fc.throughput_cv_pct, &fc.stability);
println!();
println!(
" {:<12} {}",
theme::label("Likelihood"),
theme::label("Items delivered")
);
println!(" {}", "-".repeat(32));
for (pct, items) in [
("50%", fc.items_delivered.p50),
("70%", fc.items_delivered.p70),
("85%", fc.items_delivered.p85),
("95%", fc.items_delivered.p95),
] {
println!(" {:<12} {:.0}", theme::number(pct), items);
}
}