tix/capacity/config.rs
1//! `[capacity]`: capacity ceilings, and the scopes they belong to (WP01).
2//!
3//! # The shape
4//!
5//! ```toml
6//! [capacity.zulu] # the key is only a handle for the record
7//! cycle = "Cycle 1" # …and exactly one of these says which scope
8//! capacity = 20
9//! unit = "d" # declared on the board the calculation runs under
10//!
11//! [capacity.platform]
12//! team = "Platform"
13//! capacity = 15
14//! unit = "d"
15//!
16//! [capacity.ada]
17//! assignee = "Ada Lovelace <ada@example.com>"
18//! capacity = 3
19//! unit = "w"
20//! ```
21//!
22//! Five decisions, and each one closes a door rather than opening one:
23//!
24//! 1. **The scope is a field, and there is exactly one of it.** `cycle`, `team` and
25//! `assignee` are three mutually exclusive keys: naming none is refused
26//! (`` `capacity.<id>.scope` `` must name one) and naming two is refused as
27//! ambiguous. A shape that derived the scope from *where* the entry sits would make
28//! "which dimension is this?" unstateable, and C-003 refuses implicit scope
29//! inheritance outright — so the entry says it, once.
30//! 2. **The unit is named, and the board the calculation runs under must declare it.**
31//! `[kanban.<board>.wip.units]` is the unit registry (the WIP mission's — reused, never
32//! re-declared here), and a capacity entry has no board of its own, so the board the
33//! arithmetic actually converts under is the one that has to know the unit. An entry
34//! counting in a unit only *another* board declares is refused at load, naming that
35//! board and the table to declare it in — otherwise it would load clean, refuse to
36//! calculate, and print a remedy for a unit the user had already declared, and a unit
37//! two boards declare with different factors would silently convert under the wrong one.
38//! A capacity with no unit is refused by name; it is *not* defaulted, and no new unit is
39//! ever invented (C-004).
40//! 3. **`capacity` is an integer at or above zero.** An enabled entry must state one.
41//! A disabled entry keeps the scope without a ceiling, which is the "omit or disable
42//! without deleting history" case the data model asks for.
43//! 4. **One ceiling per scope.** Two entries whose scope **resolves to** one
44//! cycle/team/assignee are refused — including two spellings of one cycle (its name and
45//! its stable id) or of one handle (`ada` and `@ada`) — because two answers to "how much
46//! is this allowed to commit?" is a question with no answer.
47//! 5. **Nothing is required to be present.** The section is omitted whole when empty, so
48//! a capacity-free `tix.toml` gains no key and round-trips byte-identically (C-001),
49//! and each entry preserves unknown metadata verbatim so a newer `tix` can add a
50//! field this build carries rather than drops.
51//!
52//! # Cycle references resolve through the crate's one adapter, over the records
53//!
54//! A cycle scope reference is what the entry's `cycle` key holds, and it is resolved by
55//! [`resolve_cycle`] over a [`ScheduleIndex`] the caller built from the cycle **records**:
56//! [`ScheduleIndex::resolve`] for the name half (the same adapter an issue's `cycles`
57//! value goes through), [`GeneratedCycleId::parse`] plus [`ScheduleIndex::cycle`] for the
58//! stable `\<schedule-id\>:\<sequence\>` id half. The records matter: `Config::cycles` is
59//! name-keyed and narrows each record to `PlanningCycle`, which carries no
60//! `schedule_id`/`sequence`/`state`, so an index built from those keys alone would see
61//! every cycle as hand-written. Feeding the records — `docs/cycles/`, or the legacy
62//! `[cycles.<name>]` tables when there is no store, which is the loader's own rule in
63//! [`cycle_records`] — makes a generated cycle's **name** classify as
64//! [`CycleReference::Generated`] with its id, exactly as
65//! [`crate::cycle_schedule::registry::load_index`] answers. There is deliberately no
66//! second name resolver in this module: the string that identifies a cycle is
67//! issue-181's, not capacity's.
68//!
69//! # Which half of the validation lives where, and why it must not move
70//!
71//! The section is checked by **two** halves, and the split is deliberate:
72//!
73//! * [`validate`] is **rootless** — it reads no file — and is called by
74//! [`crate::config::Config::validate`], i.e. by every *validate-then-save* path in the
75//! crate (`tix wip unit --remove`, the schedule registry, the MCP write tools, planning).
76//! It covers what the configuration alone decides: the entry handle, the extensions
77//! shadow check, exactly one scope with a non-blank unpadded reference, a team label that
78//! exists, an assignee identity the model parses, the ceiling (enabled ⇒ present and
79//! non-negative), the unit (enabled ⇒ present, not blank, **declared by a board**), and a
80//! scope no other entry already took. The declared-unit check belongs here beyond
81//! argument: `tix wip unit --remove` drops a unit and then validates the *same in-memory*
82//! config before writing, so without it the command would persist a file whose next load
83//! refuses — a repository nobody could read again.
84//! * [`validate_cycle_references`] needs a **project root**, and is called by the two gates
85//! that have one: [`crate::config::Config::load_with_warning`] (every hand-edited file)
86//! and [`set_capacity`] (every programmatic change). It resolves each cycle reference
87//! against the cycle **records** — `docs/cycles/`, whose generated identities are the only
88//! place a generated cycle's name can be told from a hand-written one — and unifies two
89//! spellings of one cycle into one ceiling. Its validity rule is provenance-independent:
90//! a reference is valid when it resolves through the records **or** when it names a
91//! `[cycles.<name>]` entry in the configuration (which, after a load, holds every stored
92//! cycle's reference name too), so the same entry is accepted whether or not the caller
93//! happened to read a cycle store.
94//!
95//! Moving the declared-unit check into the records' half (or dropping it from the rootless
96//! one) is the failure this split exists to prevent; moving the *record* resolution into
97//! the rootless half is impossible, because `Config::validate` has no root. Anything added
98//! here should go to the half whose inputs it actually reads.
99//!
100//! # Closed cycles: what this package guarantees, and what it leaves to WP04
101//!
102//! A closed cycle's capacity is **historical evidence** (C-008). WP01 guarantees two
103//! things and no more:
104//!
105//! * **No-op.** This section holds no snapshot state, so validating and saving a
106//! `tix.toml` cannot rewrite a snapshot: there is nothing in the record to rewrite.
107//! A change to a *current or future* scope is therefore applied and the closed cycle's
108//! evidence file is left byte-identical — asserted end to end.
109//! * **Refusal, on the write path.** [`set_capacity`] is the one way this package changes
110//! capacity, and it calls [`ensure_cycle_editable`] before it writes anything: an
111//! ordinary [`CapacityEdit::Configure`] aimed at a terminal cycle — closed, whose
112//! snapshot is the record, or skipped, which was never generated — is refused, leaving
113//! both the in-memory section and `tix.toml` as they were. The one way through is
114//! [`CapacityEdit::Reopen`], which carries the audit reason of the explicit append-only
115//! `History` event.
116//!
117//! WP04 owns the other half: capturing the snapshot at close, storing it, and appending
118//! the reopen/edit event. This module knows none of that — it only refuses to be a
119//! second way in.
120
121use std::collections::HashMap;
122use std::path::Path;
123
124use indexmap::IndexMap;
125use serde::{Deserialize, Serialize};
126
127use crate::config::Config;
128use crate::cycle_schedule::identity::{
129 CycleReference, GeneratedCycle, GeneratedCycleId, GeneratedCycleState, ScheduleIndex,
130};
131use crate::cycle_store::{CycleRecord, CycleStore};
132use crate::error::{Result, TixError};
133
134/// The scope dimension a capacity ceiling belongs to (C-003, data-model §1).
135///
136/// `cycle`, `team` and `assignee` are the three the contract names and there is no
137/// fourth: "unsupported or implicit scope inheritance is an escalation trigger", so a
138/// variant that swept several issues together would be a new semantic rather than a new
139/// spelling. The variants serialize as the entry keys of the same name.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
141#[serde(rename_all = "lowercase")]
142pub enum CapacityScopeKind {
143 /// One cycle, by the name the registry/records answer to or by generated id.
144 Cycle,
145 /// One configured team, by its `[teams.<label>]` label.
146 Team,
147 /// One assignee identity, in the shape an issue's `assignee` field tolerates.
148 Assignee,
149}
150
151impl CapacityScopeKind {
152 /// The stable spelling every surface prints and `tix.toml` stores.
153 #[must_use]
154 pub const fn as_str(self) -> &'static str {
155 match self {
156 Self::Cycle => "cycle",
157 Self::Team => "team",
158 Self::Assignee => "assignee",
159 }
160 }
161
162 /// Every scope kind, in the order a diagnostic lists them.
163 #[must_use]
164 pub const fn all() -> [Self; 3] {
165 [Self::Cycle, Self::Team, Self::Assignee]
166 }
167}
168
169impl std::fmt::Display for CapacityScopeKind {
170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 f.write_str(self.as_str())
172 }
173}
174
175/// Metadata this build does not interpret, preserved verbatim.
176///
177/// The same guarantee the sibling registries give their records
178/// ([`crate::cycle_schedule::config::CycleScheduleExtensions`],
179/// [`crate::roadmap::registry::InitiativeExtensions`]): a key a newer `tix` adds must
180/// survive an edit-and-save round trip instead of being dropped. The one rule it cannot
181/// break is that a preserved key must not **shadow** a built-in field — a file can never
182/// deliver such a key (serde matches the known field first), but the public write path
183/// could inject one, and `toml` serializes duplicate table keys without complaining, so
184/// the write would appear to succeed while the next load failed on a duplicate key.
185/// [`validate`] refuses it instead.
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187#[serde(transparent)]
188pub struct CapacityExtensions(toml::Table);
189
190impl CapacityExtensions {
191 /// Whether no unknown metadata is present.
192 #[must_use]
193 pub fn is_empty(&self) -> bool {
194 self.0.is_empty()
195 }
196
197 /// The number of preserved keys.
198 #[must_use]
199 pub fn len(&self) -> usize {
200 self.0.len()
201 }
202
203 /// Look up a preserved key.
204 #[must_use]
205 pub fn get(&self, key: &str) -> Option<&toml::Value> {
206 self.0.get(key)
207 }
208
209 /// The preserved keys, in canonical order.
210 pub fn keys(&self) -> impl Iterator<Item = &str> {
211 self.0.keys().map(String::as_str)
212 }
213
214 /// Iterate the preserved `(key, value)` pairs in canonical order.
215 pub fn iter(&self) -> impl Iterator<Item = (&str, &toml::Value)> {
216 self.0.iter().map(|(k, v)| (k.as_str(), v))
217 }
218
219 /// The underlying table, for a caller that must serialize it whole.
220 #[must_use]
221 pub fn table(&self) -> &toml::Table {
222 &self.0
223 }
224
225 /// Preserve one `(key, value)` pair, returning the previous value if any.
226 pub fn insert(&mut self, key: impl Into<String>, value: toml::Value) -> Option<toml::Value> {
227 self.0.insert(key.into(), value)
228 }
229}
230
231impl PartialEq for CapacityExtensions {
232 fn eq(&self, other: &Self) -> bool {
233 // Compared through the rendered value, so two maps that hold the same keys and
234 // values compare equal whatever order they were built in.
235 toml::Value::Table(self.0.clone()).to_string()
236 == toml::Value::Table(other.0.clone()).to_string()
237 }
238}
239
240impl Eq for CapacityExtensions {}
241
242impl From<toml::Table> for CapacityExtensions {
243 fn from(table: toml::Table) -> Self {
244 Self(table)
245 }
246}
247
248/// Indexing by handle, so a caller that has validated a config reads
249/// `config.capacity["zulu"]` the way it reads any other registry in `tix.toml`.
250///
251/// Panics on an unknown handle, exactly as indexing an `IndexMap` does: a missing entry
252/// is a programming error by the time a caller has a handle, and [`CapacityConfig::get`]
253/// is the total alternative.
254impl std::ops::Index<&str> for CapacityConfig {
255 type Output = CapacityEntry;
256
257 fn index(&self, id: &str) -> &Self::Output {
258 &self.0[id]
259 }
260}
261
262/// The fields [`CapacityEntry`] reads as typed values.
263///
264/// This is the set serde treats as **known** for the record — the fields that survive
265/// through the typed struct rather than being swept into the preserved
266/// [`extensions`](CapacityEntry::extensions) table. It must stay in step with the
267/// struct: add a field there and add it here, or the shadow check would let the new name
268/// through and the next load would fail on a duplicate key.
269pub const CAPACITY_ENTRY_KNOWN_FIELDS: &[&str] =
270 &["cycle", "team", "assignee", "capacity", "unit", "enabled"];
271
272/// One capacity ceiling, for exactly one scope.
273///
274/// The three scope keys are `Option<String>` on purpose: "absent" has to be
275/// distinguishable from "set", because an entry that names no scope is refused by name
276/// (C-003) rather than guessed from the surrounding table. `capacity` is `i64` rather
277/// than an unsigned integer so that a negative number is *refused with its field path*
278/// instead of failing in the TOML parser, where no diagnostic could name the record.
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280#[serde(default)]
281pub struct CapacityEntry {
282 /// The cycle this ceiling belongs to: the name a `[cycles.<name>]` record answers
283 /// to, or a generated cycle's stable id. See [`resolve_cycle`].
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub cycle: Option<String>,
286 /// The team this ceiling belongs to: a `[teams.<label>]` key.
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub team: Option<String>,
289 /// The assignee this ceiling belongs to: an identity in the shape an issue's
290 /// `assignee` field tolerates (a handle, or `Name <email>`).
291 #[serde(skip_serializing_if = "Option::is_none")]
292 pub assignee: Option<String>,
293 /// The ceiling itself, at or above zero, counted in [`unit`](Self::unit).
294 ///
295 /// Required for an enabled entry; a **disabled** entry may omit it, which is how a
296 /// scope stays configured without a ceiling in force.
297 #[serde(skip_serializing_if = "Option::is_none")]
298 pub capacity: Option<i64>,
299 /// The estimate unit [`capacity`](Self::capacity) counts in. Required for an enabled
300 /// entry, refused when blank, and refused when no board declares it.
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub unit: Option<String>,
303 /// Whether the ceiling is in force. `false` keeps the numbers for later without
304 /// changing today's calculations.
305 #[serde(
306 default = "crate::config::default_true",
307 skip_serializing_if = "crate::config::is_true"
308 )]
309 pub enabled: bool,
310 /// Unknown metadata, preserved verbatim.
311 #[serde(flatten)]
312 pub extensions: CapacityExtensions,
313}
314
315impl Default for CapacityEntry {
316 fn default() -> Self {
317 Self {
318 cycle: None,
319 team: None,
320 assignee: None,
321 capacity: None,
322 unit: None,
323 enabled: true,
324 extensions: CapacityExtensions::default(),
325 }
326 }
327}
328
329impl CapacityEntry {
330 /// The `(kind, reference)` this entry names, in scope-kind order.
331 ///
332 /// Zero entries means "no scope", more than one means "ambiguous"; both are refused
333 /// by [`validate`]. A caller that has validated a config always gets exactly one.
334 pub fn scopes(&self) -> impl Iterator<Item = (CapacityScopeKind, &str)> {
335 [
336 (CapacityScopeKind::Cycle, self.cycle.as_deref()),
337 (CapacityScopeKind::Team, self.team.as_deref()),
338 (CapacityScopeKind::Assignee, self.assignee.as_deref()),
339 ]
340 .into_iter()
341 .filter_map(|(kind, reference)| reference.map(|reference| (kind, reference)))
342 }
343
344 /// The scope kind this entry names, when it names exactly one.
345 #[must_use]
346 pub fn scope_kind(&self) -> Option<CapacityScopeKind> {
347 let mut scopes = self.scopes();
348 let first = scopes.next()?;
349 scopes.next().is_none().then_some(first.0)
350 }
351
352 /// The scope reference this entry names, when it names exactly one.
353 #[must_use]
354 pub fn scope_id(&self) -> Option<&str> {
355 let mut scopes = self.scopes();
356 let first = scopes.next()?;
357 scopes.next().is_none().then_some(first.1)
358 }
359
360 /// Whether the ceiling is in force: enabled, with a value and a unit.
361 ///
362 /// The resolver every surface should ask, so "declared but disabled" cannot be read
363 /// as "enforced" by one caller and not another — the same rule
364 /// [`crate::wip::config::WipConfig::effective_limit`] follows for limits.
365 #[must_use]
366 pub fn effective(&self) -> bool {
367 self.enabled && self.capacity.is_some() && self.unit.is_some()
368 }
369}
370
371/// The `[capacity]` section: capacity entries keyed by a handle, in declaration order.
372///
373/// Serialized transparently as the TOML table `[capacity.<entry-id>]` and omitted whole
374/// when empty, so a repository that configures no capacity gains no key on save and a
375/// legacy `tix.toml` round-trips byte-identically (C-001).
376#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(transparent)]
378pub struct CapacityConfig(IndexMap<String, CapacityEntry>);
379
380impl CapacityConfig {
381 /// An empty section.
382 #[must_use]
383 pub fn new() -> Self {
384 Self::default()
385 }
386
387 /// Whether no capacity is configured: the state that writes nothing.
388 #[must_use]
389 pub fn is_empty(&self) -> bool {
390 self.0.is_empty()
391 }
392
393 /// How many entries are configured.
394 #[must_use]
395 pub fn len(&self) -> usize {
396 self.0.len()
397 }
398
399 /// One entry by its handle.
400 #[must_use]
401 pub fn get(&self, id: &str) -> Option<&CapacityEntry> {
402 self.0.get(id)
403 }
404
405 /// One entry by its handle, mutably.
406 #[must_use]
407 pub fn get_mut(&mut self, id: &str) -> Option<&mut CapacityEntry> {
408 self.0.get_mut(id)
409 }
410
411 /// The entries, in declaration order.
412 pub fn iter(&self) -> impl Iterator<Item = (&str, &CapacityEntry)> {
413 self.0.iter().map(|(id, entry)| (id.as_str(), entry))
414 }
415
416 /// The entries as a map, for a caller that must serialize or compare them whole.
417 #[must_use]
418 pub fn entries(&self) -> &IndexMap<String, CapacityEntry> {
419 &self.0
420 }
421
422 /// The entries mutably, for the declaration-order restoration the loader performs.
423 #[must_use]
424 pub fn entries_mut(&mut self) -> &mut IndexMap<String, CapacityEntry> {
425 &mut self.0
426 }
427
428 /// Preserve one entry, returning the previous one if the handle was already used.
429 pub fn insert(&mut self, id: impl Into<String>, entry: CapacityEntry) -> Option<CapacityEntry> {
430 self.0.insert(id.into(), entry)
431 }
432
433 /// Restore the declaration order the `toml` parser loses (it sorts table keys) from
434 /// the `[capacity.<entry-id>]` headers in the source document.
435 pub fn restore_declaration_order(&mut self, contents: &str) {
436 crate::config::restore_section_order(&mut self.0, contents, "capacity");
437 }
438}
439
440/// Whether the board capacity calculations run under declares `unit`.
441///
442/// A unit is declared by a board under `[kanban.<board>.wip.units]`; that map is the one
443/// owner of "what units exist and how they convert" (`configurable-wip-limits-01M3C6WX`),
444/// and capacity reuses it rather than restating it.
445///
446/// The check is against **that one board** — [`Config::default_config`], the registry the
447/// calculation converts under — and not against the union of every board's units. The
448/// union was wrong in two ways, and neither showed up until a project declared more than
449/// one board:
450///
451/// * an entry counting in a unit only *another* board declares was accepted at load and
452/// could never be calculated: the conversion under the calculation board answers
453/// `UnknownUnit`, and the remedy it printed ("declare the unit under
454/// `[kanban.<board>.wip.units]`") pointed at a unit the user had already declared;
455/// * a unit two boards declare with **different factors** was accepted and then converted
456/// under the calculation board's factor — a wrong number, with no diagnostic at all.
457///
458/// Checking here, where the refusing happens, closes both: an accepted entry counts in a
459/// unit the arithmetic can actually resolve, and the factor it resolves to is the one this
460/// gate looked at. A capacity entry has no board of its own — a scope names a cycle, a team,
461/// or an assignee — so there is no other board it could honestly be judged against.
462fn declared_unit(config: &Config, unit: &str) -> bool {
463 config.default_config().wip.units.contains_key(unit)
464}
465
466/// The cycle records a capacity reference resolves against.
467///
468/// [`crate::cycle_store::CycleStore::list`] is the canonical reader — the store's records
469/// when `docs/cycles/` exists, the implicit `Default` cycle when it does not. The second
470/// half is the loader's own fallback: with no store, `Config::cycles` **is** this project's
471/// cycle registry (the legacy `[cycles.<name>]` tables the loader keeps), so those names
472/// are added as the name-only records they are. An index built from the store alone would
473/// call every legacy cycle unknown, which is why the fallback is here and not at the call
474/// site.
475fn cycle_records(config: &Config, root: &Path) -> Result<Vec<CycleRecord>> {
476 let store = CycleStore::new(root);
477 let mut records = store.list()?;
478 if !store.exists() {
479 records.extend(config.cycles.keys().map(|name| CycleRecord {
480 name: name.clone(),
481 ..CycleRecord::default()
482 }));
483 }
484 Ok(records)
485}
486
487/// The [`ScheduleIndex`] a capacity reference resolves against.
488///
489/// Built from [`cycle_records`]: `CycleStore::list()`'s records — the same input
490/// [`crate::cycle_schedule::registry::load_index`] feeds its index — plus, when there is no
491/// store, the legacy `[cycles.<name>]` names a store cannot see. So with a store present it
492/// *is* `load_index`'s index, and without one it is a strict superset of it: a caller
493/// holding either may pass it to [`resolve_cycle`], and both answer `Generated` or `Legacy`
494/// identically for anything the store contains. They differ only on a store-less project's
495/// legacy names, where the store-only index answers `Unknown` — which is why
496/// [`validate_cycle_references`] decides validity by *records or configuration*, never by
497/// which provenance the caller happens to have.
498pub fn schedule_index(config: &Config, root: &Path) -> Result<ScheduleIndex> {
499 Ok(ScheduleIndex::build(config, &cycle_records(config, root)?))
500}
501
502/// The stored record a scope reference names, when one exists.
503///
504/// A generated cycle's stable id names its record directly; a name is the record's own
505/// `name`, whatever kind of cycle it belongs to. `None` means the record is not on this
506/// project's disk — a legacy `[cycles.<name>]` table, or the implicit `Default` cycle —
507/// which is also the answer "no lifecycle state to consult".
508fn cycle_record<'a>(
509 index: &ScheduleIndex,
510 records: &'a [CycleRecord],
511 reference: &str,
512) -> Option<&'a CycleRecord> {
513 if let Ok(id) = GeneratedCycleId::parse(reference)
514 && let Some(cycle) = index.cycle(&id)
515 {
516 return records.iter().find(|record| record.name == cycle.name);
517 }
518 records.iter().find(|record| record.name == reference)
519}
520
521/// Resolve a cycle scope reference through the crate's one cycle identity adapter.
522///
523/// The reference is the name a cycle record answers to — hand-written or generated — or
524/// a generated cycle's stable `<schedule-id>:<sequence>` id. Names are decided by
525/// [`ScheduleIndex::resolve`]; ids by [`GeneratedCycleId::parse`] plus an existence check
526/// through [`ScheduleIndex::cycle`], because `by_reference` is keyed by name. Both halves
527/// answer with the same three-answer [`CycleReference`] vocabulary, and an unknown
528/// reference stays unknown rather than being rebound to whatever cycle is nearby.
529/// Neither rewrites an issue reference or a cycle date.
530///
531/// `index` is the caller's, built from the cycle records — everything
532/// [`ScheduleIndex::build`] reads. [`crate::cycle_schedule::registry::load_index`] builds
533/// exactly that from a project root.
534#[must_use]
535pub fn resolve_cycle(index: &ScheduleIndex, reference: &str) -> CycleReference {
536 let resolved = index.resolve(reference);
537 if !matches!(resolved, CycleReference::Unknown { .. }) {
538 return resolved;
539 }
540 let Ok(id) = GeneratedCycleId::parse(reference) else {
541 return resolved;
542 };
543 let Some(cycle) = index.cycle(&id) else {
544 return resolved;
545 };
546 CycleReference::Generated {
547 name: cycle.name.clone(),
548 id,
549 schedule_id: cycle.schedule_id.clone(),
550 }
551}
552
553/// The authorization a capacity change carries (T004, C-008).
554#[derive(Debug, Clone, PartialEq, Eq)]
555pub enum CapacityEdit<'a> {
556 /// An ordinary configuration change: it applies to current and future calculations
557 /// only.
558 Configure,
559 /// The explicit reopen path — the append-only `History` event — carrying the reason
560 /// the audit trail stores.
561 Reopen {
562 /// Why the closed cycle is being reopened. Required, and never blank.
563 reason: &'a str,
564 },
565}
566
567/// The closed-cycle boundary every capacity change goes through (T004, C-008).
568///
569/// A closed generated cycle's capacity is the historical record: WP04 captures it as an
570/// immutable snapshot at close, and this function is how a configuration change is kept
571/// away from it. The state is read from the cycle **record** — through
572/// [`GeneratedCycle::from_record`], the crate's one parser for it — rather than from a
573/// caller-supplied flag, and a partial generated identity is propagated as the finding
574/// it is instead of being treated as open.
575///
576/// * [`CapacityEdit::Configure`] is refused for a terminal cycle: `closed` has a
577/// snapshot that must not be rewritten, and `skipped` was never generated, so it has
578/// no capacity to configure.
579/// * [`CapacityEdit::Reopen`] is the one way through, and it refuses a blank reason
580/// because the event it produces is auditable.
581///
582/// A hand-written cycle carries no lifecycle state at all, so it is always editable.
583pub fn ensure_cycle_editable(record: &CycleRecord, edit: &CapacityEdit<'_>) -> Result<()> {
584 match edit {
585 CapacityEdit::Reopen { reason } => {
586 if reason.trim().is_empty() {
587 return Err(TixError::Config(
588 "`reopen.reason` must not be blank: a reopen is auditable, and its reason is \
589 stored with the append-only `History` event"
590 .into(),
591 ));
592 }
593 Ok(())
594 }
595 CapacityEdit::Configure => match GeneratedCycle::from_record(record)? {
596 Some(cycle) if cycle.state == GeneratedCycleState::Closed => {
597 Err(TixError::Config(format!(
598 "`cycles.{}` is closed; its capacity (`{}`) is the historical record captured \
599 at close, and a configuration change applies to current and future \
600 calculations only — reopen it through the explicit `History` event instead",
601 record.name, cycle.id
602 )))
603 }
604 Some(cycle) if cycle.state == GeneratedCycleState::Skipped => {
605 Err(TixError::Config(format!(
606 "`cycles.{}` is skipped; a skipped sequence was never generated, so it has no \
607 capacity to configure",
608 record.name
609 )))
610 }
611 _ => Ok(()),
612 },
613 }
614}
615
616/// Validate the `[capacity]` section against the rest of `config` (FR-001, C-001,
617/// C-003, C-004) — **without** a project root.
618///
619/// This is the half every *validate-then-save* path already calls, because
620/// [`Config::validate`](crate::config::Config::validate) is what they validate with:
621/// `tix wip unit --remove`, the schedule registry, the MCP write tools, planning. It
622/// covers exactly what the configuration alone decides, and nothing that needs
623/// `docs/cycles/`:
624///
625/// * the entry's handle (non-blank, unpadded, no `.`),
626/// * a preserved key that would shadow a built-in field,
627/// * exactly one scope, with a non-blank, unpadded reference,
628/// * a team label that exists in `[teams.*]`,
629/// * an assignee identity the model's own parser accepts,
630/// * an enabled entry's ceiling (present, not negative) and unit (present, not blank,
631/// **declared by a board**) — the check that keeps a change like "remove the unit a
632/// ceiling names" from writing a file the next load refuses,
633/// * one ceiling per scope, on the identity this gate can see.
634///
635/// A cycle reference is accepted here when `config.cycles` names it — that is the legacy
636/// registry, and after a load it also holds every stored cycle's reference name — and is
637/// otherwise **deferred**, not refused: whether a generated cycle answers to a reference
638/// is a question for the records, which this gate has no root to read. Deferring is what
639/// keeps the verdict provenance-independent: a valid file is never refused because the
640/// caller happened to be a save path rather than the loader.
641///
642/// Nothing here reads a file, which is why a capacity-free section returning immediately
643/// makes a repository that configures no capacity provably unaffected.
644pub fn validate(capacity: &CapacityConfig, config: &Config) -> Result<()> {
645 if capacity.is_empty() {
646 return Ok(());
647 }
648 let mut claimed: HashMap<(CapacityScopeKind, String), String> = HashMap::new();
649 for (id, entry) in capacity.iter() {
650 let path = format!("capacity.{id}");
651 validate_entry_id(id)?;
652 check_extensions(&path, &entry.extensions)?;
653 let (kind, reference) = validate_scope(&path, entry)?;
654 let identity = scope_identity(&path, config, None, kind, &reference)?;
655 if let Some(first) = claimed.insert((kind, identity.canonical.clone()), id.to_string()) {
656 return Err(duplicate_scope(&path, kind, &reference, &identity, &first));
657 }
658 validate_ceiling(&path, entry, config)?;
659 }
660 Ok(())
661}
662
663/// The records' half of the section's validation: resolve every cycle reference, and
664/// treat two spellings of one cycle as one ceiling (FR-001, C-003).
665///
666/// Called by the two gates that have a project root —
667/// [`Config::load_with_warning`](crate::config::Config::load_with_warning) for every
668/// hand-edited file and [`set_capacity`] for every programmatic change — because the
669/// records are the only place a generated cycle's identity lives. It is the *extra* half,
670/// not a replacement for [`validate`]: a caller runs both. A section with no cycle scope
671/// returns without touching the filesystem.
672///
673/// The validity rule is deliberately provenance-independent: a reference is valid when it
674/// resolves through the records **or** when it names a `[cycles.<name>]` entry in the
675/// configuration, so a store-less project's legacy table is accepted whether or not the
676/// caller read a cycle store (the crate's store-only
677/// [`crate::cycle_schedule::registry::load_index`] answers `Unknown` for those names,
678/// while [`schedule_index`] adds them). Only a reference that neither resolves nor names a
679/// configured cycle is refused — and the message says so, rather than telling a user to
680/// add a table their file already has.
681pub fn validate_cycle_references(
682 capacity: &CapacityConfig,
683 config: &Config,
684 root: &Path,
685) -> Result<()> {
686 if !capacity.iter().any(|(_, entry)| entry.cycle.is_some()) {
687 return Ok(());
688 }
689 validate_cycle_identities(capacity, config, &schedule_index(config, root)?)
690}
691
692/// The cycle scopes of a section, resolved against an index.
693fn validate_cycle_identities(
694 capacity: &CapacityConfig,
695 config: &Config,
696 index: &ScheduleIndex,
697) -> Result<()> {
698 let mut claimed: HashMap<String, String> = HashMap::new();
699 for (id, entry) in capacity.iter() {
700 let Some((CapacityScopeKind::Cycle, reference)) = entry.scopes().next() else {
701 continue;
702 };
703 let path = format!("capacity.{id}");
704 let identity = scope_identity(
705 &path,
706 config,
707 Some(index),
708 CapacityScopeKind::Cycle,
709 reference,
710 )?;
711 if let Some(first) = claimed.insert(identity.canonical.clone(), id.to_string()) {
712 return Err(duplicate_scope(
713 &path,
714 CapacityScopeKind::Cycle,
715 reference,
716 &identity,
717 &first,
718 ));
719 }
720 }
721 Ok(())
722}
723
724/// Set one capacity ceiling, through the closed-cycle boundary (T004, C-008).
725///
726/// This is the write path this package exposes, and the call site of
727/// [`ensure_cycle_editable`]. A caller hands over the entry it wants and the edit it
728/// believes it is making; the change happens only if
729///
730/// 1. the boundary allows it — an ordinary [`CapacityEdit::Configure`] aimed at a closed
731/// cycle (whose ceiling is the historical record) or a skipped one is refused before
732/// anything is written, and
733/// 2. the whole section still validates with the entry in place — a scope that resolves
734/// nowhere, a second ceiling for a scope another entry already holds, or a missing
735/// unit leaves both `config` and `tix.toml` exactly as they were.
736///
737/// `root` is the project root: the cycle records that decide the boundary and the
738/// resolution are read from `docs/cycles/` there (the legacy `[cycles.<name>]` tables when
739/// there is no store), and the file is written atomically by [`Config::save`]. The change
740/// is staged on a copy and committed only once both gates pass, so a refusal cannot leave
741/// a half-applied section behind — the shape the other registries' commit boundaries use.
742pub fn set_capacity(
743 config: &mut Config,
744 id: &str,
745 entry: CapacityEntry,
746 edit: &CapacityEdit<'_>,
747 root: &Path,
748) -> Result<()> {
749 let records = cycle_records(config, root)?;
750 let index = ScheduleIndex::build(config, &records);
751 if let Some((CapacityScopeKind::Cycle, reference)) = entry.scopes().next()
752 && let Some(record) = cycle_record(&index, &records, reference)
753 {
754 ensure_cycle_editable(record, edit)?;
755 }
756 let mut candidate = config.clone();
757 candidate.capacity.insert(id, entry);
758 // Both halves: what the configuration alone decides, and what the records decide.
759 validate(&candidate.capacity, &candidate)?;
760 validate_cycle_identities(&candidate.capacity, &candidate, &index)?;
761 candidate.save(&root.join("tix.toml"))?;
762 *config = candidate;
763 Ok(())
764}
765
766/// The entry handle is a real key: non-blank, unpadded, and free of the one character
767/// that would read as a nested table in a header or in a diagnostic path.
768///
769/// Unlike the stable-id registries this handle is not an identity — nothing points at a
770/// capacity entry — so it is free-form otherwise: a user names it whatever reads best in
771/// their file.
772fn validate_entry_id(id: &str) -> Result<()> {
773 if id.trim().is_empty() {
774 return Err(TixError::Config(
775 "`capacity` must not contain an entry with a blank id".into(),
776 ));
777 }
778 if id.trim() != id {
779 return Err(TixError::Config(format!(
780 "`capacity` entry id `{id}` has leading or trailing whitespace"
781 )));
782 }
783 if id.contains('.') {
784 return Err(TixError::Config(format!(
785 "`capacity` entry id `{id}` contains `.`; a dot reads as a nested table in \
786 `[capacity.<id>]` and in a diagnostic path"
787 )));
788 }
789 Ok(())
790}
791
792/// Refuse a preserved key that would shadow a built-in field.
793fn check_extensions(path: &str, extensions: &CapacityExtensions) -> Result<()> {
794 for key in extensions.keys() {
795 if CAPACITY_ENTRY_KNOWN_FIELDS.contains(&key) {
796 return Err(TixError::Config(format!(
797 "`{path}.{key}` collides with a built-in field; rename the extension key"
798 )));
799 }
800 }
801 Ok(())
802}
803
804/// Exactly one of `cycle`, `team`, `assignee`: the entry's scope.
805fn validate_scope(path: &str, entry: &CapacityEntry) -> Result<(CapacityScopeKind, String)> {
806 let named: Vec<(CapacityScopeKind, &str)> = entry.scopes().collect();
807 match named.len() {
808 0 => Err(TixError::Config(format!(
809 "`{path}.scope` must name exactly one scope: set `cycle`, `team`, or `assignee`"
810 ))),
811 1 => {
812 let (kind, reference) = named[0];
813 if reference.trim().is_empty() {
814 return Err(TixError::Config(format!(
815 "`{path}.{kind}` must not be blank"
816 )));
817 }
818 if reference.trim() != reference {
819 return Err(TixError::Config(format!(
820 "`{path}.{kind}` (`{reference}`) has leading or trailing whitespace"
821 )));
822 }
823 Ok((kind, reference.to_string()))
824 }
825 _ => {
826 let listed = named
827 .iter()
828 .map(|(kind, _)| format!("`{kind}`"))
829 .collect::<Vec<_>>()
830 .join(" and ");
831 Err(TixError::Config(format!(
832 "`{path}.scope` names more than one scope ({listed}); an entry targets exactly one \
833 cycle, team, or assignee"
834 )))
835 }
836 }
837}
838
839/// The identity a scope reference **resolves to**, for the one-ceiling-per-scope rule.
840///
841/// `canonical` is what two entries are compared on: a cycle's stable id or resolved name,
842/// an assignee's canonical handle, a team label. `label` is the human spelling to print,
843/// which differs from the literal a user wrote whenever two spellings resolve to one
844/// identity (`@ada` and `ada`, a generated cycle's name and its id) — the case the
845/// duplicate diagnostic has to explain.
846struct ScopeIdentity {
847 canonical: String,
848 label: String,
849}
850
851/// The message every repeated-scope refusal uses.
852fn duplicate_scope(
853 path: &str,
854 kind: CapacityScopeKind,
855 reference: &str,
856 identity: &ScopeIdentity,
857 first: &str,
858) -> TixError {
859 let note = if identity.label == reference {
860 String::new()
861 } else {
862 format!(" (`{reference}` resolves to `{}`)", identity.label)
863 };
864 TixError::Config(format!(
865 "`{path}.{kind}` (`{reference}`) repeats the scope \
866 `capacity.{first}.{kind}` already configures{note}; one ceiling per scope"
867 ))
868}
869
870/// Resolve a scope reference to the identity the one-ceiling rule compares.
871///
872/// `index` is `Some` at the records' gate and `None` where only the configuration is
873/// available. The cycle arm is the one that differs, and that difference is the whole
874/// point of the split: with an index, a reference that resolves neither through the
875/// records nor through `config.cycles` is refused by name; without one, a reference the
876/// configuration does not name is accepted under a reference-shaped identity and left for
877/// the records' gate to judge.
878fn scope_identity(
879 path: &str,
880 config: &Config,
881 index: Option<&ScheduleIndex>,
882 kind: CapacityScopeKind,
883 reference: &str,
884) -> Result<ScopeIdentity> {
885 match kind {
886 CapacityScopeKind::Cycle => {
887 if let Some(index) = index {
888 return Ok(match resolve_cycle(index, reference) {
889 CycleReference::Generated { id, .. } => ScopeIdentity {
890 canonical: format!("cycle-id:{id}"),
891 label: id.to_string(),
892 },
893 CycleReference::Legacy { name } => ScopeIdentity {
894 canonical: format!("cycle-name:{name}"),
895 label: name,
896 },
897 // Not in the records: the configuration's own cycle registry is the
898 // second half of the validity rule, so a legacy table — or a stored
899 // cycle's name the loader copied in — is still a valid reference.
900 CycleReference::Unknown { .. } if config.cycles.contains_key(reference) => {
901 ScopeIdentity {
902 canonical: format!("cycle-name:{reference}"),
903 label: reference.to_string(),
904 }
905 }
906 CycleReference::Unknown { .. } => {
907 return Err(TixError::Config(format!(
908 "`{path}.cycle` (`{reference}`) does not name a cycle; add \
909 `[cycles.\"{reference}\"]`, or name a generated cycle's \
910 `<schedule-id>:<sequence>` id or its `cycles` value"
911 )));
912 }
913 });
914 }
915 if config.cycles.contains_key(reference) {
916 return Ok(ScopeIdentity {
917 canonical: format!("cycle-name:{reference}"),
918 label: reference.to_string(),
919 });
920 }
921 // Only the records can say whether a generated cycle answers to this, and this
922 // gate has no root: defer rather than refuse a reference the loader accepts.
923 Ok(ScopeIdentity {
924 canonical: format!("cycle-ref:{reference}"),
925 label: reference.to_string(),
926 })
927 }
928 CapacityScopeKind::Team => {
929 if !config.teams.contains_key(reference) {
930 return Err(TixError::Config(format!(
931 "`{path}.team` (`{reference}`) is not a configured team; add \
932 `[teams.{reference}]`"
933 )));
934 }
935 Ok(ScopeIdentity {
936 canonical: format!("team:{reference}"),
937 label: reference.to_string(),
938 })
939 }
940 CapacityScopeKind::Assignee => {
941 // The model's own parser folds the spellings: `ada` and `@ada` are one
942 // identity, so they are one scope here too.
943 let Some(canonical) = crate::assignee::parse_assignee(reference) else {
944 return Err(TixError::Config(format!(
945 "`{path}.assignee` (`{reference}`) is not an assignee identity; use a handle \
946 (`ada`, `@ada`) or `Ada Lovelace <ada@example.com>`"
947 )));
948 };
949 Ok(ScopeIdentity {
950 canonical: format!("assignee:{canonical}"),
951 label: canonical,
952 })
953 }
954 }
955}
956
957/// An enabled entry needs a non-negative ceiling in a declared unit; a disabled one may
958/// keep the scope alone.
959fn validate_ceiling(path: &str, entry: &CapacityEntry, config: &Config) -> Result<()> {
960 match entry.capacity {
961 Some(value) if value < 0 => {
962 return Err(TixError::Config(format!(
963 "`{path}.capacity` ({value}) must not be negative"
964 )));
965 }
966 None if entry.enabled => {
967 return Err(TixError::Config(format!(
968 "`{path}.capacity` must be set for an enabled entry; set `enabled = false` to keep \
969 the scope without a ceiling"
970 )));
971 }
972 _ => {}
973 }
974 match entry.unit.as_deref() {
975 Some(unit) if unit.trim().is_empty() => {
976 Err(TixError::Config(format!("`{path}.unit` must not be blank")))
977 }
978 Some(unit) if unit.trim() != unit => Err(TixError::Config(format!(
979 "`{path}.unit` (`{unit}`) has leading or trailing whitespace"
980 ))),
981 Some(unit) if !declared_unit(config, unit) => Err(TixError::Config(format!(
982 "`{path}.unit` (`{unit}`) is not a declared estimate unit on board `{board}`, which \
983 is the board capacity calculations run under; declare `{unit}` under \
984 `[kanban.{board}.wip.units]`",
985 board = crate::workflow::active_board_name(config)
986 ))),
987 Some(_) => Ok(()),
988 None if entry.enabled => Err(TixError::Config(format!(
989 "`{path}.unit` must name the estimate unit `capacity` counts in; a capacity with no \
990 unit cannot be calculated"
991 ))),
992 None => Ok(()),
993 }
994}
995
996#[cfg(test)]
997mod tests {
998 #[allow(clippy::wildcard_imports)]
999 use super::*;
1000
1001 fn closed_record() -> CycleRecord {
1002 CycleRecord {
1003 name: "Platform sprint".into(),
1004 start_date: Some("2026-01-05".into()),
1005 due_date: Some("2026-01-19".into()),
1006 schedule_id: Some("cs-1".into()),
1007 sequence: Some(1),
1008 state: Some("closed".into()),
1009 ..CycleRecord::default()
1010 }
1011 }
1012
1013 #[test]
1014 fn a_closed_cycle_is_refused_a_configuration_edit_but_not_a_reopen() {
1015 let error = ensure_cycle_editable(&closed_record(), &CapacityEdit::Configure)
1016 .expect_err("a closed cycle is historical evidence");
1017 let message = error.to_string();
1018 assert!(message.contains("closed"), "{message}");
1019 assert!(message.contains("cs-1:000001"), "{message}");
1020
1021 assert!(
1022 ensure_cycle_editable(&closed_record(), &CapacityEdit::Reopen { reason: " " })
1023 .is_err(),
1024 "a reopen without a reason is not auditable"
1025 );
1026 ensure_cycle_editable(
1027 &closed_record(),
1028 &CapacityEdit::Reopen {
1029 reason: "recounting after an import",
1030 },
1031 )
1032 .expect("an explicit, reasoned reopen is the way through");
1033 }
1034
1035 #[test]
1036 fn a_hand_written_cycle_has_no_state_to_refuse() {
1037 let record = CycleRecord {
1038 name: "Cycle 1".into(),
1039 ..CycleRecord::default()
1040 };
1041 ensure_cycle_editable(&record, &CapacityEdit::Configure)
1042 .expect("a hand-written cycle has no lifecycle state");
1043 }
1044
1045 #[test]
1046 fn an_active_cycle_is_editable_and_a_skipped_one_is_not() {
1047 let active = CycleRecord {
1048 state: Some("active".into()),
1049 ..closed_record()
1050 };
1051 ensure_cycle_editable(&active, &CapacityEdit::Configure).expect("active is current work");
1052
1053 let skipped = CycleRecord {
1054 state: Some("skipped".into()),
1055 ..closed_record()
1056 };
1057 let message = ensure_cycle_editable(&skipped, &CapacityEdit::Configure)
1058 .expect_err("a skipped sequence was never generated")
1059 .to_string();
1060 assert!(message.contains("skipped"), "{message}");
1061 }
1062
1063 #[test]
1064 fn a_partial_generated_identity_is_a_finding_at_the_boundary() {
1065 let record = CycleRecord {
1066 state: None,
1067 ..closed_record()
1068 };
1069 assert!(
1070 ensure_cycle_editable(&record, &CapacityEdit::Configure).is_err(),
1071 "half an identity must not read as an open cycle"
1072 );
1073 }
1074
1075 #[test]
1076 fn the_scope_accessors_report_none_when_the_entry_is_ambiguous() {
1077 let mut entry = CapacityEntry::default();
1078 assert_eq!(entry.scope_kind(), None);
1079 assert_eq!(entry.scope_id(), None);
1080
1081 entry.cycle = Some("Cycle 1".into());
1082 assert_eq!(entry.scope_kind(), Some(CapacityScopeKind::Cycle));
1083 assert_eq!(entry.scope_id(), Some("Cycle 1"));
1084
1085 entry.team = Some("Platform".into());
1086 assert_eq!(entry.scope_kind(), None, "two scopes are not a scope");
1087 assert_eq!(entry.scope_id(), None);
1088 }
1089
1090 #[test]
1091 fn a_disabled_entry_without_numbers_is_not_effective() {
1092 let mut entry = CapacityEntry {
1093 team: Some("Platform".into()),
1094 ..CapacityEntry::default()
1095 };
1096 assert!(!entry.effective(), "no ceiling is configured yet");
1097 entry.capacity = Some(0);
1098 assert!(!entry.effective(), "no unit means it cannot be calculated");
1099 entry.unit = Some("d".into());
1100 assert!(entry.effective());
1101 entry.enabled = false;
1102 assert!(!entry.effective(), "a disabled entry enforces nothing");
1103 }
1104}