tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
//! The one shared capacity calculation (mission `cycle-capacity-planning-01M3C85C`, WP03,
//! T013–T016).
//!
//! # The formulas (C-005)
//!
//! For one scope — a cycle, a team, or an assignee — in the unit a capacity entry
//! declares:
//!
//! * **committed** — the sum of the **valid, converted** estimates of the issues in
//!   scope. An issue that states no estimate, or whose estimate cannot be read in that
//!   unit, contributes nothing and stays visible: it is counted in
//!   [`CapacityCalculation::unestimated_issues`] or
//!   [`CapacityCalculation::uncounted_issues`] and its own diagnostic names it. It is
//!   never counted as zero, and the sum is never presented as complete without them.
//! * **completed** — the same per-issue numbers, restricted to the issues
//!   [`IssueSnapshot::is_completed`] answers for. That answer is the shared #183
//!   status-category service, resolved with board context once, when the snapshot was
//!   built (C-002). Neither this module nor any surface compares a status string.
//! * **remaining** — `committed - completed`. `completed` is the same numbers over a
//!   subset of the same issues, so the subtraction cannot underflow.
//! * **overflow** — `max(committed - capacity, 0)`, and `Some(0)` at or under the
//!   ceiling: a scope exactly at its ceiling is not over it.
//! * **utilization** — `floor(committed * 100 / capacity)`: the crate's **floored
//!   whole-percent** policy, implemented here in exact `u128` integer arithmetic. The
//!   epics sibling implements the same floor policy independently with exact `i128`
//!   arithmetic over its signed weights, so both stay correct at very large magnitudes.
//!   This module's own policy — the rounding this package owns — is pinned by the table in
//!   `tests/capacity_calculate.rs`. It is `None` when no ceiling is configured —
//!   unconfigured is not `0%` — and when the ceiling is `0`, where the ratio has no value
//!   at all.
//!
//! # One pass, and no second one
//!
//! The snapshot indexed the issues once (WP02): cycle/team/assignee membership, the
//! completion answer, every estimate parse and every explicit conversion. A calculation
//! walks the **membership list** the snapshot already holds and reads per-issue values out
//! of its tables — it never opens a file, never re-parses an estimate and never re-derives
//! completion (NFR-001). [`CapacityCalculation::for_scope`] therefore takes a
//! `&IssueSnapshot`, the `&ScheduleIndex` that built it, and the project `&Config` that
//! built it: there is no store, no project root and no writer in the signature at all,
//! which is how the read-only guarantee is kept rather than promised (C-001/C-006).
//!
//! # An uncalculable result is not a zero
//!
//! Every derived field is `None` exactly when the calculation could not run, and
//! [`CapacityCalculation::state`] says which of the two reasons applies: the scope has no
//! ceiling in force ([`CapacityState::Unconfigured`], which is not a zero ceiling — a zero
//! ceiling would report every commit as an overflow), or it has a ceiling whose unit the
//! snapshot carries no conversion for ([`CapacityState::Uncountable`], with a diagnostic
//! saying so). `Some(0)` always means "counted, and the total is zero".
//!
//! # One vocabulary
//!
//! Every finding is a [`CapacityWarning`] carrying a [`CapacityWarningKind`] borrowed from
//! the registry that already owns the condition, so CLI, TUI, MCP and portable output
//! consume one value rather than each re-deriving a formula or a rounding rule
//! (C-007/NFR-003).

use super::config::{CapacityEntry, CapacityScopeKind, resolve_cycle};
use super::scope::IssueSnapshot;
use super::warning::{CapacityWarning, CapacityWarningKind};
use crate::assignee::parse_assignee;
use crate::config::Config;
use crate::cycle_schedule::identity::{CycleReference, ScheduleIndex};
use crate::wip::WipDiagnosticKind;

/// Whether a calculation had a ceiling it could count against (T016).
///
/// Derived from the calculation's own fields rather than stored beside them, so the state
/// and the numbers cannot drift apart.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CapacityState {
    /// No capacity entry is in force for the scope: there is no ceiling and no unit, so
    /// every derived value is `None`. **Unconfigured is not zero capacity.**
    Unconfigured,
    /// A ceiling is in force, but the snapshot resolved no conversion for its unit, so
    /// nothing was counted and every derived value is `None`. A diagnostic says why.
    Uncountable,
    /// A ceiling is in force and the values were derived.
    Configured,
}

impl CapacityState {
    /// The stable spelling every surface prints.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Unconfigured => "unconfigured",
            Self::Uncountable => "uncountable",
            Self::Configured => "configured",
        }
    }
}

