tix/storage/parent.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl Store {
5 /// Point `child` at `parent`, or repoint it if it already has one.
6 ///
7 /// Both selectors go through [`Store::resolve_issue`], so `#4`, `4` and a title
8 /// fragment all work and an unresolvable one produces the CLI's own message. The
9 /// canonical issue id is what lands in the file, because neither a title nor
10 /// a branch-local number is a stable identity.
11 pub fn set_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
12 let child_issue = self.resolve_issue(child)?;
13 let parent_issue = self.resolve_issue(parent)?;
14 let (index, issues) = self.hierarchy_index()?;
15 let child_ref = crate::id::canonical_ref(&child_issue);
16 let parent_ref = crate::id::canonical_ref(&parent_issue);
17 let change = hierarchy::set_parent_ref(&index, child_ref, parent_ref)?;
18 self.commit_parent_change(Store::child_in_ref(&issues, child_ref)?, change)
19 }
20
21 /// Repoint `child` at a new parent. A no-op when the parent is unchanged.
22 pub fn change_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
23 let child_issue = self.resolve_issue(child)?;
24 let parent_issue = self.resolve_issue(parent)?;
25 let (index, issues) = self.hierarchy_index()?;
26 let child_ref = crate::id::canonical_ref(&child_issue);
27 let parent_ref = crate::id::canonical_ref(&parent_issue);
28 let change = hierarchy::change_parent_ref(&index, child_ref, parent_ref)?;
29 self.commit_parent_change(Store::child_in_ref(&issues, child_ref)?, change)
30 }
31
32 /// Remove `child`'s parent, yielding the absent state.
33 pub fn clear_parent(&self, child: &str) -> Result<ParentOutcome> {
34 let child_issue = self.resolve_issue(child)?;
35 let (index, issues) = self.hierarchy_index()?;
36 let child_ref = crate::id::canonical_ref(&child_issue);
37 let change = hierarchy::clear_parent_ref(&index, child_ref)?;
38 self.commit_parent_change(Store::child_in_ref(&issues, child_ref)?, change)
39 }
40
41 /// Apply an accepted change: write the field and append exactly one event.
42 ///
43 /// The single write path for all three operations, so they cannot drift in
44 /// whether they record what they did.
45 /// The `issue` must come from the same snapshot the service validated against;
46 /// see [`Store::hierarchy_index`]. That is what makes the assert below sound.
47 pub(in crate::storage) fn commit_parent_change(
48 &self,
49 mut issue: Issue,
50 change: hierarchy::ParentChange,
51 ) -> Result<ParentOutcome> {
52 let Some(event) = change.event else {
53 // A no-op: nothing is written, nothing is read for write-back, and no
54 // event is appended. `change.parent` is still the real current state,
55 // because the service derived it from the snapshot, so the caller does
56 // not have to re-read to learn it.
57 return Ok(ParentOutcome {
58 written: None,
59 parent: change.parent,
60 event: None,
61 changed: false,
62 });
63 };
64 // Read the previous value **before** overwriting it. Building the detail
65 // after the assignment compares the new value with itself, and every
66 // repoint was recorded as `parent #7 -> #7` — an audit trail that names
67 // neither the old parent nor the fact that anything moved.
68 let previous = issue.parent;
69 let kind = HistoryEventKind::from_parent(event.kind);
70 // The detail names both sides so a reader can follow the change without
71 // reconstructing state from the surrounding entries. Rendered by the
72 // service, because the `--parent` edit flag needs the identical string and
73 // two copies of a spelling rule drift.
74 let detail = hierarchy::change_detail(change.previous, change.parent);
75 // Sound now, and it is the service's rule rather than storage's: `issue`
76 // comes from the validated snapshot, so `previous` is by construction what
77 // the service compared against. It was a `debug_assert_ne!` on a value
78 // re-read through a *second* snapshot, where two files sharing a `number:`
79 // made it fire on a correct write — panicking every dev build — and where
80 // release builds compiled it out and wrote the contentless
81 // `parent #N -> #N` event it was there to catch. The comment on
82 // [`Store::hierarchy_index`] is the reason it cannot drift again.
83 debug_assert_ne!(
84 previous, change.parent_ref,
85 "an accepted change must alter the parent, or the event records nothing"
86 );
87 // The field only. **Nothing is written to the parent's file**: child
88 // membership is derived by inverting this pointer, never stored (C-003).
89 issue.parent = change.parent_ref;
90 issue.history.append_now(&actor_identity(), kind, &detail);
91 issue.bump_updated();
92 self.update(&issue.filename, &issue)?;
93 Ok(ParentOutcome {
94 written: Some(issue),
95 parent: change.parent,
96 event: Some(event),
97 changed: true,
98 })
99 }
100
101 /// The in-memory snapshot the hierarchy service reads.
102 ///
103 /// `pub` because the `--parent` edit flag (WP03) must validate through the
104 /// service before any write, exactly as this module does. It is a read, so
105 /// exposing it costs no more than a duplicate `list()` would.
106 ///
107 /// One `IssueView` per issue, shared with the relationship service rather than
108 /// a second projection — two views of one issue type is how two services end up
109 /// disagreeing about the same snapshot.
110 ///
111 /// The `Vec` is returned rather than discarded because the write path needs the
112 /// child **from this same snapshot**. Re-reading it through a second `list()`
113 /// would give the write and the validation two different notions of "current":
114 /// two files carrying the same `number:` (reachable by hand-edit, since
115 /// `HierarchyIndex::build` keys by number and last-write-wins while
116 /// `Store::get` is first-match), or a concurrent writer, would make the
117 /// service decide on one parent and the writer emit another. The invariant is
118 /// then not merely unasserted, it is false.
119 pub fn hierarchy_index(&self) -> Result<(HierarchyIndex, Vec<Issue>)> {
120 let issues = self.list_all()?;
121 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
122 Ok((HierarchyIndex::build(&views), issues))
123 }
124
125 /// The child, taken from the snapshot the service just validated against.
126 ///
127 /// Returns an error rather than a default so a snapshot that somehow lacks the
128 /// child is a loud failure, not a silent write of an unrelated file.
129 pub(in crate::storage) fn child_in_ref(
130 snapshot: &[Issue],
131 reference: crate::id::IssueRef,
132 ) -> Result<Issue> {
133 snapshot
134 .iter()
135 .find(|issue| crate::id::canonical_ref(*issue) == reference)
136 .cloned()
137 .ok_or_else(|| {
138 TixError::NotFound(format!(
139 "issue {reference} vanished between validating and writing it"
140 ))
141 })
142 }
143
144 /// Build a [`RelationshipGraph`] over every issue in the store.
145 ///
146 /// The service is the only place relationship semantics live, so the write
147 /// path validates through it rather than re-deriving direction handling,
148 /// cycle detection, or blocked state locally (C-003).
149 /// The board's configured completion column.
150 ///
151 /// Read from the first configured board. Completion is always resolved
152 /// from configuration, never by matching the literal string `done`; when no
153 /// board configures one, the service's `done` fallback applies.
154 ///
155 /// Resolved from the **active** board — the one named `default`, falling back
156 /// to the first declared, which is exactly what
157 /// [`Config::default_config`](crate::config::Config::default_config) returns
158 /// and what every other single-surface consumer already uses.
159 ///
160 /// It used to take the **first declared** board instead
161 /// (`configurations.values().next()`). That is a different board whenever a
162 /// configuration is declared before `default` with a different
163 /// `done_column`, and it put two different boards inside a single decision:
164 /// `blocked_move_guard` asked "is the target the completion column?" via
165 /// `completion_column(config)` — the active board — and then "is this issue
166 /// blocked?" via this function's graph — the *first declared* board. The
167 /// guard was reasoning about two different workflows in one call.
168 ///
169 /// `data-model.md` §4 says completion "is always resolved through the board's
170 /// configured `done_column`" and that the predicate is "the only definition
171 /// used by FR-003, FR-004, the move guard, and every surface". That requires
172 /// one board, not "whichever is declared first".
173 pub fn relationship_done_column(&self) -> String {
174 // WP09: the service's completion column. The name says `done_column` because
175 // that is what the `RelationshipGraph` and `hierarchy` services take as a
176 // parameter — re-deriving the *meaning* inside them is what this mission
177 // ended; supplying that meaning from one place is the whole of the fix, and
178 // it does not change either service's signature.
179 //
180 // Before: `effective_done_column().unwrap_or("done")`, so on a board whose
181 // `category_map` puts `completed` on a column other than `done_column` the
182 // blocked marker and the hierarchy rollup were computed against the wrong
183 // column. `unwrap_or("done")` also invented a completion column on a board
184 // that declared none.
185 crate::workflow::completion_column_or_default(self.config.default_config(), &self.config)
186 }
187}