tix/bulk/selection.rs
1//! Selection resolution: which issues a bulk operation acts on (WP01 T001, T002,
2//! T005).
3//!
4//! The seam other work packages import, and the module that **owns** these types —
5//! `crate::bulk` re-exports from here rather than the other way round. An earlier
6//! arrangement had both modules re-exporting each other, which does not compile
7//! and which is a sign the ownership was not decided.
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Result, TixError};
12use crate::model::Issue;
13use crate::storage::{IssueSelector, Store};
14
15// ---------------------------------------------------------------------------
16// T001–T002 — selection
17// ---------------------------------------------------------------------------
18
19/// Where a selection came from.
20///
21/// Recorded rather than inferred, because the three sources have genuinely
22/// different failure modes and a caller that has to report "what did you mean?"
23/// needs to know whether the user typed a fragment or ran a filter (C-003).
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum SelectionSource {
26 /// One or more explicit selectors: numbers, filename fragments, title
27 /// fragments.
28 Explicit,
29 /// A `tix list --filter` query.
30 Filter,
31 /// A `tix search` query.
32 Search,
33}
34
35impl SelectionSource {
36 /// The word the diagnostics use, so a message names the grammar the user
37 /// actually typed.
38 pub fn as_str(self) -> &'static str {
39 match self {
40 Self::Explicit => "selector",
41 Self::Filter => "filter",
42 Self::Search => "search",
43 }
44 }
45}
46
47/// Which issues are in play, active or archived.
48///
49/// The archive mission (#190) owns archived-view policy. This enum does not
50/// implement it: it carries the **request** so the later work packages can
51/// delegate, and the default is the one the contract names — operational
52/// selection follows the active view (data-model §1, contract §6).
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
54pub enum SelectionScope {
55 /// Active issues only. The default, and the only scope that writes.
56 #[default]
57 Active,
58 /// Active **and** archived.
59 ActiveAndArchived,
60 /// Archived only.
61 ArchivedOnly,
62}
63
64impl SelectionScope {
65 pub fn as_str(self) -> &'static str {
66 match self {
67 Self::Active => "active",
68 Self::ActiveAndArchived => "active+archived",
69 Self::ArchivedOnly => "archived",
70 }
71 }
72
73 /// Parse the CLI spelling. Case-insensitive, because it is a user-typed word
74 /// and the vocabulary is three items.
75 pub fn parse(raw: &str) -> Result<Self> {
76 match raw.trim().to_ascii_lowercase().as_str() {
77 "active" => Ok(Self::Active),
78 "active+archived" | "all" => Ok(Self::ActiveAndArchived),
79 "archived" | "archived-only" => Ok(Self::ArchivedOnly),
80 other => Err(TixError::InvalidInput(format!(
81 "unknown scope '{other}' — expected one of: active, active+archived, archived"
82 ))),
83 }
84 }
85}
86
87/// A resolved, ordered, immutable snapshot of the issues a bulk operation acts on.
88///
89/// Built by [`Selection::resolve`]. The store is read; **nothing is written**, and
90/// there is no method on this type that can write — that is the property
91/// contract §3 needs, and it is structural rather than a promise.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Selection {
94 source: SelectionSource,
95 /// The selectors as the user typed them, in the order they typed them, with
96 /// duplicates removed. Kept because a diagnostic that says "3 issues selected"
97 /// when the user typed 4 selectors is a bug report waiting to happen.
98 selectors: Vec<String>,
99 /// The query, when the source was Filter or Search.
100 query: Option<String>,
101 issues: Vec<Issue>,
102 scope: SelectionScope,
103 /// Selectors that named the same issue as an earlier one.
104 duplicates: Vec<String>,
105}
106
107impl Selection {
108 /// Resolve a selection from explicit selectors and/or a query.
109 ///
110 /// Resolution order, and why: **explicit selectors first**, then the query's
111 /// issues, then de-duplication. A user who typed both means the union, and
112 /// taking the union in that order means the explicit ones lead — which is what
113 /// a dry run should show first.
114 ///
115 /// Ambiguity and not-found are the **existing** diagnostics, propagated
116 /// unwrapped (contract §2). The first version wrapped the selector failure in
117 /// a fresh `NotFound`, and the user saw `not found: not found: issue matching
118 /// …` — the same doubled prefix a relationship message had. One message, from
119 /// one place.
120 pub fn resolve(
121 store: &Store,
122 selectors: &[String],
123 query: Option<&str>,
124 source: SelectionSource,
125 scope: SelectionScope,
126 ) -> Result<Self> {
127 let mut issues: Vec<Issue> = Vec::new();
128 let mut seen_numbers: Vec<u32> = Vec::new();
129 let mut seen_selectors: Vec<String> = Vec::new();
130 let mut duplicates: Vec<String> = Vec::new();
131
132 for raw in selectors {
133 let selector = crate::commands::parse_selector(store, raw)?;
134 let issue = store.get(selector).map_err(|_| {
135 TixError::NotFound(format!(
136 "issue matching '{raw}' by number, filename or title"
137 ))
138 })?;
139 // De-duplicated by **number**, not by the text typed: `12` and
140 // `2026-01-12-alpha` name the same issue, and de-duplicating on the
141 // text would apply the patch to it twice.
142 if seen_numbers.contains(&issue.number) {
143 duplicates.push(raw.clone());
144 continue;
145 }
146 seen_numbers.push(issue.number);
147 seen_selectors.push(raw.clone());
148 issues.push(issue);
149 }
150
151 if let Some(query) = query {
152 for issue in selection_query(store, query, source, scope)? {
153 if seen_numbers.contains(&issue.number) {
154 continue;
155 }
156 seen_numbers.push(issue.number);
157 issues.push(issue);
158 }
159 }
160
161 if issues.is_empty() {
162 // Not an error. An empty board is a valid selection — the plan then
163 // proposes zero changes, which is the honest answer to a filter that
164 // matched nothing. Conflating it with an error would make
165 // `--dry-run` on an empty result look like a failure.
166 return Ok(Self {
167 source,
168 selectors: seen_selectors,
169 query: query.map(str::to_string),
170 issues,
171 scope,
172 duplicates,
173 });
174 }
175
176 Ok(Self {
177 source,
178 selectors: seen_selectors,
179 query: query.map(str::to_string),
180 issues,
181 scope,
182 duplicates,
183 })
184 }
185
186 /// A snapshot from issues the caller **already has**, with no store involved.
187 ///
188 /// Two callers need this and neither is a test: the TUI multi-select (WP07)
189 /// resolves a selection from the board's cards rather than from typed
190 /// selectors, and a caller previewing a large in-memory set has no store to
191 /// resolve against. Both want the same guarantee — a **value** that cannot grow
192 /// — which is what makes this a constructor on the type rather than a `pub`
193 /// field.
194 ///
195 /// The order is preserved rather than sorted: a board's column order is what a
196 /// user sees, and a selection's order is presentation (the *fingerprint* is the
197 /// order-insensitive part, and is the thing an apply is authorized for).
198 ///
199 /// Numbers are de-duplicated, because a board can list the same issue twice and
200 /// a plan that writes it twice is the failure the data-model calls "the plan
201 /// never double-writes an issue unintentionally".
202 pub fn from_issues(
203 issues: impl IntoIterator<Item = Issue>,
204 source: SelectionSource,
205 scope: SelectionScope,
206 ) -> Self {
207 let mut seen: Vec<u32> = Vec::new();
208 let mut kept = Vec::new();
209 for issue in issues {
210 if seen.contains(&issue.number) {
211 continue;
212 }
213 seen.push(issue.number);
214 kept.push(issue);
215 }
216 Self {
217 source,
218 selectors: Vec::new(),
219 query: None,
220 issues: kept,
221 scope,
222 duplicates: Vec::new(),
223 }
224 }
225
226 /// The issues, in the snapshot's order.
227 pub fn issues(&self) -> &[Issue] {
228 &self.issues
229 }
230
231 /// Take the snapshot, so a later stage owns it rather than borrowing.
232 pub fn into_issues(self) -> Vec<Issue> {
233 self.issues
234 }
235
236 pub fn source(&self) -> SelectionSource {
237 self.source
238 }
239
240 pub fn query(&self) -> Option<&str> {
241 self.query.as_deref()
242 }
243
244 pub fn scope(&self) -> SelectionScope {
245 self.scope
246 }
247
248 /// The selectors after de-duplication, in the order they resolved.
249 pub fn selectors(&self) -> &[String] {
250 &self.selectors
251 }
252
253 /// Selectors that named an issue an earlier selector had already named.
254 ///
255 /// Reported rather than silently dropped: a user who typed `12 12 alpha` and
256 /// sees "3 issues" would otherwise have no way to know the third selector was
257 /// ignored. The plan renders this (WP02 T009).
258 pub fn duplicates(&self) -> &[String] {
259 &self.duplicates
260 }
261
262 pub fn len(&self) -> usize {
263 self.issues.len()
264 }
265
266 pub fn is_empty(&self) -> bool {
267 self.issues.is_empty()
268 }
269
270 /// The issue numbers, for a confirmation echo.
271 pub fn numbers(&self) -> Vec<u32> {
272 self.issues.iter().map(|i| i.number).collect()
273 }
274
275 /// A stable identity for this selection, so an apply can refuse a plan that
276 /// was confirmed against a different snapshot (contract §4: "apply requires
277 /// the exact confirmed plan").
278 ///
279 /// Built from the **numbers**, not from a hash of the issue contents: a user
280 /// may edit a selected issue between preview and apply, and the plan still
281 /// applies to *those issues* — the values were validated at preview time and
282 /// re-validated at apply time, so a content hash would refuse a legitimate
283 /// apply for the wrong reason. Membership is what has to match.
284 pub fn fingerprint(&self) -> Vec<u32> {
285 self.numbers()
286 }
287
288 /// Whether this selection matches a fingerprint, ignoring order.
289 ///
290 /// Order-insensitive on purpose. A selection's order is presentation; its
291 /// **membership** is what an apply is authorized for, and rejecting an apply
292 /// because the board came back in a different order would be a refusal with
293 /// no user-visible cause.
294 pub fn matches_fingerprint(&self, fingerprint: &[u32]) -> bool {
295 let mut mine = self.numbers();
296 let mut theirs = fingerprint.to_vec();
297 mine.sort_unstable();
298 theirs.sort_unstable();
299 mine == theirs
300 }
301}
302
303/// The issues a filter or search query names, through the **existing** matcher.
304///
305/// There is deliberately no bulk-specific filter language (contract §7). The only
306/// reason this is a function rather than an inline call is that `Filter` and
307/// `Search` resolve differently — a `Search` is pure substring, while a `Filter`
308/// also interprets `key:value` tokens — and the difference has to be the existing
309/// one. A bare term is a case-insensitive substring for **both** (tix #1498).
310///
311/// A `--filter` goes through the **same pair the list path uses**
312/// ([`crate::search::resolve_filter_criteria`] then
313/// [`crate::search::matches_issue`]) rather than straight to the token matcher.
314/// The store-level tokens (`category:`, `scope:`, `parent:`) are *lifted out* of
315/// the query by the resolver and answered from the store and the board's
316/// resolution, so a matcher called on the raw string had no arm for them and fell
317/// through to "matched nothing": `tix triage batch --filter scope:triage` reported
318/// `0 of 0 would change` and exited 0, which is indistinguishable from a filter
319/// that legitimately selected nothing (found by tix's post-merge mission review,
320/// RISK-2).
321fn selection_query(
322 store: &Store,
323 query: &str,
324 source: SelectionSource,
325 scope: SelectionScope,
326) -> Result<Vec<Issue>> {
327 let issues = store.list()?;
328 // `from_issues` defers serialization until a matcher asks for it (tix #1520).
329 let corpus = crate::search::Corpus::from_issues(issues.clone());
330 // A bare term is a case-insensitive substring over the corpus for both
331 // sources; a filter additionally interprets `key:value` tokens.
332 // `query_matches` is the grammar matcher and `search_substring` is the
333 // plain-substring one, so the two sources get the two **existing** matchers
334 // rather than one approximation of both.
335 //
336 // The dispatch is on `source`, not `scope`: an archived-scope *search* is
337 // still a search. The first version matched on `scope`, which does not carry
338 // that distinction and so could not name a matcher at all.
339 let _ = scope;
340 let out: Vec<Issue> = match source {
341 SelectionSource::Search => crate::search::search_substring(&corpus, query)
342 .into_iter()
343 .map(|hit| hit.issue)
344 .collect(),
345 _ => {
346 // `matches_issue(query, filter, …)`: the resolver returns the query with
347 // its store-level tokens lifted out, plus the structured filter that now
348 // carries them.
349 let (query_text, criteria) = crate::search::resolve_filter_criteria(store, query)?;
350 let (_, resolution) = crate::workflow::active_resolution(&store.config);
351 corpus
352 .issues
353 .iter()
354 .zip(corpus.texts().iter())
355 .filter(|(issue, text)| {
356 crate::search::matches_issue(&query_text, &criteria, issue, text, &resolution)
357 })
358 .map(|(issue, _)| issue.clone())
359 .collect()
360 }
361 };
362 Ok(out)
363}
364
365// ---------------------------------------------------------------------------
366// T005 — scope metadata
367// ---------------------------------------------------------------------------
368
369/// The scope metadata an enumeration exposes, so a caller can say what it did.
370///
371/// A separate function from [`Selection::resolve`] because the **archive
372/// enumeration seam** does not exist yet (#190 owns it). Rather than guess at its
373/// shape and have WP05 rewrite the guess, this returns the request and says so —
374/// and `can_write` is the one thing that genuinely depends on the scope, because
375/// a bulk write to an archived issue is not a thing this mission does.
376pub fn scope_metadata(scope: SelectionScope, selected: usize) -> ScopeMetadata {
377 ScopeMetadata {
378 scope,
379 selected,
380 // Archived issues are read-only in this mission. #190 owns the archive
381 // service; until it lands there is nothing to delegate a write to, and
382 // claiming otherwise would be a promise with no implementation behind it.
383 can_write: scope == SelectionScope::Active,
384 delegation_pending: scope != SelectionScope::Active,
385 }
386}
387
388/// What a scope selection means, for the plan's header.
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
390pub struct ScopeMetadata {
391 pub scope: SelectionScope,
392 pub selected: usize,
393 /// Whether this scope permits writes at all.
394 pub can_write: bool,
395 /// Whether a request for this scope needs the archive service before it can be
396 /// honoured. Reported so a caller can say "not yet" instead of silently
397 /// behaving as though archived issues were active ones.
398 pub delegation_pending: bool,
399}
400
401impl std::fmt::Display for ScopeMetadata {
402 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403 write!(f, "{} ({} selected)", self.scope.as_str(), self.selected)?;
404 if self.delegation_pending {
405 write!(f, " — read-only, the archive service is not wired yet")?;
406 }
407 Ok(())
408 }
409}
410
411/// Resolve a selector for a caller that only wants the number.
412///
413/// Exists so a test or a later surface can assert "this selector names that
414/// issue" without building a whole `Selection`, and so the de-duplication
415/// key — the number — is computed in exactly one place.
416pub fn selector_number(store: &Store, raw: &str) -> Result<u32> {
417 let selector: IssueSelector = crate::commands::parse_selector(store, raw)?;
418 Ok(store.get(selector)?.number)
419}