impl std::fmt::Display for CapacityState {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The resolved scope one calculation runs for (data-model §2).
///
/// `reference` is what the caller wrote; `display` is what it resolves to. The two differ
/// whenever a spelling is not the identity — a generated cycle's stable id versus its
/// name, `@ada` versus `ada` — and keeping both is what lets a surface print the cycle a
/// human named while comparing on the identity the records answer with.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapacityScope {
    /// The dimension the scope names.
    pub kind: CapacityScopeKind,
    /// The reference as the caller wrote it: a cycle name or stable id, a team label, or
    /// an assignee identity.
    pub reference: String,
    /// The human label the reference resolves to.
    pub display: String,
}

impl Default for CapacityScope {
    /// The empty scope: a cycle reference that resolves to nothing. A calculation over it
    /// is unconfigured unless a section happens to declare a ceiling for it.
    fn default() -> Self {
        Self {
            kind: CapacityScopeKind::Cycle,
            reference: String::new(),
            display: String::new(),
        }
    }
}

impl CapacityScope {
    /// Resolve one reference into a scope, through the crate's own adapters.
    ///
    /// The index is what turns a reference into a display label and into the identity two
    /// spellings share ([`resolve_cycle`], [`crate::assignee::parse_assignee`]) — the same
    /// resolution [`super::config::validate`] applies to a capacity entry, so a ceiling
    /// and the scope a caller asks about cannot disagree about which cycle they mean.
    #[must_use]
    pub fn new(
        kind: CapacityScopeKind,
        reference: impl Into<String>,
        index: &ScheduleIndex,
    ) -> Self {
        let reference = reference.into();
        let display = match kind {
            CapacityScopeKind::Cycle => match resolve_cycle(index, &reference) {
                CycleReference::Generated { name, .. } | CycleReference::Legacy { name } => name,
                CycleReference::Unknown { name } => name,
            },
            CapacityScopeKind::Team => reference.clone(),
            CapacityScopeKind::Assignee => {
                parse_assignee(&reference).unwrap_or_else(|| reference.clone())
            }
        };
        Self {
            kind,
            reference,
            display,
        }
    }

    /// The scope as one message reads it: `cycle `Cycle 1``.
    #[must_use]
    pub fn describe(&self) -> String {
        format!("{} `{}`", self.kind, self.display)
    }
}

/// Which snapshot a calculation read (T013's source reference).
///
/// A [`CapacityCalculation`] never owns the snapshot: every surface receives the DTO, and
/// copying 10,000 issue records into each one would make the shared value the expensive
/// part of the system. This is the reference a surface can compare and quote instead —
/// how many issues the snapshot indexed and which units it resolved conversions for, i.e.
/// the exact reading the numbers came from. A historical snapshot *identity* is WP04's
/// (the close snapshot), not this: these numbers are always the current reading.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CapacitySource {
    /// How many issues the snapshot indexed ([`IssueSnapshot::len`]).
    pub issues: usize,
    /// The units the snapshot resolved conversions for ([`IssueSnapshot::targets`]), in
    /// the snapshot's stable order.
    pub units: Vec<String>,
}

impl From<&IssueSnapshot> for CapacitySource {
    fn from(snapshot: &IssueSnapshot) -> Self {
        Self {
            issues: snapshot.len(),
            units: snapshot.targets().to_vec(),
        }
    }
}

