tix/storage/epics.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl Store {
5 /// Reference an epic from an issue.
6 ///
7 /// The service resolves the epic (by id, or by name as a convenience),
8 /// enforces `(issue, epic_id)` uniqueness, and returns the resulting list
9 /// plus the History event; this method writes them through the existing
10 /// [`Store::update`] path. A refused reference writes nothing and appends
11 /// nothing, and an already-present reference is an idempotent no-op that does
12 /// the same.
13 pub fn reference_epic(
14 &self,
15 issue_selector: &str,
16 epic_reference: &str,
17 ) -> Result<EpicReferenceOutcome> {
18 let issue = self.resolve_issue(issue_selector)?;
19 let snapshot = self.list_all()?;
20 let change =
21 crate::epics::reference_epic(&self.config, &snapshot, issue.number, epic_reference);
22 self.commit_epic_change(&snapshot, change)
23 }
24
25 /// Remove an issue's reference to an epic id.
26 ///
27 /// `epic_id` is compared **verbatim** against the stored list, as the service
28 /// documents: a name is not resolved here, so removing a stale id that is no
29 /// longer a valid epic shape still works โ the repair path C-005 promises.
30 /// Dereferencing an absent reference is an idempotent no-op.
31 pub fn dereference_epic(
32 &self,
33 issue_selector: &str,
34 epic_id: &str,
35 ) -> Result<EpicReferenceOutcome> {
36 let issue = self.resolve_issue(issue_selector)?;
37 let snapshot = self.list_all()?;
38 let change = crate::epics::dereference_epic(&snapshot, issue.number, epic_id);
39 self.commit_epic_change(&snapshot, change)
40 }
41
42 /// Reference an epic from each selector independently, writing every accepted
43 /// change.
44 ///
45 /// One rejection must not abort the rest (`contracts/epic-service.md` ยง5): the
46 /// returned vector has one entry per selector, in input order. Each accepted
47 /// change is written to its own file and appends exactly one History event; a
48 /// refusal writes nothing. An unresolvable selector becomes that entry's own
49 /// error rather than failing the run.
50 ///
51 /// ## Two selectors for one issue
52 ///
53 /// Selectors are resolved and applied one at a time against a **working copy**
54 /// of the issue set, advanced after every accepted write โ the same reasoning
55 /// as [`Store::bulk_associate`]. Applying each selector to the one immutable
56 /// read would let a second selector naming the same issue miss the first
57 /// write, so the service's already-present branch would not fire and a second
58 /// `epic_referenced` entry would be appended for a reference that is already
59 /// there. With the working copy the second commit *is* seen, so idempotence
60 /// stays the service's decision rather than a membership rule re-implemented
61 /// here.
62 pub fn bulk_reference(
63 &self,
64 epic_reference: &str,
65 selectors: &[String],
66 ) -> Result<Vec<BulkReferenceOutcome>> {
67 let mut working = self.list_all()?;
68 let mut outcomes = Vec::with_capacity(selectors.len());
69 for selector in selectors {
70 let outcome = match self.resolve_issue(selector) {
71 Err(error) => Err(error),
72 Ok(issue) => {
73 let change = crate::epics::reference_epic(
74 &self.config,
75 &working,
76 issue.number,
77 epic_reference,
78 );
79 match self.commit_epic_change(&working, change) {
80 Ok(applied) => {
81 if let Some(written) = &applied.written {
82 if let Some(slot) = working
83 .iter_mut()
84 .find(|candidate| candidate.number == issue.number)
85 {
86 *slot = written.clone();
87 }
88 }
89 Ok(applied)
90 }
91 Err(error) => Err(error),
92 }
93 }
94 };
95 outcomes.push(BulkReferenceOutcome {
96 selector: selector.clone(),
97 outcome,
98 });
99 }
100 Ok(outcomes)
101 }
102
103 /// Apply a service decision: write only an accepted change and append its
104 /// event.
105 ///
106 /// The single write path for reference/dereference/bulk, so they cannot drift
107 /// in whether they record what they did. `snapshot` must be the issue set the
108 /// service validated against, exactly as [`Store::commit_project_change`]
109 /// requires: the written list is then the one the service derived rather than
110 /// a second, possibly stale, read.
111 pub(in crate::storage) fn commit_epic_change(
112 &self,
113 snapshot: &[Issue],
114 change: std::result::Result<crate::epics::EpicChange, crate::epics::EpicError>,
115 ) -> Result<EpicReferenceOutcome> {
116 let change = change.map_err(TixError::from)?;
117 let Some(event) = change.event else {
118 // A no-op: nothing is written and no event is appended. The current
119 // list is still reported, so the caller does not have to re-read.
120 return Ok(EpicReferenceOutcome {
121 written: None,
122 epic_ids: change.epic_ids,
123 event: None,
124 changed: false,
125 });
126 };
127 let mut issue = snapshot
128 .iter()
129 .find(|issue| issue.number == change.issue.number())
130 .cloned()
131 .ok_or_else(|| {
132 TixError::NotFound(format!(
133 "issue #{} vanished between validating and writing it",
134 change.issue.number()
135 ))
136 })?;
137 // The service's list, verbatim: storage does not re-derive uniqueness or
138 // membership. A rule implemented in two places is a defect, so the only
139 // copy of it lives in `crate::epics`.
140 issue.epics = Some(change.epic_ids.clone());
141 // Exactly one event per accepted change, appended to the issue's existing
142 // History โ never a second log.
143 let kind = HistoryEventKind::from_epic(event.kind);
144 issue
145 .history
146 .append_now(&actor_identity(), kind, &event.detail);
147 issue.bump_updated();
148 issue.filename = self.update(&issue.filename, &issue)?;
149 Ok(EpicReferenceOutcome {
150 written: Some(issue),
151 epic_ids: change.epic_ids,
152 event: Some(event),
153 changed: true,
154 })
155 }
156}