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
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
//! The `F10` **Portfolio** page (WP06): the project registry this mission
//! introduces, as a **reader**.
//!
//! # Why a page and not another modal
//!
//! `tix project …` writes the registry, and the `F1` issue form chooses from it,
//! but neither answers "what projects exist, and what does each one say?". The
//! Portfolio is that reader — a top-level mode like `F4`/`F5`/`F6` and the `F11`
//! Library page, with its own [`crate::keybindings::InputContext`] so it takes no
//! key the board or the form already uses. Its **Projects** tab ships with this
//! mission; further portfolio tabs (a timeline, a health roll-up) arrive with
//! their own missions.
//!
//! # Read-only, and only what the CLI exposes
//!
//! Nothing here writes. The list shows `id`, `name` and the derived member count —
//! exactly what `tix project list` prints — and the detail pane shows the record's
//! fields and its ordered members — exactly what `tix project show` prints, through
//! the same [`crate::projects::MemberRef::display`] renderer. The page derives
//! **no figure of its own**: a surface that computed a project's progress or health
//! here would be the second implementation C-006 forbids, and the service's typed
//! value is what a surface must read instead. [`detail_lines`] is therefore the one
//! function to extend when that value arrives: one more `key: value` row, no
//! layout change and no change to the list.
//!
//! # Empty states are part of the page
//!
//! A key that silently does nothing is indistinguishable from a bug, so an empty
//! registry draws the sentence that says how to fill it instead of a blank pane —
//! the rule the Library page states.

use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};

use crate::app::App;

/// Which registry the page is showing.
///
/// A `const ALL` in display order: the strip, the empty state and the cursor all
/// read this list, so a further tab is additive here and nowhere else. The tab keys
/// (`Tab`/`Shift+Tab`) are bound because there are three now.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum PortfolioTab {
    /// The project registry (`[projects.<id>]`).
    #[default]
    Projects,
    /// The epic registry (`[epics.<id>]`), read from the epic service (WP06 T032,
    /// FR-003). Its content is the **same** canonical rows `tix show`, the detail
    /// pane and the epic view render, so the page cannot disagree with the CLI about
    /// a figure (C-008).
    Epics,
    /// The roadmap (`mission roadmap-initiatives-01M3C8SX`, WP05): the shared
    /// source-attributed [`crate::roadmap::RoadmapProjection`] the CLI renders, its
    /// normalized filter and its initiative actions. A **tab**, not a new top-level
    /// mode: the surface `#1593` gives the registry. The tab itself lives in
    /// [`crate::ui::roadmap`], which owns its layout and overlay.
    Roadmap,
}

impl PortfolioTab {
    /// The tabs in display order.
    pub const ALL: [Self; 3] = [Self::Projects, Self::Epics, Self::Roadmap];

    /// The tab's label in the strip.
    pub const fn label(self) -> &'static str {
        match self {
            Self::Projects => "Projects",
            Self::Epics => "Epics",
            Self::Roadmap => crate::ui::roadmap::TAB_LABEL,
        }
    }

    /// The tab's registry name, as `tix.toml` spells it.
    pub const fn section(self) -> &'static str {
        match self {
            Self::Projects => "projects",
            Self::Epics => "epics",
            Self::Roadmap => crate::ui::roadmap::TAB_SECTION,
        }
    }

    /// The tab's noun, for the detail pane's "nothing selected" sentence.
    pub const fn noun(self) -> &'static str {
        match self {
            Self::Projects => "project",
            Self::Epics => "epic",
            Self::Roadmap => crate::ui::roadmap::TAB_NOUN,
        }
    }

    /// What to say when the registry is empty, naming the two ways to fill it.
    pub const fn empty_hint(self) -> &'static str {
        match self {
            Self::Projects => {
                "No projects yet — run `tix project create <name>`, or associate one \
                 from an issue's form."
            }
            Self::Epics => {
                "No epics yet — run `tix epic create <name>`, or reference one from \
                 an issue's form."
            }
            Self::Roadmap => crate::ui::roadmap::TAB_EMPTY_HINT,
        }
    }

    /// The one-line hint under the strip.
    ///
    /// The page reads three registries/projections now, so the tab key is
    /// advertised; the Epics tab additionally advertises the member cursor and the
    /// direct issue-open, and the Roadmap tab its own command letters. The words
    /// match the `InputContext::Portfolio` bindings and the tab's raw keys exactly.
    pub const fn hint(self) -> &'static str {
        match self {
            Self::Projects => "j/k move · Tab switch tabs · q close",
            Self::Epics => "j/k epic · h/l member · Enter open issue · Tab tabs · q close",
            Self::Roadmap => crate::ui::roadmap::TAB_HINT,
        }
    }
}

