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
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
//! `tix analytics burndown|projection|export` — the CLI half of the burndown and
//! scope-analytics mission (`burndown-scope-analytics-01M3C9J1`, WP05; T024–T027).
//!
//! # A thin adapter, not a second fold
//!
//! Every value this module prints is a field of the shared DTOs, and the one thing it
//! builds is the **chart document** WP04 owns: each verb resolves its scope, mode and
//! unit, then calls [`chart_document`]
//! ([`crate::commands::export::chart_document`]) and reads the answer out of the
//! document's own metadata, points, coverage and diagnostics. The daily fold
//! ([`crate::analytics::series`]), the estimate conversion
//! ([`crate::analytics::units::estimate_series`]), the forecast
//! ([`crate::analytics::projection::project`]) and the document
//! ([`crate::analytics::chart`]) are the pure services' — nothing here recomputes a
//! formula, re-derives a conversion, or serializes a document of its own (C-008).
//!
//! Sharing the document rather than a parallel composition is what makes
//! `tix analytics burndown`, `tix analytics projection`, `tix analytics export` and
//! WP04's `tix export --target chart` one reading of the repository: there is no second
//! place a formula version, a period or a coverage tally could drift (NFR-003).
//!
//! # Where the period comes from
//!
//! The scope's period is the **cycle or milestone's own planning data** — the cycle's
//! declared `start_date`/`due_date`, the milestone's target date — resolved through the
//! crate's one scope resolver ([`cycle_scope`]/[`milestone_scope`]). `--from`/`--to`
//! replace the bound they name and leave the other alone, which is the remedy the shared
//! projection findings themselves name ("widen the series' period with an earlier
//! `--from`").
//!
//! # stdout is the result, stderr is the diagnostic
//!
//! A report's block goes to **stdout** line by line (`scope:`, `period:`, `mode:`,
//! `unit:`, `formula:`, the coverage tally, the finding count, and for `burndown` a
//! tab-separated daily table), and the findings themselves are rendered to **stderr** by
//! [`AnalyticsDiagnostic::render`] — a finding interleaved into the value lines would be
//! a parse error rather than a warning. The one figure on stdout is the finding **count**
//! the stderr lines agree with, so a reader piping stdout still sees that findings exist.
//!
//! # A value the record cannot support is `-`, never a zero
//!
//! Every number is an `Option` on the DTO, and [`number`] renders `None` as `-`: an
//! unobserved day has no additions (`-`, not `0`), an unanswerable remaining level is
//! `-`, an unprojected completion date and an undecided `meets_end_date` are `-`. The
//! day's `coverage` says `known`, `unknown` or `coverage_limited` beside them (C-005).
//!
//! # A refusal is not an answer
//!
//! `burndown` **refuses** — non-zero, with the fold's own reason and remedy — when the
//! fold answers no day at all, because there is then no report to print: an unenumerable
//! period, or estimate mode with a unit the project declares no conversion for. The same
//! document is still *exportable*: `tix analytics export` emits it, with its diagnostics
//! in-band, because a document that says why it holds no day is a document a script can
//! store (and WP04's own target does the same). A request that cannot be carried out at
//! all — an unknown scope kind or mode, a malformed date, estimate mode with no `--unit`,
//! a forecast with no planning end date — is refused before any fold runs. Every refusal
//! carries a reason and a remedy through the crate's one error renderer.
//!
//! # Read-only
//!
//! Nothing here opens an issue, a cycle, a milestone or a snapshot for writing: the only
//! file this module ever writes is the caller's own `--output` path. A `tix analytics`
//! run therefore leaves every byte the repository held exactly as it was.

use std::path::{Path, PathBuf};

