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