/// The page's session state: which tab, and the row cursor inside it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PortfolioState {
    /// The active tab.
    pub tab: PortfolioTab,
    /// The selected row inside the active tab, `0`-based.
    pub selected: usize,
    /// The selected **member** of the Epics tab's selected epic, `0`-based.
    ///
    /// Only meaningful on the Epics tab (the Projects and Roadmap tabs have no
    /// member cursor); reset whenever the epic row or the tab changes, because it
    /// indexes the newly selected epic's own member list.
    pub member: usize,
}

impl PortfolioState {
    /// A fresh page on the Projects tab with the cursor on the first row.
    pub fn new() -> Self {
        Self::default()
    }

    /// Move the cursor by `delta` rows, clamped to `rows` entries (which may be
    /// zero). Returns `true` when the cursor moved.
    pub fn move_selected(&mut self, delta: isize, rows: usize) -> bool {
        let last = rows.saturating_sub(1);
        let before = self.selected;
        let next = if delta.is_negative() {
            self.selected.saturating_sub(delta.unsigned_abs())
        } else {
            self.selected.saturating_add(delta as usize)
        };
        self.selected = next.min(last);
        let moved = self.selected != before;
        if moved {
            // The member cursor indexes the newly selected epic's own list.
            self.member = 0;
        }
        moved
    }

    /// Move the member cursor by `delta`, clamped to `len` members. Returns `true`
    /// when it moved.
    pub fn move_member(&mut self, delta: isize, len: usize) -> bool {
        let last = len.saturating_sub(1);
        let before = self.member;
        let next = if delta.is_negative() {
            self.member.saturating_sub(delta.unsigned_abs())
        } else {
            self.member.saturating_add(delta as usize)
        };
        self.member = next.min(last);
        self.member != before
    }

    /// Clamp the cursor into `rows` entries, for use after the registry changed
    /// underneath the page (a project deleted from the CLI, say).
    pub fn clamp_selected(&mut self, rows: usize) {
        self.selected = self.selected.min(rows.saturating_sub(1));
    }

    /// Clamp the member cursor into `members` entries, for use after the epic
    /// registry changed underneath the page.
    pub fn clamp_member(&mut self, members: usize) {
        self.member = self.member.min(members.saturating_sub(1));
    }

    /// Switch to the tab `delta` steps away (wrapping), resetting the row cursor.
    ///
    /// Resetting is the point: `selected` indexes the *active* tab's rows, and a
    /// cursor carried from a two-row project list onto a one-row epic list would be
    /// out of range. Returns `true` when the tab changed.
    pub fn switch_tab(&mut self, delta: isize) -> bool {
        let current = PortfolioTab::ALL
            .iter()
            .position(|tab| *tab == self.tab)
            .unwrap_or(0);
        let len = PortfolioTab::ALL.len();
        let next = (current as isize + delta).rem_euclid(len as isize) as usize;
        let tab = PortfolioTab::ALL[next];
        if tab == self.tab {
            return false;
        }
        self.tab = tab;
        self.selected = 0;
        self.member = 0;
        true
    }
}

/// One row of the project list, as display data: the widget never reads the
/// registry, and the app builds these from the service's own list.
///
/// The three fields are exactly `tix project list`'s three columns, so the page
/// cannot show a project the listing does not, or hide one it does.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortfolioEntry {
    /// The registry id — shown beside the name because the id is what selects.
    pub id: crate::config::ProjectId,
    /// The mutable display label.
    pub name: String,
    /// The service's derived member count.
    pub members: usize,
}

impl PortfolioEntry {
    /// The row's headline: `name (id)`, through the shared display renderer
    /// ([`crate::projects::project_display`]) so the page and `tix project list`
    /// cannot spell a project two ways.
    pub fn title(&self) -> String {
        crate::projects::project_display(&self.id, &self.name)
    }
}

/// The detail pane: its heading, the record's `key: value` lines, and the member
/// rows under them.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PortfolioDetail {
    /// The heading line, e.g. `Project`.
    pub heading: String,
    /// The `key: value` lines, in the order `tix project show` prints them.
    pub lines: Vec<(String, String)>,
    /// One line per member, from the service's derived display label.
    pub members: Vec<String>,
}