use crate::analytics::chart::{ChartExport, ChartProjection};
use crate::analytics::diagnostics::{AnalyticsDiagnostic, AnalyticsDiagnosticKind};
use crate::analytics::events::{AnalyticsScope, Period, ScopeKind, cycle_scope, milestone_scope};
use crate::analytics::series::Mode;
use crate::cli::{AnalyticsCommand, AnalyticsScopeArgs};
use crate::commands::export::chart_document;
use crate::config::Config;
use crate::diagnostics::Diagnostic;
use crate::error::{Result, TixError};
use crate::ics;
use crate::model::validate_date;
use crate::planning::Planner;
use crate::storage::Store;

/// Run the `tix analytics` verb the caller selected.
pub fn run(config: &Config, store: &Store, command: &AnalyticsCommand) -> Result<()> {
    match command {
        AnalyticsCommand::Burndown { args } => burndown(config, store, args),
        AnalyticsCommand::Projection { args, as_of } => projection(config, store, args, as_of),
        AnalyticsCommand::Export {
            args,
            as_of,
            output,
        } => export(config, store, args, as_of.as_deref(), output.as_deref()),
    }
}

// ---------------------------------------------------------------------------
// The shared reports (the CLI's verbs and WP07's MCP tools)
// ---------------------------------------------------------------------------
//
// Each verb's report is built **once**, here, and both surfaces read it: the CLI
// prints `AnalyticsReport::text` and renders the document's findings to stderr, and
// the MCP tools (WP07, `src/mcp/tools.rs`) return the same `text` as their `content`
// and the same document as their `structuredContent`. The blocks below are therefore
// not "shared renderers" a caller might forget to use — they are the only way either
// surface can produce this output, so a second rendering cannot appear and drift
// (NFR-003). The refusal path is shared the same way: every request-level refusal is
// decided once inside `document`, and `crate::error::render_error` renders it for
// both surfaces.

/// One verb's report: the text a surface shows and the **document** it was read from.
///
/// The document travels beside the text because the CLI renders the document's own
/// findings to stderr after printing the block, and the MCP tools return that same
/// document as their structured answer. Neither surface re-folds anything to get it.
///
/// Crate-visible (not private) so the MCP tools can hand the document to `serde_json`:
/// the text is a rendering, and the document is the typed answer.
pub(crate) struct AnalyticsReport {
    /// The verb's stdout block, byte-for-byte — the MCP tool's `content` text.
    pub(crate) text: String,
    /// The shared chart document the text was rendered from.
    pub(crate) document: ChartExport,
}

/// The burndown report for one request, or the fold's own refusal.
///
/// The empty-series refusal is decided here **once**, so the CLI and the MCP
/// `analytics_burndown` tool refuse the same request for the same reason rather than
/// each deciding when a report is printable.
pub(crate) fn burndown_report(
    config: &Config,
    store: &Store,
    args: &AnalyticsScopeArgs,
) -> Result<AnalyticsReport> {
    let document = document(config, store, args, mode_of(args.mode.as_deref())?, None)?;
    if document.is_empty() {
        return Err(refusal(&document.series.diagnostics));
    }
    Ok(AnalyticsReport {
        text: burndown_block(&document),
        document,
    })
}

/// The end-date forecast report for one request, or the request-level refusal.
///
/// An unanswerable forecast is an **answer**, not a refusal: the projection's own
/// `status` (`insufficient_data`/`unknown`) and the `-`-rendered values it could not
/// derive say so, which is the state the data model requires a surface to expose.
pub(crate) fn projection_report(
    config: &Config,
    store: &Store,
    args: &AnalyticsScopeArgs,
    as_of: &str,
) -> Result<AnalyticsReport> {
    let document = document(
        config,
        store,
        args,
        mode_of(args.mode.as_deref())?,
        Some(as_of),
    )?;
    let Some(forecast) = document.projection.as_ref() else {
        // Unreachable: `document` builds a forecast whenever it is handed an origin. A
        // branch rather than a panic, so a future change to that seam fails loudly here
        // instead of printing a block with no status in it.
        return Err(refuse(
            "the projection verb asked for a forecast and the document carries none",
            "report this: the request and the document disagree",
        ));
    };
    Ok(AnalyticsReport {
        text: projection_block(&document, forecast),
        document,
    })
}