/// The shared derived result for one scope (data-model §4, T013).
///
/// Every derived field is `None` exactly when the calculation could not run; see the
/// module docs for the rule. The DTO is read-only by construction: it holds no store, no
/// root and no writer, so the same value that a CLI prints is the one an MCP tool returns
/// and the one portable output serializes (C-007).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CapacityCalculation {
    /// The scope the numbers describe.
    pub scope: CapacityScope,
    /// The ceiling in force, or `None` when no capacity entry applies: **unconfigured is
    /// not a zero ceiling.**
    pub capacity: Option<u64>,
    /// The unit every number is counted in, from the capacity entry. `None` when the
    /// scope is unconfigured — a capacity with no unit cannot be calculated (C-004).
    pub unit: Option<String>,
    /// The sum of the valid, converted estimates in scope. `None` when nothing could be
    /// counted; `Some(0)` only when the scope was counted and holds no counted work.
    pub committed: Option<u64>,
    /// The committed part whose issues the shared completion service calls complete
    /// (T015). Always `<= committed`: it is the same per-issue numbers over a subset.
    pub completed: Option<u64>,
    /// `committed - completed`. `None` exactly when those are.
    pub remaining: Option<u64>,
    /// `max(committed - capacity, 0)`. `None` when either input is unknown; `Some(0)` at
    /// or under the ceiling, because a scope exactly at its ceiling is not over it.
    pub overflow: Option<u64>,
    /// `committed` as a floored whole percentage of `capacity` — the one rounding policy
    /// (C-007). `None` when no ceiling is configured or the ceiling is `0`, where the
    /// ratio has no value; never `0` standing in for "unknown".
    pub utilization: Option<u32>,
    /// Issues whose estimate was counted into `committed`.
    pub counted_issues: Option<u32>,
    /// Issues that state an estimate which could not be counted in the unit; each has a
    /// diagnostic naming it. They contribute nothing to the sums.
    pub uncounted_issues: Option<u32>,
    /// Issues that state no estimate at all. Visible, never zero, and never an error —
    /// stating nothing is not a mistake (C-005, data-model §3).
    pub unestimated_issues: Option<u32>,
    /// The snapshot the numbers came from.
    pub source: CapacitySource,
    /// The findings, in derivation order: about the scope itself, then about its members
    /// in issue order, then about the total derived from them (T017).
    pub diagnostics: Vec<CapacityWarning>,
}

impl CapacityCalculation {
    /// Calculate one scope from an already-built snapshot.
    ///
    /// `index` is the [`ScheduleIndex`] `snapshot` was built with (a cycle reference
    /// resolves through it), and `config` is the project configuration that snapshot was
    /// built under: the snapshot's conversion table includes every unit `config.capacity`
    /// declares, and the ceiling and unit come from the entry that targets `scope`. A
    /// reference is matched on the identity [`super::config::validate`] keys a ceiling by
    /// — never on the text — so `@ada` for `ada` and a generated cycle's stable id for its
    /// name are one scope.
    ///
    /// Nothing is mutated, and nothing outside the snapshot is read: the issues are
    /// already indexed, and this walks the scope's membership list once, in issue order.
    #[must_use]
    pub fn for_scope(
        snapshot: &IssueSnapshot,
        index: &ScheduleIndex,
        config: &Config,
        scope: CapacityScope,
    ) -> Self {
        let mut calculation = Self {
            scope: scope.clone(),
            source: CapacitySource::from(snapshot),
            ..Self::default()
        };

        // The scope reference itself. A cycle nothing resolves to selects no issues, and
        // saying so is what keeps "no work" from being indistinguishable from "no such
        // cycle" — data-model §2's rule that an unknown reference is a diagnostic and
        // never a guess.
        if scope.kind == CapacityScopeKind::Cycle
            && matches!(
                resolve_cycle(index, &scope.reference),
                CycleReference::Unknown { .. }
            )
        {
            calculation
                .diagnostics
                .push(CapacityWarning::unresolved_scope(scope.clone()));
        }

        // The membership findings that are about this scope, in the snapshot's own order.
        for finding in snapshot.diagnostics() {
            if finding.scope_kind == scope.kind
                && scope_identity(config, index, scope.kind, &finding.reference)
                    == scope_identity(config, index, scope.kind, &scope.reference)
            {
                calculation
                    .diagnostics
                    .push(CapacityWarning::from_scope_diagnostic(
                        scope.clone(),
                        finding,
                    ));
            }
        }

        // The one authority on whether a ceiling applies. An entry that is present but not
        // in force leaves the scope unconfigured: a disabled ceiling keeps its numbers for
        // later without changing today's calculation (config `CapacityEntry::effective`).
        let Some(entry) = entry_for(config, index, &scope) else {
            return calculation;
        };
        let Some(ceiling) = entry.capacity.and_then(|value| u64::try_from(value).ok()) else {
            return calculation;
        };
        let Some(unit) = entry
            .unit
            .as_deref()
            .map(str::trim)
            .filter(|unit| !unit.is_empty())
        else {
            return calculation;
        };

        // A ceiling is in force, so it is reported even when nothing can be counted in its
        // unit — the ceiling is configuration, not a derived value.
        calculation.capacity = Some(ceiling);
        calculation.unit = Some(unit.to_string());

        let targets = snapshot.targets();
        if !targets.iter().any(|target| target == unit) {
            // The snapshot resolved its conversion table from the section it was built
            // under; a section naming a unit outside it is a different configuration, and
            // counting in a unit the table never resolved would be a guess.
            calculation.diagnostics.push(
                CapacityWarning::new(
                    CapacityWarningKind::Conversion(WipDiagnosticKind::UnknownUnit),
                    scope.clone(),
                    format!(
                        "the capacity unit `{unit}` is not among the units this snapshot \
                         resolved ({}), so nothing was counted",
                        targets.join(", ")
                    ),
                )
                .with_remedy(
                    "recalculate against the `[capacity]` section this snapshot was built \
                     under: the unit is already declared, so this is a stale snapshot rather \
                     than a missing unit",
                ),
            );
            return calculation;
        }

        let members = snapshot.members(scope.kind, &scope.reference, index);
        let mut committed = 0u64;
        let mut completed = 0u64;
        let mut counted = 0u32;
        let mut uncounted = 0u32;
        let mut unestimated = 0u32;
        // The issues whose estimates make up `committed`, in membership order: the
        // actionable half of an overflow warning (C-006).
        let mut contributing: Vec<u32> = Vec::new();

        for &number in members {
            let Some(estimate) = snapshot.estimate_in(number, unit) else {
                // A member is always in the snapshot, and `unit` is in the table above, so
                // this arm is unreachable for a caller that passes the section the
                // snapshot was built under. Counting it as uncounted keeps the partition
                // total rather than dropping an issue silently.
                uncounted += 1;
                continue;
            };
            match estimate.converted_value {
                Some(value) => {
                    committed += u64::from(value);
                    counted += 1;
                    contributing.push(number);
                    if snapshot.is_completed(number) {
                        completed += u64::from(value);
                    }
                }
                None if estimate.is_unestimated() => unestimated += 1,
                None => {
                    uncounted += 1;
                    for finding in estimate.diagnostics() {
                        calculation
                            .diagnostics
                            .push(CapacityWarning::from_estimate_diagnostic(
                                scope.clone(),
                                number,
                                unit,
                                finding,
                            ));
                    }
                }
            }
        }

        calculation.committed = Some(committed);
        calculation.completed = Some(completed);
        calculation.remaining = Some(committed - completed);
        calculation.overflow = Some(committed.saturating_sub(ceiling));
        calculation.utilization = utilization_percent(committed, ceiling);
        calculation.counted_issues = Some(counted);
        calculation.uncounted_issues = Some(uncounted);
        calculation.unestimated_issues = Some(unestimated);

        if committed > ceiling {
            calculation.diagnostics.push(CapacityWarning::overflow(
                scope.clone(),
                committed,
                ceiling,
                unit,
                contributing,
            ));
        }

        calculation
    }