/// One row per project, in the registry's declaration order and with the service's
/// own derived member count.
///
/// Pure: it maps [`crate::projects::list_projects`]' output and counts nothing, so
/// the page and `tix project list` cannot disagree about who is a member.
pub fn entries(projects: &[crate::projects::Project]) -> Vec<PortfolioEntry> {
    projects
        .iter()
        .map(|project| PortfolioEntry {
            id: project.id.clone(),
            name: project.name.clone(),
            members: project.member_count,
        })
        .collect()
}

/// The detail pane for one row of `projects`, or `None` when `index` is past the
/// end (the registry shrank under the cursor).
///
/// `reports` carries the service's typed health value for each project, read once
/// for the whole frame by the caller; the pane renders it and computes nothing
/// (C-006).
pub fn detail(
    projects: &[crate::projects::Project],
    reports: &std::collections::BTreeMap<
        crate::config::ProjectId,
        crate::projects::ProjectHealthReport,
    >,
    index: usize,
) -> Option<PortfolioDetail> {
    let project = projects.get(index)?;
    Some(PortfolioDetail {
        heading: "Project".to_string(),
        lines: detail_lines(project, reports.get(&project.id)),
        members: member_lines(project),
    })
}

/// The `key: value` lines of the detail pane.
///
/// **The single extension point for a new field.** They are the same keys and the
/// same `-`-for-absent spelling `tix project show` uses
/// ([`crate::commands::project::record_lines`]), so the page and the CLI cannot
/// describe one record two ways.
///
/// The progress and health rows are the **service's typed value** — the same value
/// `tix project show` prints — read from `report` rather than derived here, so the
/// page renders "exactly the data the CLI exposes" (C-006, the F10 requirement). A
/// caller with no snapshot renders the record alone rather than inventing zeros.
pub fn detail_lines(
    project: &crate::projects::Project,
    report: Option<&crate::projects::ProjectHealthReport>,
) -> Vec<(String, String)> {
    let or_dash = |value: &Option<String>| value.clone().unwrap_or_else(|| "-".to_string());
    let mut lines = vec![
        ("id".to_string(), project.id.to_string()),
        ("name".to_string(), project.name.clone()),
        ("description".to_string(), or_dash(&project.description)),
        ("status".to_string(), or_dash(&project.status)),
        ("lead".to_string(), or_dash(&project.lead)),
        ("start-date".to_string(), or_dash(&project.start_date)),
        ("due-date".to_string(), or_dash(&project.due_date)),
        ("members".to_string(), project.member_count.to_string()),
    ];
    if let Some(report) = report {
        lines.push((
            "progress".to_string(),
            format!("{}/{}", report.progress.done, report.progress.total),
        ));
        lines.push(("overdue".to_string(), report.health.overdue.to_string()));
        lines.push(("blocked".to_string(), report.health.blocked.to_string()));
        lines.push((
            "unassigned".to_string(),
            report.health.unassigned.to_string(),
        ));
        lines.push((
            "missing-references".to_string(),
            report.health.missing_references.len().to_string(),
        ));
    }
    lines
}

/// The member rows of the detail pane, from the service's derived labels.
pub fn member_lines(project: &crate::projects::Project) -> Vec<String> {
    project
        .members
        .iter()
        .map(|member| member.display.clone())
        .collect()
}

/// The Epics tab's detail pane for one row of `entries`, or `None` when `index` is
/// past the end (the registry shrank under the cursor) — the analogue of [`detail`]
/// for the epic registry (WP06 T032).
///
/// The rows are the **service's own**: [`crate::commands::view::EpicViewEntry::epic`]
/// is the same canonical heading-and-context block `tix show`, the detail pane and
/// the epic view render (C-008), and `members` is the derived membership the service
/// returned. Nothing here is recomputed or counted apart from `members.len()`, which
/// is the derived list's own length.
pub fn epic_detail(
    entries: &[crate::commands::view::EpicViewEntry],
    index: usize,
) -> Option<PortfolioDetail> {
    let entry = entries.get(index)?;
    let mut lines = vec![("heading".to_string(), entry.epic.heading.clone())];
    for line in &entry.epic.detail {
        // The canonical rows are `key: value`; a blocked member's line is
        // `  <member>: <reason>`. Split on the first separator so each keeps its
        // own key, and fall back to a value-only row for anything else.
        match line.split_once(": ") {
            Some((key, value)) => lines.push((key.to_string(), value.to_string())),
            None => lines.push((String::new(), line.clone())),
        }
    }
    Some(PortfolioDetail {
        heading: "Epic".to_string(),
        lines,
        members: entry.members.iter().map(|m| m.display.clone()).collect(),
    })
}

