Skip to main content

tix/commands/
view.rs

1use crate::commands::link;
2use crate::commands::parse_selector;
3use crate::config::ProjectId;
4use crate::error::Result;
5use crate::model::Issue;
6use crate::projects::{ProjectHealthReport, ProjectIndex};
7use crate::relationships::IssueView;
8use crate::relationships::RelationshipGraph;
9use crate::storage::Store;
10use std::collections::{BTreeMap, HashSet};
11
12/// The WP04 adapter for project health.
13///
14/// [`crate::projects::HealthNode`] is a WP04-local trait, and `src/projects.rs`
15/// stays free of the issue model (its module contract). WP03 owns
16/// `src/model.rs`, which this WP may not edit, so the one impl for the real record
17/// lives here, in the work package's authoritative surface.
18impl crate::projects::HealthNode for Issue {
19    fn assignee(&self) -> Option<&str> {
20        self.assignee.as_deref()
21    }
22}
23
24/// `tix view <issue>`: print the full issue (frontmatter + `---` + body)
25/// verbatim to stdout. No trailing newline beyond what the issue file
26/// contains.
27///
28/// `--show relationships` appends the service-derived block: every relationship
29/// touching this issue, its transitive chain, and its blocked reason. It is
30/// behind a flag because the default output is the **file**, and
31/// `tix view <n> > copy.md` is a thing people do. Appending derived state
32/// unconditionally would make the output no longer a valid issue file, which
33/// would trade a readability feature for a round-trip regression.
34pub fn run(store: &Store, raw: &str, show: Option<&str>) -> Result<()> {
35    // Validate `--show` **before** anything is printed.
36    //
37    // The order matters for the stdout contract. Printing first and validating
38    // second means `tix view 0 --show bogus` writes the whole issue file to
39    // stdout and *then* exits non-zero — so a consumer that redirects stdout has
40    // a file that looks complete and a status code that says it failed, and
41    // cannot tell which to believe. A rejected invocation must produce no output.
42    let want_relationships = link::wants_relationships(show)?;
43    let issue = get(store, raw)?;
44    print!("{}", issue.to_file_contents());
45    // The hierarchy block is **unconditional** — `contracts/cli-surface.md` §2 says
46    // "detail includes a parent block and, for a parent, its rollup", with no flag
47    // in the spelling, unlike the relationships block beside it. A parent is
48    // structural rather than extra detail, and behind a flag `tix show` on a child
49    // would silently omit the one fact that explains why it is a child.
50    //
51    // Every figure comes from the shared `HierarchyFacts`, so this block and
52    // `tix parent show` cannot describe different snapshots (parity contract §2).
53    {
54        let (index, issues, done_column) = crate::commands::parent::source(store)?;
55        let facts = crate::commands::parent::facts_for(&index, &issues, issue.number, &done_column);
56        print!("{}", crate::commands::parent::detail_block(&facts));
57    }
58    // The category block, **unconditional**, for the same reason the hierarchy
59    // block is: `contracts/cli-surface.md` §2 puts the category in the detail view
60    // with no flag, and the category is the answer to "what stage is this in?",
61    // which a status column alone does not give on a board whose columns and
62    // categories differ.
63    //
64    // Appended *after* the hierarchy block and before relationships, so the file
65    // comes first, then the structural facts, then the extra detail — the order a
66    // reader scans them in.
67    {
68        let (_, resolution) = crate::workflow::active_resolution(&store.config);
69        print!("{}", category_block(&issue, &resolution));
70    }
71    // The project block, **unconditional** (`contracts/cli-surface.md` §4: detail
72    // includes the issue's project references and their health context, with no
73    // flag in the spelling). It renders nothing at all for an issue in no project.
74    print!("{}", projects_block(store, &issue)?);
75    // The epic block, **unconditional**, for the same reason the project block is:
76    // `contracts/cli-surface.md` §3 puts the issue's epic references and their
77    // progress context in the detail view with no flag in the spelling. It renders
78    // nothing at all for an issue in no epic (T017 step 5).
79    print!("{}", epics_block(store, &issue)?);
80    // The mentions block (OpenSpec change `issue-mentions`, task 6.1): the pending
81    // mentions for the **local user** on this issue, so `tix view` surfaces a mention
82    // the way the TUI's notice does. Not behind a flag, for the reason the hierarchy
83    // and category blocks are not: a mention addressed to you is the one fact that
84    // explains why the issue is on your plate. It renders nothing at all when there are
85    // none, so `tix view <n> > copy.md` is unchanged for an unmentioned issue.
86    print!("{}", mentions_block(store, &issue)?);
87    if want_relationships {
88        // One graph for all three derived reads, so the relationships, the
89        // chain, and the blocked reason cannot describe different snapshots.
90        let (issues, done_column) = store.graph_source()?;
91        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
92        let graph = RelationshipGraph::new(&views, &done_column);
93        print!("{}", link::derived_block(&graph, issue.number));
94    }
95    Ok(())
96}
97
98/// The mentions block appended to `tix view`'s output (OpenSpec change
99/// `issue-mentions`).
100///
101/// Empty when the feature is off, when the local user's git identity is unknown, or when
102/// the issue carries no unread mention for them — the three cases in which there is
103/// nothing to say. Otherwise one row per mention, naming the id that `tix mentions read`
104/// takes, so the block is actionable rather than merely informative.
105pub fn mentions_block(store: &Store, issue: &Issue) -> Result<String> {
106    let Some(mentions) = store.mention_store() else {
107        return Ok(String::new());
108    };
109    let pending =
110        mentions.pending_for_issue(&crate::mentions::identity::local_candidates(), issue.number);
111    if pending.is_empty() {
112        return Ok(String::new());
113    }
114    let mut out = String::new();
115    let _ = std::fmt::Write::write_fmt(
116        &mut out,
117        format_args!("\nMentions for you ({}):\n\n", pending.len()),
118    );
119    for mention in &pending {
120        let _ = std::fmt::Write::write_fmt(
121            &mut out,
122            format_args!("  {}  {}\n", mention.id, mention.context),
123        );
124    }
125    let _ = std::fmt::Write::write_fmt(
126        &mut out,
127        format_args!("\n  act with: tix mentions read <id> | ignore <id> | jump <id>\n"),
128    );
129    Ok(out)
130}
131
132/// The derived category block appended to `tix show`'s output (WP08 T036).
133///
134/// Two lines, and the second is the one that matters:
135///
136/// - `category: <key> (<label>)` — the **key** because a script wants a stable
137///   word, the **label** because a human reads it. The list column shows the label
138///   only (`--show category` is a narrow column); the detail view has room for
139///   both, and `t035_the_category_label_is_identical_on_every_path` pins the label
140///   to the same string both render.
141/// - `workflow: <what this category means>` — `complete`, `terminal, not complete`,
142///   or `in progress`. T036 step 2 asks for the completion context precisely so a
143///   reader cannot conclude from a `canceled` issue that the work was finished:
144///   `canceled` is terminal and is **not** complete, and a detail view that said
145///   only `Canceled` would leave that to be guessed.
146///
147/// The label comes from the service's `label()`, never from a local string, and
148/// the *unmapped* case is spelled the way the list column spells it (`(unmapped)`)
149/// rather than blanked or turned into an error — T036 step 3.
150pub fn category_block(issue: &Issue, resolution: &crate::workflow::CategoryResolution) -> String {
151    let (key, label, meaning) = match crate::workflow::category_of(issue, resolution) {
152        Some(c) => {
153            let meaning = if c.is_complete() {
154                "complete"
155            } else if c.is_terminal() {
156                "terminal, not complete"
157            } else {
158                "in progress"
159            };
160            (c.key(), c.label(), meaning)
161        }
162        None => (
163            "(unmapped)",
164            "(unmapped)",
165            "unmapped column: this board configures no category for it",
166        ),
167    };
168    format!("\n## Workflow\n\n- category: {key} ({label})\n- workflow: {meaning}\n")
169}
170
171/// The typed project health values for one snapshot (WP04 T015/T016, C-006).
172///
173/// One [`ProjectIndex`] build and one health read per project, so every surface
174/// reads the same value for the same snapshot and no figure is computed twice. The
175/// consumed inputs are gathered here from the **existing** reads — the planning
176/// overdue read and the relationship graph's blocked state — and are never
177/// recomputed (research R4); `src/projects.rs` owns counting them.
178///
179/// `pub(crate)` because `tix list --show progress` reads the same value for the
180/// same snapshot, which is what makes the two surfaces agree by construction rather
181/// than by convention.
182pub(crate) fn project_reports(
183    store: &Store,
184    issues: &[Issue],
185) -> Result<(ProjectIndex, BTreeMap<ProjectId, ProjectHealthReport>)> {
186    let index = ProjectIndex::build(&store.config, issues);
187    let assigned = crate::projects::assigned_keys(issues);
188    let planner =
189        crate::planning::Planner::from_config(store.config.clone(), std::path::PathBuf::new());
190    let overdue: HashSet<u32> = planner
191        .overdue_batch(store, &crate::model::today())
192        .iter()
193        .map(|issue| issue.number)
194        .collect();
195    let blocked = blocked_keys(store)?;
196    let mut reports = BTreeMap::new();
197    for project in index.projects() {
198        let report = index.health(
199            &store.config,
200            project.id.as_str(),
201            &assigned,
202            &overdue,
203            &blocked,
204        )?;
205        reports.insert(project.id.clone(), report);
206    }
207    Ok((index, reports))
208}
209
210/// The blocked issue numbers of the snapshot, from the relationship service.
211///
212/// The derivation lives in `RelationshipGraph::blocked_state` and is **consumed**
213/// here, never reimplemented (research R4).
214fn blocked_keys(store: &Store) -> Result<HashSet<u32>> {
215    let (issues, done_column) = store.graph_source()?;
216    let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
217    let graph = RelationshipGraph::new(&views, &done_column);
218    Ok(issues
219        .iter()
220        .filter(|issue| graph.blocked_state(issue.number).is_blocked)
221        .map(|issue| issue.number)
222        .collect())
223}
224
225/// The shared rendering of one project's figures, so the detail block and the
226/// list column cannot word the same value differently (T019's cross-surface rule).
227pub(crate) fn health_suffix(report: &ProjectHealthReport) -> String {
228    format!(
229        " done, overdue {}, blocked {}, unassigned {}",
230        report.health.overdue, report.health.blocked, report.health.unassigned
231    )
232}
233
234/// One referenced epic as presentation rows (WP04 T017/T019, FR-003).
235///
236/// The **content** every surface renders: a heading that carries the epic's id and
237/// name, then one context line per fact. `tix show` and the TUI detail pane render
238/// these strings verbatim (the pane adds its own styling), so the two cannot word
239/// the same epic differently — `contracts/surface-parity.md` §2 permits a different
240/// *look*, never different *content*.
241///
242/// Every figure is a field of the service's [`crate::epics::EpicRollup`]; nothing
243/// here is recomputed (C-008).
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct EpicBlockEntry {
246    /// `Name (id)` for a resolved epic, or `<id> (missing reference)`.
247    pub heading: String,
248    /// The context lines, in one canonical order.
249    pub detail: Vec<String>,
250}
251
252/// One referenced epic, as the service resolved it for one issue (WP04 T017/T019).
253///
254/// The **single** resolution path every presentation surface reads: `tix show` and
255/// the TUI detail pane render [`epic_block_entry`] of each item, and `tix list`
256/// renders [`epic_cell`]. One definition, so a surface cannot resolve a reference a
257/// second way (C-002) and a missing reference cannot be dropped on one path but
258/// diagnosed on another (C-005).
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub(crate) enum EpicRefItem {
261    /// A reference the registry resolves: the service's own rollup and hierarchy.
262    Resolved(
263        Box<crate::epics::EpicRollup>,
264        Box<crate::epics::EpicHierarchy>,
265    ),
266    /// A reference that does not resolve, with the service's recorded reason.
267    Missing { id: String, reason: String },
268}
269
270/// The compact completion figure, read straight from the service's typed value.
271pub(crate) fn progress_figure(progress: &crate::epics::EpicProgress) -> String {
272    format!("{}/{} done", progress.done, progress.total)
273}
274
275/// The compact weight-aware figure, read straight from the service's typed value.
276pub(crate) fn weighted_figure(weighted: &crate::epics::WeightedProgress) -> String {
277    match weighted.percent {
278        Some(percent) => format!(
279            "{}/{} ({percent}%)",
280            weighted.done_weight, weighted.total_weight
281        ),
282        None => "none (no weighted children)".to_string(),
283    }
284}
285
286/// Resolve one issue's epic references against a pre-built snapshot (C-008).
287///
288/// The index, the targets and the graph are built **once** per snapshot by the
289/// callers below, so a listing resolves every row against one read (contract §7).
290/// The order is the issue's own recorded order, with duplicates collapsed exactly as
291/// the service buckets them (T007 step 7).
292///
293/// A reference that does not resolve carries the index's own recorded reason
294/// ([`crate::epics::EpicIndex::missing`]): this function never decides *why* a
295/// reference failed, the service already did. The fallback wording copies the
296/// service's functions for the one case the index cannot see — an archived issue,
297/// which `graph_source` excludes from its snapshot.
298pub(crate) fn epic_ref_items(
299    config: &crate::config::Config,
300    index: &crate::epics::EpicIndex,
301    targets: &crate::epics::ReferenceTargets,
302    graph: &RelationshipGraph<'_>,
303    issue: &Issue,
304) -> Result<Vec<EpicRefItem>> {
305    let Some(ids) = issue.epics.as_ref().filter(|ids| !ids.is_empty()) else {
306        return Ok(Vec::new());
307    };
308    let holder = crate::epics::ReferenceHolder::Issue(crate::epics::IssueKey::new(issue.number));
309    let mut out = Vec::new();
310    let mut seen: Vec<&str> = Vec::new();
311    for raw in ids {
312        if seen.contains(&raw.as_str()) {
313            continue;
314        }
315        seen.push(raw.as_str());
316        let resolved = crate::config::EpicId::new(raw)
317            .ok()
318            .filter(|id| index.epic(id).is_some());
319        match resolved {
320            Some(id) => {
321                let rollup = index
322                    .rollup(config, id.as_str(), targets, graph)
323                    .map_err(|err| crate::error::TixError::InvalidInput(err.message()))?;
324                let hierarchy = index
325                    .hierarchy(id.as_str())
326                    .map_err(|err| crate::error::TixError::InvalidInput(err.message()))?;
327                out.push(EpicRefItem::Resolved(Box::new(rollup), Box::new(hierarchy)));
328            }
329            None => {
330                let reason = index
331                    .missing()
332                    .iter()
333                    .find(|missing| missing.holder == holder && missing.target == raw.as_str())
334                    .map(|missing| missing.reason.clone())
335                    .unwrap_or_else(|| {
336                        if crate::config::EpicId::new(raw).is_ok() {
337                            crate::epics::missing_epic_reason(&holder, raw)
338                        } else {
339                            crate::epics::malformed_epic_reason(issue.number, raw)
340                        }
341                    });
342                out.push(EpicRefItem::Missing {
343                    id: raw.clone(),
344                    reason,
345                });
346            }
347        }
348    }
349    Ok(out)
350}
351
352/// The presentation rows for one item — the canonical content every surface renders.
353fn epic_block_entry(item: &EpicRefItem) -> EpicBlockEntry {
354    match item {
355        EpicRefItem::Missing { id, reason } => EpicBlockEntry {
356            heading: format!("{id} (missing reference)"),
357            detail: vec![reason.clone()],
358        },
359        EpicRefItem::Resolved(rollup, hierarchy) => resolved_entry(rollup, hierarchy),
360    }
361}
362
363/// The canonical rows for a resolved epic, from the service's own two values.
364///
365/// The one place the order and the wording are decided, so the issue detail block, the
366/// list cell and the TUI epic view cannot reword the same epic.
367fn resolved_entry(
368    rollup: &crate::epics::EpicRollup,
369    hierarchy: &crate::epics::EpicHierarchy,
370) -> EpicBlockEntry {
371    let mut detail = vec![
372        format!("progress: {}", progress_figure(&rollup.progress)),
373        format!(
374            "weighted progress: {}",
375            weighted_figure(&rollup.weighted_progress)
376        ),
377        format!(
378            "weight: {}",
379            rollup
380                .weighted_progress
381                .own_weight
382                .map_or_else(|| "unset".to_string(), |weight| weight.to_string())
383        ),
384        format!("children: {}", rollup.child_count),
385        match (&hierarchy.parent, &hierarchy.unresolved) {
386            (Some(parent), _) => format!("parent: {}", parent.display),
387            (None, Some(unresolved)) => format!(
388                "parent: {} (missing reference: {})",
389                unresolved.recorded_parent, unresolved.reason
390            ),
391            (None, None) => "parent: (none)".to_string(),
392        },
393        format!("depth: {}", hierarchy.depth),
394        format!("blocked: {}", rollup.blocked),
395    ];
396    for member in &rollup.blocked_members {
397        detail.push(format!("  {}: {}", member.display, member.reason));
398    }
399    for missing in &rollup.missing_references {
400        detail.push(format!(
401            "missing {} reference: {}",
402            missing.target_kind.as_str(),
403            missing.reason
404        ));
405    }
406    EpicBlockEntry {
407        heading: crate::epics::epic_display(&rollup.epic_id, &rollup.name),
408        detail,
409    }
410}
411
412/// The compact list cell for one item (WP04 T018, C-008).
413pub(crate) fn epic_cell(item: &EpicRefItem) -> String {
414    match item {
415        EpicRefItem::Missing { id, .. } => format!("{id} (missing reference)"),
416        EpicRefItem::Resolved(rollup, _) => format!(
417            "{} {}, weighted {}, blocked {}",
418            crate::epics::epic_display(&rollup.epic_id, &rollup.name),
419            progress_figure(&rollup.progress),
420            weighted_figure(&rollup.weighted_progress),
421            rollup.blocked,
422        ),
423    }
424}
425
426/// [`epic_entries`], over a snapshot the caller already holds (WP04 T019, C-008).
427///
428/// `App::detail_state_for` already has the board snapshot in hand for the graph and
429/// the hierarchy rows, so re-reading it here would be a third full directory walk for
430/// one pane open. `snapshot` and `done_column` are exactly what
431/// [`crate::storage::Store::graph_source`] returns, so the two entry points resolve the
432/// same values.
433pub(crate) fn epic_entries_over(
434    config: &crate::config::Config,
435    snapshot: &[Issue],
436    done_column: &str,
437    issue: &Issue,
438) -> Result<Vec<EpicBlockEntry>> {
439    let index = crate::epics::EpicIndex::build(config, snapshot);
440    let planner = crate::planning::Planner::from_config(config.clone(), std::path::PathBuf::new());
441    let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
442    let graph = crate::commands::graph_over(snapshot, done_column);
443    let items = epic_ref_items(config, &index, &targets, &graph, issue)?;
444    Ok(items.iter().map(epic_block_entry).collect())
445}
446
447/// The issue's referenced epics as presentation rows (WP04 T017/T019, FR-003).
448///
449/// One service read per epic, from the **one** [`crate::epics::EpicIndex`] built
450/// over the snapshot, so `tix show` and the TUI detail pane read the same values for
451/// the same snapshot.
452pub fn epic_entries(store: &Store, issue: &Issue) -> Result<Vec<EpicBlockEntry>> {
453    // One snapshot for the index and the graph, so a member's blocked state and the
454    // epic's membership cannot describe different reads of the same board.
455    let (issues, done_column) = store.graph_source()?;
456    epic_entries_over(&store.config, &issues, &done_column, issue)
457}
458
459/// Every issue's epic cell, read from the service in one pass (WP04 T018, FR-003).
460///
461/// The same snapshot shape [`epic_entries`] reads for one issue: the index, the
462/// targets and the graph are built once for the whole listing, and each row's cell is
463/// the shared [`epic_cell`] of the items [`epic_ref_items`] resolved — so a row can
464/// never report a figure that disagrees with `tix show` for the same snapshot
465/// (C-008). An issue in no epic has no entry (rendered `-`), and a reference that no
466/// longer resolves renders its recorded id and `(missing reference)`, so the two
467/// empty-looking cases stay distinguishable.
468pub(crate) fn epic_cells(store: &Store, rows: &[Issue]) -> Result<BTreeMap<u32, String>> {
469    // The figures are read from the **full** snapshot, not from the listed slice: an
470    // epic's progress is a property of the epic, and a `--status`/`--filter`
471    // narrowing must not silently reduce it (the rule `project_cells` follows).
472    let (snapshot, done_column) = store.graph_source()?;
473    let index = crate::epics::EpicIndex::build(&store.config, &snapshot);
474    let planner =
475        crate::planning::Planner::from_config(store.config.clone(), std::path::PathBuf::new());
476    let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
477    let graph = crate::commands::graph_over(&snapshot, &done_column);
478    let mut out = BTreeMap::new();
479    for issue in rows {
480        let items = epic_ref_items(&store.config, &index, &targets, &graph, issue)?;
481        if items.is_empty() {
482            continue;
483        }
484        out.insert(
485            issue.number,
486            items.iter().map(epic_cell).collect::<Vec<_>>().join("; "),
487        );
488    }
489    Ok(out)
490}
491
492/// One member of an epic, for the TUI epic view's direct issue navigation (FR-004).
493#[derive(Debug, Clone, PartialEq, Eq)]
494pub struct EpicMemberRef {
495    /// The member issue's number, the selector a surface opens it by.
496    pub number: u32,
497    /// `#<number> <title>`, the service's display label.
498    pub display: String,
499}
500
501/// One epic as the TUI epic view presents it (WP04 T020, FR-003).
502///
503/// `epic` is the **same** canonical row `tix show` and the detail pane render, so the
504/// three surfaces cannot disagree about one epic's figures (C-008); `members` is the
505/// derived membership the service returned, so a member's issue opens directly
506/// without the epic layer (FR-004).
507#[derive(Debug, Clone, PartialEq, Eq)]
508pub struct EpicViewEntry {
509    /// Heading and context lines, from [`resolved_entry`].
510    pub epic: EpicBlockEntry,
511    /// The epic's members, in the service's order.
512    pub members: Vec<EpicMemberRef>,
513}
514
515/// Every epic, with its context lines and its member list (WP04 T020, FR-003/FR-004).
516///
517/// Built in **declaration order** (the service's [`crate::epics::EpicIndex::epics`]),
518/// from one index over one snapshot, so the view's order is deterministic and its
519/// figures equal `tix show`'s for the same snapshot. A task-labelled member is a member
520/// like any other: the renderer has no task section, because there is no task entity
521/// (C-001).
522pub fn epic_view(store: &Store) -> Result<Vec<EpicViewEntry>> {
523    // The **active** issue set: `graph_source` is `Store::list`, which excludes
524    // archived issues. A surface that mirrors `tix epic list` / `tix epic show`
525    // (which read `Store::list_all`) must call [`epic_view_over`] with that set
526    // instead, or an archived member's count and list would disagree with the CLI.
527    let (snapshot, done_column) = store.graph_source()?;
528    epic_view_over(&store.config, &snapshot, &done_column)
529}
530
531/// [`epic_view`] over a snapshot the caller already holds (WP06).
532///
533/// Same derivation, same order, same content — the epic index, the targets, the
534/// graph and every entry are built from the `issues` and `done_column` handed in, so
535/// a caller that mirrors a CLI listing can pass the **same** set that listing reads
536/// (`Store::list_all`, archived issues included) and the two cannot disagree about a
537/// member count or a member list (C-008). `epic_view` is the `graph_source`
538/// (active-only) shorthand for callers with no such constraint.
539pub fn epic_view_over(
540    config: &crate::config::Config,
541    issues: &[Issue],
542    done_column: &str,
543) -> Result<Vec<EpicViewEntry>> {
544    let index = crate::epics::EpicIndex::build(config, issues);
545    let planner = crate::planning::Planner::from_config(config.clone(), std::path::PathBuf::new());
546    let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
547    let graph = crate::commands::graph_over(issues, done_column);
548    let mut out = Vec::new();
549    for epic in index.epics() {
550        let id = epic.id.clone();
551        let rollup = index
552            .rollup(config, id.as_str(), &targets, &graph)
553            .map_err(|err| crate::error::TixError::InvalidInput(err.message()))?;
554        let hierarchy = index
555            .hierarchy(id.as_str())
556            .map_err(|err| crate::error::TixError::InvalidInput(err.message()))?;
557        let members = epic
558            .members
559            .iter()
560            .map(|member| EpicMemberRef {
561                number: member.issue.number(),
562                display: member.display.clone(),
563            })
564            .collect();
565        out.push(EpicViewEntry {
566            epic: resolved_entry(&rollup, &hierarchy),
567            members,
568        });
569    }
570    Ok(out)
571}
572
573/// The epic block appended to `tix show`'s output (WP04 T017, FR-003, C-005).
574///
575/// One line per referenced epic, carrying the **id**, the name, and the rollup
576/// context. Every figure comes from the service's typed value ([`epic_entries`]) and
577/// none is recomputed here (C-008). A reference that no longer resolves is rendered
578/// explicitly with the recorded id and the service's reason rather than silently
579/// dropped (C-005).
580///
581/// The heading appears **only** when the issue carries at least one reference: an
582/// issue in no epic renders nothing at all, not an empty heading.
583pub fn epics_block(store: &Store, issue: &Issue) -> Result<String> {
584    let entries = epic_entries(store, issue)?;
585    if entries.is_empty() {
586        return Ok(String::new());
587    }
588    let mut out = String::from("\n## Epics\n\n");
589    for entry in entries {
590        out.push_str(&format!("- {}\n", entry.heading));
591        for line in entry.detail {
592            out.push_str(&format!("  - {line}\n"));
593        }
594    }
595    Ok(out)
596}
597
598/// The project block appended to `tix show`'s output (WP04 T017, C-005).
599///
600/// One line per association, carrying the **id**, the name, and the health context.
601/// Every figure comes from the service's typed value ([`project_reports`]) and none
602/// is recomputed here (C-006). An association that no longer resolves is rendered
603/// explicitly with the recorded id and the service's reason rather than silently
604/// dropped (C-005).
605///
606/// The heading appears **only** when the issue carries at least one association: an
607/// issue in no project renders nothing at all, not an empty heading.
608pub fn projects_block(store: &Store, issue: &Issue) -> Result<String> {
609    let Some(ids) = issue.projects.as_ref().filter(|ids| !ids.is_empty()) else {
610        return Ok(String::new());
611    };
612    let issues = store.list()?;
613    let (_index, reports) = project_reports(store, &issues)?;
614    let mut out = String::from("\n## Projects\n\n");
615    let mut seen: Vec<&str> = Vec::new();
616    for raw in ids {
617        if seen.contains(&raw.as_str()) {
618            continue;
619        }
620        seen.push(raw.as_str());
621        match ProjectId::new(raw).ok().and_then(|id| reports.get(&id)) {
622            Some(report) => out.push_str(&format!(
623                "- {} {}/{}{}\n",
624                crate::projects::project_display(&report.project_id, &report.name),
625                report.progress.done,
626                report.progress.total,
627                health_suffix(report),
628            )),
629            None => out.push_str(&format!(
630                "- {raw} (missing reference): {}\n",
631                crate::projects::missing_reference_reason(issue.number, raw)
632            )),
633        }
634    }
635    Ok(out)
636}
637
638/// The view core (WP03): resolve `<issue>` and load the [`Issue`] record.
639///
640/// This is what MCP's `get_issue` tool (WP04) calls. Side-effect policy
641/// (C-005): the core never reads `stdin`/writes `stdout`; the CLI printer in
642/// [`run`] renders the record. Selector resolution and ambiguity handling
643/// reuse `commands/mod.rs` unchanged (Directive 024).
644pub fn get(store: &Store, raw: &str) -> Result<Issue> {
645    let selector = parse_selector(store, raw)?;
646    store.get(selector)
647}
648
649#[cfg(test)]
650mod tests {
651    #[allow(clippy::wildcard_imports)]
652    use super::*;
653    use crate::model::{Issue, Priority, Status};
654    use std::fs;
655    use tempfile::TempDir;
656
657    fn make_store() -> (TempDir, Store) {
658        let tmp = TempDir::new().unwrap();
659        let dir = tmp.path().join("issues");
660        fs::create_dir_all(dir.join("alice")).unwrap();
661        let config_path = tmp.path().join("tix.toml");
662        let config = crate::config::Config {
663            issues_dir: dir.clone(),
664            ..crate::config::Config::default()
665        };
666        config.save(&config_path).unwrap();
667        let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
668        (
669            tmp,
670            Store {
671                issues_dir: dir,
672                config,
673            },
674        )
675    }
676
677    fn seed(store: &Store, number: u32, title: &str) -> Issue {
678        let mut issue = Issue {
679            projects: None,
680            id: None,
681            number,
682            title: title.to_string(),
683            status: Status::new("todo"),
684            labels: vec![],
685            priority: Priority::default(),
686            assignee: Some("alice".to_string()),
687            estimate: None,
688            commit: None,
689            gitlab_id: None,
690            linear_id: None,
691            mission: None,
692            task: None,
693            relationships: vec![],
694            related_issues: vec![],
695            files: vec![],
696            cycles: None,
697            milestones: None,
698            reviewer: None,
699            start_date: None,
700            due_date: None,
701            created: "2026-01-01".to_string(),
702            type_: None,
703            updated: "2026-01-01".to_string(),
704            filename: String::new(),
705            body: "the body".to_string(),
706            history: crate::model::History::new(),
707            has_retired_links_key: false,
708            time_entries: vec![],
709            parent: None,
710            series: None,
711            health: None,
712            epics: None,
713            extra_frontmatter: std::collections::BTreeMap::new(),
714            archived: false,
715            entered_at: None,
716            source: None,
717        };
718        issue.filename = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
719        fs::write(
720            store.issues_dir.join(&issue.filename),
721            issue.to_file_contents(),
722        )
723        .unwrap();
724        issue
725    }
726
727    #[test]
728    fn get_returns_the_full_record() {
729        let (tmp, store) = make_store();
730        let _ = tmp;
731        let seeded = seed(&store, 1, "Get me");
732        let issue = get(&store, &seeded.number.to_string()).unwrap();
733        assert_eq!(issue.title, "Get me");
734        assert_eq!(issue.body, "the body");
735    }
736
737    #[test]
738    fn get_selector_miss_is_not_found() {
739        let (tmp, store) = make_store();
740        let _ = tmp;
741        let err = get(&store, "999").unwrap_err();
742        assert!(
743            matches!(err, crate::error::TixError::NotFound(_)),
744            "missing issue must surface as NotFound, got {err}"
745        );
746    }
747
748    #[test]
749    fn get_ambiguous_selector_is_an_error() {
750        let (tmp, store) = make_store();
751        let _ = tmp;
752        seed(&store, 1, "Shared title");
753        seed(&store, 2, "Shared title other");
754        // A bare filename fragment matches nothing; a title fragment matching
755        // two issues is ambiguous per contracts/cli.md.
756        let err = get(&store, "Shared").unwrap_err();
757        assert!(
758            matches!(err, crate::error::TixError::InvalidInput(_)),
759            "ambiguous selector must be an InvalidInput error, got {err}"
760        );
761    }
762
763    // ---- WP03: `--show relationships` --------------------------------
764
765    #[test]
766    fn show_relationships_is_off_by_default_so_the_output_is_still_the_file() {
767        // The reason the block is behind a flag: `tix view <n> > copy.md` has to
768        // keep producing a valid issue file.
769        let (tmp, store) = make_store();
770        let _ = tmp;
771        let seeded = seed(&store, 1, "Plain");
772        let on_disk = fs::read_to_string(store.issues_dir.join(&seeded.filename)).unwrap();
773        assert_eq!(
774            get(&store, "1").unwrap().to_file_contents(),
775            on_disk,
776            "precondition: the record round-trips to exactly the file's bytes"
777        );
778        assert!(!link::wants_relationships(None).unwrap());
779    }
780
781    #[test]
782    fn the_derived_block_shows_an_edge_stored_on_the_other_endpoint() {
783        // The regression `Store::relationships_for` was written to prevent: a
784        // relationship is stored **once**, in its canonical source's file, so
785        // reading this issue's own `relationships` field would show nothing
786        // where the service reports one.
787        let (tmp, store) = make_store();
788        let _ = tmp;
789        let source = seed(&store, 1, "Source");
790        let target = seed(&store, 2, "Target");
791        store
792            .link(
793                &source.number.to_string(),
794                &target.number.to_string(),
795                crate::relationships::RelationKind::Blocks,
796            )
797            .unwrap();
798
799        // The target's own stored field is empty — the edge lives on the source.
800        let target_record = get(&store, "2").unwrap();
801        assert!(
802            target_record.relationships.is_empty(),
803            "precondition: the edge is stored once, on the source"
804        );
805
806        let (issues, done_column) = store.graph_source().unwrap();
807        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
808        let graph = RelationshipGraph::new(&views, &done_column);
809        let block = link::derived_block(&graph, target_record.number);
810
811        assert!(
812            block.contains("Source"),
813            "the target must see the edge that names it:\n{block}"
814        );
815        assert!(
816            block.contains("blocked"),
817            "and the blocked reason follows from it:\n{block}"
818        );
819    }
820    // ----------------------------------------------------------------------
821    // WP08 T036 — the category in issue detail
822    // ----------------------------------------------------------------------
823
824    /// A store on a **mapped** board, so a category exists to render.
825    fn mapped_store(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> (TempDir, Store) {
826        let tmp = TempDir::new().unwrap();
827        let dir = tmp.path().join("issues");
828        fs::create_dir_all(dir.join("alice")).unwrap();
829        let config = crate::config::Config {
830            issues_dir: dir.clone(),
831            kanban: crate::config::KanbanSection {
832                configurations: indexmap::IndexMap::from([(
833                    "default".to_string(),
834                    crate::config::BoardConfiguration {
835                        columns: columns.iter().map(|c| (*c).to_string()).collect(),
836                        done_column: Some(done_column.to_string()),
837                        category_map: map
838                            .iter()
839                            .map(|(c, k)| ((*c).to_string(), (*k).to_string()))
840                            .collect(),
841                        ..Default::default()
842                    },
843                )]),
844            },
845            ..crate::config::Config::default()
846        };
847        config.save(&tmp.path().join("tix.toml")).unwrap();
848        let config =
849            crate::config::Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
850        (
851            tmp,
852            Store {
853                issues_dir: dir,
854                config,
855            },
856        )
857    }
858
859    fn five_categories() -> (TempDir, Store) {
860        mapped_store(
861            &["triage", "wip", "review", "shipped", "abandoned"],
862            "shipped",
863            &[
864                ("triage", "backlog"),
865                ("wip", "active"),
866                ("review", "active"),
867                ("shipped", "completed"),
868                ("abandoned", "canceled"),
869            ],
870        )
871    }
872
873    fn write(store: &Store, number: u32, title: &str, column: &str) {
874        let slug = crate::slug::slugify(title);
875        fs::write(
876            store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
877            format!(
878                "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{column}\"\n\
879                 labels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\n\
880                 updated: \"2026-01-01\"\n---\n\nbody\n"
881            ),
882        )
883        .unwrap();
884    }
885
886    #[test]
887    fn t036_the_detail_block_carries_the_key_the_label_and_the_completion_context() {
888        let (_tmp, store) = five_categories();
889        write(&store, 1, "Shipped", "shipped");
890        write(&store, 2, "Abandoned", "abandoned");
891        write(&store, 3, "Wip", "wip");
892        let (_, resolution) = crate::workflow::active_resolution(&store.config);
893        for (selector, key, label, meaning) in [
894            ("1", "completed", "Done", "complete"),
895            ("2", "canceled", "Canceled", "terminal, not complete"),
896            ("3", "active", "In progress", "in progress"),
897        ] {
898            let issue = crate::commands::view::get(&store, selector).unwrap();
899            let block = crate::commands::view::category_block(&issue, &resolution);
900            assert!(
901                block.contains(&format!("- category: {key} ({label})")),
902                "{selector}: {block}"
903            );
904            assert!(
905                block.contains(&format!("- workflow: {meaning}")),
906                "{selector}: {block}"
907            );
908        }
909    }
910
911    #[test]
912    fn t036_a_canceled_issue_never_reads_as_complete() {
913        // T036 step 2, the reason the second line exists at all. A detail view
914        // saying only `Canceled` leaves a reader to guess whether the work was
915        // finished, and this is the C-004 rule made visible.
916        let (_tmp, store) = five_categories();
917        write(&store, 1, "Abandoned", "abandoned");
918        let (_, resolution) = crate::workflow::active_resolution(&store.config);
919        let issue = crate::commands::view::get(&store, "1").unwrap();
920        let block = crate::commands::view::category_block(&issue, &resolution);
921        assert!(block.contains("terminal, not complete"), "{block}");
922        assert!(
923            !block.contains("workflow: complete"),
924            "a canceled issue must never say complete: {block}"
925        );
926    }
927
928    #[test]
929    fn t036_an_unmapped_column_shows_a_label_not_an_error_and_not_a_blank() {
930        // Two distinct cases, and the block must not conflate them.
931        //  - configured but unmapped → the safe default's label
932        //  - not configured at all    → `(unmapped)`, spelled as the list column
933        //                                 spells it
934        let (_tmp, store) = mapped_store(
935            &["triage", "wip", "shipped"],
936            "shipped",
937            &[("wip", "active"), ("shipped", "completed")],
938        );
939        write(&store, 1, "Defaulted", "triage");
940        write(&store, 2, "Orphan", "not-a-column");
941        let (_, resolution) = crate::workflow::active_resolution(&store.config);
942        let defaulted = crate::commands::view::get(&store, "1").unwrap();
943        let block = crate::commands::view::category_block(&defaulted, &resolution);
944        assert!(block.contains("unstarted (Not started)"), "{block}");
945        let orphan = crate::commands::view::get(&store, "2").unwrap();
946        let block = crate::commands::view::category_block(&orphan, &resolution);
947        assert!(
948            block.contains("category: (unmapped) ((unmapped))"),
949            "{block}"
950        );
951        assert!(
952            block.contains("this board configures no category for it"),
953            "an unmapped column is stated, not left blank: {block}"
954        );
955    }
956
957    // ---- WP04 T017/T019: the project block and cross-surface parity ---------
958
959    /// A store whose registry holds `(id, name)` projects and whose default board
960    /// is the shipped one.
961    fn project_store(projects: &[(&str, &str)]) -> (TempDir, Store) {
962        let tmp = TempDir::new().unwrap();
963        let dir = tmp.path().join("issues");
964        fs::create_dir_all(dir.join("alice")).unwrap();
965        let mut config = crate::config::Config {
966            issues_dir: dir.clone(),
967            ..crate::config::Config::default()
968        };
969        for (id, name) in projects {
970            config.projects.insert(
971                (*id).to_string(),
972                crate::config::ProjectConfiguration {
973                    name: (*name).to_string(),
974                    ..crate::config::ProjectConfiguration::default()
975                },
976            );
977        }
978        (
979            tmp,
980            Store {
981                issues_dir: dir,
982                config,
983            },
984        )
985    }
986
987    /// The issue with its associations and assignee set, written back to disk.
988    fn rewrite(store: &Store, mut issue: Issue, projects: &[&str], assignee: Option<&str>) {
989        issue.projects = Some(projects.iter().map(|p| (*p).to_string()).collect());
990        issue.assignee = assignee.map(str::to_string);
991        fs::write(
992            store.issues_dir.join(&issue.filename),
993            issue.to_file_contents(),
994        )
995        .unwrap();
996    }
997
998    #[test]
999    fn t017_the_project_block_shows_both_projects_with_id_name_and_health() {
1000        let (_tmp, store) = project_store(&[("p-a", "Platform"), ("p-b", "Docs")]);
1001        rewrite(
1002            &store,
1003            seed(&store, 1, "Shared"),
1004            &["p-a", "p-b"],
1005            Some("alice"),
1006        );
1007        let loaded = get(&store, "1").unwrap();
1008        let block = projects_block(&store, &loaded).unwrap();
1009        assert!(block.contains("## Projects"), "{block}");
1010        assert!(
1011            block.contains("- Platform (p-a) 0/1 done, overdue 0, blocked 0, unassigned 0"),
1012            "the id, the name, and every figure are rendered: {block}"
1013        );
1014        assert!(block.contains("- Docs (p-b) 0/1 done"), "{block}");
1015    }
1016
1017    #[test]
1018    fn t017_an_issue_in_no_project_renders_no_project_heading() {
1019        let (_tmp, store) = project_store(&[("p-a", "Platform")]);
1020        seed(&store, 1, "None");
1021        let loaded = get(&store, "1").unwrap();
1022        assert_eq!(
1023            projects_block(&store, &loaded).unwrap(),
1024            "",
1025            "an empty association list is the empty state, not an empty heading"
1026        );
1027    }
1028
1029    #[test]
1030    fn t017_a_missing_reference_is_rendered_with_its_id_and_reason() {
1031        let (_tmp, store) = project_store(&[("p-a", "Platform")]);
1032        rewrite(
1033            &store,
1034            seed(&store, 1, "Dangling"),
1035            &["p-a", "p-gone"],
1036            Some("alice"),
1037        );
1038        let loaded = get(&store, "1").unwrap();
1039        let block = projects_block(&store, &loaded).unwrap();
1040        assert!(
1041            block.contains("- p-gone (missing reference):"),
1042            "a dangling association is named, not dropped: {block}"
1043        );
1044        assert!(
1045            block.contains("not in the registry"),
1046            "the service's reason is rendered: {block}"
1047        );
1048        assert!(
1049            block.contains("Platform (p-a)"),
1050            "the resolvable association is still shown: {block}"
1051        );
1052    }
1053
1054    #[test]
1055    fn t019_c006_show_and_list_report_the_same_figures_for_one_snapshot() {
1056        // What this proves: the two surfaces agree on the figures for one snapshot,
1057        // because both read the service's typed value. What it cannot prove: the
1058        // **absence** of a second computation — a test cannot observe that. The
1059        // review check for that is recorded in the WP04 report.
1060        let (_tmp, store) = project_store(&[("p-a", "Platform")]);
1061        let mut done = seed(&store, 1, "Done");
1062        done.status = crate::model::Status::new("done");
1063        rewrite(&store, done, &["p-a"], Some("alice"));
1064        rewrite(&store, seed(&store, 2, "Open"), &["p-a"], None);
1065
1066        let loaded = get(&store, "1").unwrap();
1067        let block = projects_block(&store, &loaded).unwrap();
1068        let issues = store.list().unwrap();
1069        let cells = crate::commands::list::project_cells(&store, &issues).unwrap();
1070        let figure = "1/2 done, overdue 0, blocked 0, unassigned 1";
1071        assert!(block.contains(figure), "`tix show` must report it: {block}");
1072        let cell = cells.get(&1).expect("issue #1 belongs to a project");
1073        assert!(
1074            cell.contains(figure),
1075            "`tix list --show progress` must report the same value: {cell}"
1076        );
1077    }
1078
1079    // ---- WP04 T017/T021: the epic block, its parity and its determinism ------
1080
1081    /// A store with an epic registry `(id, name, weight, parent)` and the default board.
1082    fn epic_store(epics: &[(&str, &str, Option<i64>, Option<&str>)]) -> (TempDir, Store) {
1083        let (tmp, mut store) = make_store();
1084        for (id, name, weight, parent) in epics {
1085            store.config.epics.insert(
1086                (*id).to_string(),
1087                crate::config::EpicConfiguration {
1088                    name: (*name).to_string(),
1089                    weight: *weight,
1090                    parent: parent.map(str::to_string),
1091                    ..crate::config::EpicConfiguration::default()
1092                },
1093            );
1094        }
1095        (tmp, store)
1096    }
1097
1098    /// Write `issue` back with its epic references set.
1099    fn with_epics(store: &Store, mut issue: Issue, epics: &[&str]) -> Issue {
1100        issue.epics = Some(epics.iter().map(|id| (*id).to_string()).collect());
1101        fs::write(
1102            store.issues_dir.join(&issue.filename),
1103            issue.to_file_contents(),
1104        )
1105        .unwrap();
1106        issue
1107    }
1108
1109    /// Write `issue` back with its status set.
1110    fn with_status(store: &Store, mut issue: Issue, status: &str) -> Issue {
1111        issue.status = crate::model::Status::new(status);
1112        fs::write(
1113            store.issues_dir.join(&issue.filename),
1114            issue.to_file_contents(),
1115        )
1116        .unwrap();
1117        issue
1118    }
1119
1120    #[test]
1121    fn t017_the_epic_block_shows_both_epics_with_id_name_and_rollup() {
1122        let (_tmp, store) = epic_store(&[
1123            ("e-aaa111", "Alpha", None, None),
1124            ("e-bbb222", "Beta", None, None),
1125        ]);
1126        let issue = with_epics(&store, seed(&store, 1, "Shared"), &["e-aaa111", "e-bbb222"]);
1127        let loaded = get(&store, &issue.number.to_string()).unwrap();
1128        let block = epics_block(&store, &loaded).unwrap();
1129        assert!(block.contains("## Epics"), "{block}");
1130        assert!(
1131            block.contains("- Alpha (e-aaa111)"),
1132            "the id and the name render: {block}"
1133        );
1134        assert!(block.contains("- Beta (e-bbb222)"), "{block}");
1135        assert!(
1136            block.contains("progress: 0/1 done"),
1137            "the rollup figures render: {block}"
1138        );
1139        assert!(block.contains("blocked: 0"), "{block}");
1140        let alpha = block.find("Alpha").unwrap();
1141        let beta = block.find("Beta").unwrap();
1142        assert!(
1143            alpha < beta,
1144            "the issue's own reference order is kept: {block}"
1145        );
1146    }
1147
1148    #[test]
1149    fn t017_an_issue_in_no_epic_renders_no_epic_heading() {
1150        let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
1151        seed(&store, 1, "None");
1152        let loaded = get(&store, "1").unwrap();
1153        assert_eq!(
1154            epics_block(&store, &loaded).unwrap(),
1155            "",
1156            "an empty reference list is the empty state, not an empty heading"
1157        );
1158    }
1159
1160    #[test]
1161    fn t017_a_missing_epic_reference_is_rendered_with_its_id_and_reason() {
1162        let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
1163        let issue = with_epics(
1164            &store,
1165            seed(&store, 1, "Dangling"),
1166            &["e-aaa111", "e-gone1", "bad.epic"],
1167        );
1168        let loaded = get(&store, &issue.number.to_string()).unwrap();
1169        let block = epics_block(&store, &loaded).unwrap();
1170        assert!(
1171            block.contains("- e-gone1 (missing reference)"),
1172            "a dangling reference is named, not dropped: {block}"
1173        );
1174        assert!(
1175            block.contains("not in the registry"),
1176            "the service's reason is rendered: {block}"
1177        );
1178        assert!(
1179            block.contains("- bad.epic (missing reference)"),
1180            "an entry that is not a well-formed id is named too: {block}"
1181        );
1182        assert!(block.contains("not a well-formed epic id"), "{block}");
1183        assert!(
1184            block.contains("Alpha (e-aaa111)"),
1185            "the resolvable reference is still shown: {block}"
1186        );
1187    }
1188
1189    #[test]
1190    fn t017_a_task_labelled_issue_shows_its_epic_like_any_other() {
1191        // C-001 in the UI, not only in the model: a `task` label changes nothing about
1192        // the issue's presence as an epic member, and there is no task section anywhere.
1193        let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
1194        let mut issue = seed(&store, 1, "A task");
1195        issue.labels = vec!["task".to_string()];
1196        let issue = with_epics(&store, issue, &["e-aaa111"]);
1197        let loaded = get(&store, &issue.number.to_string()).unwrap();
1198        let block = epics_block(&store, &loaded).unwrap();
1199        assert!(block.contains("Alpha (e-aaa111)"), "{block}");
1200        assert!(
1201            !block.contains("task"),
1202            "no task heading or task section exists (C-001): {block}"
1203        );
1204    }
1205
1206    /// The fixed parity fixture: Alpha (weight 5) has child Beta (weight 2); #1 (done)
1207    /// references both, and #2 (todo) references Alpha. Alpha therefore reads `1/2`
1208    /// members done and `2/2 (100%)` weighted, and Beta is complete.
1209    fn epic_parity_store() -> (TempDir, Store) {
1210        let (_tmp, store) = epic_store(&[
1211            ("e-aaa111", "Alpha", Some(5), None),
1212            ("e-bbb222", "Beta", Some(2), Some("e-aaa111")),
1213        ]);
1214        let one = with_epics(&store, seed(&store, 1, "Done"), &["e-aaa111", "e-bbb222"]);
1215        with_status(&store, one, "done");
1216        with_epics(&store, seed(&store, 2, "Open"), &["e-aaa111"]);
1217        (_tmp, store)
1218    }
1219
1220    #[test]
1221    fn t021_c008_the_epic_figures_are_equal_across_show_list_and_the_tui_paths() {
1222        // What this proves: the CLI block, the listing cell, the TUI-data rows and the
1223        // epic view agree on one snapshot's figures, because each reads the one service
1224        // value. What it **cannot** prove: the *absence* of a second computation, or
1225        // that the pane is fed correctly — a test cannot observe either. The first is a
1226        // review check (WP04 report); the second lives in
1227        // `tests/tui_state.rs::t019_c008_the_detail_pane_reads_its_epic_block_through_the_real_app_builder`,
1228        // which drives the real `App::open_issue_by_filename` and the real pane.
1229        let (_tmp, store) = epic_parity_store();
1230        let loaded = get(&store, "2").unwrap();
1231        let block = epics_block(&store, &loaded).unwrap();
1232        let issues = store.list().unwrap();
1233        let cells = epic_cells(&store, &issues).unwrap();
1234        let cell = cells.get(&2).expect("#2 references Alpha");
1235        // `epic_entries` is `epics_block`'s own input, so this leg compares the block
1236        // against its source, not against a separately-populated pane; it says what it
1237        // proves (the show/list figures are the service's) and no more.
1238        let entries = epic_entries(&store, &loaded).unwrap();
1239        let view = crate::commands::view::epic_view(&store).unwrap();
1240
1241        // The correctness anchor: the exact figures, so the equality below is not
1242        // satisfied by rendering the same constant everywhere.
1243        for text in [block.as_str(), cell.as_str()] {
1244            assert!(text.contains("1/2 done"), "{text}");
1245            assert!(text.contains("2/2 (100%)"), "{text}");
1246        }
1247        assert_eq!(entries.len(), 1, "issue #2 references exactly Alpha");
1248        assert_eq!(entries[0].heading, "Alpha (e-aaa111)");
1249        for line in [
1250            "progress: 1/2 done",
1251            "weighted progress: 2/2 (100%)",
1252            "weight: 5",
1253            "children: 1",
1254            "blocked: 0",
1255        ] {
1256            assert!(
1257                entries[0].detail.iter().any(|detail| detail == line),
1258                "the TUI detail path must carry `{line}`: {:?}",
1259                entries[0].detail
1260            );
1261        }
1262
1263        // The TUI epic view: declaration order, and the same figures for Alpha.
1264        assert_eq!(
1265            view.first().map(|entry| entry.epic.heading.as_str()),
1266            Some("Alpha (e-aaa111)"),
1267            "epics render in declaration order"
1268        );
1269        let alpha = view
1270            .iter()
1271            .find(|entry| entry.epic.heading.starts_with("Alpha"))
1272            .expect("Alpha is declared");
1273        for line in ["progress: 1/2 done", "weighted progress: 2/2 (100%)"] {
1274            assert!(
1275                alpha.epic.detail.iter().any(|detail| detail == line),
1276                "the epic view must carry `{line}`: {:?}",
1277                alpha.epic.detail
1278            );
1279        }
1280        // And the widget renders those canonical lines, so the equality reaches the
1281        // drawn surface rather than only the data behind it.
1282        let state = crate::ui::planning::EpicViewState::new();
1283        let lines = crate::ui::planning::epic_lines(&view, &state, 40);
1284        let text = lines
1285            .iter()
1286            .map(|line| {
1287                line.spans
1288                    .iter()
1289                    .map(|span| span.content.as_ref())
1290                    .collect::<String>()
1291            })
1292            .collect::<Vec<_>>()
1293            .join("\n");
1294        assert!(text.contains("progress: 1/2 done"), "{text}");
1295        assert!(text.contains("weighted progress: 2/2 (100%)"), "{text}");
1296    }
1297
1298    #[test]
1299    fn t021_the_epic_figures_are_deterministic_and_stable_under_a_no_op() {
1300        let (_tmp, store) = epic_parity_store();
1301        let loaded = get(&store, "2").unwrap();
1302        let first = epic_entries(&store, &loaded).unwrap();
1303        let first_cells = epic_cells(&store, &store.list().unwrap()).unwrap();
1304        let first_view = crate::commands::view::epic_view(&store).unwrap();
1305        // The correctness anchor: determinism is only meaningful if the value is right,
1306        // so this is asserted before the loop rather than being implied by it.
1307        assert!(
1308            first[0]
1309                .detail
1310                .iter()
1311                .any(|line| line == "progress: 1/2 done")
1312        );
1313        assert!(
1314            first[0]
1315                .detail
1316                .iter()
1317                .any(|line| line == "weighted progress: 2/2 (100%)")
1318        );
1319
1320        for _ in 0..3 {
1321            assert_eq!(epic_entries(&store, &loaded).unwrap(), first);
1322            assert_eq!(
1323                epic_cells(&store, &store.list().unwrap()).unwrap(),
1324                first_cells
1325            );
1326            assert_eq!(
1327                crate::commands::view::epic_view(&store).unwrap(),
1328                first_view
1329            );
1330        }
1331
1332        // A no-op dereference of an absent reference produces no change and no event
1333        // (contract §5), and leaves every rendered figure untouched.
1334        let issues = store.list().unwrap();
1335        let change = crate::epics::dereference_epic(&issues, 2, "e-bbb222").unwrap();
1336        assert!(change.event.is_none(), "an absent reference is a no-op");
1337        assert_eq!(
1338            epic_entries(&store, &loaded).unwrap(),
1339            first,
1340            "a no-op must not move a figure"
1341        );
1342    }
1343}