/// The chart document `tix analytics export` writes, or the request-level refusal.
///
/// `--mode` is required here (the contract's own spelling for this verb): the document
/// states the mode it was folded in, and a default would be a guess about which
/// arithmetic produced the numbers.
pub(crate) fn export_document(
    config: &Config,
    store: &Store,
    args: &AnalyticsScopeArgs,
    as_of: Option<&str>,
) -> Result<ChartExport> {
    let Some(raw) = args.mode.as_deref() else {
        return Err(refuse(
            "`tix analytics export` needs an explicit `--mode`",
            "pass `--mode count` or `--mode estimate`: the document states the mode it was \
             folded in",
        ));
    };
    document(config, store, args, parse_mode(raw)?, as_of)
}

// ---------------------------------------------------------------------------
// The verbs
// ---------------------------------------------------------------------------

/// `tix analytics burndown`: the shared daily series as a parseable report.
///
/// The block is [`scope_lines`] plus the formula version, the day count, the coverage
/// tally and the finding count, then the tab-separated table — every cell a DTO field.
/// Refused when the fold answers no day: see the module docs.
fn burndown(config: &Config, store: &Store, args: &AnalyticsScopeArgs) -> Result<()> {
    let report = burndown_report(config, store, args)?;
    print!("{}", report.text);
    render_findings(&report.document, None);
    Ok(())
}

/// `tix analytics projection`: the read-only end-date forecast at `--as-of`.
///
/// The request-level refusals (a malformed date, no planning end date, no `--unit` in
/// estimate mode) happen before the fold; an unanswerable forecast is the report's own
/// `status`.
fn projection(
    config: &Config,
    store: &Store,
    args: &AnalyticsScopeArgs,
    as_of: &str,
) -> Result<()> {
    let report = projection_report(config, store, args, as_of)?;
    print!("{}", report.text);
    // The forecast is the document's own projection, so the findings rendered are the
    // ones the text's `diagnostics:` count already includes.
    render_findings(&report.document, report.document.projection.as_ref());
    Ok(())
}