/// The style of a tab that is not active, and of everything secondary.
fn dim() -> Style {
    Style::default().fg(Color::DarkGray)
}

/// The tab strip: `F10 Portfolio` on the left, then every tab, the active one cyan
/// and bold.
fn tab_line(active: PortfolioTab) -> Line<'static> {
    let mut spans = vec![
        Span::styled(
            "F10 Portfolio",
            Style::default().add_modifier(Modifier::BOLD),
        ),
        Span::raw("  "),
    ];
    for (index, tab) in PortfolioTab::ALL.iter().enumerate() {
        if index > 0 {
            spans.push(Span::raw("  "));
        }
        let style = if *tab == active {
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD)
        } else {
            dim()
        };
        spans.push(Span::styled(format!("[{}]", tab.label()), style));
    }
    Line::from(spans)
}

/// Draw the page: the strip and its hint on two lines, then the active tab's list
/// beside the selected record's detail.
///
/// The data comes from `App` (which owns the store), so this function reads no file
/// and derives no figure: the Projects tab lays out what
/// [`crate::projects::list_projects`] returned and the Epics tab what
/// [`crate::commands::view::epic_view`] returned, both through the one list/detail
/// layout so the tabs cannot drift apart. The Roadmap tab owns its whole body and
/// dispatches to [`crate::ui::roadmap`] (its rows come from the CLI's own
/// renderers), so this function does not lay it out.
#[allow(clippy::too_many_lines)]
pub fn render(app: &App, f: &mut Frame, area: Rect) {
    let state = app.portfolio_state();

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Min(1),
        ])
        .split(area);
    f.render_widget(Paragraph::new(tab_line(state.tab)), rows[0]);
    f.render_widget(
        Paragraph::new(Span::styled(state.tab.hint(), dim())),
        rows[1],
    );

    // The Roadmap tab owns its whole body (the criteria line, the rows, the
    // CLI detail pane and any open overlay), so the page dispatches to it rather
    // than re-laying it out here — one tab, one renderer, no drift.
    if state.tab == PortfolioTab::Roadmap {
        crate::ui::roadmap::render_tab(app, f, rows[2], state.selected);
        return;
    }

    let body = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(45), Constraint::Min(24)])
        .split(rows[2]);

    // One row shape for the registry tabs: the record's shareable title and its
    // derived member count, which is exactly what `tix project list` / `tix epic
    // list` print. (The Roadmap tab renders its own CLI-shaped rows and returns
    // above.)
    let (listed, detail_pane): (Vec<(String, usize)>, Option<PortfolioDetail>) = match state.tab {
        PortfolioTab::Projects => {
            // One snapshot for the whole frame: the rows, the detail and the
            // cursor's clamp all describe the same set of projects, and the
            // service is asked once.
            let projects = app.projects_snapshot();
            let reports = app.portfolio_reports();
            let listed = entries(&projects)
                .iter()
                .map(|entry| (entry.title(), entry.members))
                .collect();
            (listed, detail(&projects, &reports, state.selected))
        }
        PortfolioTab::Epics => {
            // The same one-read shape: `epic_view`'s entries are the canonical
            // rows the CLI exposes, read once at mode entry.
            let view = app.portfolio_epics();
            let listed = view
                .iter()
                .map(|entry| (entry.epic.heading.clone(), entry.members.len()))
                .collect();
            (listed, epic_detail(view, state.selected))
        }
        // The Roadmap tab owns its whole body and returns above; this arm is
        // unreachable but keeps the match total without a catch-all that would
        // silently swallow a future tab.
        PortfolioTab::Roadmap => unreachable!("the Roadmap tab renders above"),
    };

    let title = format!("{} ({})", state.tab.label(), listed.len());
    let list = Block::default().borders(Borders::ALL).title(title);
    if listed.is_empty() {
        // The empty state is the page's content, not a missing pane: same block,
        // sentence inside it, wrapped so a narrow terminal does not clip it.
        f.render_widget(
            Paragraph::new(state.tab.empty_hint())
                .block(list)
                .wrap(Wrap { trim: true })
                .style(dim()),
            body[0],
        );
    } else {
        let mut lines: Vec<Line<'static>> = Vec::new();
        for (index, (heading, members)) in listed.iter().enumerate() {
            let selected = index == state.selected;
            let title_style = if selected {
                Style::default().add_modifier(Modifier::BOLD)
            } else {
                Style::default()
            };
            lines.push(Line::from(vec![
                Span::raw(if selected { "> " } else { "  " }),
                Span::styled(heading.clone(), title_style),
            ]));
            lines.push(Line::from(Span::styled(
                format!(
                    "    {} {}",
                    members,
                    crate::ui::planning::issues_word(*members)
                ),
                dim(),
            )));
        }
        f.render_widget(Paragraph::new(lines).block(list), body[0]);
    }

    let detail_block = Block::default()
        .borders(Borders::ALL)
        .title(detail_title(state.tab, detail_pane.as_ref()));
    match detail_pane.as_ref() {
        Some(detail) => {
            let mut lines: Vec<Line<'static>> = detail
                .lines
                .iter()
                .map(|(key, value)| {
                    Line::from(vec![
                        Span::styled(format!("{key}: "), dim()),
                        Span::raw(value.clone()),
                    ])
                })
                .collect();
            // The Epics tab's detail pane is where a member is opened from: the
            // cursor marks the row `Enter` acts on (FR-004), an ordinary issue row
            // with no epic layer in between.
            let selected_member = (state.tab == PortfolioTab::Epics).then_some(state.member);
            for (index, member) in detail.members.iter().enumerate() {
                let is_selected = selected_member == Some(index);
                let style = if is_selected {
                    Style::default()
                        .fg(Color::White)
                        .add_modifier(Modifier::BOLD)
                } else {
                    Style::default().fg(Color::White)
                };
                lines.push(Line::from(vec![
                    Span::raw(if is_selected { "> " } else { "  " }),
                    Span::styled(member.clone(), style),
                ]));
            }
            f.render_widget(
                Paragraph::new(lines)
                    .block(detail_block)
                    .wrap(Wrap { trim: false }),
                body[1],
            );
        }
        None => f.render_widget(
            Paragraph::new(Span::styled(
                format!(
                    "Nothing selected. A {} has to exist before the page can describe it.",
                    state.tab.noun()
                ),
                dim(),
            ))
            .block(detail_block)
            .wrap(Wrap { trim: true }),
            body[1],
        ),
    }
}

