use std::collections::HashMap;
use std::path::Path;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::cycle_schedule::identity::{
CycleReference, GeneratedCycle, GeneratedCycleId, GeneratedCycleState, ScheduleIndex,
};
use crate::cycle_store::{CycleRecord, CycleStore};
use crate::error::{Result, TixError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CapacityScopeKind {
Cycle,
Team,
Assignee,
}
impl CapacityScopeKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Cycle => "cycle",
Self::Team => "team",
Self::Assignee => "assignee",
}
}
#[must_use]
pub const fn all() -> [Self; 3] {
[Self::Cycle, Self::Team, Self::Assignee]
}
}
impl std::fmt::Display for CapacityScopeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CapacityExtensions(toml::Table);
impl CapacityExtensions {
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&toml::Value> {
self.0.get(key)
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.0.keys().map(String::as_str)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &toml::Value)> {
self.0.iter().map(|(k, v)| (k.as_str(), v))
}
#[must_use]
pub fn table(&self) -> &toml::Table {
&self.0
}
pub fn insert(&mut self, key: impl Into<String>, value: toml::Value) -> Option<toml::Value> {
self.0.insert(key.into(), value)
}
}
impl PartialEq for CapacityExtensions {
fn eq(&self, other: &Self) -> bool {
toml::Value::Table(self.0.clone()).to_string()
== toml::Value::Table(other.0.clone()).to_string()
}
}
impl Eq for CapacityExtensions {}
impl From<toml::Table> for CapacityExtensions {
fn from(table: toml::Table) -> Self {
Self(table)
}
}
impl std::ops::Index<&str> for CapacityConfig {
type Output = CapacityEntry;
fn index(&self, id: &str) -> &Self::Output {
&self.0[id]
}
}
pub const CAPACITY_ENTRY_KNOWN_FIELDS: &[&str] =
&["cycle", "team", "assignee", "capacity", "unit", "enabled"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct CapacityEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub cycle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub team: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capacity: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
#[serde(
default = "crate::config::default_true",
skip_serializing_if = "crate::config::is_true"
)]
pub enabled: bool,
#[serde(flatten)]
pub extensions: CapacityExtensions,
}
impl Default for CapacityEntry {
fn default() -> Self {
Self {
cycle: None,
team: None,
assignee: None,
capacity: None,
unit: None,
enabled: true,
extensions: CapacityExtensions::default(),
}
}
}
impl CapacityEntry {
pub fn scopes(&self) -> impl Iterator<Item = (CapacityScopeKind, &str)> {
[
(CapacityScopeKind::Cycle, self.cycle.as_deref()),
(CapacityScopeKind::Team, self.team.as_deref()),
(CapacityScopeKind::Assignee, self.assignee.as_deref()),
]
.into_iter()
.filter_map(|(kind, reference)| reference.map(|reference| (kind, reference)))
}
#[must_use]
pub fn scope_kind(&self) -> Option<CapacityScopeKind> {
let mut scopes = self.scopes();
let first = scopes.next()?;
scopes.next().is_none().then_some(first.0)
}
#[must_use]
pub fn scope_id(&self) -> Option<&str> {
let mut scopes = self.scopes();
let first = scopes.next()?;
scopes.next().is_none().then_some(first.1)
}
#[must_use]
pub fn effective(&self) -> bool {
self.enabled && self.capacity.is_some() && self.unit.is_some()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CapacityConfig(IndexMap<String, CapacityEntry>);
impl CapacityConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&CapacityEntry> {
self.0.get(id)
}
#[must_use]
pub fn get_mut(&mut self, id: &str) -> Option<&mut CapacityEntry> {
self.0.get_mut(id)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &CapacityEntry)> {
self.0.iter().map(|(id, entry)| (id.as_str(), entry))
}
#[must_use]
pub fn entries(&self) -> &IndexMap<String, CapacityEntry> {
&self.0
}
#[must_use]
pub fn entries_mut(&mut self) -> &mut IndexMap<String, CapacityEntry> {
&mut self.0
}
pub fn insert(&mut self, id: impl Into<String>, entry: CapacityEntry) -> Option<CapacityEntry> {
self.0.insert(id.into(), entry)
}
pub fn restore_declaration_order(&mut self, contents: &str) {
crate::config::restore_section_order(&mut self.0, contents, "capacity");
}
}
fn declared_unit(config: &Config, unit: &str) -> bool {
config.default_config().wip.units.contains_key(unit)
}
fn cycle_records(config: &Config, root: &Path) -> Result<Vec<CycleRecord>> {
let store = CycleStore::new(root);
let mut records = store.list()?;
if !store.exists() {
records.extend(config.cycles.keys().map(|name| CycleRecord {
name: name.clone(),
..CycleRecord::default()
}));
}
Ok(records)
}
pub fn schedule_index(config: &Config, root: &Path) -> Result<ScheduleIndex> {
Ok(ScheduleIndex::build(config, &cycle_records(config, root)?))
}
fn cycle_record<'a>(
index: &ScheduleIndex,
records: &'a [CycleRecord],
reference: &str,
) -> Option<&'a CycleRecord> {
if let Ok(id) = GeneratedCycleId::parse(reference)
&& let Some(cycle) = index.cycle(&id)
{
return records.iter().find(|record| record.name == cycle.name);
}
records.iter().find(|record| record.name == reference)
}
#[must_use]
pub fn resolve_cycle(index: &ScheduleIndex, reference: &str) -> CycleReference {
let resolved = index.resolve(reference);
if !matches!(resolved, CycleReference::Unknown { .. }) {
return resolved;
}
let Ok(id) = GeneratedCycleId::parse(reference) else {
return resolved;
};
let Some(cycle) = index.cycle(&id) else {
return resolved;
};
CycleReference::Generated {
name: cycle.name.clone(),
id,
schedule_id: cycle.schedule_id.clone(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CapacityEdit<'a> {
Configure,
Reopen {
reason: &'a str,
},
}
pub fn ensure_cycle_editable(record: &CycleRecord, edit: &CapacityEdit<'_>) -> Result<()> {
match edit {
CapacityEdit::Reopen { reason } => {
if reason.trim().is_empty() {
return Err(TixError::Config(
"`reopen.reason` must not be blank: a reopen is auditable, and its reason is \
stored with the append-only `History` event"
.into(),
));
}
Ok(())
}
CapacityEdit::Configure => match GeneratedCycle::from_record(record)? {
Some(cycle) if cycle.state == GeneratedCycleState::Closed => {
Err(TixError::Config(format!(
"`cycles.{}` is closed; its capacity (`{}`) is the historical record captured \
at close, and a configuration change applies to current and future \
calculations only — reopen it through the explicit `History` event instead",
record.name, cycle.id
)))
}
Some(cycle) if cycle.state == GeneratedCycleState::Skipped => {
Err(TixError::Config(format!(
"`cycles.{}` is skipped; a skipped sequence was never generated, so it has no \
capacity to configure",
record.name
)))
}
_ => Ok(()),
},
}
}
pub fn validate(capacity: &CapacityConfig, config: &Config) -> Result<()> {
if capacity.is_empty() {
return Ok(());
}
let mut claimed: HashMap<(CapacityScopeKind, String), String> = HashMap::new();
for (id, entry) in capacity.iter() {
let path = format!("capacity.{id}");
validate_entry_id(id)?;
check_extensions(&path, &entry.extensions)?;
let (kind, reference) = validate_scope(&path, entry)?;
let identity = scope_identity(&path, config, None, kind, &reference)?;
if let Some(first) = claimed.insert((kind, identity.canonical.clone()), id.to_string()) {
return Err(duplicate_scope(&path, kind, &reference, &identity, &first));
}
validate_ceiling(&path, entry, config)?;
}
Ok(())
}
pub fn validate_cycle_references(
capacity: &CapacityConfig,
config: &Config,
root: &Path,
) -> Result<()> {
if !capacity.iter().any(|(_, entry)| entry.cycle.is_some()) {
return Ok(());
}
validate_cycle_identities(capacity, config, &schedule_index(config, root)?)
}
fn validate_cycle_identities(
capacity: &CapacityConfig,
config: &Config,
index: &ScheduleIndex,
) -> Result<()> {
let mut claimed: HashMap<String, String> = HashMap::new();
for (id, entry) in capacity.iter() {
let Some((CapacityScopeKind::Cycle, reference)) = entry.scopes().next() else {
continue;
};
let path = format!("capacity.{id}");
let identity = scope_identity(
&path,
config,
Some(index),
CapacityScopeKind::Cycle,
reference,
)?;
if let Some(first) = claimed.insert(identity.canonical.clone(), id.to_string()) {
return Err(duplicate_scope(
&path,
CapacityScopeKind::Cycle,
reference,
&identity,
&first,
));
}
}
Ok(())
}
pub fn set_capacity(
config: &mut Config,
id: &str,
entry: CapacityEntry,
edit: &CapacityEdit<'_>,
root: &Path,
) -> Result<()> {
let records = cycle_records(config, root)?;
let index = ScheduleIndex::build(config, &records);
if let Some((CapacityScopeKind::Cycle, reference)) = entry.scopes().next()
&& let Some(record) = cycle_record(&index, &records, reference)
{
ensure_cycle_editable(record, edit)?;
}
let mut candidate = config.clone();
candidate.capacity.insert(id, entry);
validate(&candidate.capacity, &candidate)?;
validate_cycle_identities(&candidate.capacity, &candidate, &index)?;
candidate.save(&root.join("tix.toml"))?;
*config = candidate;
Ok(())
}
fn validate_entry_id(id: &str) -> Result<()> {
if id.trim().is_empty() {
return Err(TixError::Config(
"`capacity` must not contain an entry with a blank id".into(),
));
}
if id.trim() != id {
return Err(TixError::Config(format!(
"`capacity` entry id `{id}` has leading or trailing whitespace"
)));
}
if id.contains('.') {
return Err(TixError::Config(format!(
"`capacity` entry id `{id}` contains `.`; a dot reads as a nested table in \
`[capacity.<id>]` and in a diagnostic path"
)));
}
Ok(())
}
fn check_extensions(path: &str, extensions: &CapacityExtensions) -> Result<()> {
for key in extensions.keys() {
if CAPACITY_ENTRY_KNOWN_FIELDS.contains(&key) {
return Err(TixError::Config(format!(
"`{path}.{key}` collides with a built-in field; rename the extension key"
)));
}
}
Ok(())
}
fn validate_scope(path: &str, entry: &CapacityEntry) -> Result<(CapacityScopeKind, String)> {
let named: Vec<(CapacityScopeKind, &str)> = entry.scopes().collect();
match named.len() {
0 => Err(TixError::Config(format!(
"`{path}.scope` must name exactly one scope: set `cycle`, `team`, or `assignee`"
))),
1 => {
let (kind, reference) = named[0];
if reference.trim().is_empty() {
return Err(TixError::Config(format!(
"`{path}.{kind}` must not be blank"
)));
}
if reference.trim() != reference {
return Err(TixError::Config(format!(
"`{path}.{kind}` (`{reference}`) has leading or trailing whitespace"
)));
}
Ok((kind, reference.to_string()))
}
_ => {
let listed = named
.iter()
.map(|(kind, _)| format!("`{kind}`"))
.collect::<Vec<_>>()
.join(" and ");
Err(TixError::Config(format!(
"`{path}.scope` names more than one scope ({listed}); an entry targets exactly one \
cycle, team, or assignee"
)))
}
}
}
struct ScopeIdentity {
canonical: String,
label: String,
}
fn duplicate_scope(
path: &str,
kind: CapacityScopeKind,
reference: &str,
identity: &ScopeIdentity,
first: &str,
) -> TixError {
let note = if identity.label == reference {
String::new()
} else {
format!(" (`{reference}` resolves to `{}`)", identity.label)
};
TixError::Config(format!(
"`{path}.{kind}` (`{reference}`) repeats the scope \
`capacity.{first}.{kind}` already configures{note}; one ceiling per scope"
))
}
fn scope_identity(
path: &str,
config: &Config,
index: Option<&ScheduleIndex>,
kind: CapacityScopeKind,
reference: &str,
) -> Result<ScopeIdentity> {
match kind {
CapacityScopeKind::Cycle => {
if let Some(index) = index {
return Ok(match resolve_cycle(index, reference) {
CycleReference::Generated { id, .. } => ScopeIdentity {
canonical: format!("cycle-id:{id}"),
label: id.to_string(),
},
CycleReference::Legacy { name } => ScopeIdentity {
canonical: format!("cycle-name:{name}"),
label: name,
},
CycleReference::Unknown { .. } if config.cycles.contains_key(reference) => {
ScopeIdentity {
canonical: format!("cycle-name:{reference}"),
label: reference.to_string(),
}
}
CycleReference::Unknown { .. } => {
return Err(TixError::Config(format!(
"`{path}.cycle` (`{reference}`) does not name a cycle; add \
`[cycles.\"{reference}\"]`, or name a generated cycle's \
`<schedule-id>:<sequence>` id or its `cycles` value"
)));
}
});
}
if config.cycles.contains_key(reference) {
return Ok(ScopeIdentity {
canonical: format!("cycle-name:{reference}"),
label: reference.to_string(),
});
}
Ok(ScopeIdentity {
canonical: format!("cycle-ref:{reference}"),
label: reference.to_string(),
})
}
CapacityScopeKind::Team => {
if !config.teams.contains_key(reference) {
return Err(TixError::Config(format!(
"`{path}.team` (`{reference}`) is not a configured team; add \
`[teams.{reference}]`"
)));
}
Ok(ScopeIdentity {
canonical: format!("team:{reference}"),
label: reference.to_string(),
})
}
CapacityScopeKind::Assignee => {
let Some(canonical) = crate::assignee::parse_assignee(reference) else {
return Err(TixError::Config(format!(
"`{path}.assignee` (`{reference}`) is not an assignee identity; use a handle \
(`ada`, `@ada`) or `Ada Lovelace <ada@example.com>`"
)));
};
Ok(ScopeIdentity {
canonical: format!("assignee:{canonical}"),
label: canonical,
})
}
}
}
fn validate_ceiling(path: &str, entry: &CapacityEntry, config: &Config) -> Result<()> {
match entry.capacity {
Some(value) if value < 0 => {
return Err(TixError::Config(format!(
"`{path}.capacity` ({value}) must not be negative"
)));
}
None if entry.enabled => {
return Err(TixError::Config(format!(
"`{path}.capacity` must be set for an enabled entry; set `enabled = false` to keep \
the scope without a ceiling"
)));
}
_ => {}
}
match entry.unit.as_deref() {
Some(unit) if unit.trim().is_empty() => {
Err(TixError::Config(format!("`{path}.unit` must not be blank")))
}
Some(unit) if unit.trim() != unit => Err(TixError::Config(format!(
"`{path}.unit` (`{unit}`) has leading or trailing whitespace"
))),
Some(unit) if !declared_unit(config, unit) => Err(TixError::Config(format!(
"`{path}.unit` (`{unit}`) is not a declared estimate unit on board `{board}`, which \
is the board capacity calculations run under; declare `{unit}` under \
`[kanban.{board}.wip.units]`",
board = crate::workflow::active_board_name(config)
))),
Some(_) => Ok(()),
None if entry.enabled => Err(TixError::Config(format!(
"`{path}.unit` must name the estimate unit `capacity` counts in; a capacity with no \
unit cannot be calculated"
))),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn closed_record() -> CycleRecord {
CycleRecord {
name: "Platform sprint".into(),
start_date: Some("2026-01-05".into()),
due_date: Some("2026-01-19".into()),
schedule_id: Some("cs-1".into()),
sequence: Some(1),
state: Some("closed".into()),
..CycleRecord::default()
}
}
#[test]
fn a_closed_cycle_is_refused_a_configuration_edit_but_not_a_reopen() {
let error = ensure_cycle_editable(&closed_record(), &CapacityEdit::Configure)
.expect_err("a closed cycle is historical evidence");
let message = error.to_string();
assert!(message.contains("closed"), "{message}");
assert!(message.contains("cs-1:000001"), "{message}");
assert!(
ensure_cycle_editable(&closed_record(), &CapacityEdit::Reopen { reason: " " })
.is_err(),
"a reopen without a reason is not auditable"
);
ensure_cycle_editable(
&closed_record(),
&CapacityEdit::Reopen {
reason: "recounting after an import",
},
)
.expect("an explicit, reasoned reopen is the way through");
}
#[test]
fn a_hand_written_cycle_has_no_state_to_refuse() {
let record = CycleRecord {
name: "Cycle 1".into(),
..CycleRecord::default()
};
ensure_cycle_editable(&record, &CapacityEdit::Configure)
.expect("a hand-written cycle has no lifecycle state");
}
#[test]
fn an_active_cycle_is_editable_and_a_skipped_one_is_not() {
let active = CycleRecord {
state: Some("active".into()),
..closed_record()
};
ensure_cycle_editable(&active, &CapacityEdit::Configure).expect("active is current work");
let skipped = CycleRecord {
state: Some("skipped".into()),
..closed_record()
};
let message = ensure_cycle_editable(&skipped, &CapacityEdit::Configure)
.expect_err("a skipped sequence was never generated")
.to_string();
assert!(message.contains("skipped"), "{message}");
}
#[test]
fn a_partial_generated_identity_is_a_finding_at_the_boundary() {
let record = CycleRecord {
state: None,
..closed_record()
};
assert!(
ensure_cycle_editable(&record, &CapacityEdit::Configure).is_err(),
"half an identity must not read as an open cycle"
);
}
#[test]
fn the_scope_accessors_report_none_when_the_entry_is_ambiguous() {
let mut entry = CapacityEntry::default();
assert_eq!(entry.scope_kind(), None);
assert_eq!(entry.scope_id(), None);
entry.cycle = Some("Cycle 1".into());
assert_eq!(entry.scope_kind(), Some(CapacityScopeKind::Cycle));
assert_eq!(entry.scope_id(), Some("Cycle 1"));
entry.team = Some("Platform".into());
assert_eq!(entry.scope_kind(), None, "two scopes are not a scope");
assert_eq!(entry.scope_id(), None);
}
#[test]
fn a_disabled_entry_without_numbers_is_not_effective() {
let mut entry = CapacityEntry {
team: Some("Platform".into()),
..CapacityEntry::default()
};
assert!(!entry.effective(), "no ceiling is configured yet");
entry.capacity = Some(0);
assert!(!entry.effective(), "no unit means it cannot be calculated");
entry.unit = Some("d".into());
assert!(entry.effective());
entry.enabled = false;
assert!(!entry.effective(), "a disabled entry enforces nothing");
}
}