/// `tix analytics export`: WP04's chart document, to stdout or `--output` (T027).
///
/// The verb owns the **flags** and calls WP04's target for the document (analysis-report
/// H9): it never re-serializes, re-orders or re-derives anything. The bytes written to
/// `--output` are therefore exactly `chart_document(…).to_json_pretty()` — the same bytes
/// a parity check reads out of the core.
fn export(
    config: &Config,
    store: &Store,
    args: &AnalyticsScopeArgs,
    as_of: Option<&str>,
    output: Option<&str>,
) -> Result<()> {
    let document = export_document(config, store, args, as_of)?;
    render_findings(&document, document.projection.as_ref());
    let json = document.to_json_pretty()?;
    match output {
        Some(path) => {
            ics::write_to_path(Path::new(path), &json)?;
            println!("{path}");
        }
        None => print!("{json}"),
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// The request: scope, period, mode, unit, origin
// ---------------------------------------------------------------------------

/// The one chart document every verb reads, built from the caller's request.
///
/// This is the whole of the adapter's composition: WP01's scope resolver, the caller's
/// mode/unit, the caller's origin (with the scope's own planning end date — never a
/// clock) and WP04's [`chart_document`]. Everything else in this module renders what
/// comes back.
fn document(
    config: &Config,
    store: &Store,
    args: &AnalyticsScopeArgs,
    mode: Mode,
    as_of: Option<&str>,
) -> Result<ChartExport> {
    let scope = resolve_scope(config, store, args)?;
    let unit = args.unit.as_deref();
    if mode == Mode::Estimate && unit.is_none() {
        // The kind's own remedy (declare the unit, or choose count) travels with the
        // refusal, so the CLI does not restate the shared advice a second time.
        return Err(refusal(&[AnalyticsDiagnostic::new(
            AnalyticsDiagnosticKind::MissingConversion,
            scope.id.clone(),
            "estimate mode needs an explicit unit conversion, and no `--unit` was named",
        )]));
    }
    let forecast = match as_of {
        Some(as_of) => {
            let as_of = date_arg("--as-of", as_of)?;
            let Some(end) = scope.period_end() else {
                return Err(refusal(&[AnalyticsDiagnostic::new(
                    AnalyticsDiagnosticKind::ProjectionUnknown,
                    scope.id.clone(),
                    format!(
                        "the {} scope `{}` declares no planning end date, so a forecast has no \
                         boundary to be drawn against",
                        scope.kind, scope.id
                    ),
                )
                .on(as_of)
                .with_remedy(
                    "pass `--to <YYYY-MM-DD>` to name the planning end the forecast is drawn \
                     against",
                )]));
            };
            Some((as_of, end.to_string()))
        }
        None => None,
    };
    chart_document(
        config,
        store,
        &scope,
        mode,
        unit,
        forecast
            .as_ref()
            .map(|(as_of, end)| (as_of.as_str(), end.as_str())),
    )
}

/// The scope the verbs select, with `--from`/`--to` replacing the bound they name.
///
/// The kind is parsed from the closed vocabulary, the scope is resolved through the
/// crate's one resolver (so a generated cycle's name and its stable id select the same
/// scope), and each caller-supplied bound is validated **before** it reaches the fold.
fn resolve_scope(
    config: &Config,
    store: &Store,
    args: &AnalyticsScopeArgs,
) -> Result<AnalyticsScope> {
    let kind = ScopeKind::parse(&args.scope).ok_or_else(|| {
        refuse(
            format!(
                "unknown --scope {:?}: the scope kinds are `cycle` and `milestone`",
                args.scope
            ),
            "pass `--scope cycle` or `--scope milestone`",
        )
    })?;
    let root = Config::project_root(&store.issues_dir).unwrap_or_else(|| PathBuf::from("."));
    let planner = Planner::from_config(config.clone(), root);
    let mut scope = match kind {
        ScopeKind::Cycle => cycle_scope(&planner, &args.id)?,
        ScopeKind::Milestone => milestone_scope(&planner, &args.id)?,
    };
    let start = match args.from.as_deref() {
        Some(raw) => Some(date_arg("--from", raw)?),
        None => scope.period.start.clone(),
    };
    let end = match args.to.as_deref() {
        Some(raw) => Some(date_arg("--to", raw)?),
        None => scope.period.end.clone(),
    };
    match Period::new(start, end) {
        Ok(period) => scope.period = period,
        // The shared period validator's own words as the reason (one spelling for one
        // condition), with the remedy the CLI can add and the library cannot.
        Err(TixError::Config(message)) => {
            return Err(refuse(
                message,
                "pass `--from` on or before `--to`, each a `YYYY-MM-DD` calendar date",
            ));
        }
        Err(other) => return Err(other),
    }
    Ok(scope)
}

/// The mode the caller named, defaulting to count when the flag is absent.
fn mode_of(raw: Option<&str>) -> Result<Mode> {
    match raw {
        Some(raw) => parse_mode(raw),
        None => Ok(Mode::Count),
    }
}

/// One mode word from the closed vocabulary; anything else is refused by name.
fn parse_mode(raw: &str) -> Result<Mode> {
    Mode::parse(raw).ok_or_else(|| {
        refuse(
            format!("unknown --mode {raw:?}: the modes are `count` and `estimate`"),
            "pass `--mode count` or `--mode estimate`",
        )
    })
}

/// One caller-supplied calendar date, validated before it can reach the fold.
///
/// The refusal uses the shared period vocabulary's own words (`invalid_period:`), so a
/// bad `--from` and a bad planning date read the same way, and adds the remedy the CLI
/// can offer.
fn date_arg(field: &str, raw: &str) -> Result<String> {
    let date = raw.trim();
    if !validate_date(date) {
        return Err(refuse(
            format!("invalid_period: `{field}` is `{raw}`, not a `YYYY-MM-DD` calendar date"),
            "pass a canonical `YYYY-MM-DD` calendar date, e.g. `2026-01-05`",
        ));
    }
    Ok(date.to_string())
}

// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------

/// The header every report opens with: the scope, its period, its snapshot binding, the
/// mode it counts in and the unit the values are expressed in.
///
/// The mode and unit come from the document, which is the same value the projection
/// inside it carries (`ChartProjection::from_projection` copies the series' own), so the
/// two cannot be printed at odds. An absent bound reads `open` (the scope's own
/// spelling) and an absent snapshot or unit reads `-`.
fn scope_lines(document: &ChartExport) -> Vec<String> {
    let scope = &document.scope;
    vec![
        format!("scope: {}", scope.kind),
        format!("id: {}", scope.id),
        format!(
            "period: {}..{}",
            scope.period.start.as_deref().unwrap_or("open"),
            scope.period.end.as_deref().unwrap_or("open")
        ),
        format!("snapshot: {}", scope.snapshot_id.as_deref().unwrap_or("-")),
        format!("mode: {}", document.mode),
        format!("unit: {}", document.unit.as_deref().unwrap_or("-")),
    ]
}

/// The burndown report: the header, the fold's metadata, then the daily table.
///
/// The table's columns are the point's own fields in the data model's order, and the
/// header row always prints — including for a series whose days are all coverage-limited
/// — so an empty body is visibly the fold's answer rather than a truncated report.
fn burndown_block(document: &ChartExport) -> String {
    let coverage = document.coverage;
    let mut lines = scope_lines(document);
    lines.push(format!("formula: {}", document.formula_version));
    lines.push(format!("days: {}", document.series.points.len()));
    lines.push(format!(
        "coverage: known={} unknown={} coverage_limited={}",
        coverage.known, coverage.unknown, coverage.coverage_limited
    ));
    lines.push(format!("diagnostics: {}", finding_count(document, None)));
    lines.push(String::new());
    lines
        .push("date\tscope_total\tremaining\tcompleted\tscope_added\tcompletions\tcoverage".into());
    for point in &document.series.points {
        lines.push(format!(
            "{}\t{}\t{}\t{}\t{}\t{}\t{}",
            point.date,
            number(point.scope_total),
            number(point.remaining),
            number(point.completed),
            number(point.scope_added),
            number(point.completions),
            point.coverage,
        ));
    }
    join_block(lines)
}

/// The projection report: the header, then the forecast's own fields.
///
/// The formula version is the **projection's** documented one, not the series' — the two
/// are different documents of two different formulas, and both are carried by the
/// document this report reads.
fn projection_block(document: &ChartExport, forecast: &ChartProjection) -> String {
    let mut lines = scope_lines(document);
    lines.push(format!("formula: {}", forecast.formula_version));
    lines.push(format!("as_of: {}", forecast.as_of));
    lines.push(format!("end_date: {}", forecast.end_date));
    lines.push(format!("status: {}", forecast.status));
    lines.push(format!("remaining: {}", number(forecast.remaining)));
    lines.push(format!("observed_rate: {}", number(forecast.observed_rate)));
    lines.push(format!(
        "projected_completion: {}",
        forecast.projected_completion.as_deref().unwrap_or("-")
    ));
    lines.push(format!(
        "meets_end_date: {}",
        number(forecast.meets_end_date)
    ));
    lines.push(format!(
        "diagnostics: {}",
        finding_count(document, Some(forecast))
    ));
    join_block(lines)
}

/// One block's lines as text with a trailing newline.
fn join_block(lines: Vec<String>) -> String {
    let mut block = lines.join("\n");
    block.push('\n');
    block
}

/// How many findings a report renders, so the printed count and the stderr lines agree:
/// the series' (the fold's), the forecast's when one was asked for, then the document's
/// own (the remote-target and version findings WP04 adds).
///
/// Private to this module, which is where both of its callers live (`burndown_block`,
/// `projection_block`). A review found the earlier `pub(crate)` rationale naming an MCP
/// consumer that does not exist: the tools return the whole document, which carries the
/// findings as arrays and no count field at all.
fn finding_count(document: &ChartExport, forecast: Option<&ChartProjection>) -> usize {
    document.series.diagnostics.len()
        + forecast.map_or(0, |forecast| forecast.diagnostics.len())
        + document.diagnostics.len()
}

/// Render every finding the document carries, to **stderr**.
///
/// The order is the count's own order, so a reader can pair the `diagnostics: N` line
/// with the lines that follow it. [`AnalyticsDiagnostic::render`] is the crate's one
/// renderer for the fold's findings — identical bytes on every surface — and it renders
/// at warning severity, which is right here: these findings sit beside a report that was
/// printed and that exits zero.
fn render_findings(document: &ChartExport, forecast: Option<&ChartProjection>) {
    for finding in &document.series.diagnostics {
        eprintln!("diagnostic: {}", finding.render());
    }
    if let Some(forecast) = forecast {
        for finding in &forecast.diagnostics {
            eprintln!("diagnostic: {}", finding.render());
        }
    }
    for finding in &document.diagnostics {
        eprintln!("diagnostic: {}", finding.render());
    }
}

/// The fold's findings as one refusal, at **error** severity, with their remedies.
///
/// [`AnalyticsDiagnostic::render`] is deliberately **not** reused: its `warning[…]`
/// level would print a warning beside a non-zero exit, and the crate's own rule (#1361)
/// is that the rendered level and the exit code agree. Everything else is shared — the
/// kind, the scope, the day, the event and the remedy all come from the finding's own
/// fields — so a refusal still speaks the one analytics vocabulary and still says what is
/// wrong and what to do about it.
fn refusal(findings: &[AnalyticsDiagnostic]) -> TixError {
    if findings.is_empty() {
        // A series cannot be empty without a finding; if one ever is, the refusal must
        // still name a reason rather than exit non-zero on silence.
        return refuse(
            "the record answers no day for this scope, so there is no series to report",
            "widen the period with `--from`/`--to`, or select a scope the record has \
             observations for",
        );
    }
    let blocks: Vec<String> = findings
        .iter()
        .map(|finding| {
            let mut block = Diagnostic::error("E-tix-analytics", finding.message.clone())
                .with_note(format!("{}: {}", finding.kind.as_str(), finding.scope));
            if let Some(date) = finding.date.as_deref() {
                block = block.with_note(format!("date: {date}"));
            }
            if let Some(event_id) = finding.event_id.as_deref() {
                block = block.with_note(format!("event: {event_id}"));
            }
            match finding.remedy.as_deref() {
                Some(remedy) => block.with_help(remedy).render(),
                None => block.render(),
            }
        })
        .collect();
    TixError::Refused(blocks.join("\n"))
}

/// A request the surface refuses before any fold runs: the reason as the headline, the
/// remedy as the `help:` footer.
///
/// It carries no `kind:` note on purpose — the closed
/// [`AnalyticsDiagnosticKind`](crate::analytics::diagnostics::AnalyticsDiagnosticKind)
/// vocabulary names *analytics findings*, and a mistyped flag is not one. The
/// `E-tix-analytics` id already says which surface refused.
fn refuse(reason: impl Into<String>, remedy: impl Into<String>) -> TixError {
    TixError::Refused(
        Diagnostic::error("E-tix-analytics", reason)
            .with_help(remedy)
            .render(),
    )
}

/// An absent number as `-`, the crate's convention for "no value" (never a zero).
fn number<T: std::fmt::Display>(value: Option<T>) -> String {
    value.map_or_else(|| "-".to_string(), |value| value.to_string())
}