/// The detail pane's title: the project's own name, or the tab it belongs to.
fn detail_title(tab: PortfolioTab, detail: Option<&PortfolioDetail>) -> String {
    match detail {
        Some(detail) => match detail.lines.first() {
            Some((_, value)) if !value.is_empty() => format!("{} {value}", detail.heading),
            _ => detail.heading.clone(),
        },
        None => format!("{} detail", tab.label()),
    }
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::config::ProjectId;
    use crate::projects::{MemberRef, Project};

    fn project(id: &str, name: &str, members: &[u32]) -> Project {
        let members: Vec<MemberRef> = members
            .iter()
            .map(|number| MemberRef {
                issue: crate::projects::IssueKey::new(*number),
                display: format!("#{number} some title"),
            })
            .collect();
        Project {
            id: ProjectId::new(id).unwrap(),
            name: name.to_string(),
            description: None,
            status: None,
            lead: None,
            start_date: None,
            due_date: None,
            member_count: members.len(),
            members,
        }
    }

    #[test]
    fn every_tab_has_a_full_empty_state_and_names_its_command() {
        assert_eq!(
            PortfolioTab::ALL,
            [
                PortfolioTab::Projects,
                PortfolioTab::Epics,
                PortfolioTab::Roadmap
            ]
        );
        assert_eq!(PortfolioTab::default(), PortfolioTab::Projects);
        assert_eq!(PortfolioTab::Projects.label(), "Projects");
        assert_eq!(PortfolioTab::Projects.section(), "projects");
        assert_eq!(PortfolioTab::Projects.noun(), "project");
        assert_eq!(PortfolioTab::Epics.label(), "Epics");
        assert_eq!(PortfolioTab::Epics.section(), "epics");
        assert_eq!(PortfolioTab::Epics.noun(), "epic");
        assert_eq!(PortfolioTab::Roadmap.label(), "Roadmap");
        assert_eq!(PortfolioTab::Roadmap.section(), "initiatives");
        assert_eq!(PortfolioTab::Roadmap.noun(), "initiative");
        let empty = PortfolioTab::Projects.empty_hint();
        assert!(empty.contains("tix project create"), "got {empty:?}");
        let empty = PortfolioTab::Epics.empty_hint();
        assert!(empty.contains("tix epic create"), "got {empty:?}");
        let empty = PortfolioTab::Roadmap.empty_hint();
        assert!(empty.contains("tix initiative create"), "got {empty:?}");
        for tab in PortfolioTab::ALL {
            assert!(!tab.hint().is_empty(), "{tab:?} needs a hint");
        }
    }

    #[test]
    fn tab_switching_wraps_and_resets_the_row_cursor() {
        let mut state = PortfolioState::new();
        state.selected = 3;
        assert!(state.switch_tab(1));
        assert_eq!(state.tab, PortfolioTab::Epics);
        assert_eq!(
            state.selected, 0,
            "the cursor indexes the active tab's rows"
        );
        // Forward stepwise through every tab and wrap back to Projects; backward
        // wraps to the last tab.
        assert!(state.switch_tab(1));
        assert_eq!(state.tab, PortfolioTab::Roadmap);
        assert!(state.switch_tab(1));
        assert_eq!(state.tab, PortfolioTab::Projects);
        assert!(state.switch_tab(-1));
        assert_eq!(state.tab, PortfolioTab::Roadmap);
    }

    #[test]
    fn epic_detail_reads_the_services_heading_rows_and_members() {
        use crate::commands::view::{EpicBlockEntry, EpicMemberRef, EpicViewEntry};

        let entries = vec![EpicViewEntry {
            epic: EpicBlockEntry {
                heading: "Alpha (e-aaa111)".to_string(),
                detail: vec!["progress: 1/2 done".to_string(), "weight: 5".to_string()],
            },
            members: vec![
                EpicMemberRef {
                    number: 3,
                    display: "#3 Some title".to_string(),
                },
                EpicMemberRef {
                    number: 1,
                    display: "#1 Another".to_string(),
                },
            ],
        }];

        let detail = epic_detail(&entries, 0).expect("a selected epic");
        assert_eq!(detail.heading, "Epic");
        assert_eq!(
            detail.lines[0],
            ("heading".to_string(), "Alpha (e-aaa111)".to_string())
        );
        assert_eq!(
            detail.lines[1],
            ("progress".to_string(), "1/2 done".to_string())
        );
        assert_eq!(
            detail.members,
            vec!["#3 Some title", "#1 Another"],
            "the service's own derived order and labels"
        );
        assert!(epic_detail(&entries, 7).is_none(), "the registry shrank");
    }

    #[test]
    fn the_cursor_clamps_in_both_directions_and_on_an_empty_registry() {
        let mut state = PortfolioState::new();
        assert_eq!(state.selected, 0);
        assert!(state.move_selected(1, 3));
        assert_eq!(state.selected, 1);
        assert!(state.move_selected(5, 3));
        assert_eq!(state.selected, 2, "clamped to the last row");
        assert!(state.move_selected(-1, 3));
        assert_eq!(state.selected, 1);
        // A deleted project shrinks the list under the cursor.
        state.clamp_selected(1);
        assert_eq!(state.selected, 0);
        assert!(!state.move_selected(1, 0), "an empty list cannot move");
        assert_eq!(state.selected, 0);
        state.move_selected(-3, 0);
        assert_eq!(state.selected, 0, "and cannot underflow");
    }

    #[test]
    fn the_detail_lines_are_the_cli_records_keys_with_dashes_for_absent() {
        let lines = detail_lines(&project("p-1", "Platform", &[3, 1]), None);
        let keys: Vec<&str> = lines.iter().map(|(key, _)| key.as_str()).collect();
        assert_eq!(
            keys,
            [
                "id",
                "name",
                "description",
                "status",
                "lead",
                "start-date",
                "due-date",
                "members"
            ],
            "the same keys `tix project show` prints, in the same order — a new \
             field is one row here and nothing else"
        );
        assert_eq!(lines[0].1, "p-1");
        assert_eq!(lines[1].1, "Platform");
        assert_eq!(lines[2].1, "-", "an absent field renders `-`, not empty");
        assert_eq!(lines[7].1, "2", "the derived member count");
    }

    #[test]
    fn the_entry_title_is_the_shared_display_form() {
        let entry = PortfolioEntry {
            id: ProjectId::new("p-3f9a2c").unwrap(),
            name: "Platform hardening".to_string(),
            members: 4,
        };
        assert_eq!(entry.title(), "Platform hardening (p-3f9a2c)");
    }

    #[test]
    fn the_member_lines_use_the_services_derived_labels() {
        let lines = member_lines(&project("p-1", "Platform", &[3, 1]));
        assert_eq!(lines, vec!["#3 some title", "#1 some title"]);
    }
}