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