use super::config::{CapacityEntry, CapacityScopeKind, resolve_cycle};
use super::scope::IssueSnapshot;
use super::warning::{CapacityWarning, CapacityWarningKind};
use crate::assignee::parse_assignee;
use crate::config::Config;
use crate::cycle_schedule::identity::{CycleReference, ScheduleIndex};
use crate::wip::WipDiagnosticKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CapacityState {
Unconfigured,
Uncountable,
Configured,
}
impl CapacityState {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Unconfigured => "unconfigured",
Self::Uncountable => "uncountable",
Self::Configured => "configured",
}
}
}
impl std::fmt::Display for CapacityState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapacityScope {
pub kind: CapacityScopeKind,
pub reference: String,
pub display: String,
}
impl Default for CapacityScope {
fn default() -> Self {
Self {
kind: CapacityScopeKind::Cycle,
reference: String::new(),
display: String::new(),
}
}
}
impl CapacityScope {
#[must_use]
pub fn new(
kind: CapacityScopeKind,
reference: impl Into<String>,
index: &ScheduleIndex,
) -> Self {
let reference = reference.into();
let display = match kind {
CapacityScopeKind::Cycle => match resolve_cycle(index, &reference) {
CycleReference::Generated { name, .. } | CycleReference::Legacy { name } => name,
CycleReference::Unknown { name } => name,
},
CapacityScopeKind::Team => reference.clone(),
CapacityScopeKind::Assignee => {
parse_assignee(&reference).unwrap_or_else(|| reference.clone())
}
};
Self {
kind,
reference,
display,
}
}
#[must_use]
pub fn describe(&self) -> String {
format!("{} `{}`", self.kind, self.display)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CapacitySource {
pub issues: usize,
pub units: Vec<String>,
}
impl From<&IssueSnapshot> for CapacitySource {
fn from(snapshot: &IssueSnapshot) -> Self {
Self {
issues: snapshot.len(),
units: snapshot.targets().to_vec(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CapacityCalculation {
pub scope: CapacityScope,
pub capacity: Option<u64>,
pub unit: Option<String>,
pub committed: Option<u64>,
pub completed: Option<u64>,
pub remaining: Option<u64>,
pub overflow: Option<u64>,
pub utilization: Option<u32>,
pub counted_issues: Option<u32>,
pub uncounted_issues: Option<u32>,
pub unestimated_issues: Option<u32>,
pub source: CapacitySource,
pub diagnostics: Vec<CapacityWarning>,
}
impl CapacityCalculation {
#[must_use]
pub fn for_scope(
snapshot: &IssueSnapshot,
index: &ScheduleIndex,
config: &Config,
scope: CapacityScope,
) -> Self {
let mut calculation = Self {
scope: scope.clone(),
source: CapacitySource::from(snapshot),
..Self::default()
};
if scope.kind == CapacityScopeKind::Cycle
&& matches!(
resolve_cycle(index, &scope.reference),
CycleReference::Unknown { .. }
)
{
calculation
.diagnostics
.push(CapacityWarning::unresolved_scope(scope.clone()));
}
for finding in snapshot.diagnostics() {
if finding.scope_kind == scope.kind
&& scope_identity(config, index, scope.kind, &finding.reference)
== scope_identity(config, index, scope.kind, &scope.reference)
{
calculation
.diagnostics
.push(CapacityWarning::from_scope_diagnostic(
scope.clone(),
finding,
));
}
}
let Some(entry) = entry_for(config, index, &scope) else {
return calculation;
};
let Some(ceiling) = entry.capacity.and_then(|value| u64::try_from(value).ok()) else {
return calculation;
};
let Some(unit) = entry
.unit
.as_deref()
.map(str::trim)
.filter(|unit| !unit.is_empty())
else {
return calculation;
};
calculation.capacity = Some(ceiling);
calculation.unit = Some(unit.to_string());
let targets = snapshot.targets();
if !targets.iter().any(|target| target == unit) {
calculation.diagnostics.push(
CapacityWarning::new(
CapacityWarningKind::Conversion(WipDiagnosticKind::UnknownUnit),
scope.clone(),
format!(
"the capacity unit `{unit}` is not among the units this snapshot \
resolved ({}), so nothing was counted",
targets.join(", ")
),
)
.with_remedy(
"recalculate against the `[capacity]` section this snapshot was built \
under: the unit is already declared, so this is a stale snapshot rather \
than a missing unit",
),
);
return calculation;
}
let members = snapshot.members(scope.kind, &scope.reference, index);
let mut committed = 0u64;
let mut completed = 0u64;
let mut counted = 0u32;
let mut uncounted = 0u32;
let mut unestimated = 0u32;
let mut contributing: Vec<u32> = Vec::new();
for &number in members {
let Some(estimate) = snapshot.estimate_in(number, unit) else {
uncounted += 1;
continue;
};
match estimate.converted_value {
Some(value) => {
committed += u64::from(value);
counted += 1;
contributing.push(number);
if snapshot.is_completed(number) {
completed += u64::from(value);
}
}
None if estimate.is_unestimated() => unestimated += 1,
None => {
uncounted += 1;
for finding in estimate.diagnostics() {
calculation
.diagnostics
.push(CapacityWarning::from_estimate_diagnostic(
scope.clone(),
number,
unit,
finding,
));
}
}
}
}
calculation.committed = Some(committed);
calculation.completed = Some(completed);
calculation.remaining = Some(committed - completed);
calculation.overflow = Some(committed.saturating_sub(ceiling));
calculation.utilization = utilization_percent(committed, ceiling);
calculation.counted_issues = Some(counted);
calculation.uncounted_issues = Some(uncounted);
calculation.unestimated_issues = Some(unestimated);
if committed > ceiling {
calculation.diagnostics.push(CapacityWarning::overflow(
scope.clone(),
committed,
ceiling,
unit,
contributing,
));
}
calculation
}
#[must_use]
pub fn state(&self) -> CapacityState {
match (self.capacity, self.committed) {
(None, _) => CapacityState::Unconfigured,
(Some(_), Some(_)) => CapacityState::Configured,
(Some(_), None) => CapacityState::Uncountable,
}
}
#[must_use]
pub fn is_over_capacity(&self) -> bool {
self.overflow.is_some_and(|overflow| overflow > 0)
}
}
fn utilization_percent(committed: u64, capacity: u64) -> Option<u32> {
(capacity > 0).then(|| {
let percent = u128::from(committed) * 100 / u128::from(capacity);
u32::try_from(percent).unwrap_or(u32::MAX)
})
}
fn entry_for<'a>(
config: &'a Config,
index: &ScheduleIndex,
scope: &CapacityScope,
) -> Option<&'a CapacityEntry> {
config
.capacity
.iter()
.map(|(_, entry)| entry)
.find(|entry| {
entry.effective()
&& entry.scope_kind() == Some(scope.kind)
&& entry.scope_id().is_some_and(|reference| {
same_scope(config, index, scope.kind, reference, &scope.reference)
})
})
}
fn same_scope(
config: &Config,
index: &ScheduleIndex,
kind: CapacityScopeKind,
left: &str,
right: &str,
) -> bool {
scope_identity(config, index, kind, left) == scope_identity(config, index, kind, right)
}
fn scope_identity(
config: &Config,
index: &ScheduleIndex,
kind: CapacityScopeKind,
reference: &str,
) -> String {
match kind {
CapacityScopeKind::Cycle => match resolve_cycle(index, reference) {
CycleReference::Generated { id, .. } => format!("cycle-id:{id}"),
CycleReference::Legacy { name } => format!("cycle-name:{name}"),
CycleReference::Unknown { .. } if config.cycles.contains_key(reference) => {
format!("cycle-name:{reference}")
}
CycleReference::Unknown { name } => format!("cycle-ref:{name}"),
},
CapacityScopeKind::Team => format!("team:{reference}"),
CapacityScopeKind::Assignee => match parse_assignee(reference) {
Some(handle) => format!("assignee:{handle}"),
None => format!("assignee-ref:{reference}"),
},
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::capacity::config::{CapacityConfig, validate};
use crate::config::{Config, PlanningCycle};
#[test]
fn the_scope_identity_is_the_one_validate_keys_a_ceiling_by() {
let mut config = Config::default();
config
.cycles
.insert("Partial 1".to_string(), PlanningCycle::default());
let index = ScheduleIndex::default();
let mut section = CapacityConfig::new();
section.insert(
"partial",
CapacityEntry {
cycle: Some("Partial 1".to_string()),
enabled: false,
..CapacityEntry::default()
},
);
validate(§ion, &config).expect("a reference the configuration names is a valid scope");
assert_eq!(
scope_identity(&config, &index, CapacityScopeKind::Cycle, "Partial 1"),
"cycle-name:Partial 1"
);
assert_eq!(
scope_identity(&config, &index, CapacityScopeKind::Cycle, "Ghost"),
"cycle-ref:Ghost"
);
}
}