    /// Whether this calculation had a ceiling it could count against (T016).
    ///
    /// The explicit no-capacity state: a scope with no entry, or with an entry that is not
    /// in force, is [`CapacityState::Unconfigured`] rather than a scope with zero capacity.
    #[must_use]
    pub fn state(&self) -> CapacityState {
        match (self.capacity, self.committed) {
            (None, _) => CapacityState::Unconfigured,
            (Some(_), Some(_)) => CapacityState::Configured,
            (Some(_), None) => CapacityState::Uncountable,
        }
    }

    /// Whether the scope is over its ceiling.
    ///
    /// The one predicate a surface asks instead of comparing `overflow` itself, so "over
    /// capacity" means one thing everywhere.
    #[must_use]
    pub fn is_over_capacity(&self) -> bool {
        self.overflow.is_some_and(|overflow| overflow > 0)
    }
}

/// `committed` as a floored whole percentage of `capacity` — the one rounding policy.
///
/// Floored, not rounded to nearest: a ceiling report that rounds up would claim a scope is
/// at 100% while it is at 99.5% — the one reading that matters at a capacity boundary.
/// The epics sibling follows the same policy with exact `i128` arithmetic over its
/// `i64` weights, so very large weights cannot pin either surface below 100%.
/// `None` when the denominator is zero, where the ratio has no value; `u32::MAX`
/// in the impossible extreme.
///
/// The test table in `tests/capacity_calculate.rs` pins *this* module's policy — the
/// rounding this package owns — rather than the sibling's implementation.
fn utilization_percent(committed: u64, capacity: u64) -> Option<u32> {
    (capacity > 0).then(|| {
        let percent = u128::from(committed) * 100 / u128::from(capacity);
        u32::try_from(percent).unwrap_or(u32::MAX)
    })
}

