Skip to main content

tix/commands/
bulk.rs

1//! `tix bulk` — the batch command surface (WP06).
2//!
3//! Four subcommands, and the shape of the surface is the contract's:
4//!
5//! ```text
6//! tix bulk edit   --select <selectors|filter|search> <patch flags> [--apply]
7//! tix bulk link   --select <...> --target <selector> [--apply]
8//! tix bulk unlink --select <...> --target <selector> [--apply]
9//! tix bulk archive --select <...> [--apply]
10//! ```
11//!
12//! ## Preview is the default, and that is a default not a courtesy
13//!
14//! `--apply` is required to write. A user who types `tix bulk edit --select 1,2,3
15//! --status wip` and gets a **table** has been told what would happen and has not
16//! changed anything; a user who expected a write has lost nothing. The reverse default
17//! would make a bulk mistake — and a bulk mistake is a mistake multiplied by the
18//! selection size — the default outcome of a plausible command line.
19//!
20//! ## A partial batch exits non-zero and keeps every outcome
21//!
22//! `contracts/cli-surface.md`: "A partial batch returns a non-zero status while
23//! retaining all outcomes; do not imply rollback."
24//!
25//! The exit code is **1**, and the choice is worth stating because the table is
26//! frozen: 0 is "everything requested happened"; 2 is a *whole-command* selection
27//! failure (not-found or ambiguous); 3 is an external tool. A partial batch is none
28//! of those, and 1 — "usage/config", read as "the request was not fully honoured" —
29//! is the only code left. Inventing a fifth code would break a frozen contract to
30//! name a situation 1 already describes.
31//!
32//! And the output says `0 write failures` even when it is zero, because a batch that
33//! silently skipped 200 items must not read like one that wrote them.
34//!
35//! ## Nothing here re-implements an apply
36//!
37//! The field path is `bulk::apply`, the pair path is `bulk::pair`, the archive path
38//! is `bulk::archive`. This module resolves a selection, builds a plan, routes it, and
39//! renders. The single-archive-state rule and the R7 outcome-shape rule are the other
40//! missions' to keep, and the only way to keep them from here is to not write a
41//! second one.
42
43use std::collections::BTreeMap;
44use std::path::Path;
45
46use crate::bulk::plan::BatchPlan;
47use crate::bulk::{
48    self, ApplyReport, ArchiveRequest, Confirmation, IssuePatch, PairOutcome, PatchValue,
49    RelationshipOp, Selection, SelectionScope, SelectionSource,
50};
51use crate::cli::ExitCode;
52use crate::commands::parse_priority;
53use crate::error::{Result, TixError};
54use crate::model::Priority;
55use crate::storage::Store;
56
57/// The selection the user asked for, and which grammar they used.
58///
59/// Three separate flags rather than one `--select` whose value might be a filter:
60/// the contract's `--select <selectors|filter|search>` is ambiguous about which
61/// grammar a value belongs to, and a bulk operation that guesses wrong edits a
62/// different set of issues than the user read on screen. One flag per grammar, exactly
63/// one required.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct SelectionRequest {
66    pub source: SelectionSource,
67    /// Comma-separated selectors, already split and trimmed.
68    pub selectors: Vec<String>,
69    /// The query, for `Filter` and `Search`.
70    pub query: Option<String>,
71    pub scope: SelectionScope,
72}
73
74impl SelectionRequest {
75    /// Build a request, refusing anything that is not exactly one grammar.
76    ///
77    /// Zero grammars and two grammars are the same error, because both mean the
78    /// command line does not say which set of issues to act on. The message names
79    /// all three so a user does not have to guess what else to try.
80    pub fn new(
81        selectors: &[String],
82        filter: Option<&str>,
83        search: Option<&str>,
84        scope: SelectionScope,
85    ) -> Result<Self> {
86        let named: Vec<(&str, &str)> = [
87            (
88                "--select",
89                selectors.first().map(|s| s.as_str()).unwrap_or(""),
90            ),
91            ("--filter", filter.unwrap_or("")),
92            ("--search", search.unwrap_or("")),
93        ]
94        .into_iter()
95        .filter(|(_, value)| !value.trim().is_empty())
96        .collect();
97        if named.len() != 1 {
98            return Err(TixError::InvalidInput(format!(
99                "name exactly one of --select, --filter or --search \
100                 ({} given) — they are different grammars, so a bulk operation will \
101                 not guess which issues you meant",
102                if named.is_empty() {
103                    "none".to_string()
104                } else {
105                    named
106                        .iter()
107                        .map(|(flag, _)| *flag)
108                        .collect::<Vec<_>>()
109                        .join(" and ")
110                }
111            )));
112        }
113        let (source, query) = match named[0].0 {
114            "--select" => (SelectionSource::Explicit, None),
115            "--filter" => (SelectionSource::Filter, Some(named[0].1.trim().to_string())),
116            _ => (SelectionSource::Search, Some(named[0].1.trim().to_string())),
117        };
118        Ok(Self {
119            source,
120            selectors: split_selectors(selectors),
121            query,
122            scope,
123        })
124    }
125}
126
127/// Split and trim a comma-separated selector list, dropping empty entries.
128///
129/// Drops empties rather than carrying them, because a trailing comma is a typo and
130/// `parse_selector("")` would refuse it with a message about an empty selector, which
131/// names a problem the user did not have.
132fn split_selectors(raw: &[String]) -> Vec<String> {
133    raw.iter()
134        .flat_map(|value| value.split(','))
135        .map(str::trim)
136        .filter(|s| !s.is_empty())
137        .map(str::to_string)
138        .collect()
139}
140
141/// The patch flags, exactly the confirmed typed fields.
142///
143/// A closed list, in the same sense `IssuePatch` is: there is no `--set key=value`, so
144/// there is no route by which `--set number=99` or `--set body=…` reaches the code.
145/// `contracts/cli-surface.md`: "Patch flags are limited to the confirmed typed fields."
146#[derive(Debug, Clone, Default, PartialEq, Eq)]
147pub struct PatchFlags {
148    pub status: Option<String>,
149    pub priority: Option<String>,
150    pub assignee: Option<String>,
151    pub labels: Option<String>,
152    pub start_date: Option<String>,
153    pub due_date: Option<String>,
154    pub estimate: Option<String>,
155    pub cycles: Option<String>,
156    pub milestones: Option<String>,
157    /// `--clear <field>`, for the fields that support clearing.
158    pub clear: Vec<String>,
159}
160
161/// Which fields `--clear` accepts, and the diagnostic for anything else.
162///
163/// A closed list, and the error names it — so `--clear body` says what *can* be
164/// cleared rather than "unknown field". `body` is not clearable, and a bulk operation
165/// that emptied a body would be doing something no single-issue command can.
166const CLEARABLE: [&str; 8] = [
167    "priority",
168    "assignee",
169    "labels",
170    "start-date",
171    "due-date",
172    "estimate",
173    "cycles",
174    "milestones",
175];
176
177// `status` is absent on purpose and for a stated reason: a column is not a field a
178// bulk operation may empty, `Issue::status` is not optional, and a `Clear` on it has
179// no meaning. It is named in the diagnostic rather than silently absent, so a user
180// who tries `--clear status` is told it is not clearable and why it is not a
181// "clearable field" — the list is the accepted set, and the message quotes it.
182
183impl PatchFlags {
184    /// The diagnostic listing every clearable field.
185    pub fn clearable() -> &'static [&'static str] {
186        &CLEARABLE
187    }
188
189    /// The flag rows, in the contract's field order, plus `clear`.
190    ///
191    /// The **one** list, so the TUI's `tab` order, the CLI's help and a test's
192    /// "every field is reachable" case cannot disagree. `clear` is last because it is
193    /// the one row that is not a *set* — it empties a field, and a user tabbing
194    /// through expects to set values first and clear one afterwards.
195    pub fn field_rows() -> &'static [&'static str] {
196        &[
197            "status",
198            "priority",
199            "assignee",
200            "labels",
201            "start-date",
202            "due-date",
203            "estimate",
204            "cycles",
205            "milestones",
206            "clear",
207        ]
208    }
209
210    /// Whether a field name can be cleared.
211    pub fn is_clearable(field: &str) -> bool {
212        CLEARABLE.contains(&field)
213    }
214
215    /// Whether any patch flag was given at all.
216    pub fn is_empty(&self) -> bool {
217        self.status.is_none()
218            && self.priority.is_none()
219            && self.assignee.is_none()
220            && self.labels.is_none()
221            && self.start_date.is_none()
222            && self.due_date.is_none()
223            && self.estimate.is_none()
224            && self.cycles.is_none()
225            && self.milestones.is_none()
226            && self.clear.is_empty()
227    }
228
229    /// Build the typed patch.
230    ///
231    /// A **blank** flag value is `Unchanged`, not `Clear` and not `Set("")`. A flag
232    /// with no value is a flag the user did not mean, and treating it as "set it to
233    /// the empty string" is the class of bug `PatchValue` exists to prevent — reached
234    /// through the CLI this time rather than through the type.
235    pub fn to_patch(&self) -> Result<IssuePatch> {
236        let text = |value: &Option<String>| -> PatchValue<String> {
237            match value {
238                Some(v) if !v.trim().is_empty() => PatchValue::Set(v.trim().to_string()),
239                _ => PatchValue::Unchanged,
240            }
241        };
242        let priority = match &self.priority {
243            Some(v) if !v.trim().is_empty() => {
244                // The **same core** a single `tix edit --priority` uses, so a bulk
245                // priority cannot be a value a single edit would reject.
246                PatchValue::Set(parse_priority(v.trim())?)
247            }
248            _ => PatchValue::Unchanged,
249        };
250        let list = |value: &Option<String>| -> PatchValue<Vec<String>> {
251            match value {
252                Some(v) if !v.trim().is_empty() => PatchValue::Set(
253                    v.split(',')
254                        .map(str::trim)
255                        .filter(|s| !s.is_empty())
256                        .map(str::to_string)
257                        .collect(),
258                ),
259                _ => PatchValue::Unchanged,
260            }
261        };
262        let mut patch = IssuePatch {
263            status: text(&self.status),
264            priority,
265            assignee: text(&self.assignee),
266            labels: list(&self.labels),
267            start_date: text(&self.start_date),
268            due_date: text(&self.due_date),
269            estimate: text(&self.estimate),
270            cycles: list(&self.cycles),
271            milestones: list(&self.milestones),
272            ..IssuePatch::default()
273        };
274        for field in &self.clear {
275            if !Self::is_clearable(field) {
276                return Err(TixError::InvalidInput(format!(
277                    "cannot clear '{field}' — clearable fields: {}",
278                    CLEARABLE.join(", ")
279                )));
280            }
281            // The *typed* slot, so a clear is a `Clear` in the same enum the type
282            // promises and not a `Set` of an empty string.
283            match field.as_str() {
284                "priority" => patch.priority = PatchValue::Clear,
285                "assignee" => patch.assignee = PatchValue::Clear,
286                "labels" => patch.labels = PatchValue::Clear,
287                "start-date" => patch.start_date = PatchValue::Clear,
288                "due-date" => patch.due_date = PatchValue::Clear,
289                "estimate" => patch.estimate = PatchValue::Clear,
290                "cycles" => patch.cycles = PatchValue::Clear,
291                "milestones" => patch.milestones = PatchValue::Clear,
292                // Unreachable: `is_clearable` gates this loop over the same list.
293                // Named rather than `_` so adding a field to `CLEARABLE` without
294                // adding an arm is a compile error rather than a silent no-op.
295                _ => unreachable!("CLEARABLE and this match must list the same fields"),
296            }
297        }
298        Ok(patch)
299    }
300}
301
302/// What the surface should do with a plan.
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum Mode {
305    /// Report and change nothing. The default.
306    Preview,
307    /// Report and apply.
308    Apply,
309}
310
311impl Mode {
312    /// The word the output uses for an item, so a preview never says it did
313    /// something.
314    pub fn verb(self, done: &'static str) -> &'static str {
315        match self {
316            Self::Preview => {
317                let _ = done;
318                "would"
319            }
320            Self::Apply => done,
321        }
322    }
323}
324
325/// The per-item result a surface reports, in the shared outcome vocabulary.
326///
327/// A re-export rather than a new type: `contracts/surface-parity.md` requires the
328/// same accept/reject decision on every surface, and the cheapest way to guarantee one
329/// outcome vocabulary is not to have two.
330pub type BulkOutcome = bulk::ItemOutcome;
331
332/// Run `tix bulk edit`.
333pub fn run_edit(
334    store: &Store,
335    request: &SelectionRequest,
336    flags: &PatchFlags,
337    mode: Mode,
338) -> Result<ExitCode> {
339    let patch = flags.to_patch()?;
340    if patch.is_empty() {
341        return Err(TixError::InvalidInput(format!(
342            "no fields to change — name at least one of: {}",
343            IssuePatch::field_names().join(", ")
344        )));
345    }
346    let selection = resolve(store, request)?;
347    let plan = build(store, selection, patch)?;
348    if mode == Mode::Preview {
349        print!("{}", render_plan(&plan, Mode::Preview));
350        return Ok(exit_code_for_plan(&plan));
351    }
352    let mut confirmed = plan.clone();
353    confirmed.confirmation = Some(Confirmation::issue(&plan));
354    let report = bulk::apply_fields(store, &confirmed, &crate::storage::actor_identity(), None)?;
355    print!("{}", render_report(&report, &confirmed, Mode::Apply));
356    Ok(exit_code_for_report(&report))
357}
358
359/// Run `tix bulk link` or `tix bulk unlink`.
360///
361/// The selection is the set of issues a `from` is drawn from, and `--target` is the
362/// other end. A pair is two named endpoints (WP04), so `--select` may name several
363/// issues and each becomes a pair with the target — and a missing target is a
364/// diagnostic for the pairs that name it, not a whole-command failure, which is why
365/// this delegates per pair rather than resolving the target once.
366pub fn run_pair(
367    store: &Store,
368    request: &SelectionRequest,
369    target: Option<&str>,
370    kind: Option<&str>,
371    link: bool,
372    mode: Mode,
373) -> Result<ExitCode> {
374    let kind = crate::commands::link::require_kind(kind)?;
375    let selection = resolve(store, request)?;
376    if selection.is_empty() {
377        return Err(TixError::NotFound(
378            "the selection is empty, so there is nothing to pair".to_string(),
379        ));
380    }
381    let mut outcomes: Vec<(String, PairOutcome)> = Vec::new();
382    let mut refused = 0usize;
383    for issue in selection.issues() {
384        let op = if link {
385            RelationshipOp::link(issue.number.to_string(), target.unwrap_or_default(), kind)
386        } else {
387            RelationshipOp::unlink(issue.number.to_string(), target.unwrap_or_default(), kind)
388        };
389        let mut plan = build(
390            store,
391            Selection::from_issues(vec![issue.clone()], request.source, request.scope),
392            IssuePatch {
393                relationship: Some(op.clone()),
394                ..IssuePatch::default()
395            },
396        )?;
397        if mode == Mode::Preview {
398            let resolution = bulk::pair::resolve_pair(store, &op);
399            match &resolution.diagnostic {
400                Some(diagnostic) => {
401                    refused += 1;
402                    println!(
403                        "#{} {} -> refused: {}",
404                        issue.number,
405                        issue.title,
406                        resolution.requested.verb()
407                    );
408                    let _ = diagnostic;
409                }
410                None => println!(
411                    "#{} {} -> would {} {} #{} ({})",
412                    issue.number,
413                    issue.title,
414                    op.verb(),
415                    issue.number,
416                    resolution.edge.expect("checked").to,
417                    kind.as_str()
418                ),
419            }
420            continue;
421        }
422        plan.confirmation = Some(Confirmation::issue(&plan));
423        let outcome = bulk::apply_pair(store, &plan)?;
424        if matches!(outcome, PairOutcome::Refused(_)) {
425            refused += 1;
426        }
427        outcomes.push((format!("#{} {}", issue.number, issue.title), outcome));
428    }
429    if mode == Mode::Preview {
430        return Ok(if refused > 0 {
431            ExitCode::UsageConfig
432        } else {
433            ExitCode::Success
434        });
435    }
436    for (label, outcome) in &outcomes {
437        println!("{label} -> {}", outcome.as_line());
438    }
439    let applied = outcomes.iter().filter(|(_, o)| o.is_applied()).count();
440    println!(
441        "{} relationship operation{} applied, {refused} refused",
442        applied,
443        if applied == 1 { "" } else { "s" }
444    );
445    Ok(if refused > 0 || applied < outcomes.len() {
446        ExitCode::UsageConfig
447    } else {
448        ExitCode::Success
449    })
450}
451
452/// Run `tix bulk archive`.
453///
454/// Preview reports the plan and writes nothing. Apply delegates each selected issue
455/// to the store's archive service through [`bulk::apply_archive`], then reports one
456/// line per item and the counts — the same shape a field batch reports, because the
457/// per-item outcomes are the same shared vocabulary.
458pub fn run_archive(store: &Store, request: &SelectionRequest, mode: Mode) -> Result<ExitCode> {
459    let selection = resolve(store, request)?;
460    let plan = build(
461        store,
462        selection.clone(),
463        IssuePatch {
464            archive: Some(ArchiveRequest::archive()),
465            ..IssuePatch::default()
466        },
467    )?;
468    if mode == Mode::Preview {
469        print!("{}", render_plan(&plan, Mode::Preview));
470        return Ok(exit_code_for_plan(&plan));
471    }
472    let mut confirmed = plan.clone();
473    confirmed.confirmation = Some(Confirmation::issue(&plan));
474    let report = bulk::apply_archive(store, &confirmed)?;
475    print!("{}", render_report(&report, &confirmed, Mode::Apply));
476    Ok(exit_code_for_report(&report))
477}
478
479/// Resolve a request to a selection, the one path every surface uses.
480///
481/// `pub` because the MCP handlers (WP08 T036) need it and must not grow a second
482/// resolution: `Selection::resolve` is what applies the existing filter and search
483/// grammars, and a second path would be a second grammar — which C-003 calls an
484/// escalation trigger.
485pub fn resolve(store: &Store, request: &SelectionRequest) -> Result<Selection> {
486    Selection::resolve(
487        store,
488        &request.selectors,
489        request.query.as_deref(),
490        request.source,
491        request.scope,
492    )
493}
494
495/// Build the shared plan, the one path every surface uses.
496///
497/// `pub` for the same reason as [`resolve`]: a surface that built its own plan would
498/// be a second implementation of the preview, and C-004's "a preview is read-only"
499/// would be a claim about one of them.
500pub fn build(store: &Store, selection: Selection, patch: IssuePatch) -> Result<BatchPlan> {
501    let mut plan = bulk::plan::build_plan(
502        &store.config,
503        &store.issues_dir,
504        selection,
505        patch,
506        |op: &RelationshipOp| bulk::pair::plan_for(store, op),
507    )?;
508    // WP07 T035: the WIP decisions, evaluated against the **store's** issues — a
509    // column's usage counts every card in it, not only the selected ones.
510    crate::wip::bulk::annotate_plan(&mut plan, &store.config, &store.list()?);
511    Ok(plan)
512}
513
514// ---------------------------------------------------------------------------
515// Rendering
516// ---------------------------------------------------------------------------
517
518/// The preview body. Pure: takes the plan, returns the text, so a surface can be
519/// tested without a process and a report can never disagree with the plan it
520/// describes.
521pub fn render_plan(plan: &BatchPlan, mode: Mode) -> String {
522    let mut out = String::new();
523    out.push_str(&render_selection(plan));
524    for item in &plan.items {
525        if item.changes.is_empty() && item.diagnostic.is_none() {
526            continue;
527        }
528        out.push_str(&format!("#{} {}\n", item.number, item.title));
529        // A **refused** item shows the refusal and nothing else.
530        //
531        // The first version printed the proposed changes, the path and the History
532        // intents *and then* the diagnostic, so a preview showed a refused item with
533        // `status: "abandoned" -> review` and `history: transition_accepted` above the
534        // refusal explaining that neither would happen. A preview that shows a change
535        // it will not make is worse than no preview, because the change is the part a
536        // user reads.
537        if let Some(diagnostic) = &item.diagnostic {
538            out.push_str(&format!(
539                "    refused [{}]: {}\n",
540                diagnostic.field, diagnostic.message
541            ));
542            continue;
543        }
544        for (field, change) in &item.changes {
545            out.push_str(&format!("    {field}: {}\n", render_change(change)));
546        }
547        if let Some(move_path) = &item.path_change {
548            out.push_str(&format!(
549                "    path: {} -> {}\n",
550                move_path.from, move_path.to
551            ));
552        }
553        for intent in &item.history {
554            out.push_str(&format!(
555                "    history: {} ({})\n",
556                intent.kind, intent.detail
557            ));
558        }
559        // WP07 T035 (mission `configurable-wip-limits-01M3C6WX`): what the shared WIP
560        // policy says about **this** item, from the projection the plan evaluated in
561        // plan order. An `allow` prints nothing — a preview that said "wip: allow" on
562        // every line of a limit-free batch would be noise — and the first diagnostic
563        // carries the sentence, which is what a reader acts on.
564        if let Some(wip) = &item.wip
565            && wip.decision.outcome != crate::wip::WipOutcome::Allow
566        {
567            let first = crate::wip::render_decision(&wip.decision);
568            let head = first.lines().next().unwrap_or_default();
569            out.push_str(&format!("    wip: {head}\n"));
570            // **Every** diagnostic, not only the first: the second one is usually the
571            // one that says what to do about it ("an authorized override is
572            // required"), and a preview that showed only "over the limit" would leave
573            // the reader with the problem and not the remedy.
574            for diagnostic in &wip.decision.diagnostics {
575                // The **kind** beside the sentence, the shape `tix recurring detect`
576                // uses for its notes: a script branches on `override_required` and a
577                // person reads the sentence beside it.
578                out.push_str(&format!(
579                    "        {}: {}\n",
580                    diagnostic.kind.as_str(),
581                    diagnostic.message
582                ));
583            }
584        }
585        let _ = mode;
586    }
587    if let Some(pair) = &plan.pair {
588        match (&pair.from, &pair.to) {
589            (Ok(from), Ok(to)) => {
590                out.push_str(&format!("pair: {} #{from} -> #{to}\n", pair.verb()))
591            }
592            _ => {
593                if let Some(diagnostic) = &pair.diagnostic {
594                    out.push_str(&format!("pair: refused — {diagnostic}\n"));
595                } else {
596                    out.push_str(&format!("pair: refused — {}\n", pair.verb()));
597                }
598            }
599        }
600    }
601    if let Some(archive) = &plan.archive {
602        // The count is the plan's own selection size, not a literal: a preview that
603        // under-reported the selection ("would archive 1 issue" for two selected)
604        // would be the one thing a preview exists to get right.
605        let count = plan.selection.len();
606        out.push_str(&format!(
607            "archive: would {} {count} issue{}\n",
608            if archive.archive {
609                "archive"
610            } else {
611                "restore"
612            },
613            if count == 1 { "" } else { "s" }
614        ));
615    }
616    for duplicate in plan.selection.duplicates() {
617        out.push_str(&format!(
618            "note: selector '{duplicate}' named an issue already selected\n"
619        ));
620    }
621    out.push_str(&format!("{}\n", plan.summary()));
622    out
623}
624
625/// The apply body: one line per item, then the counts, then the final paths.
626pub fn render_report(report: &ApplyReport, plan: &BatchPlan, mode: Mode) -> String {
627    let mut out = String::new();
628    out.push_str(&render_selection(plan));
629    let by_number: BTreeMap<u32, &str> = plan
630        .items
631        .iter()
632        .map(|i| (i.number, i.title.as_str()))
633        .collect();
634    for (number, outcome) in &report.outcomes {
635        let title = by_number.get(number).copied().unwrap_or("");
636        out.push_str(&outcome.as_line(*number, title));
637        out.push('\n');
638    }
639    // Every count, even the zeros: a batch that silently skipped 200 items must not
640    // read like one that wrote them.
641    out.push_str(&format!("{}\n", report.summary()));
642    // The final paths, because "applied" without "where" leaves a user hunting.
643    let moved: Vec<String> = report
644        .outcomes
645        .iter()
646        .filter_map(|(n, o)| match o {
647            BulkOutcome::Applied { filename } => Some(format!("#{n} -> {filename}")),
648            _ => None,
649        })
650        .collect();
651    if !moved.is_empty() {
652        out.push_str("final paths:\n");
653        for line in moved {
654            out.push_str(&format!("    {line}\n"));
655        }
656    }
657    let _ = mode;
658    out
659}
660
661/// The selection header, read from the **plan** rather than from a second value.
662///
663/// The first version took the selection as a second parameter, so the header and the
664/// plan's own items were two sources for the same facts — and a caller that passed a
665/// different selection would print a header describing issues the plan does not
666/// contain. The plan already carries `selection`.
667fn render_selection(plan: &BatchPlan) -> String {
668    let selection = &plan.selection;
669    let mut out = format!(
670        "selection: {} issue{} ({}, {} scope)\n",
671        selection.len(),
672        if selection.len() == 1 { "" } else { "s" },
673        selection.source().as_str(),
674        selection.scope().as_str()
675    );
676    let listing: Vec<String> = selection
677        .issues()
678        .iter()
679        .map(|i| format!("    #{} {}", i.number, i.title))
680        .collect();
681    if !listing.is_empty() {
682        out.push_str(&listing.join("\n"));
683        out.push('\n');
684    }
685    out
686}
687
688fn render_change(change: &bulk::FieldChange) -> String {
689    use bulk::FieldChange::{Clear, NoChange, Set};
690    match change {
691        Set { from, to } => format!("{from:?} -> {to}"),
692        Clear { from } => format!("{from:?} -> (empty)"),
693        NoChange { value } => format!("{value:?} (already in that state)"),
694    }
695}
696
697/// The exit code for a plan: non-zero if any item was refused, so a preview of a
698/// batch that will partly fail says so in the status as well as the text.
699///
700/// An archive or a pair plan is not itself a failure: its per-item verdicts come from
701/// the apply, and a preview that exited non-zero for a performable operation would say
702/// the operation is unavailable. The refusals a preview can see are a blocked field
703/// item and a pair whose endpoints do not resolve.
704pub fn exit_code_for_plan(plan: &BatchPlan) -> ExitCode {
705    let refused = plan.blocked().count()
706        + usize::from(plan.pair.as_ref().is_some_and(|p| p.diagnostic.is_some()));
707    if refused > 0 {
708        ExitCode::UsageConfig
709    } else {
710        ExitCode::Success
711    }
712}
713
714/// The exit code for an applied batch: non-zero unless every item did what was
715/// asked.
716pub fn exit_code_for_report(report: &ApplyReport) -> ExitCode {
717    let not_applied = report.noop() + report.rejected() + report.failed();
718    if not_applied > 0 {
719        ExitCode::UsageConfig
720    } else {
721        ExitCode::Success
722    }
723}
724
725/// The agents.rs documentation for the batch surface.
726///
727/// Generated rather than hand-written so the documented surface and the implemented one
728/// cannot drift — the same reason `tix init-agents` exists, and the same failure it
729/// was built to prevent.
730///
731/// Every list in the output comes from [`crate::bulk::metadata`] or from the flag
732/// struct, never from a literal here. A hand-written list would be a fifth
733/// description of the whitelist, and the previous four already disagreed at least
734/// once: WP06's `--estimate ""` finding was a flag, a schema and a doc treating a
735/// blank value three different ways.
736pub fn agents_doc() -> String {
737    use crate::bulk::metadata;
738    let operations = metadata::BulkOperation::ALL
739        .iter()
740        .map(|op| {
741            let mut line = format!("tix bulk {:<8} --select <list>", op.as_str());
742            if op.needs_target() {
743                line.push_str(" --target <sel>");
744            }
745            if op.needs_kind() {
746                line.push_str(" --type <kind>");
747            }
748            if op.is_field_patch() {
749                line.push_str(" <patch flags>");
750            }
751            line.push_str(" [--apply]");
752            line
753        })
754        .collect::<Vec<_>>()
755        .join("\n");
756    let out = format!(
757        "### `tix bulk`\n\n\
758         Batch editing over a selection. **Preview is the default**; `--apply` writes.\n\n\
759         ```text\n\
760         {operations}\n\
761         ```\n\n\
762         - Name **exactly one** of `--select <list>`, `--filter <query>` or\n  \
763           `--search <query>`. They are different grammars, so a bulk operation will\n  \
764           not guess which issues you meant.\n\
765         - Patch flags (the whole whitelist):\n  {}\n  \
766           `title` and `body` are deliberately **not** in it: a batch title rewrite is\n  \
767           the one operation nobody has asked for and everybody would regret.\n\
768         - `--clear <field>` takes one of:\n  {}\n  \
769           One per invocation — a second is refused rather than silently dropped. A\n  \
770           **blank** flag value is *unchanged*, never \"set to empty\": to empty a field,\n  \
771           name it in `--clear`.\n\
772         - `archive` delegates to the store's archive service: each selected issue's\n  \
773           file is renamed **in place** to a dot-prefixed name and exactly one History\n  \
774           event records it. `number`, `status` and every other frontmatter field are\n  \
775           left byte-identical, re-running reports the no-op, and nothing is deleted.\n\
776         - A **partial** batch exits non-zero and prints one outcome per selected\n  \
777           issue. Items that failed are **not** rolled back, and the output does not\n  \
778           imply that they are. Re-running is idempotent and reports the no-op.\n\
779         - Per-item statuses are the same four words on every surface:\n  {}\n",
780        metadata::field_names().join(", "),
781        metadata::clearable_field_names().join(", "),
782        metadata::OUTCOME_STATUSES
783            .iter()
784            .map(|s| format!("`{s}`"))
785            .collect::<Vec<_>>()
786            .join(", "),
787    );
788    out
789}
790
791/// The entry point `tix bulk` dispatches to: parse the action, build the request and
792/// the flags, and route.
793///
794/// The selection flags arrive as `Option<&str>` and the scope as a **string**, so the
795/// vocabulary lives in one place — [`SelectionScope::parse`] — and a new scope word
796/// cannot be accepted on one surface and not another.
797pub fn run(
798    store: &Store,
799    action: &crate::cli::BulkAction,
800    flags: &crate::cli::BulkSelection,
801    mode: Mode,
802) -> Result<ExitCode> {
803    let scope = match flags.scope.as_deref() {
804        Some(raw) => SelectionScope::parse(raw)?,
805        None => SelectionScope::Active,
806    };
807    let request = SelectionRequest::new(
808        &[flags.select.clone().unwrap_or_default()],
809        flags.filter.as_deref(),
810        flags.search.as_deref(),
811        scope,
812    )?;
813    match action {
814        crate::cli::BulkAction::Edit {
815            selection: _,
816            status,
817            priority,
818            assignee,
819            labels,
820            start_date,
821            due_date,
822            estimate,
823            cycles,
824            milestones,
825            clear,
826        } => run_edit(
827            store,
828            &request,
829            &PatchFlags {
830                status: status.clone(),
831                priority: priority.clone(),
832                assignee: assignee.clone(),
833                labels: labels.clone(),
834                start_date: start_date.clone(),
835                due_date: due_date.clone(),
836                estimate: estimate.clone(),
837                cycles: cycles.clone(),
838                milestones: milestones.clone(),
839                clear: clear.iter().cloned().collect(),
840            },
841            mode,
842        ),
843        crate::cli::BulkAction::Link {
844            selection: _,
845            target,
846            r#type,
847        } => run_pair(store, &request, Some(target), r#type.as_deref(), true, mode),
848        crate::cli::BulkAction::Unlink {
849            selection: _,
850            target,
851            r#type,
852        } => run_pair(
853            store,
854            &request,
855            Some(target),
856            r#type.as_deref(),
857            false,
858            mode,
859        ),
860        crate::cli::BulkAction::Archive { selection: _ } => run_archive(store, &request, mode),
861    }
862}
863
864// `Result<ExitCode>` everywhere is a **success path carrying a status**, not an error:
865// a partial batch is a completed command that reports non-zero, and
866// `contracts/cli-surface.md` requires the output be retained, which means the outcome
867// vector has to survive the return. So a refused item is a status, not an `Err` — and
868// the only `Err`s are about the *command line* (no selection, two selections, an
869// unknown `--clear`, a bad priority), which is the right place for them.
870
871/// The issues directory a store opened for, for a caller that reports paths.
872pub fn issues_dir(store: &Store) -> &Path {
873    &store.issues_dir
874}
875
876/// Silence the unused-import lint for a type only used in a doc link.
877const _: Option<Priority> = None;