Skip to main content

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}