Skip to main content

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}