use std::path::{Path, PathBuf};
use crate::capacity::calculate::{CapacityCalculation, CapacityScope};
use crate::capacity::config::{
CapacityEdit, CapacityEntry, CapacityExtensions, CapacityScopeKind, ensure_cycle_editable,
set_capacity, validate, validate_cycle_references,
};
use crate::capacity::scope::IssueSnapshot;
use crate::capacity::snapshot::CapacitySnapshot;
use crate::capacity::warning::{CapacityWarning, CapacityWarningKind};
use crate::capacity::{resolve_cycle, schedule_index};
use crate::cli::CapacityCommand;
use crate::config::Config;
use crate::cycle_schedule::identity::{CycleReference, ScheduleIndex};
use crate::cycle_schedule::lifecycle::{LifecycleAction, LifecycleRequest};
use crate::cycle_schedule::snapshot::CycleSnapshotStore;
use crate::cycle_store::CycleStore;
use crate::error::{Result, TixError};
use crate::storage::Store;
pub fn run(config: &mut Config, store: &Store, command: &CapacityCommand) -> Result<()> {
let root = root_from_store(store);
match command {
CapacityCommand::List => list(config, &root),
CapacityCommand::Show { reference, scope } => {
show(config, &root, scope.as_deref(), reference)
}
CapacityCommand::Set {
scope,
id,
value,
unit,
disabled,
} => set(config, &root, scope, id, *value, unit, *disabled),
CapacityCommand::Delete { scope, id } => delete(config, &root, scope, id),
CapacityCommand::Calculate {
scope,
id,
warnings,
} => calculate(config, store, &root, scope, id, *warnings),
CapacityCommand::Snapshot { cycle, warnings } => {
read_snapshot(config, &root, cycle, *warnings)
}
}
}
fn root_from_store(store: &Store) -> PathBuf {
Config::project_root(&store.issues_dir).unwrap_or_else(|| PathBuf::from("."))
}
pub(crate) fn snapshot_for(
config: &Config,
root: &Path,
store: &Store,
) -> Result<(ScheduleIndex, IssueSnapshot)> {
let index = schedule_index(config, root)?;
let snapshot = IssueSnapshot::build(config, &index, &store.list()?);
Ok((index, snapshot))
}
pub(crate) fn calculation_for(
config: &Config,
root: &Path,
store: &Store,
kind: CapacityScopeKind,
reference: &str,
) -> Result<CapacityCalculation> {
let (index, snapshot) = snapshot_for(config, root, store)?;
let scope = CapacityScope::new(kind, reference, &index);
Ok(CapacityCalculation::for_scope(
&snapshot, &index, config, scope,
))
}
pub(crate) fn with_close_capacity(
request: LifecycleRequest,
config: &Config,
root: &Path,
store: &Store,
) -> Result<LifecycleRequest> {
if request.action != LifecycleAction::Close {
return Ok(request);
}
let Some(cycle_id) = request.cycle_id.clone() else {
return Ok(request);
};
let (index, snapshot) = snapshot_for(config, root, store)?;
let Some(cycle) = index.cycle(&cycle_id) else {
return Ok(request);
};
let scope = CapacityScope::new(CapacityScopeKind::Cycle, &cycle.name, &index);
Ok(request.with_capacity(CapacityCalculation::for_scope(
&snapshot, &index, config, scope,
)))
}
fn list(config: &Config, root: &Path) -> Result<()> {
let index = schedule_index(config, root)?;
println!("id\tscope\treference\tdisplay\tcapacity\tunit\tenabled");
for (id, entry) in config.capacity.iter() {
let Some(kind) = entry.scope_kind() else {
continue;
};
let reference = entry.scope_id().unwrap_or_default();
let display = scope_label(kind, reference, &index);
println!(
"{id}\t{kind}\t{reference}\t{display}\t{ceiling}\t{unit}\t{enabled}",
kind = kind.as_str(),
ceiling = number(entry.capacity),
unit = entry.unit.as_deref().unwrap_or("-"),
enabled = entry.enabled,
);
}
Ok(())
}
fn show(config: &Config, root: &Path, scope: Option<&str>, reference: &str) -> Result<()> {
let wanted = scope.map(parse_scope_kind).transpose()?;
let index = schedule_index(config, root)?;
let (id, kind, entry) = one_entry(config, wanted, reference, &index)?;
let display = scope_label(kind, entry.scope_id().unwrap_or_default(), &index);
println!("id: {id}");
println!("scope: {}", kind.as_str());
println!("reference: {}", entry.scope_id().unwrap_or_default());
println!("display: {display}");
println!("capacity: {}", number(entry.capacity));
println!("unit: {}", entry.unit.as_deref().unwrap_or("-"));
println!("enabled: {}", entry.enabled);
Ok(())
}
fn set(
config: &mut Config,
root: &Path,
scope: &str,
id: &str,
value: i64,
unit: &str,
disabled: bool,
) -> Result<()> {
let kind = parse_scope_kind(scope)?;
let reference = required("--id", id)?;
let unit = required("--unit", unit)?;
let index = schedule_index(config, root)?;
let label = scope_label(kind, reference, &index);
let key = capacity_key(kind, &label);
let occupied = config.capacity.get(&key).map(|entry| {
(
entry.scope_kind(),
entry.scope_id().unwrap_or_default().to_string(),
)
});
if let Some((occupied_kind, occupied_scope)) = occupied {
let same_scope =
occupied_kind == Some(kind) && scope_label(kind, &occupied_scope, &index) == label;
if !same_scope {
let occupied = occupied_kind.map_or_else(
|| format!("an entry naming no single scope (`{occupied_scope}`)"),
|occupied_kind| format!("{kind} `{occupied_scope}`", kind = occupied_kind.as_str()),
);
let remedy = occupied_kind.map_or_else(
|| "Remove that entry first".to_string(),
|occupied_kind| {
format!(
"Update the configured scope by naming it exactly (`--id \
{occupied_scope}`), or remove it first (`tix capacity delete --scope \
{kind} --id {occupied_scope}`)",
kind = occupied_kind.as_str(),
)
},
);
return Err(TixError::Config(format!(
"`capacity.{key}` already configures {occupied}, which resolves to the same entry \
handle as `--id {reference}`; writing it would replace that ceiling. {remedy}"
)));
}
}
let mut entry = CapacityEntry {
capacity: Some(value),
unit: Some(unit.to_string()),
enabled: !disabled,
extensions: config
.capacity
.get(&key)
.map_or_else(CapacityExtensions::default, |entry| {
entry.extensions.clone()
}),
..CapacityEntry::default()
};
match kind {
CapacityScopeKind::Cycle => entry.cycle = Some(reference.to_string()),
CapacityScopeKind::Team => entry.team = Some(reference.to_string()),
CapacityScopeKind::Assignee => entry.assignee = Some(reference.to_string()),
}
set_capacity(config, &key, entry, &CapacityEdit::Configure, root)?;
println!(
"Set capacity for {kind} `{reference}`: {value}{unit}{disabled}",
kind = kind.as_str(),
disabled = if disabled { " (disabled)" } else { "" },
);
Ok(())
}
fn delete(config: &mut Config, root: &Path, scope: &str, id: &str) -> Result<()> {
let kind = parse_scope_kind(scope)?;
let reference = required("--id", id)?;
let index = schedule_index(config, root)?;
let (key, _, target) = one_entry(config, Some(kind), reference, &index)?;
if kind == CapacityScopeKind::Cycle
&& let Some(reference) = target.scope_id()
{
ensure_cycle_editable_for(config, root, reference)?;
}
let key = key.to_string();
let mut candidate = config.clone();
candidate.capacity.entries_mut().shift_remove(&key);
validate(&candidate.capacity, &candidate)?;
validate_cycle_references(&candidate.capacity, &candidate, root)?;
candidate.save(&root.join("tix.toml"))?;
*config = candidate;
println!("Removed capacity for {kind} `{reference}`");
Ok(())
}
fn calculate(
config: &Config,
store: &Store,
root: &Path,
scope: &str,
id: &str,
warnings: bool,
) -> Result<()> {
let kind = parse_scope_kind(scope)?;
let reference = required("--id", id)?;
let calculation = calculation_for(config, root, store, kind, reference)?;
print_calculation(&calculation);
render_findings(&calculation.diagnostics, warnings);
Ok(())
}
pub(crate) fn calculation_text(calculation: &CapacityCalculation) -> String {
format!(
"scope: {scope}\nreference: {reference}\ndisplay: {display}\nstate: {state}\n\
capacity: {capacity}\nunit: {unit}\ncommitted: {committed}\ncompleted: {completed}\n\
remaining: {remaining}\noverflow: {overflow}\nutilization: {utilization}\n\
counted_issues: {counted}\nuncounted_issues: {uncounted}\n\
unestimated_issues: {unestimated}\nsource_issues: {source_issues}\n\
source_units: {source_units}\ndiagnostics: {diagnostics}\n",
scope = calculation.scope.kind.as_str(),
reference = calculation.scope.reference,
display = calculation.scope.display,
state = calculation.state().as_str(),
capacity = number(calculation.capacity),
unit = calculation.unit.as_deref().unwrap_or("-"),
committed = number(calculation.committed),
completed = number(calculation.completed),
remaining = number(calculation.remaining),
overflow = number(calculation.overflow),
utilization = utilization(calculation.utilization),
counted = number(calculation.counted_issues),
uncounted = number(calculation.uncounted_issues),
unestimated = number(calculation.unestimated_issues),
source_issues = calculation.source.issues,
source_units = joined(&calculation.source.units),
diagnostics = calculation.diagnostics.len(),
)
}
fn print_calculation(calculation: &CapacityCalculation) {
print!("{}", calculation_text(calculation));
}
fn render_findings(diagnostics: &[CapacityWarning], overflow: bool) {
for warning in diagnostics {
if overflow || !matches!(warning.kind, CapacityWarningKind::Overflow) {
eprintln!("{}", warning.render());
}
}
}
fn read_snapshot(config: &Config, root: &Path, cycle: &str, warnings: bool) -> Result<()> {
let index = schedule_index(config, root)?;
let cycle_id = match resolve_cycle(&index, cycle) {
CycleReference::Generated { id, .. } => id,
CycleReference::Legacy { .. } => {
return Err(TixError::NotFound(format!(
"cycle `{cycle}` is a hand-written cycle and has no generated snapshot; \
`tix capacity snapshot` reads the evidence a generated cycle's close captured"
)));
}
CycleReference::Unknown { .. } => {
return Err(TixError::NotFound(format!(
"no cycle `{cycle}` resolves to a record, so there is no snapshot to read"
)));
}
};
let store = CycleSnapshotStore::new(root);
let Some(stored) = store.read(&cycle_id)? else {
return Err(TixError::NotFound(format!(
"cycle `{cycle_id}` has no snapshot: it has not been closed, or its snapshot was \
removed"
)));
};
println!("cycle: {cycle_id}");
println!(
"snapshot: docs/cycle_snapshots/{filename}",
filename = CycleSnapshotStore::filename_for(&cycle_id)
);
match CapacitySnapshot::from_cycle_snapshot(&stored)? {
None => {
println!("evidence: none");
eprintln!(
"note: this snapshot carries no capacity payload; it was written before capacity \
evidence was captured, and it is not an error — nothing is migrated in place"
);
}
Some(evidence) => {
println!("evidence: present");
print_evidence(&evidence);
render_findings(&evidence.diagnostics, warnings);
}
}
Ok(())
}
pub(crate) fn evidence_text(evidence: &CapacitySnapshot) -> String {
format!(
"snapshot_id: {snapshot_id}\nclosed_at: {closed_at}\nclosed_by: {closed_by}\n\
scope: {scope}\nreference: {reference}\ndisplay: {display}\nstate: {state}\n\
capacity: {capacity}\nunit: {unit}\ncommitted: {committed}\ncompleted: {completed}\n\
remaining: {remaining}\noverflow: {overflow}\nutilization: {utilization}\n\
counted_issues: {counted}\nuncounted_issues: {uncounted}\n\
unestimated_issues: {unestimated}\nsource_issues: {source_issues}\n\
source_units: {source_units}\ndiagnostics: {diagnostics}\n",
snapshot_id = evidence.snapshot_id,
closed_at = evidence.closed_at,
closed_by = evidence.closed_by,
scope = evidence.scope.kind.as_str(),
reference = evidence.scope.reference,
display = evidence.scope.display,
state = evidence.state.as_str(),
capacity = number(evidence.capacity),
unit = evidence.unit.as_deref().unwrap_or("-"),
committed = number(evidence.committed),
completed = number(evidence.completed),
remaining = number(evidence.remaining),
overflow = number(evidence.overflow),
utilization = utilization(evidence.utilization),
counted = number(evidence.counted_issues),
uncounted = number(evidence.uncounted_issues),
unestimated = number(evidence.unestimated_issues),
source_issues = evidence.source.issues,
source_units = joined(&evidence.source.units),
diagnostics = evidence.diagnostics.len(),
)
}
fn print_evidence(evidence: &CapacitySnapshot) {
print!("{}", evidence_text(evidence));
}
pub(crate) fn parse_scope_kind(raw: &str) -> Result<CapacityScopeKind> {
match raw {
"cycle" => Ok(CapacityScopeKind::Cycle),
"team" => Ok(CapacityScopeKind::Team),
"assignee" => Ok(CapacityScopeKind::Assignee),
other => Err(TixError::InvalidInput(format!(
"`{other}` is not a capacity scope; expected one of `{}`",
CapacityScopeKind::all()
.iter()
.map(|kind| kind.as_str())
.collect::<Vec<_>>()
.join("`, `")
))),
}
}
pub(crate) fn required<'a>(flag: &str, value: &'a str) -> Result<&'a str> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(TixError::InvalidInput(format!(
"`{flag}` must not be blank"
)));
}
if trimmed != value {
return Err(TixError::InvalidInput(format!(
"`{flag}` (`{value}`) has leading or trailing whitespace"
)));
}
Ok(value)
}
pub(crate) fn scope_label(
kind: CapacityScopeKind,
reference: &str,
index: &ScheduleIndex,
) -> String {
CapacityScope::new(kind, reference, index).display
}
fn capacity_key(kind: CapacityScopeKind, label: &str) -> String {
format!(
"{kind}-{slug}",
kind = kind.as_str(),
slug = crate::slug::slugify(label)
)
}
pub(crate) fn entries_matching<'a>(
config: &'a Config,
kind: Option<CapacityScopeKind>,
reference: &str,
index: &ScheduleIndex,
) -> Vec<(&'a str, CapacityScopeKind, &'a CapacityEntry)> {
config
.capacity
.iter()
.filter_map(|(id, entry)| {
let entry_kind = entry.scope_kind()?;
if kind.is_some_and(|wanted| wanted != entry_kind) {
return None;
}
let entry_label = scope_label(entry_kind, entry.scope_id()?, index);
(entry_label == scope_label(entry_kind, reference, index))
.then_some((id, entry_kind, entry))
})
.collect()
}
pub(crate) fn one_entry<'a>(
config: &'a Config,
kind: Option<CapacityScopeKind>,
reference: &str,
index: &ScheduleIndex,
) -> Result<(&'a str, CapacityScopeKind, &'a CapacityEntry)> {
let matches = entries_matching(config, kind, reference, index);
match matches.as_slice() {
[] => Err(TixError::NotFound(match kind {
Some(kind) => format!(
"no capacity entry for {kind} `{reference}`; configure one with `tix capacity \
set --scope {kind} --id {reference} --value <N> --unit <UNIT>`"
),
None => format!(
"no capacity entry names `{reference}`; `tix capacity list` shows the configured \
scopes"
),
})),
[(id, kind, entry)] => Ok((*id, *kind, *entry)),
many => {
let listed = many
.iter()
.map(|(id, kind, entry)| {
format!(
"`{id}` ({kind} `{scope}`)",
kind = kind.as_str(),
scope = entry.scope_id().unwrap_or_default()
)
})
.collect::<Vec<_>>()
.join(", ");
Err(TixError::InvalidInput(format!(
"`{reference}` matches {} capacity entries: {listed}; pass `--scope` to name one",
many.len()
)))
}
}
}
fn ensure_cycle_editable_for(config: &Config, root: &Path, reference: &str) -> Result<()> {
let records = CycleStore::new(root).list()?;
let index = ScheduleIndex::build(config, &records);
let name = match resolve_cycle(&index, reference) {
CycleReference::Generated { name, .. } | CycleReference::Legacy { name } => name,
CycleReference::Unknown { .. } => return Ok(()),
};
if let Some(record) = records.iter().find(|record| record.name == name) {
ensure_cycle_editable(record, &CapacityEdit::Configure)?;
}
Ok(())
}
pub(crate) fn number<T: std::fmt::Display>(value: Option<T>) -> String {
value.map_or_else(|| "-".to_string(), |value| value.to_string())
}
pub(crate) fn utilization(value: Option<u32>) -> String {
value.map_or_else(|| "-".to_string(), |value| format!("{value}%"))
}
pub(crate) fn joined(units: &[String]) -> String {
if units.is_empty() {
"-".to_string()
} else {
units.join(", ")
}
}