/// The capacity entry that is in force for `scope`, if any.
///
/// Matched on the resolved identity rather than on the text, so an entry naming a
/// generated cycle's stable id and a caller asking about its name are one scope; the
/// section's declaration order breaks a tie, which a validated section cannot have (two
/// entries for one scope are refused by [`super::config::validate`]).
fn entry_for<'a>(
    config: &'a Config,
    index: &ScheduleIndex,
    scope: &CapacityScope,
) -> Option<&'a CapacityEntry> {
    config
        .capacity
        .iter()
        .map(|(_, entry)| entry)
        .find(|entry| {
            entry.effective()
                && entry.scope_kind() == Some(scope.kind)
                && entry.scope_id().is_some_and(|reference| {
                    same_scope(config, index, scope.kind, reference, &scope.reference)
                })
        })
}

/// Whether two references name one scope, through the same folding
/// [`super::config::validate`] applies.
fn same_scope(
    config: &Config,
    index: &ScheduleIndex,
    kind: CapacityScopeKind,
    left: &str,
    right: &str,
) -> bool {
    scope_identity(config, index, kind, left) == scope_identity(config, index, kind, right)
}

/// The identity a scope reference resolves to, in the spelling
/// [`super::config::validate`] keys a ceiling by.
///
/// A cycle's stable id or resolved name, a canonical assignee handle, a team label — the
/// same canonicalisation that makes `@ada` and `ada` one scope. The cycle arm mirrors
/// validate's `scope_identity` (`src/capacity/config.rs:899-920`) **including its second
/// half**: a reference the identity index answers nothing for but the configuration does
/// name — a legacy `[cycles.<name>]` table, or a stored record whose generated identity is
/// partial, which the loader keeps in `config.cycles` while `ScheduleIndex::add_record`
/// drops the record — is `cycle-name:<name>` there, and the mission's one-ceiling rule is
/// stated in that identity. A name neither the records nor the configuration knows keeps
/// its own text, so two unresolvable names are not mistaken for one scope.
fn scope_identity(
    config: &Config,
    index: &ScheduleIndex,
    kind: CapacityScopeKind,
    reference: &str,
) -> String {
    match kind {
        CapacityScopeKind::Cycle => match resolve_cycle(index, reference) {
            CycleReference::Generated { id, .. } => format!("cycle-id:{id}"),
            CycleReference::Legacy { name } => format!("cycle-name:{name}"),
            CycleReference::Unknown { .. } if config.cycles.contains_key(reference) => {
                format!("cycle-name:{reference}")
            }
            CycleReference::Unknown { name } => format!("cycle-ref:{name}"),
        },
        CapacityScopeKind::Team => format!("team:{reference}"),
        CapacityScopeKind::Assignee => match parse_assignee(reference) {
            Some(handle) => format!("assignee:{handle}"),
            None => format!("assignee-ref:{reference}"),
        },
    }
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::capacity::config::{CapacityConfig, validate};
    use crate::config::{Config, PlanningCycle};

    /// The identity this module compares on has to be the one
    /// [`super::config::validate`] keys a ceiling by. A reference the identity index
    /// answers nothing for but the configuration *does* name — a `[cycles.<name>]` table,
    /// or a stored record whose generated identity is partial, which the loader keeps in
    /// `config.cycles` while `ScheduleIndex::add_record` drops the record — is
    /// `cycle-name:<name>` there (`src/capacity/config.rs:899-905`), not a spelling of
    /// this module's own: the mission's one-ceiling rule is stated in that identity, so a
    /// surface that keys a ceiling or a calculation by validate's canonical has to find
    /// this scope.
    ///
    /// This test fails if the identity falls back to `cycle-ref:` for such a reference.
    #[test]
    fn the_scope_identity_is_the_one_validate_keys_a_ceiling_by() {
        let mut config = Config::default();
        config
            .cycles
            .insert("Partial 1".to_string(), PlanningCycle::default());
        let index = ScheduleIndex::default();

        // Validate accepts a ceiling naming that reference — which it can only do by
        // resolving it through the configuration, because the index answers nothing for
        // it. The two callers therefore have to agree on one spelling.
        let mut section = CapacityConfig::new();
        section.insert(
            "partial",
            CapacityEntry {
                cycle: Some("Partial 1".to_string()),
                enabled: false,
                ..CapacityEntry::default()
            },
        );
        validate(&section, &config).expect("a reference the configuration names is a valid scope");

        assert_eq!(
            scope_identity(&config, &index, CapacityScopeKind::Cycle, "Partial 1"),
            "cycle-name:Partial 1"
        );
        // A name neither the records nor the configuration knows keeps its own text, so
        // two unresolvable names are not mistaken for one scope.
        assert_eq!(
            scope_identity(&config, &index, CapacityScopeKind::Cycle, "Ghost"),
            "cycle-ref:Ghost"
        );
    }
}