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    use super::*;
652    use crate::model::{Issue, Priority, Status};
653    use std::fs;
654    use tempfile::TempDir;
655
656    fn make_store() -> (TempDir, Store) {
657        let tmp = TempDir::new().unwrap();
658        let dir = tmp.path().join("issues");
659        fs::create_dir_all(dir.join("alice")).unwrap();
660        let config_path = tmp.path().join("tix.toml");
661        let config = crate::config::Config {
662            issues_dir: dir.clone(),
663            ..crate::config::Config::default()
664        };
665        config.save(&config_path).unwrap();
666        let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
667        (
668            tmp,
669            Store {
670                issues_dir: dir,
671                config,
672            },
673        )
674    }
675
676    fn seed(store: &Store, number: u32, title: &str) -> Issue {
677        let mut issue = Issue {
678            projects: None,
679            id: None,
680            number,
681            title: title.to_string(),
682            status: Status::new("todo"),
683            labels: vec![],
684            priority: Priority::default(),
685            assignee: Some("alice".to_string()),
686            estimate: None,
687            commit: None,
688            gitlab_id: None,
689            linear_id: None,
690            mission: None,
691            task: None,
692            relationships: vec![],
693            related_issues: vec![],
694            files: vec![],
695            cycles: None,
696            milestones: None,
697            reviewer: None,
698            start_date: None,
699            due_date: None,
700            created: "2026-01-01".to_string(),
701            type_: None,
702            updated: "2026-01-01".to_string(),
703            filename: String::new(),
704            body: "the body".to_string(),
705            history: crate::model::History::new(),
706            has_retired_links_key: false,
707            time_entries: vec![],
708            parent: None,
709            series: None,
710            health: None,
711            epics: None,
712            extra_frontmatter: std::collections::BTreeMap::new(),
713            archived: false,
714            entered_at: None,
715            source: None,
716        };
717        issue.filename = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
718        fs::write(
719            store.issues_dir.join(&issue.filename),
720            issue.to_file_contents(),
721        )
722        .unwrap();
723        issue
724    }
725
726    #[test]
727    fn get_returns_the_full_record() {
728        let (tmp, store) = make_store();
729        let _ = tmp;
730        let seeded = seed(&store, 1, "Get me");
731        let issue = get(&store, &seeded.number.to_string()).unwrap();
732        assert_eq!(issue.title, "Get me");
733        assert_eq!(issue.body, "the body");
734    }
735
736    #[test]
737    fn get_selector_miss_is_not_found() {
738        let (tmp, store) = make_store();
739        let _ = tmp;
740        let err = get(&store, "999").unwrap_err();
741        assert!(
742            matches!(err, crate::error::TixError::NotFound(_)),
743            "missing issue must surface as NotFound, got {err}"
744        );
745    }
746
747    #[test]
748    fn get_ambiguous_selector_is_an_error() {
749        let (tmp, store) = make_store();
750        let _ = tmp;
751        seed(&store, 1, "Shared title");
752        seed(&store, 2, "Shared title other");
753        // A bare filename fragment matches nothing; a title fragment matching
754        // two issues is ambiguous per contracts/cli.md.
755        let err = get(&store, "Shared").unwrap_err();
756        assert!(
757            matches!(err, crate::error::TixError::InvalidInput(_)),
758            "ambiguous selector must be an InvalidInput error, got {err}"
759        );
760    }
761
762    // ---- WP03: `--show relationships` --------------------------------
763
764    #[test]
765    fn show_relationships_is_off_by_default_so_the_output_is_still_the_file() {
766        // The reason the block is behind a flag: `tix view <n> > copy.md` has to
767        // keep producing a valid issue file.
768        let (tmp, store) = make_store();
769        let _ = tmp;
770        let seeded = seed(&store, 1, "Plain");
771        let on_disk = fs::read_to_string(store.issues_dir.join(&seeded.filename)).unwrap();
772        assert_eq!(
773            get(&store, "1").unwrap().to_file_contents(),
774            on_disk,
775            "precondition: the record round-trips to exactly the file's bytes"
776        );
777        assert!(!link::wants_relationships(None).unwrap());
778    }
779
780    #[test]
781    fn the_derived_block_shows_an_edge_stored_on_the_other_endpoint() {
782        // The regression `Store::relationships_for` was written to prevent: a
783        // relationship is stored **once**, in its canonical source's file, so
784        // reading this issue's own `relationships` field would show nothing
785        // where the service reports one.
786        let (tmp, store) = make_store();
787        let _ = tmp;
788        let source = seed(&store, 1, "Source");
789        let target = seed(&store, 2, "Target");
790        store
791            .link(
792                &source.number.to_string(),
793                &target.number.to_string(),
794                crate::relationships::RelationKind::Blocks,
795            )
796            .unwrap();
797
798        // The target's own stored field is empty — the edge lives on the source.
799        let target_record = get(&store, "2").unwrap();
800        assert!(
801            target_record.relationships.is_empty(),
802            "precondition: the edge is stored once, on the source"
803        );
804
805        let (issues, done_column) = store.graph_source().unwrap();
806        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
807        let graph = RelationshipGraph::new(&views, &done_column);
808        let block = link::derived_block(&graph, target_record.number);
809
810        assert!(
811            block.contains("Source"),
812            "the target must see the edge that names it:\n{block}"
813        );
814        assert!(
815            block.contains("blocked"),
816            "and the blocked reason follows from it:\n{block}"
817        );
818    }
819    // ----------------------------------------------------------------------
820    // WP08 T036 — the category in issue detail
821    // ----------------------------------------------------------------------
822
823    /// A store on a **mapped** board, so a category exists to render.
824    fn mapped_store(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> (TempDir, Store) {
825        let tmp = TempDir::new().unwrap();
826        let dir = tmp.path().join("issues");
827        fs::create_dir_all(dir.join("alice")).unwrap();
828        let config = crate::config::Config {
829            issues_dir: dir.clone(),
830            kanban: crate::config::KanbanSection {
831                configurations: indexmap::IndexMap::from([(
832                    "default".to_string(),
833                    crate::config::BoardConfiguration {
834                        columns: columns.iter().map(|c| (*c).to_string()).collect(),
835                        done_column: Some(done_column.to_string()),
836                        category_map: map
837                            .iter()
838                            .map(|(c, k)| ((*c).to_string(), (*k).to_string()))
839                            .collect(),
840                        ..Default::default()
841                    },
842                )]),
843            },
844            ..crate::config::Config::default()
845        };
846        config.save(&tmp.path().join("tix.toml")).unwrap();
847        let config =
848            crate::config::Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
849        (
850            tmp,
851            Store {
852                issues_dir: dir,
853                config,
854            },
855        )
856    }
857
858    fn five_categories() -> (TempDir, Store) {
859        mapped_store(
860            &["triage", "wip", "review", "shipped", "abandoned"],
861            "shipped",
862            &[
863                ("triage", "backlog"),
864                ("wip", "active"),
865                ("review", "active"),
866                ("shipped", "completed"),
867                ("abandoned", "canceled"),
868            ],
869        )
870    }
871
872    fn write(store: &Store, number: u32, title: &str, column: &str) {
873        let slug = crate::slug::slugify(title);
874        fs::write(
875            store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
876            format!(
877                "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{column}\"\n\
878                 labels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\n\
879                 updated: \"2026-01-01\"\n---\n\nbody\n"
880            ),
881        )
882        .unwrap();
883    }
884
885    #[test]
886    fn t036_the_detail_block_carries_the_key_the_label_and_the_completion_context() {
887        let (_tmp, store) = five_categories();
888        write(&store, 1, "Shipped", "shipped");
889        write(&store, 2, "Abandoned", "abandoned");
890        write(&store, 3, "Wip", "wip");
891        let (_, resolution) = crate::workflow::active_resolution(&store.config);
892        for (selector, key, label, meaning) in [
893            ("1", "completed", "Done", "complete"),
894            ("2", "canceled", "Canceled", "terminal, not complete"),
895            ("3", "active", "In progress", "in progress"),
896        ] {
897            let issue = crate::commands::view::get(&store, selector).unwrap();
898            let block = crate::commands::view::category_block(&issue, &resolution);
899            assert!(
900                block.contains(&format!("- category: {key} ({label})")),
901                "{selector}: {block}"
902            );
903            assert!(
904                block.contains(&format!("- workflow: {meaning}")),
905                "{selector}: {block}"
906            );
907        }
908    }
909
910    #[test]
911    fn t036_a_canceled_issue_never_reads_as_complete() {
912        // T036 step 2, the reason the second line exists at all. A detail view
913        // saying only `Canceled` leaves a reader to guess whether the work was
914        // finished, and this is the C-004 rule made visible.
915        let (_tmp, store) = five_categories();
916        write(&store, 1, "Abandoned", "abandoned");
917        let (_, resolution) = crate::workflow::active_resolution(&store.config);
918        let issue = crate::commands::view::get(&store, "1").unwrap();
919        let block = crate::commands::view::category_block(&issue, &resolution);
920        assert!(block.contains("terminal, not complete"), "{block}");
921        assert!(
922            !block.contains("workflow: complete"),
923            "a canceled issue must never say complete: {block}"
924        );
925    }
926
927    #[test]
928    fn t036_an_unmapped_column_shows_a_label_not_an_error_and_not_a_blank() {
929        // Two distinct cases, and the block must not conflate them.
930        //  - configured but unmapped → the safe default's label
931        //  - not configured at all    → `(unmapped)`, spelled as the list column
932        //                                 spells it
933        let (_tmp, store) = mapped_store(
934            &["triage", "wip", "shipped"],
935            "shipped",
936            &[("wip", "active"), ("shipped", "completed")],
937        );
938        write(&store, 1, "Defaulted", "triage");
939        write(&store, 2, "Orphan", "not-a-column");
940        let (_, resolution) = crate::workflow::active_resolution(&store.config);
941        let defaulted = crate::commands::view::get(&store, "1").unwrap();
942        let block = crate::commands::view::category_block(&defaulted, &resolution);
943        assert!(block.contains("unstarted (Not started)"), "{block}");
944        let orphan = crate::commands::view::get(&store, "2").unwrap();
945        let block = crate::commands::view::category_block(&orphan, &resolution);
946        assert!(
947            block.contains("category: (unmapped) ((unmapped))"),
948            "{block}"
949        );
950        assert!(
951            block.contains("this board configures no category for it"),
952            "an unmapped column is stated, not left blank: {block}"
953        );
954    }
955
956    // ---- WP04 T017/T019: the project block and cross-surface parity ---------
957
958    /// A store whose registry holds `(id, name)` projects and whose default board
959    /// is the shipped one.
960    fn project_store(projects: &[(&str, &str)]) -> (TempDir, Store) {
961        let tmp = TempDir::new().unwrap();
962        let dir = tmp.path().join("issues");
963        fs::create_dir_all(dir.join("alice")).unwrap();
964        let mut config = crate::config::Config {
965            issues_dir: dir.clone(),
966            ..crate::config::Config::default()
967        };
968        for (id, name) in projects {
969            config.projects.insert(
970                (*id).to_string(),
971                crate::config::ProjectConfiguration {
972                    name: (*name).to_string(),
973                    ..crate::config::ProjectConfiguration::default()
974                },
975            );
976        }
977        (
978            tmp,
979            Store {
980                issues_dir: dir,
981                config,
982            },
983        )
984    }
985
986    /// The issue with its associations and assignee set, written back to disk.
987    fn rewrite(store: &Store, mut issue: Issue, projects: &[&str], assignee: Option<&str>) {
988        issue.projects = Some(projects.iter().map(|p| (*p).to_string()).collect());
989        issue.assignee = assignee.map(str::to_string);
990        fs::write(
991            store.issues_dir.join(&issue.filename),
992            issue.to_file_contents(),
993        )
994        .unwrap();
995    }
996
997    #[test]
998    fn t017_the_project_block_shows_both_projects_with_id_name_and_health() {
999        let (_tmp, store) = project_store(&[("p-a", "Platform"), ("p-b", "Docs")]);
1000        rewrite(
1001            &store,
1002            seed(&store, 1, "Shared"),
1003            &["p-a", "p-b"],
1004            Some("alice"),
1005        );
1006        let loaded = get(&store, "1").unwrap();
1007        let block = projects_block(&store, &loaded).unwrap();
1008        assert!(block.contains("## Projects"), "{block}");
1009        assert!(
1010            block.contains("- Platform (p-a) 0/1 done, overdue 0, blocked 0, unassigned 0"),
1011            "the id, the name, and every figure are rendered: {block}"
1012        );
1013        assert!(block.contains("- Docs (p-b) 0/1 done"), "{block}");
1014    }
1015
1016    #[test]
1017    fn t017_an_issue_in_no_project_renders_no_project_heading() {
1018        let (_tmp, store) = project_store(&[("p-a", "Platform")]);
1019        seed(&store, 1, "None");
1020        let loaded = get(&store, "1").unwrap();
1021        assert_eq!(
1022            projects_block(&store, &loaded).unwrap(),
1023            "",
1024            "an empty association list is the empty state, not an empty heading"
1025        );
1026    }
1027
1028    #[test]
1029    fn t017_a_missing_reference_is_rendered_with_its_id_and_reason() {
1030        let (_tmp, store) = project_store(&[("p-a", "Platform")]);
1031        rewrite(
1032            &store,
1033            seed(&store, 1, "Dangling"),
1034            &["p-a", "p-gone"],
1035            Some("alice"),
1036        );
1037        let loaded = get(&store, "1").unwrap();
1038        let block = projects_block(&store, &loaded).unwrap();
1039        assert!(
1040            block.contains("- p-gone (missing reference):"),
1041            "a dangling association is named, not dropped: {block}"
1042        );
1043        assert!(
1044            block.contains("not in the registry"),
1045            "the service's reason is rendered: {block}"
1046        );
1047        assert!(
1048            block.contains("Platform (p-a)"),
1049            "the resolvable association is still shown: {block}"
1050        );
1051    }
1052
1053    #[test]
1054    fn t019_c006_show_and_list_report_the_same_figures_for_one_snapshot() {
1055        // What this proves: the two surfaces agree on the figures for one snapshot,
1056        // because both read the service's typed value. What it cannot prove: the
1057        // **absence** of a second computation — a test cannot observe that. The
1058        // review check for that is recorded in the WP04 report.
1059        let (_tmp, store) = project_store(&[("p-a", "Platform")]);
1060        let mut done = seed(&store, 1, "Done");
1061        done.status = crate::model::Status::new("done");
1062        rewrite(&store, done, &["p-a"], Some("alice"));
1063        rewrite(&store, seed(&store, 2, "Open"), &["p-a"], None);
1064
1065        let loaded = get(&store, "1").unwrap();
1066        let block = projects_block(&store, &loaded).unwrap();
1067        let issues = store.list().unwrap();
1068        let cells = crate::commands::list::project_cells(&store, &issues).unwrap();
1069        let figure = "1/2 done, overdue 0, blocked 0, unassigned 1";
1070        assert!(block.contains(figure), "`tix show` must report it: {block}");
1071        let cell = cells.get(&1).expect("issue #1 belongs to a project");
1072        assert!(
1073            cell.contains(figure),
1074            "`tix list --show progress` must report the same value: {cell}"
1075        );
1076    }
1077
1078    // ---- WP04 T017/T021: the epic block, its parity and its determinism ------
1079
1080    /// A store with an epic registry `(id, name, weight, parent)` and the default board.
1081    fn epic_store(epics: &[(&str, &str, Option<i64>, Option<&str>)]) -> (TempDir, Store) {
1082        let (tmp, mut store) = make_store();
1083        for (id, name, weight, parent) in epics {
1084            store.config.epics.insert(
1085                (*id).to_string(),
1086                crate::config::EpicConfiguration {
1087                    name: (*name).to_string(),
1088                    weight: *weight,
1089                    parent: parent.map(str::to_string),
1090                    ..crate::config::EpicConfiguration::default()
1091                },
1092            );
1093        }
1094        (tmp, store)
1095    }
1096
1097    /// Write `issue` back with its epic references set.
1098    fn with_epics(store: &Store, mut issue: Issue, epics: &[&str]) -> Issue {
1099        issue.epics = Some(epics.iter().map(|id| (*id).to_string()).collect());
1100        fs::write(
1101            store.issues_dir.join(&issue.filename),
1102            issue.to_file_contents(),
1103        )
1104        .unwrap();
1105        issue
1106    }
1107
1108    /// Write `issue` back with its status set.
1109    fn with_status(store: &Store, mut issue: Issue, status: &str) -> Issue {
1110        issue.status = crate::model::Status::new(status);
1111        fs::write(
1112            store.issues_dir.join(&issue.filename),
1113            issue.to_file_contents(),
1114        )
1115        .unwrap();
1116        issue
1117    }
1118
1119    #[test]
1120    fn t017_the_epic_block_shows_both_epics_with_id_name_and_rollup() {
1121        let (_tmp, store) = epic_store(&[
1122            ("e-aaa111", "Alpha", None, None),
1123            ("e-bbb222", "Beta", None, None),
1124        ]);
1125        let issue = with_epics(&store, seed(&store, 1, "Shared"), &["e-aaa111", "e-bbb222"]);
1126        let loaded = get(&store, &issue.number.to_string()).unwrap();
1127        let block = epics_block(&store, &loaded).unwrap();
1128        assert!(block.contains("## Epics"), "{block}");
1129        assert!(
1130            block.contains("- Alpha (e-aaa111)"),
1131            "the id and the name render: {block}"
1132        );
1133        assert!(block.contains("- Beta (e-bbb222)"), "{block}");
1134        assert!(
1135            block.contains("progress: 0/1 done"),
1136            "the rollup figures render: {block}"
1137        );
1138        assert!(block.contains("blocked: 0"), "{block}");
1139        let alpha = block.find("Alpha").unwrap();
1140        let beta = block.find("Beta").unwrap();
1141        assert!(
1142            alpha < beta,
1143            "the issue's own reference order is kept: {block}"
1144        );
1145    }
1146
1147    #[test]
1148    fn t017_an_issue_in_no_epic_renders_no_epic_heading() {
1149        let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
1150        seed(&store, 1, "None");
1151        let loaded = get(&store, "1").unwrap();
1152        assert_eq!(
1153            epics_block(&store, &loaded).unwrap(),
1154            "",
1155            "an empty reference list is the empty state, not an empty heading"
1156        );
1157    }
1158
1159    #[test]
1160    fn t017_a_missing_epic_reference_is_rendered_with_its_id_and_reason() {
1161        let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
1162        let issue = with_epics(
1163            &store,
1164            seed(&store, 1, "Dangling"),
1165            &["e-aaa111", "e-gone1", "bad.epic"],
1166        );
1167        let loaded = get(&store, &issue.number.to_string()).unwrap();
1168        let block = epics_block(&store, &loaded).unwrap();
1169        assert!(
1170            block.contains("- e-gone1 (missing reference)"),
1171            "a dangling reference is named, not dropped: {block}"
1172        );
1173        assert!(
1174            block.contains("not in the registry"),
1175            "the service's reason is rendered: {block}"
1176        );
1177        assert!(
1178            block.contains("- bad.epic (missing reference)"),
1179            "an entry that is not a well-formed id is named too: {block}"
1180        );
1181        assert!(block.contains("not a well-formed epic id"), "{block}");
1182        assert!(
1183            block.contains("Alpha (e-aaa111)"),
1184            "the resolvable reference is still shown: {block}"
1185        );
1186    }
1187
1188    #[test]
1189    fn t017_a_task_labelled_issue_shows_its_epic_like_any_other() {
1190        // C-001 in the UI, not only in the model: a `task` label changes nothing about
1191        // the issue's presence as an epic member, and there is no task section anywhere.
1192        let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
1193        let mut issue = seed(&store, 1, "A task");
1194        issue.labels = vec!["task".to_string()];
1195        let issue = with_epics(&store, issue, &["e-aaa111"]);
1196        let loaded = get(&store, &issue.number.to_string()).unwrap();
1197        let block = epics_block(&store, &loaded).unwrap();
1198        assert!(block.contains("Alpha (e-aaa111)"), "{block}");
1199        assert!(
1200            !block.contains("task"),
1201            "no task heading or task section exists (C-001): {block}"
1202        );
1203    }
1204
1205    /// The fixed parity fixture: Alpha (weight 5) has child Beta (weight 2); #1 (done)
1206    /// references both, and #2 (todo) references Alpha. Alpha therefore reads `1/2`
1207    /// members done and `2/2 (100%)` weighted, and Beta is complete.
1208    fn epic_parity_store() -> (TempDir, Store) {
1209        let (_tmp, store) = epic_store(&[
1210            ("e-aaa111", "Alpha", Some(5), None),
1211            ("e-bbb222", "Beta", Some(2), Some("e-aaa111")),
1212        ]);
1213        let one = with_epics(&store, seed(&store, 1, "Done"), &["e-aaa111", "e-bbb222"]);
1214        with_status(&store, one, "done");
1215        with_epics(&store, seed(&store, 2, "Open"), &["e-aaa111"]);
1216        (_tmp, store)
1217    }
1218
1219    #[test]
1220    fn t021_c008_the_epic_figures_are_equal_across_show_list_and_the_tui_paths() {
1221        // What this proves: the CLI block, the listing cell, the TUI-data rows and the
1222        // epic view agree on one snapshot's figures, because each reads the one service
1223        // value. What it **cannot** prove: the *absence* of a second computation, or
1224        // that the pane is fed correctly — a test cannot observe either. The first is a
1225        // review check (WP04 report); the second lives in
1226        // `tests/tui_state.rs::t019_c008_the_detail_pane_reads_its_epic_block_through_the_real_app_builder`,
1227        // which drives the real `App::open_issue_by_filename` and the real pane.
1228        let (_tmp, store) = epic_parity_store();
1229        let loaded = get(&store, "2").unwrap();
1230        let block = epics_block(&store, &loaded).unwrap();
1231        let issues = store.list().unwrap();
1232        let cells = epic_cells(&store, &issues).unwrap();
1233        let cell = cells.get(&2).expect("#2 references Alpha");
1234        // `epic_entries` is `epics_block`'s own input, so this leg compares the block
1235        // against its source, not against a separately-populated pane; it says what it
1236        // proves (the show/list figures are the service's) and no more.
1237        let entries = epic_entries(&store, &loaded).unwrap();
1238        let view = crate::commands::view::epic_view(&store).unwrap();
1239
1240        // The correctness anchor: the exact figures, so the equality below is not
1241        // satisfied by rendering the same constant everywhere.
1242        for text in [block.as_str(), cell.as_str()] {
1243            assert!(text.contains("1/2 done"), "{text}");
1244            assert!(text.contains("2/2 (100%)"), "{text}");
1245        }
1246        assert_eq!(entries.len(), 1, "issue #2 references exactly Alpha");
1247        assert_eq!(entries[0].heading, "Alpha (e-aaa111)");
1248        for line in [
1249            "progress: 1/2 done",
1250            "weighted progress: 2/2 (100%)",
1251            "weight: 5",
1252            "children: 1",
1253            "blocked: 0",
1254        ] {
1255            assert!(
1256                entries[0].detail.iter().any(|detail| detail == line),
1257                "the TUI detail path must carry `{line}`: {:?}",
1258                entries[0].detail
1259            );
1260        }
1261
1262        // The TUI epic view: declaration order, and the same figures for Alpha.
1263        assert_eq!(
1264            view.first().map(|entry| entry.epic.heading.as_str()),
1265            Some("Alpha (e-aaa111)"),
1266            "epics render in declaration order"
1267        );
1268        let alpha = view
1269            .iter()
1270            .find(|entry| entry.epic.heading.starts_with("Alpha"))
1271            .expect("Alpha is declared");
1272        for line in ["progress: 1/2 done", "weighted progress: 2/2 (100%)"] {
1273            assert!(
1274                alpha.epic.detail.iter().any(|detail| detail == line),
1275                "the epic view must carry `{line}`: {:?}",
1276                alpha.epic.detail
1277            );
1278        }
1279        // And the widget renders those canonical lines, so the equality reaches the
1280        // drawn surface rather than only the data behind it.
1281        let state = crate::ui::planning::EpicViewState::new();
1282        let lines = crate::ui::planning::epic_lines(&view, &state, 40);
1283        let text = lines
1284            .iter()
1285            .map(|line| {
1286                line.spans
1287                    .iter()
1288                    .map(|span| span.content.as_ref())
1289                    .collect::<String>()
1290            })
1291            .collect::<Vec<_>>()
1292            .join("\n");
1293        assert!(text.contains("progress: 1/2 done"), "{text}");
1294        assert!(text.contains("weighted progress: 2/2 (100%)"), "{text}");
1295    }
1296
1297    #[test]
1298    fn t021_the_epic_figures_are_deterministic_and_stable_under_a_no_op() {
1299        let (_tmp, store) = epic_parity_store();
1300        let loaded = get(&store, "2").unwrap();
1301        let first = epic_entries(&store, &loaded).unwrap();
1302        let first_cells = epic_cells(&store, &store.list().unwrap()).unwrap();
1303        let first_view = crate::commands::view::epic_view(&store).unwrap();
1304        // The correctness anchor: determinism is only meaningful if the value is right,
1305        // so this is asserted before the loop rather than being implied by it.
1306        assert!(
1307            first[0]
1308                .detail
1309                .iter()
1310                .any(|line| line == "progress: 1/2 done")
1311        );
1312        assert!(
1313            first[0]
1314                .detail
1315                .iter()
1316                .any(|line| line == "weighted progress: 2/2 (100%)")
1317        );
1318
1319        for _ in 0..3 {
1320            assert_eq!(epic_entries(&store, &loaded).unwrap(), first);
1321            assert_eq!(
1322                epic_cells(&store, &store.list().unwrap()).unwrap(),
1323                first_cells
1324            );
1325            assert_eq!(
1326                crate::commands::view::epic_view(&store).unwrap(),
1327                first_view
1328            );
1329        }
1330
1331        // A no-op dereference of an absent reference produces no change and no event
1332        // (contract §5), and leaves every rendered figure untouched.
1333        let issues = store.list().unwrap();
1334        let change = crate::epics::dereference_epic(&issues, 2, "e-bbb222").unwrap();
1335        assert!(change.event.is_none(), "an absent reference is a no-op");
1336        assert_eq!(
1337            epic_entries(&store, &loaded).unwrap(),
1338            first,
1339            "a no-op must not move a figure"
1340        );
1341    }
1342}