Skip to main content

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}