use serde_yaml::{Mapping, Value};
use super::calculate::{CapacityCalculation, CapacityScope, CapacitySource, CapacityState};
use super::config::CapacityScopeKind;
use super::scope::ScopeDiagnosticKind;
use super::warning::{CapacityWarning, CapacityWarningKind};
use crate::cycle_schedule::identity::GeneratedCycleId;
use crate::cycle_schedule::snapshot::{CycleSnapshot, CycleSnapshotMetadata, CycleSnapshotStore};
use crate::error::{Result, TixError};
use crate::model::HistoryEventKind;
use crate::wip::WipDiagnosticKind;
pub const SNAPSHOT_ID_KEY: &str = "snapshot_id";
const CYCLE_ID_KEY: &str = "cycle_id";
const CLOSED_AT_KEY: &str = "closed_at";
const CLOSED_BY_KEY: &str = "closed_by";
const SCOPE_KIND_KEY: &str = "scope_kind";
const SCOPE_REFERENCE_KEY: &str = "scope_reference";
const SCOPE_DISPLAY_KEY: &str = "scope_display";
const STATE_KEY: &str = "state";
const CAPACITY_KEY: &str = "capacity";
const UNIT_KEY: &str = "unit";
const COMMITTED_KEY: &str = "committed";
const COMPLETED_KEY: &str = "completed";
const REMAINING_KEY: &str = "remaining";
const OVERFLOW_KEY: &str = "overflow";
const UTILIZATION_KEY: &str = "utilization";
const COUNTED_ISSUES_KEY: &str = "counted_issues";
const UNCOUNTED_ISSUES_KEY: &str = "uncounted_issues";
const UNESTIMATED_ISSUES_KEY: &str = "unestimated_issues";
const SOURCE_ISSUES_KEY: &str = "source_issues";
const SOURCE_UNITS_KEY: &str = "source_units";
const DIAGNOSTICS_KEY: &str = "diagnostics";
const BREAKDOWN_KEY: &str = "breakdown";
const KIND_KEY: &str = "kind";
const ISSUES_KEY: &str = "issues";
const CURRENT_KEY: &str = "current";
const LIMIT_KEY: &str = "limit";
const REASON_KEY: &str = "reason";
const REMEDY_KEY: &str = "remedy";
pub const SNAPSHOT_MEASUREMENT_KEYS: [&str; 22] = [
SNAPSHOT_ID_KEY,
CYCLE_ID_KEY,
CLOSED_AT_KEY,
CLOSED_BY_KEY,
SCOPE_KIND_KEY,
SCOPE_REFERENCE_KEY,
SCOPE_DISPLAY_KEY,
STATE_KEY,
CAPACITY_KEY,
UNIT_KEY,
COMMITTED_KEY,
COMPLETED_KEY,
REMAINING_KEY,
OVERFLOW_KEY,
UTILIZATION_KEY,
COUNTED_ISSUES_KEY,
UNCOUNTED_ISSUES_KEY,
UNESTIMATED_ISSUES_KEY,
SOURCE_ISSUES_KEY,
SOURCE_UNITS_KEY,
DIAGNOSTICS_KEY,
BREAKDOWN_KEY,
];
#[derive(Debug, Clone, PartialEq)]
pub struct CapacitySnapshot {
pub snapshot_id: String,
pub cycle_id: String,
pub closed_at: String,
pub closed_by: String,
pub scope: CapacityScope,
pub state: CapacityState,
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>,
pub breakdown: Option<CycleSnapshotMetadata>,
pub extensions: CycleSnapshotMetadata,
}
impl CapacitySnapshot {
#[must_use]
pub fn capture(
cycle_id: &GeneratedCycleId,
closed_at: &str,
closed_by: &str,
calculation: &CapacityCalculation,
) -> Self {
Self {
snapshot_id: snapshot_id(cycle_id, closed_at),
cycle_id: cycle_id.as_str().to_string(),
closed_at: closed_at.trim().to_string(),
closed_by: closed_by.trim().to_string(),
scope: calculation.scope.clone(),
state: calculation.state(),
capacity: calculation.capacity,
unit: calculation.unit.clone(),
committed: calculation.committed,
completed: calculation.completed,
remaining: calculation.remaining,
overflow: calculation.overflow,
utilization: calculation.utilization,
counted_issues: calculation.counted_issues,
uncounted_issues: calculation.uncounted_issues,
unestimated_issues: calculation.unestimated_issues,
source: calculation.source.clone(),
diagnostics: calculation.diagnostics.clone(),
breakdown: None,
extensions: CycleSnapshotMetadata::new(),
}
}
#[must_use]
pub fn with_breakdown(mut self, breakdown: CycleSnapshotMetadata) -> Self {
self.breakdown = (!breakdown.is_empty()).then_some(breakdown);
self
}
#[must_use]
pub fn summary(&self) -> String {
evidence_summary(
self.state,
self.committed,
self.completed,
self.capacity,
self.unit.as_deref(),
)
}
#[must_use]
pub fn to_measurements(&self) -> CycleSnapshotMetadata {
let mut map = CycleSnapshotMetadata::new();
let mut put = |key: &str, value: Value| {
map.insert(key.to_string(), value);
};
put(SNAPSHOT_ID_KEY, Value::String(self.snapshot_id.clone()));
put(CYCLE_ID_KEY, Value::String(self.cycle_id.clone()));
put(CLOSED_AT_KEY, Value::String(self.closed_at.clone()));
put(CLOSED_BY_KEY, Value::String(self.closed_by.clone()));
put(
SCOPE_KIND_KEY,
Value::String(self.scope.kind.as_str().to_string()),
);
put(
SCOPE_REFERENCE_KEY,
Value::String(self.scope.reference.clone()),
);
put(SCOPE_DISPLAY_KEY, Value::String(self.scope.display.clone()));
put(STATE_KEY, Value::String(self.state.as_str().to_string()));
for (key, value) in [
(CAPACITY_KEY, self.capacity),
(COMMITTED_KEY, self.committed),
(COMPLETED_KEY, self.completed),
(REMAINING_KEY, self.remaining),
(OVERFLOW_KEY, self.overflow),
] {
if let Some(value) = value {
put(key, Value::Number(value.into()));
}
}
if let Some(unit) = self.unit.as_deref() {
put(UNIT_KEY, Value::String(unit.to_string()));
}
for (key, value) in [
(UTILIZATION_KEY, self.utilization),
(COUNTED_ISSUES_KEY, self.counted_issues),
(UNCOUNTED_ISSUES_KEY, self.uncounted_issues),
(UNESTIMATED_ISSUES_KEY, self.unestimated_issues),
] {
if let Some(value) = value {
put(key, Value::Number(value.into()));
}
}
put(
SOURCE_ISSUES_KEY,
Value::Number(u64::try_from(self.source.issues).unwrap_or(u64::MAX).into()),
);
put(
SOURCE_UNITS_KEY,
Value::Sequence(
self.source
.units
.iter()
.map(|unit| Value::String(unit.clone()))
.collect(),
),
);
put(
DIAGNOSTICS_KEY,
Value::Sequence(self.diagnostics.iter().map(warning_to_value).collect()),
);
if let Some(breakdown) = &self.breakdown {
put(BREAKDOWN_KEY, metadata_to_value(breakdown));
}
for (key, value) in &self.extensions {
map.insert(key.clone(), value.clone());
}
map
}
pub fn from_measurements(measurements: &CycleSnapshotMetadata) -> Result<Option<Self>> {
if !measurements.contains_key(SNAPSHOT_ID_KEY) {
return Ok(None);
}
let scope = CapacityScope {
kind: scope_kind(&required_string(measurements, SCOPE_KIND_KEY)?)?,
reference: required_string(measurements, SCOPE_REFERENCE_KEY)?,
display: required_string(measurements, SCOPE_DISPLAY_KEY)?,
};
let snapshot = Self {
snapshot_id: required_string(measurements, SNAPSHOT_ID_KEY)?,
cycle_id: required_string(measurements, CYCLE_ID_KEY)?,
closed_at: required_string(measurements, CLOSED_AT_KEY)?,
closed_by: required_string(measurements, CLOSED_BY_KEY)?,
scope,
state: state_of(&required_string(measurements, STATE_KEY)?)?,
capacity: optional_u64(measurements, CAPACITY_KEY)?,
unit: optional_string(measurements, UNIT_KEY)?,
committed: optional_u64(measurements, COMMITTED_KEY)?,
completed: optional_u64(measurements, COMPLETED_KEY)?,
remaining: optional_u64(measurements, REMAINING_KEY)?,
overflow: optional_u64(measurements, OVERFLOW_KEY)?,
utilization: optional_u32(measurements, UTILIZATION_KEY)?,
counted_issues: optional_u32(measurements, COUNTED_ISSUES_KEY)?,
uncounted_issues: optional_u32(measurements, UNCOUNTED_ISSUES_KEY)?,
unestimated_issues: optional_u32(measurements, UNESTIMATED_ISSUES_KEY)?,
source: CapacitySource {
issues: usize::try_from(required_u64(measurements, SOURCE_ISSUES_KEY)?)
.map_err(|_| type_error(SOURCE_ISSUES_KEY, "a count that fits in a usize"))?,
units: string_list(measurements, SOURCE_UNITS_KEY)?,
},
diagnostics: diagnostic_list(measurements, DIAGNOSTICS_KEY)?,
breakdown: optional_metadata(measurements, BREAKDOWN_KEY)?,
extensions: unknown_metadata(measurements),
};
Ok(Some(snapshot))
}
pub fn from_cycle_snapshot(snapshot: &CycleSnapshot) -> Result<Option<Self>> {
let Some(evidence) = Self::from_measurements(&snapshot.measurements)? else {
return Ok(None);
};
let file = snapshot.cycle_id.as_str();
if evidence.cycle_id != file
|| evidence.closed_at != snapshot.closed_at
|| evidence.closed_by != snapshot.closed_by
{
return Err(TixError::Parse(format!(
"capacity evidence in `docs/cycle_snapshots/{}` names `{}` closed at `{}` by \
`{}`, but the snapshot is {} closed at `{}` by `{}`; the evidence is refused \
rather than quoted against the wrong snapshot",
CycleSnapshotStore::filename_for(&snapshot.cycle_id),
evidence.cycle_id,
evidence.closed_at,
evidence.closed_by,
file,
snapshot.closed_at,
snapshot.closed_by,
)));
}
Ok(Some(evidence))
}
pub fn read(store: &CycleSnapshotStore, cycle_id: &GeneratedCycleId) -> Result<Option<Self>> {
match store.read(cycle_id)? {
Some(snapshot) => Self::from_cycle_snapshot(&snapshot),
None => Ok(None),
}
}
}
pub fn reopen_event(
reason: &str,
prior: Option<&CapacitySnapshot>,
) -> Result<(HistoryEventKind, String)> {
let reason = audited_reason(reason)?;
Ok((
HistoryEventKind::CycleReopened,
format!(
"reason: {reason}; state: reopened; snapshot: {}",
prior.map_or("none", |evidence| evidence.snapshot_id.as_str())
),
))
}
#[must_use]
pub fn snapshot_id(cycle_id: &GeneratedCycleId, closed_at: &str) -> String {
format!("{}@{}", cycle_id.as_str(), closed_at.trim())
}
fn audited_reason(reason: &str) -> Result<&str> {
let trimmed = reason.trim();
if trimmed.is_empty() {
return Err(TixError::InvalidInput(
"a reopen is auditable: a non-blank reason is required and is stored with the \
append-only `History` event"
.into(),
));
}
Ok(trimmed)
}
fn evidence_summary(
state: CapacityState,
committed: Option<u64>,
completed: Option<u64>,
capacity: Option<u64>,
unit: Option<&str>,
) -> String {
format!(
"state={state}, committed={committed}, completed={completed}, capacity={capacity}, \
unit={unit}",
state = state.as_str(),
committed = number_or_none(committed),
completed = number_or_none(completed),
capacity = number_or_none(capacity),
unit = unit.unwrap_or("none"),
)
}
fn number_or_none(value: Option<u64>) -> String {
value.map_or_else(|| "none".to_string(), |value| value.to_string())
}
fn warning_to_value(warning: &CapacityWarning) -> Value {
let mut map = Mapping::new();
let mut put = |key: &str, value: Value| {
map.insert(Value::String(key.to_string()), value);
};
put(KIND_KEY, Value::String(warning.kind.as_str().to_string()));
put(
SCOPE_KIND_KEY,
Value::String(warning.scope.kind.as_str().to_string()),
);
put(
SCOPE_REFERENCE_KEY,
Value::String(warning.scope.reference.clone()),
);
put(
SCOPE_DISPLAY_KEY,
Value::String(warning.scope.display.clone()),
);
if !warning.issues.is_empty() {
put(
ISSUES_KEY,
Value::Sequence(
warning
.issues
.iter()
.map(|issue| Value::Number(u64::from(*issue).into()))
.collect(),
),
);
}
for (key, value) in [
(CURRENT_KEY, warning.current),
(LIMIT_KEY, warning.limit),
(OVERFLOW_KEY, warning.overflow),
] {
if let Some(value) = value {
put(key, Value::Number(value.into()));
}
}
if let Some(unit) = warning.unit.as_deref() {
put(UNIT_KEY, Value::String(unit.to_string()));
}
put(REASON_KEY, Value::String(warning.reason.clone()));
if let Some(remedy) = warning.remedy.as_deref() {
put(REMEDY_KEY, Value::String(remedy.to_string()));
}
Value::Mapping(map)
}
fn metadata_to_value(metadata: &CycleSnapshotMetadata) -> Value {
let mut map = Mapping::new();
for (key, value) in metadata {
map.insert(Value::String(key.clone()), value.clone());
}
Value::Mapping(map)
}
fn unknown_metadata(measurements: &CycleSnapshotMetadata) -> CycleSnapshotMetadata {
measurements
.iter()
.filter(|(key, _)| !SNAPSHOT_MEASUREMENT_KEYS.contains(&key.as_str()))
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
fn required_string(fields: &CycleSnapshotMetadata, key: &str) -> Result<String> {
match fields.get(key) {
Some(Value::String(value)) => Ok(value.clone()),
Some(other) => Err(type_error(
key,
&format!("a string, found {}", kind_of(other)),
)),
None => Err(missing(key)),
}
}
fn optional_string(fields: &CycleSnapshotMetadata, key: &str) -> Result<Option<String>> {
match fields.get(key) {
Some(Value::String(value)) => Ok(Some(value.clone())),
Some(other) => Err(type_error(
key,
&format!("a string, found {}", kind_of(other)),
)),
None => Ok(None),
}
}
fn required_u64(fields: &CycleSnapshotMetadata, key: &str) -> Result<u64> {
match fields.get(key) {
Some(Value::Number(value)) => value
.as_u64()
.ok_or_else(|| type_error(key, "a whole number at or above zero")),
Some(other) => Err(type_error(
key,
&format!("a number, found {}", kind_of(other)),
)),
None => Err(missing(key)),
}
}
fn optional_u64(fields: &CycleSnapshotMetadata, key: &str) -> Result<Option<u64>> {
match fields.get(key) {
Some(Value::Number(value)) => value
.as_u64()
.map(Some)
.ok_or_else(|| type_error(key, "a whole number at or above zero")),
Some(other) => Err(type_error(
key,
&format!("a number, found {}", kind_of(other)),
)),
None => Ok(None),
}
}
fn optional_u32(fields: &CycleSnapshotMetadata, key: &str) -> Result<Option<u32>> {
let Some(value) = optional_u64(fields, key)? else {
return Ok(None);
};
u32::try_from(value)
.map(Some)
.map_err(|_| type_error(key, "a number that fits in 32 bits"))
}
fn string_list(fields: &CycleSnapshotMetadata, key: &str) -> Result<Vec<String>> {
let Some(Value::Sequence(items)) = fields.get(key) else {
return Err(match fields.get(key) {
Some(other) => type_error(key, &format!("a list, found {}", kind_of(other))),
None => missing(key),
});
};
items
.iter()
.map(|item| match item {
Value::String(value) => Ok(value.clone()),
other => Err(type_error(
key,
&format!("a list of strings, found {}", kind_of(other)),
)),
})
.collect()
}
fn diagnostic_list(fields: &CycleSnapshotMetadata, key: &str) -> Result<Vec<CapacityWarning>> {
let Some(Value::Sequence(items)) = fields.get(key) else {
return Err(match fields.get(key) {
Some(other) => type_error(key, &format!("a list, found {}", kind_of(other))),
None => missing(key),
});
};
items
.iter()
.map(|item| diagnostic_from_value(item, key))
.collect()
}
fn diagnostic_from_value(value: &Value, key: &str) -> Result<CapacityWarning> {
let fields = value_to_metadata(value, key)?;
let scope = CapacityScope {
kind: scope_kind(&required_string(&fields, SCOPE_KIND_KEY)?)?,
reference: required_string(&fields, SCOPE_REFERENCE_KEY)?,
display: required_string(&fields, SCOPE_DISPLAY_KEY)?,
};
let kind = warning_kind(&required_string(&fields, KIND_KEY)?)?;
let mut warning = CapacityWarning::new(kind, scope, required_string(&fields, REASON_KEY)?);
warning.issues = match fields.get(ISSUES_KEY) {
None => Vec::new(),
Some(Value::Sequence(items)) => items
.iter()
.map(|item| match item {
Value::Number(value) => value
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.ok_or_else(|| type_error(ISSUES_KEY, "a list of issue numbers")),
other => Err(type_error(
ISSUES_KEY,
&format!("a list, found {}", kind_of(other)),
)),
})
.collect::<Result<Vec<u32>>>()?,
Some(other) => {
return Err(type_error(
ISSUES_KEY,
&format!("a list, found {}", kind_of(other)),
));
}
};
warning.current = optional_u64(&fields, CURRENT_KEY)?;
warning.limit = optional_u64(&fields, LIMIT_KEY)?;
warning.overflow = optional_u64(&fields, OVERFLOW_KEY)?;
warning.unit = optional_string(&fields, UNIT_KEY)?;
warning.remedy = optional_string(&fields, REMEDY_KEY)?;
Ok(warning)
}
fn optional_metadata(
fields: &CycleSnapshotMetadata,
key: &str,
) -> Result<Option<CycleSnapshotMetadata>> {
match fields.get(key) {
Some(value) => Ok(Some(value_to_metadata(value, key)?)),
None => Ok(None),
}
}
fn value_to_metadata(value: &Value, key: &str) -> Result<CycleSnapshotMetadata> {
let Some(mapping) = value.as_mapping() else {
return Err(type_error(key, &format!("a map, found {}", kind_of(value))));
};
let mut metadata = CycleSnapshotMetadata::new();
for (field, value) in mapping {
let Some(field) = field.as_str() else {
return Err(type_error(key, "a map with string keys"));
};
metadata.insert(field.to_string(), value.clone());
}
Ok(metadata)
}
fn scope_kind(spelling: &str) -> Result<CapacityScopeKind> {
match spelling {
"cycle" => Ok(CapacityScopeKind::Cycle),
"team" => Ok(CapacityScopeKind::Team),
"assignee" => Ok(CapacityScopeKind::Assignee),
other => Err(TixError::Parse(format!(
"`{other}` is not a capacity scope kind; expected `cycle`, `team` or `assignee`"
))),
}
}
fn state_of(spelling: &str) -> Result<CapacityState> {
match spelling {
"unconfigured" => Ok(CapacityState::Unconfigured),
"uncountable" => Ok(CapacityState::Uncountable),
"configured" => Ok(CapacityState::Configured),
other => Err(TixError::Parse(format!(
"`{other}` is not a capacity state; expected `unconfigured`, `uncountable` or \
`configured`"
))),
}
}
fn warning_kind(spelling: &str) -> Result<CapacityWarningKind> {
Ok(match spelling {
"capacity_overflow" => CapacityWarningKind::Overflow,
"malformed_estimate" => {
CapacityWarningKind::Conversion(WipDiagnosticKind::MalformedEstimate)
}
"missing_conversion" => {
CapacityWarningKind::Conversion(WipDiagnosticKind::MissingConversion)
}
"ambiguous_conversion" => {
CapacityWarningKind::Conversion(WipDiagnosticKind::AmbiguousConversion)
}
"unknown_unit" => CapacityWarningKind::Conversion(WipDiagnosticKind::UnknownUnit),
"over_limit" => CapacityWarningKind::Conversion(WipDiagnosticKind::OverLimit),
"override_applied" => CapacityWarningKind::Conversion(WipDiagnosticKind::OverrideApplied),
"override_required" => CapacityWarningKind::Conversion(WipDiagnosticKind::OverrideRequired),
"unconfirmed_override" => {
CapacityWarningKind::Conversion(WipDiagnosticKind::UnconfirmedOverride)
}
"missing_override_field" => {
CapacityWarningKind::Conversion(WipDiagnosticKind::MissingOverrideField)
}
"stale_override_limit" => {
CapacityWarningKind::Conversion(WipDiagnosticKind::StaleOverrideLimit)
}
"rejected_transition" => {
CapacityWarningKind::Conversion(WipDiagnosticKind::RejectedTransition)
}
"unknown_scope" => CapacityWarningKind::Scope(ScopeDiagnosticKind::UnknownScope),
"ambiguous_scope" => CapacityWarningKind::Scope(ScopeDiagnosticKind::AmbiguousScope),
other => {
return Err(TixError::Parse(format!(
"`{other}` is not a capacity warning kind this build records"
)));
}
})
}
fn missing(key: &str) -> TixError {
TixError::Parse(format!(
"capacity evidence is missing `{key}`; the payload claims to be a capacity snapshot, so \
a required field is never defaulted"
))
}
fn type_error(key: &str, expected: &str) -> TixError {
TixError::Parse(format!("capacity evidence `{key}` must be {expected}"))
}
fn kind_of(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Sequence(_) => "a list",
Value::Mapping(_) => "a map",
Value::Tagged(_) => "a tagged value",
}
}