Skip to main content

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}