Skip to main content

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}