Skip to main content

tix/commands/
mv.rs

1use std::io::IsTerminal;
2
3use crate::commands::parse_selector;
4use crate::config::Config;
5use crate::editor::read_tty_line;
6use crate::error::{Result, TixError};
7use crate::model::{HistoryEventKind, Issue};
8use crate::relationships::BlockedState;
9use crate::storage::Store;
10
11/// The board's configured completion column, never the literal `"done"`.
12///
13/// Research R7, and the reason this is a function rather than an inline
14/// `== "done"`: a board may name its completion column anything, and a move guard
15/// that hard-codes the string would silently stop guarding those boards — or, far
16/// worse, would guard the wrong column on a board that has no column named `done`
17/// at all.
18///
19/// **Workflow-*category* semantics are deliberately not handled here.** Category
20/// mapping and transition policy belong to source issue #183
21/// (`workflow-status-categories`). Adding a category concept to this guard would
22/// make completion mean two different things on two boards, which is the failure
23/// this function exists to prevent.
24pub fn completion_column(config: &Config) -> Option<String> {
25    // WP09: the **service's** completion column, not `done_column`. The two differ on
26    // a board whose `category_map` places `completed` somewhere other than
27    // `done_column`, and the difference disabled the blocked-move guard on exactly
28    // those boards: the guard early-returns for any column that is not `done_column`,
29    // so a blocked issue moved into the *real* completion column got no warning, no
30    // `--yes` requirement and no History entry, while a move into `done_column` —
31    // not complete at all — demanded confirmation. See
32    // `workflow::completion_column`, which records the reproduction.
33    crate::workflow::completion_column(config.default_config(), config)
34}
35
36/// What the shared move policy decided about one issue (FR-004).
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum MoveGuard {
39    /// Apply the move as-is. Either the move does not target the completion
40    /// column, or the issue has no open blockers, or the issue is **already** in
41    /// the target column.
42    Clear,
43    /// The issue has open blockers and the move targets the completion column.
44    /// The move needs an explicit acknowledgement before it may be applied.
45    Blocked(BlockedState),
46}
47
48impl MoveGuard {
49    /// Whether this move must be acknowledged before it can be applied.
50    pub fn needs_acknowledgement(&self) -> bool {
51        matches!(self, MoveGuard::Blocked(_))
52    }
53
54    /// The blocked reason to show, or `None` when the move is clear.
55    pub fn warning(&self) -> Option<String> {
56        match self {
57            MoveGuard::Clear => None,
58            MoveGuard::Blocked(state) => crate::commands::blocked_note(state),
59        }
60    }
61}
62
63/// The one blocked-move policy, shared by the CLI and the TUI (FR-004, C-003).
64///
65/// This is the single place the "moving blocked work toward completion warns,
66/// confirms, and records the decision" rule exists. The CLI and the TUI both call
67/// it, which is what makes their outcomes identical rather than merely similar —
68/// a TUI path with its own copy of this rule is the highest-risk defect in the
69/// mission, and factoring it here is the only thing that prevents one.
70///
71/// Three cases return [`MoveGuard::Clear`], and the third is the subtle one:
72///
73/// 1. the target column is not the board's completion column — blockers do not
74///    block ordinary progress;
75/// 2. the issue has no open blockers;
76/// 3. **the issue is already in the target column.** Re-running an applied move
77///    is a no-op, so it must not warn a second time and must not be the thing
78///    that appends a second acknowledgement. Making idempotency a property of the
79///    *guard* rather than of the caller's bookkeeping means a caller that forgets
80///    to check still cannot produce a duplicate event.
81///
82/// Blocked state is read from the service, never computed here (C-003): a local
83/// re-derivation is exactly the drift the parity contract's bypass rule exists to
84/// catch.
85/// The WIP decision for a move, from the shared policy (WP04 T018).
86///
87/// Returns the one History intent an `override` carries (`None` for `allow` and
88/// `warn`) and refuses with the policy's own rendered decision for a `block` or a
89/// `rejected` one — having written nothing, because nothing here can write. A `warn`
90/// is reported on **stderr** so stdout stays parseable (T021), and the move
91/// proceeds: a warning is not a refusal.
92fn wip_gate(
93    config: &Config,
94    store: &Store,
95    issue: &Issue,
96    column: &str,
97    override_reason: Option<&str>,
98    override_wip: bool,
99    confirmed: bool,
100) -> Result<Option<crate::wip::HistoryIntent>> {
101    let issues = store.list()?;
102    let context = crate::wip::MoveContext::new(config, &issues);
103    let mut request = crate::wip::MoveRequest::new(issue, column);
104    if let Some(reason) = override_reason {
105        request = request.because(reason);
106    }
107    // The bound limit is the one in force **now**: that is the limit the caller is
108    // being asked to authorize. A limit that changes between the question and the
109    // write makes the override stale rather than silently authoritative, which is
110    // what `--override` being a request rather than an authorization means (T020).
111    let decision = match config
112        .default_config()
113        .wip
114        .effective_limit(column)
115        .cloned()
116        .filter(|_| override_wip)
117    {
118        Some(limit) => {
119            // `confirmed` is the CLI's `--yes`, and it is **not** implied by
120            // `--override`: a flag that authorizes is not a flag that confirms the
121            // authorization, and the policy refuses the difference by name
122            // (`unconfirmed_override`) rather than assuming a caller meant it (T020).
123            let over = crate::wip::OverrideRequest {
124                confirmed,
125                ..crate::wip::OverrideRequest::new(
126                    crate::storage::actor_identity(),
127                    crate::model::utc_now_rfc3339(),
128                    override_reason.unwrap_or_default(),
129                    crate::wip::BoundLimit::of(column, &limit),
130                )
131            };
132            crate::wip::evaluate(&context, &request.overriding(&over))
133        }
134        None => crate::wip::evaluate(&context, &request),
135    };
136    match decision.outcome {
137        crate::wip::WipOutcome::Allow => Ok(None),
138        crate::wip::WipOutcome::Warn => {
139            eprintln!("{}", crate::wip::render_decision(&decision));
140            Ok(None)
141        }
142        crate::wip::WipOutcome::Override => Ok(decision.history.clone()),
143        // The policy asks #183 first (it must, to decide), so a refused *transition*
144        // arrives here too. It is deliberately **not** re-reported: the seam is about
145        // to make the same call, and its `TixError::Workflow` names the condition the
146        // way this command always has (`unknown_column`, `invalid_transition`), with
147        // the exit code that already had a contract. One refusal, one wording, one
148        // code — the existing conventions survive (T018).
149        crate::wip::WipOutcome::Rejected
150            if decision.diagnostics.first().is_some_and(|diagnostic| {
151                diagnostic.kind == crate::wip::WipDiagnosticKind::RejectedTransition
152            }) =>
153        {
154            Ok(None)
155        }
156        crate::wip::WipOutcome::Block | crate::wip::WipOutcome::Rejected => {
157            Err(TixError::Refused(crate::wip::render_decision(&decision)))
158        }
159    }
160}
161
162pub fn blocked_move_guard(
163    config: &Config,
164    store: &Store,
165    issue: &Issue,
166    column: &str,
167) -> Result<MoveGuard> {
168    if completion_column(config).as_deref() != Some(column) {
169        return Ok(MoveGuard::Clear);
170    }
171    if issue.status.is(column) {
172        // Already applied. See case 3 above.
173        return Ok(MoveGuard::Clear);
174    }
175    let (issues, done_column) = store.graph_source()?;
176    let graph = crate::commands::graph_over(&issues, &done_column);
177    let state = graph.blocked_state(issue.number);
178    Ok(if state.is_blocked {
179        MoveGuard::Blocked(state)
180    } else {
181        MoveGuard::Clear
182    })
183}
184
185/// What one guarded move did.
186#[derive(Debug, Clone)]
187pub struct GuardedMove {
188    /// The issue as stored after the move.
189    pub issue: Issue,
190    /// The blocked warning, when the guard raised one.
191    pub warning: Option<String>,
192    /// Whether a `blocked_move_acknowledged` History event was appended.
193    pub acknowledged: bool,
194}
195
196/// Apply a move through the shared guard, recording the acknowledgement when one
197/// was given and required.
198///
199/// `acknowledged` is the caller's answer to the guard: a CLI `--yes`, a TUI
200/// confirmation key, or a bulk/import path that has already made the decision. A
201/// caller that passes `false` for a move the guard flagged is **refused**, and
202/// refused before any file is written (SC-005) — not warned and then applied.
203///
204/// The History event is appended **here** and not by the TUI. Every surface
205/// recording the decision through one function is what makes the audit trail
206/// complete: a TUI that wrote its own event would be free to word it differently,
207/// or skip it, and the History would no longer be a record of decisions.
208///
209/// ## WP03: two writes, and why that is not a second write *path*
210///
211/// An acknowledged move writes twice: the seam's [`Store::set_status`], then this
212/// function's acknowledgement event. That is pre-existing behaviour and it is kept
213/// deliberately, because the two entries record two different decisions — a
214/// `transition_accepted` says the move was permitted, a
215/// `blocked_move_acknowledged` says the user chose to proceed past open blockers.
216/// Collapsing them would lose one of the two facts the audit trail exists for.
217///
218/// The seam is still the only *path* status is written on: this second
219/// `store.update` re-serialises an issue the seam has already validated, and adds
220/// a History line. It does not judge a transition, so it holds no policy. The test
221/// that would catch a future leak is in the WP's Test Strategy: one
222/// `transition_accepted` per accepted move, and nothing added by a refusal.
223// The move's inputs are the guard's (config, store, selector, target), the write's
224// (commit, override reason), the acknowledgement, and now the WIP override's event:
225// eight independent facts, each of which a caller supplies or does not. A struct
226// would only move the list one line up; `run` above carries the same allow.
227#[allow(clippy::too_many_arguments)]
228pub fn apply_guarded(
229    config: &Config,
230    store: &Store,
231    raw: &str,
232    column: &str,
233    commit: Option<&str>,
234    override_reason: Option<&str>,
235    acknowledged: bool,
236    wip: Option<&crate::wip::HistoryIntent>,
237) -> Result<GuardedMove> {
238    let issue = store.get(parse_selector(store, raw)?)?;
239    let guard = blocked_move_guard(config, store, &issue, column)?;
240    let warning = guard.warning();
241
242    if guard.needs_acknowledgement() && !acknowledged {
243        return Err(TixError::InvalidInput(format!(
244            "{} — moving to '{column}' with open blockers requires an explicit \
245             confirmation; re-run with --yes to record the decision",
246            warning.unwrap_or_else(|| "blocked".to_string())
247        )));
248    }
249
250    let acknowledgement = guard.needs_acknowledgement();
251    // One transaction for the whole move (#1580). An acknowledged move writes
252    // twice — the status seam's `set_status`, then this function's
253    // acknowledgement event — and `apply` may itself write a second time (the
254    // inbox entry stamp), so the sequence is one unit: a failure on the later
255    // write must not leave the status change committed without its record.
256    let moved = store.transaction(|| -> Result<crate::model::Issue> {
257        let mut moved = apply(store, raw, column, commit, override_reason, wip)?;
258        if acknowledgement {
259            // Appended only here, only on the accepted mutation, so a re-run — which
260            // the guard reports as `Clear` because the issue is already in the
261            // column — cannot append a second entry. Existing entries are never
262            // touched: `append_now` is the only mutation, so prior History is
263            // byte-identical afterwards.
264            //
265            // The **service's** `reason`, not the surfaces' `blocked_note` wrapper.
266            // `blocked_note` prepends `blocked: ` for the CLI warning, the TUI prompt
267            // and the detail view, so using it here produced
268            // `moved to done: blocked: Blocked by #0 Issue A` — the prefix twice.
269            // The History line is not a surface; it is the record, and it reads
270            // better naming the blockers than saying `blocked:` before naming them.
271            let reason = match &guard {
272                MoveGuard::Blocked(state) => state.reason.clone(),
273                MoveGuard::Clear => String::new(),
274            };
275            moved.history.append_now(
276                &crate::storage::actor_identity(),
277                HistoryEventKind::BlockedMoveAcknowledged,
278                &format!("moved to {column}: {reason}"),
279            );
280            moved.bump_updated();
281            moved.filename = store.update(&moved.filename, &moved)?;
282        }
283        Ok(moved)
284    })?;
285
286    Ok(GuardedMove {
287        issue: moved,
288        warning,
289        acknowledged: acknowledgement,
290    })
291}
292
293/// `tix move <issue> <column>` (FR-005): validate the column, and when the
294/// target is the `done_column`, prompt for a commit hash on a TTY or accept
295/// `--commit <hash>`; moving *out* of done clears the recorded commit.
296///
297/// The interactive commit prompt (`read_line` on a TTY) is CLI glue
298/// (C-005); the done-column rule itself lives in [`apply`], which the MCP
299/// `move_issue` tool (WP04) calls directly.
300#[allow(clippy::too_many_arguments)]
301pub fn run(
302    config: &Config,
303    store: &Store,
304    raw: &str,
305    column: &str,
306    commit: Option<&str>,
307    override_reason: Option<&str>,
308    yes: bool,
309    override_wip: bool,
310) -> Result<()> {
311    // WP04 T016 step 2: the commit-prompt trigger used to be
312    // `board.effective_done_column() == Some(column)` — a completion check that
313    // lives in the command and knows nothing about categories (C-004). It is now
314    // the **service's** decision on the target column, so the prompt fires for
315    // exactly the moves the seam will record as reaching completion.
316    //
317    // These two could genuinely disagree, and the disagreement is user-visible
318    // rather than academic. A board that maps `shipped` to `completed` while
319    // keeping `done_column = "done"` has a completion column that is *not* the
320    // column the service calls complete: the old trigger prompted for a commit
321    // on a move to `done` that the seam would not record as complete, and stayed
322    // silent on a move to `shipped` that it would. One of the two answers was a
323    // lie, and which one depended on the board's configuration.
324    let (board_name, resolution) = crate::workflow::active_resolution(config);
325    let issue = store.get(parse_selector(store, raw)?)?;
326
327    // The guard runs **before** the commit prompt: a move that will be refused
328    // for open blockers should not first ask the user for a commit hash, and
329    // auto-capturing `HEAD` for a move that is then refused records nothing but
330    // wastes the one clean chance the user had to notice.
331    let guard = blocked_move_guard(config, store, &issue, column)?;
332    if let Some(warning) = guard.warning() {
333        eprintln!(
334            "{}",
335            crate::diagnostics::Diagnostic::warning("W-tix-move", warning.clone()).render_auto()
336        );
337        if !yes {
338            return Err(TixError::InvalidInput(format!(
339                "{warning} — moving to '{column}' with open blockers requires an \
340                 explicit confirmation; re-run with --yes to record the decision"
341            )));
342        }
343    }
344
345    // WP04 T018 (mission `configurable-wip-limits-01M3C6WX`): the **one** WIP
346    // decision, from the shared policy service, before anything is written. It runs
347    // after the blocker guard (a move refused for open blockers should not first be
348    // judged against a limit) and before the commit prompt (a move that will be
349    // refused must not ask the user for a hash). A `warn` proceeds with a warning on
350    // stderr; an `override` proceeds and carries the one History entry the move
351    // append writes; a `block` or a `rejected` decision refuses here, having written
352    // nothing.
353    let wip = wip_gate(
354        config,
355        store,
356        &issue,
357        column,
358        override_reason,
359        override_wip,
360        yes,
361    )?;
362
363    // WP04 T016 steps 3-4: the transition is judged by the seam, on the way to the
364    // write, and the reason supplied here is what lands in the `TransitionDecision`.
365    //
366    // **This function does not pre-judge the transition, and that is a correction.**
367    // It used to call `validate_transition` here first, on the stated reasoning that
368    // a refusal must be explained before the commit prompt is shown. WP04's own
369    // verification showed that reasoning was wrong: the pre-judgement returned
370    // `TixError::Workflow(diagnostic)` — the *same* diagnostic the seam returns — so
371    // it produced an identical message and changed nothing observable. Reverting it
372    // left every WP04 test green, which is what proved it was a duplicate
373    // evaluation rather than a second safety net.
374    //
375    // It is also safe to omit, which is not obvious. A move that reaches completion
376    // can never be refused: `unknown_column` cannot apply to a configured column, the
377    // terminal lock only refuses leaving a terminal category, and a same-column
378    // request is accepted. So no refused move ever reaches the prompt below, and
379    // dropping the pre-judgement cannot make a user answer a prompt and *then* be
380    // refused.
381    let reaches_completion = resolution
382        .resolve(column)
383        .is_some_and(crate::workflow::WorkflowCategory::is_complete);
384
385    let commit = if reaches_completion && commit.is_none() {
386        // Auto-capture `HEAD` (the work commit the user just made) before
387        // falling back to the interactive prompt / hard error, so the common
388        // "commit, then close" flow records the hash without a second pass.
389        if let Some(head) = std::env::current_dir()
390            .ok()
391            .as_deref()
392            .and_then(crate::git::current_head)
393        {
394            Some(head)
395        } else if std::io::stdin().is_terminal() {
396            let trimmed = read_tty_line("Commit hash (or leave empty): ")?;
397            if trimmed.is_empty() {
398                None
399            } else {
400                Some(trimmed)
401            }
402        } else {
403            return Err(TixError::InvalidInput(format!(
404                "moving to '{column}' requires a commit hash — pass --commit <hash>"
405            )));
406        }
407    } else {
408        commit.map(str::to_string)
409    };
410    let moved = apply_guarded(
411        config,
412        store,
413        raw,
414        column,
415        commit.as_deref(),
416        override_reason,
417        yes,
418        wip.as_ref(),
419    )?;
420    let issue = moved.issue;
421    match issue.commit.as_deref() {
422        Some(hash) => println!("Moved {} to {column} (commit {hash})", issue.filename),
423        None => println!("Moved {} to {column}", issue.filename),
424    }
425    if moved.acknowledged {
426        // Said on stdout as well as stderr so a non-interactive run's log records
427        // that the decision was taken deliberately rather than slipping through.
428        println!("Acknowledged blocked move to {column}");
429    }
430    // T016 step 5: report the resolved category, not just the column. The column
431    // name is what a user typed; the category is what the board considers it, and
432    // on a board with a `category_map` those are different things — reporting only
433    // the column leaves a user unable to tell whether a move landed in the category
434    // they meant.
435    if let Some(category) = crate::workflow::category_of(&issue, &resolution) {
436        println!(
437            "  {column} is `{}` ({}) on board {board_name}",
438            category.key(),
439            category.label()
440        );
441    }
442    Ok(())
443}
444
445/// `tix move <ISSUES…> <COLUMN>` — the batch form.
446///
447/// A selection of one delegates to [`run`], so the single-issue surface (the
448/// commit prompt, the resolved-category report) is byte-compatible. A larger
449/// selection prints a plan and, with `--yes`, applies each item through the same
450/// [`apply_guarded`] the single command uses: the guards, the transition seam and
451/// the WIP policy are not re-implemented here. A refused item does not roll back
452/// the items already applied, and the command exits `1` when any item was
453/// refused, matching `tix bulk`.
454#[allow(clippy::too_many_arguments)]
455#[allow(clippy::too_many_lines)]
456pub fn run_batch(
457    config: &Config,
458    store: &Store,
459    args: &[String],
460    select: Option<&str>,
461    filter: Option<&str>,
462    search: Option<&str>,
463    commit: Option<&str>,
464    override_reason: Option<&str>,
465    yes: bool,
466    override_wip: bool,
467    dry_run: bool,
468) -> Result<crate::cli::ExitCode> {
469    use crate::cli::ExitCode;
470
471    let Some(column) = args.last().cloned() else {
472        return Err(TixError::InvalidInput(
473            "`tix move` needs a target column as its last value".to_string(),
474        ));
475    };
476    let mut selectors: Vec<String> = args[..args.len() - 1].to_vec();
477    if let Some(select) = select {
478        selectors.push(select.to_string());
479    }
480    let request = crate::commands::bulk::SelectionRequest::new(
481        &selectors,
482        filter,
483        search,
484        crate::bulk::SelectionScope::Active,
485    )?;
486    let selection = crate::commands::bulk::resolve(store, &request)?;
487    if selection.is_empty() {
488        return Err(TixError::NotFound(
489            "no issues match the move selection".to_string(),
490        ));
491    }
492
493    // `--dry-run` is read-only for any size, including a single selector.
494    if dry_run {
495        print_batch_move_plan(&selection, &column, true);
496        return Ok(ExitCode::Success);
497    }
498
499    // A selection of one is the single-issue command, unchanged.
500    if selection.len() == 1 {
501        let raw = selection.issues()[0].number.to_string();
502        run(
503            config,
504            store,
505            &raw,
506            &column,
507            commit,
508            override_reason,
509            yes,
510            override_wip,
511        )?;
512        return Ok(ExitCode::Success);
513    }
514
515    if !yes {
516        print_batch_move_plan(&selection, &column, false);
517        return Ok(ExitCode::UsageConfig);
518    }
519
520    // One commit answer for the batch. `auto` stays `auto` so each issue resolves
521    // its own closing commit; an explicit hash is recorded verbatim; absent both,
522    // HEAD is captured once, and a non-TTY batch refuses before writing anything.
523    let (_, resolution) = crate::workflow::active_resolution(config);
524    let reaches_completion = resolution
525        .resolve(&column)
526        .is_some_and(crate::workflow::WorkflowCategory::is_complete);
527    let commit: Option<String> = if reaches_completion {
528        match commit {
529            Some(value) => Some(value.to_string()),
530            None => {
531                if let Some(head) = std::env::current_dir()
532                    .ok()
533                    .as_deref()
534                    .and_then(crate::git::current_head)
535                {
536                    Some(head)
537                } else if std::io::stdin().is_terminal() {
538                    let answer = read_tty_line("Commit hash (or leave empty): ")?;
539                    let answer = answer.trim();
540                    (!answer.is_empty()).then(|| answer.to_string())
541                } else {
542                    return Err(TixError::InvalidInput(format!(
543                        "moving to '{column}' requires a commit hash — pass --commit <hash>"
544                    )));
545                }
546            }
547        }
548    } else {
549        commit.map(str::to_string)
550    };
551
552    let mut moved = 0usize;
553    let mut refused: Vec<(u32, String)> = Vec::new();
554    for issue in selection.issues() {
555        let wip = match wip_gate(
556            config,
557            store,
558            issue,
559            &column,
560            override_reason,
561            override_wip,
562            yes,
563        ) {
564            Ok(intent) => intent,
565            Err(error) => {
566                refused.push((issue.number, error.to_string()));
567                continue;
568            }
569        };
570        match apply_guarded(
571            config,
572            store,
573            &issue.filename,
574            &column,
575            commit.as_deref(),
576            override_reason,
577            yes,
578            wip.as_ref(),
579        ) {
580            Ok(outcome) => {
581                moved += 1;
582                match outcome.issue.commit.as_deref() {
583                    Some(hash) => println!("#{} moved to {column} (commit {hash})", issue.number),
584                    None => println!("#{} moved to {column}", issue.number),
585                }
586            }
587            Err(error) => refused.push((issue.number, error.to_string())),
588        }
589    }
590    for (number, reason) in &refused {
591        println!("#{number} refused: {reason}");
592    }
593    println!("{} moved, {} refused", moved, refused.len());
594    Ok(if refused.is_empty() {
595        ExitCode::Success
596    } else {
597        ExitCode::UsageConfig
598    })
599}
600
601/// The batch plan: the selection, the target and the fact that nothing was
602/// written. One line per issue, so `--dry-run` shows exactly what `--yes` would
603/// act on.
604fn print_batch_move_plan(selection: &crate::bulk::Selection, column: &str, dry_run: bool) {
605    println!(
606        "selection: {} issue{} ({}, {} scope)",
607        selection.len(),
608        if selection.len() == 1 { "" } else { "s" },
609        selection.source().as_str(),
610        selection.scope().as_str()
611    );
612    for issue in selection.issues() {
613        println!("    #{} {}", issue.number, issue.title);
614    }
615    println!("would move {} issue(s) to {column}", selection.len());
616    if dry_run {
617        println!("dry run: nothing written");
618    } else {
619        println!("nothing written; re-run with --yes to apply");
620    }
621}
622
623///
624/// Side-effect policy: never reads `stdin`/writes `stdout` — the done-column
625/// commit *prompt* stays in [`run`]; this core only applies the rule:
626/// - moving into `done_column` records the passed `commit` (or `None`),
627/// - `commit` of `"auto"` resolves the closing commit from git history,
628/// - moving out of `done_column` clears `commit` (set to `None`).
629///
630/// Selector ambiguity/missing resolution is unchanged from the CLI path.
631///
632/// ## WP03: this is a thin adapter now
633///
634/// Every line of policy that used to live here — the unknown-column check, the
635/// `done_column` coupling, the status assignment, the `updated` bump, the write —
636/// moved into [`Store::set_status`], the storage seam. What remains is resolving
637/// the selector and `"auto"`, which are command-layer concerns.
638///
639/// The consequence worth stating: this function can no longer be *the* move
640/// implementation, so the TUI, MCP, and `tix edit --status` cannot bypass policy
641/// by going around it. Research R8 found MCP could change a status with no check
642/// at all, precisely because the checked path was a command-layer one and MCP had
643/// a route that skipped it.
644///
645/// **`config` is gone from the signature.** It became unused once the policy
646/// moved, because the seam reads the configuration from the `Store` that owns it.
647/// A parameter left in place would tell the next reader that this function
648/// consults a configuration it does not, and that is the kind of thing that
649/// survives for years. The only non-test caller is [`apply_guarded`], which still
650/// needs `config` for the blocked-move guard.
651#[allow(clippy::too_many_arguments)]
652pub fn apply(
653    store: &Store,
654    raw: &str,
655    column: &str,
656    commit: Option<&str>,
657    override_reason: Option<&str>,
658    wip: Option<&crate::wip::HistoryIntent>,
659) -> Result<Issue> {
660    let selector = parse_selector(store, raw)?;
661    let issue = store.get(selector)?;
662
663    // The override reason is written into the **same line-based `## History`
664    // grammar** every other move uses, so a line break in it would end the event's
665    // line and start another the next reader parses as a real event. Refused, not
666    // stripped, and through the shared predicate the triage service uses — one
667    // decision about the grammar rather than a fourth local copy (tix #1493). The
668    // value is named by *meaning* so a surface whose flag is spelled differently
669    // (MCP's `reason`, the TUI's prompt) still reads correctly.
670    if let Some(reason) = override_reason {
671        if reason.chars().any(crate::triage::decision::is_line_break) {
672            return Err(TixError::InvalidInput(
673                "invalid_reason: the override reason has a line break; it is written to \
674                 one History line, so a line break would look like a second event"
675                    .to_string(),
676            ));
677        }
678    }
679
680    let commit: Option<String> = match commit {
681        Some("auto") => Some(crate::commands::resolve_auto_commit(store, &issue)?),
682        Some(other) => Some(other.to_string()),
683        None => None,
684    };
685
686    // The seam: validates against the transition service, applies the done-column
687    // commit coupling, appends exactly one `TransitionDecision`, and writes once.
688    // A refusal arrives as `Err(TixError::Workflow(_))` having touched nothing.
689    let outcome = store.set_status(
690        &issue.filename,
691        crate::storage::StatusChange::to(column)
692            .with_commit(commit.as_deref())
693            .with_override(override_reason),
694    )?;
695
696    // A move into the inbox is an **entry**: stamp the queue age it is measured
697    // from, with the `moved` origin (tix #1489). The status seam wrote the move
698    // first — the landing column is what decides whether the stamp is due — so the
699    // stamp rides a second write, and only when the move actually wrote (a
700    // same-column re-apply must not restart the wait).
701    let mut moved = outcome.issue;
702    if outcome.written && crate::triage::record_move_entry(&store.config, &mut moved)? {
703        moved.filename = store.update(&moved.filename, &moved)?;
704    }
705    // The override's one History entry (WP04 T020): appended **only** when the move
706    // actually wrote, through the same `## History` grammar every other event uses,
707    // and with the actor and timestamp the override was authorized with rather than
708    // a fresh clock read. A refused or same-column move writes nothing here, so a
709    // repeated request cannot append a second entry.
710    if let Some(intent) = wip
711        && outcome.written
712    {
713        moved.history.append(crate::model::HistoryEvent::new(
714            intent.timestamp.clone(),
715            &intent.actor,
716            intent.kind,
717            &intent.detail,
718        ));
719        moved.bump_updated();
720        moved.filename = store.update(&moved.filename, &moved)?;
721    }
722    Ok(moved)
723}
724
725#[cfg(test)]
726mod tests {
727    #[allow(clippy::wildcard_imports)]
728    use super::*;
729    use crate::commands::create::create;
730    use crate::model::Issue;
731    use crate::model::Status;
732    use crate::storage::IssueSelector;
733    use std::fs;
734    use tempfile::TempDir;
735
736    fn make_store() -> (TempDir, Store) {
737        let tmp = TempDir::new().unwrap();
738        let dir = tmp.path().join("issues");
739        fs::create_dir_all(&dir).unwrap();
740        let config_path = tmp.path().join("tix.toml");
741        let config = crate::config::Config {
742            issues_dir: dir.clone(),
743            ..crate::config::Config::default()
744        };
745        config.save(&config_path).unwrap();
746        let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
747        (
748            tmp,
749            Store {
750                issues_dir: dir,
751                config,
752            },
753        )
754    }
755
756    /// Seed an issue with a **distinct** title.
757    ///
758    /// The title has to vary per call: the filename is derived from it, so two
759    /// issues with the same title resolve to the same path and the second
760    /// silently overwrites the first. That was invisible while every test seeded
761    /// exactly one issue — `create` returns the in-memory record, so a clobbered
762    /// issue still *looks* created while `store.get` cannot find it. Seeding more
763    /// than one is what exposed it.
764    fn seed_named(store: &Store, title: &str) -> Issue {
765        create(
766            &store.config,
767            store,
768            title,
769            Some("body"),
770            None,
771            None,
772            Some(""),
773            None,
774            None,
775            None,
776            None,
777            None,
778            None,
779            None,
780            None,
781            None,
782            None,
783            None,
784        )
785        .unwrap()
786    }
787
788    /// Seed an issue with a title unique to this call.
789    fn seed(store: &Store) -> Issue {
790        thread_local! {
791            static COUNTER: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
792        }
793        let n = COUNTER.with(|c| {
794            let n = c.get();
795            c.set(n + 1);
796            n
797        });
798        seed_named(store, &format!("Move me {n}"))
799    }
800
801    #[test]
802    fn apply_into_done_records_commit() {
803        let (tmp, store) = make_store();
804        let _ = tmp;
805        let seeded = seed(&store);
806        let moved = apply(
807            &store,
808            &seeded.number.to_string(),
809            "done",
810            Some("abc123"),
811            None,
812            None,
813        )
814        .unwrap();
815        assert_eq!(moved.status.display(), "done");
816        assert_eq!(moved.commit.as_deref(), Some("abc123"));
817        assert_eq!(moved.number, seeded.number);
818        let reloaded = store
819            .get(crate::storage::IssueSelector::Filename(
820                moved.filename.clone(),
821            ))
822            .unwrap();
823        assert_eq!(reloaded.commit.as_deref(), Some("abc123"));
824    }
825
826    #[test]
827    fn apply_out_of_done_clears_commit() {
828        let (tmp, store) = make_store();
829        let _ = tmp;
830        let seeded = seed(&store);
831        apply(
832            &store,
833            &seeded.number.to_string(),
834            "done",
835            Some("abc123"),
836            /* override_reason */ None,
837            None,
838        )
839        .unwrap();
840        // WP03: on a board with **no** `category_map` — which is every board in
841        // the wild before this mission — the move out of `done` is permitted, so
842        // the commit is cleared exactly as before. This is the half of the
843        // behaviour that must not change: the terminal lock and adjacency are
844        // opt-in, and this is the proof that a legacy board does not acquire a
845        // policy it never asked for.
846        let moved_out = apply(
847            &store,
848            &seeded.number.to_string(),
849            "in progress",
850            None,
851            None,
852            None,
853        )
854        .unwrap();
855        assert_eq!(moved_out.status.display(), "in progress");
856        assert_eq!(
857            moved_out.commit, None,
858            "moving out of done clears the commit"
859        );
860    }
861
862    #[test]
863    fn apply_out_of_done_is_refused_on_a_board_that_declared_a_category_map() {
864        // The other half of the test above, and the reason the opt-in is keyed on
865        // `category_map` rather than on `terminal_locked`. The same move, on a
866        // board whose author *did* describe their categories, is refused by name
867        // and writes nothing.
868        let (tmp, store) = store_with_category_map();
869        let _ = tmp;
870        let seeded = seed(&store);
871        let moved = apply(
872            &store,
873            &seeded.number.to_string(),
874            "done",
875            Some("abc123"),
876            None,
877            None,
878        )
879        .unwrap();
880        let before = std::fs::read_to_string(store.issues_dir.join(&moved.filename)).unwrap();
881
882        let refused =
883            apply(&store, &seeded.number.to_string(), "todo", None, None, None).unwrap_err();
884        assert!(
885            matches!(&refused, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
886            "expected the named `invalid_transition`, got: {refused:?}"
887        );
888        let text = refused.to_string();
889        assert!(
890            text.contains("needs an explicit override with a reason"),
891            "and the message must say how to proceed: {text}"
892        );
893        assert_eq!(
894            std::fs::read_to_string(store.issues_dir.join(&moved.filename)).unwrap(),
895            before,
896            "a refusal must be byte-identical, not merely unsuccessful"
897        );
898
899        // With a reason, the same move applies, and the reason is recorded so the
900        // audit trail explains itself.
901        let moved_out = apply(
902            &store,
903            &seeded.number.to_string(),
904            "todo",
905            None,
906            Some("reopening: filed against the wrong epic"),
907            None,
908        )
909        .unwrap();
910        assert_eq!(moved_out.status.display(), "todo");
911        assert_eq!(moved_out.commit, None, "and the commit is cleared");
912        let events = moved_out.history.events();
913        let decision = events
914            .iter()
915            .rev()
916            .find(|e| e.kind == HistoryEventKind::TransitionAccepted)
917            .expect("the accepted move records a decision");
918        let parsed = crate::model::TransitionDecision::from_event(decision).unwrap();
919        assert_eq!(parsed.from, "done");
920        assert_eq!(parsed.to, "todo");
921        assert_eq!(parsed.from_category, "completed");
922        assert_eq!(parsed.to_category, "unstarted");
923        assert_eq!(
924            parsed.override_reason.as_deref(),
925            Some("reopening: filed against the wrong epic"),
926            "the reason is recorded, so the audit trail explains the override"
927        );
928    }
929
930    #[test]
931    fn apply_into_done_without_commit_leaves_none() {
932        let (tmp, store) = make_store();
933        let _ = tmp;
934        let seeded = seed(&store);
935        let moved = apply(&store, &seeded.number.to_string(), "done", None, None, None).unwrap();
936        assert_eq!(moved.status.display(), "done");
937        assert_eq!(moved.commit, None);
938    }
939
940    #[test]
941    fn apply_rejects_unknown_column() {
942        let (tmp, store) = make_store();
943        let _ = tmp;
944        let seeded = seed(&store);
945        let err = apply(
946            &store,
947            &seeded.number.to_string(),
948            "bogus",
949            None,
950            None,
951            None,
952        )
953        .unwrap_err();
954        // WP03: the service owns this message now, and it names the *kind* before
955        // the prose — so this asserts the contract-relevant part (the shared
956        // diagnostic name, contract §6) as well as the full text. Before WP03 this
957        // was `TixError::InvalidInput`, a string identical in shape to a typo
958        // caught by argument parsing, so a caller could not tell "no such column"
959        // from "that move is against policy". That distinction is the reason the
960        // variant exists, so it is asserted rather than assumed.
961        assert!(
962            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "unknown_column"),
963            "expected the named `unknown_column` diagnostic, got: {err:?}"
964        );
965        assert_eq!(
966            err.to_string(),
967            "transition rejected (unknown_column) for todo -> bogus: `bogus` is not a \
968             column on board `default`; valid columns: todo, in progress, in review, done"
969        );
970    }
971
972    #[test]
973    fn apply_invalid_selector_is_not_found() {
974        let (tmp, store) = make_store();
975        let _ = tmp;
976        let _ = seed(&store);
977        let err = apply(&store, "999", "todo", None, None, None).unwrap_err();
978        assert!(matches!(err, TixError::NotFound(_)));
979    }
980
981    // ---- T016/T017: the blocked-move guard ---------------------------
982
983    /// A store whose board **declares a `category_map`**, i.e. one that has opted
984    /// into category policy.
985    ///
986    /// WP03. The lock and adjacency are gated on exactly this (see
987    /// `CategoryResolution::explicit_policy`), so a test that wants policy needs
988    /// this and a test that wants the *absence* of policy needs [`make_store`],
989    /// which has no map. The mapping is chosen so a move out of `done` is
990    /// unambiguous: `done` is `completed`, and `todo`/`doing` are `unstarted` —
991    /// which is backwards in the declared order, so the lock fires first and the
992    /// diagnostic is the lock's rather than adjacency's.
993    fn store_with_category_map() -> (TempDir, Store) {
994        let tmp = TempDir::new().unwrap();
995        let dir = tmp.path().join("issues");
996        fs::create_dir_all(&dir).unwrap();
997        let config_path = tmp.path().join("tix.toml");
998        let config = crate::config::Config {
999            issues_dir: dir.clone(),
1000            kanban: crate::config::KanbanSection {
1001                configurations: indexmap::IndexMap::from([(
1002                    "default".to_string(),
1003                    crate::config::BoardConfiguration {
1004                        columns: vec!["todo".into(), "doing".into(), "done".into()],
1005                        done_column: Some("done".to_string()),
1006                        category_map: indexmap::IndexMap::from([
1007                            ("todo".to_string(), "unstarted".to_string()),
1008                            ("doing".to_string(), "active".to_string()),
1009                            ("done".to_string(), "completed".to_string()),
1010                        ]),
1011                        ..Default::default()
1012                    },
1013                )]),
1014            },
1015            ..crate::config::Config::default()
1016        };
1017        config.save(&config_path).unwrap();
1018        let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
1019        (
1020            tmp,
1021            Store {
1022                issues_dir: dir,
1023                config,
1024            },
1025        )
1026    }
1027
1028    /// A store whose board names its completion column `done_column`, default
1029    /// `done`.
1030    fn store_with_done_column(done_column: &str) -> (TempDir, Store) {
1031        let tmp = TempDir::new().unwrap();
1032        let dir = tmp.path().join("issues");
1033        fs::create_dir_all(&dir).unwrap();
1034        let config_path = tmp.path().join("tix.toml");
1035        let config = crate::config::Config {
1036            issues_dir: dir.clone(),
1037            kanban: crate::config::KanbanSection {
1038                configurations: indexmap::IndexMap::from([(
1039                    "default".to_string(),
1040                    crate::config::BoardConfiguration {
1041                        columns: vec!["todo".into(), "doing".into(), done_column.into()],
1042                        done_column: Some(done_column.to_string()),
1043                        // Derived, not listed: naming every field here would make
1044                        // the test fail to compile the next time one is added, for
1045                        // no extra assurance about the guard.
1046                        ..Default::default()
1047                    },
1048                )]),
1049            },
1050            ..crate::config::Config::default()
1051        };
1052        config.save(&config_path).unwrap();
1053        let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
1054        (
1055            tmp,
1056            Store {
1057                issues_dir: dir,
1058                config,
1059            },
1060        )
1061    }
1062
1063    /// A blocker and a blocked issue joined by an open `blocks` edge, plus a
1064    /// third issue that is only `related` to the blocked one.
1065    fn seed_blocked_pair(store: &Store) -> (Issue, Issue, Issue) {
1066        let blocker = seed(store);
1067        let subject = seed(store);
1068        let friend = seed(store);
1069        store
1070            .link(
1071                &blocker.number.to_string(),
1072                &subject.number.to_string(),
1073                crate::relationships::RelationKind::Blocks,
1074            )
1075            .unwrap();
1076        store
1077            .link(
1078                &friend.number.to_string(),
1079                &subject.number.to_string(),
1080                crate::relationships::RelationKind::Related,
1081            )
1082            .unwrap();
1083        (blocker, subject, friend)
1084    }
1085
1086    /// Every `blocked_move_acknowledged` events in an issue, in file order.
1087    fn acknowledged_events(issue: &Issue) -> Vec<String> {
1088        issue
1089            .history
1090            .events()
1091            .iter()
1092            .filter(|e| e.kind == HistoryEventKind::BlockedMoveAcknowledged)
1093            .map(|e| e.detail.clone())
1094            .collect()
1095    }
1096
1097    #[test]
1098    fn t016_a_blocked_move_toward_completion_is_flagged_and_names_the_blocker() {
1099        let (tmp, store) = make_store();
1100        let _ = tmp;
1101        let (blocker, subject, _) = seed_blocked_pair(&store);
1102        let guard = blocked_move_guard(
1103            &store.config,
1104            &store,
1105            &store.get(IssueSelector::Number(subject.number)).unwrap(),
1106            "done",
1107        )
1108        .unwrap();
1109        assert!(guard.needs_acknowledgement(), "{guard:?}");
1110        let warning = guard.warning().unwrap();
1111        assert!(
1112            warning.contains(&blocker.number.to_string()),
1113            "the warning names the blocking issue: {warning}"
1114        );
1115        assert!(
1116            warning.contains("blocked:"),
1117            "and uses the shared wording every surface renders: {warning}"
1118        );
1119    }
1120
1121    #[test]
1122    fn t016_the_warning_is_the_service_reason_verbatim() {
1123        // T016's note: warning content is part of the contract, and it must be
1124        // derived from `BlockedState.reason` rather than composed per surface.
1125        let (tmp, store) = make_store();
1126        let _ = tmp;
1127        let (_, subject, _) = seed_blocked_pair(&store);
1128        let issue = store.get(IssueSelector::Number(subject.number)).unwrap();
1129        let (issues, done_column) = store.graph_source().unwrap();
1130        let graph = crate::commands::graph_over(&issues, &done_column);
1131        let expected = graph.blocked_state(subject.number).reason;
1132        let guard = blocked_move_guard(&store.config, &store, &issue, "done").unwrap();
1133        assert_eq!(
1134            guard.warning().unwrap(),
1135            format!("blocked: {expected}"),
1136            "the guard must not re-word the service"
1137        );
1138    }
1139
1140    #[test]
1141    fn t016_without_acknowledgement_the_move_is_refused_and_the_file_is_unchanged() {
1142        let (tmp, store) = make_store();
1143        let _ = tmp;
1144        let (_, subject, _) = seed_blocked_pair(&store);
1145        let sel = subject.number.to_string();
1146        let before = fs::read_to_string(store.issues_dir.join(&subject.filename)).unwrap();
1147
1148        let err = apply_guarded(
1149            &store.config,
1150            &store,
1151            &sel,
1152            "done",
1153            Some("abc123"),
1154            None,
1155            false,
1156            None,
1157        )
1158        .unwrap_err();
1159        let text = err.to_string();
1160        assert!(
1161            text.contains("blocked"),
1162            "the refusal names the reason: {text}"
1163        );
1164        assert!(text.contains("--yes"), "and says how to proceed: {text}");
1165        assert_eq!(
1166            fs::read_to_string(store.issues_dir.join(&subject.filename)).unwrap(),
1167            before,
1168            "a refused move must not write (SC-005)"
1169        );
1170    }
1171
1172    #[test]
1173    fn t016_with_acknowledgement_the_move_applies_and_records_exactly_one_event() {
1174        let (tmp, store) = make_store();
1175        let _ = tmp;
1176        let (_, subject, _) = seed_blocked_pair(&store);
1177        let moved = apply_guarded(
1178            &store.config,
1179            &store,
1180            &subject.number.to_string(),
1181            "done",
1182            Some("abc123"),
1183            None,
1184            true,
1185            None,
1186        )
1187        .unwrap();
1188        assert!(moved.acknowledged);
1189        assert_eq!(moved.issue.status.display(), "done");
1190
1191        let reread = store.get(IssueSelector::Number(subject.number)).unwrap();
1192        let events = acknowledged_events(&reread);
1193        assert_eq!(events.len(), 1, "exactly one event, got {events:?}");
1194        assert!(
1195            events[0].contains("done"),
1196            "the event records the decision: {events:?}"
1197        );
1198    }
1199
1200    #[test]
1201    fn t016_rerunning_the_move_is_idempotent() {
1202        // "no second warning storm, no duplicate History entry". Made a property
1203        // of the *guard* — an issue already in the target column is `Clear` — so
1204        // a caller that forgets to check still cannot duplicate the event.
1205        let (tmp, store) = make_store();
1206        let _ = tmp;
1207        let (_, subject, _) = seed_blocked_pair(&store);
1208        let sel = subject.number.to_string();
1209
1210        let first = apply_guarded(
1211            &store.config,
1212            &store,
1213            &sel,
1214            "done",
1215            Some("abc"),
1216            None,
1217            true,
1218            None,
1219        )
1220        .unwrap();
1221        assert!(first.acknowledged);
1222        let after_first = store.get(IssueSelector::Number(subject.number)).unwrap();
1223
1224        let second = apply_guarded(
1225            &store.config,
1226            &store,
1227            &sel,
1228            "done",
1229            Some("abc"),
1230            None,
1231            true,
1232            None,
1233        )
1234        .unwrap();
1235        assert!(!second.acknowledged, "a re-run acknowledges nothing");
1236        assert!(
1237            second.warning.is_none(),
1238            "and must not warn again: {:?}",
1239            second.warning
1240        );
1241        let after_second = store.get(IssueSelector::Number(subject.number)).unwrap();
1242        assert_eq!(
1243            acknowledged_events(&after_second).len(),
1244            1,
1245            "no second acknowledgement event"
1246        );
1247        assert_eq!(
1248            after_second.history.events().len(),
1249            after_first.history.events().len(),
1250            "no History growth at all on a re-run"
1251        );
1252    }
1253
1254    #[test]
1255    fn t016_history_is_append_only_across_an_acknowledged_move() {
1256        let (tmp, store) = make_store();
1257        let _ = tmp;
1258        let (blocker, subject, _) = seed_blocked_pair(&store);
1259        let blocker = store.get(IssueSelector::Number(blocker.number)).unwrap();
1260        let before = store.get(IssueSelector::Number(subject.number)).unwrap();
1261        let prior: Vec<String> = before
1262            .history
1263            .events()
1264            .iter()
1265            .map(|e| format!("{e:?}"))
1266            .collect();
1267        assert!(
1268            !blocker.history.events().is_empty(),
1269            "precondition: the edge recorded an event on its canonical owner"
1270        );
1271
1272        apply_guarded(
1273            &store.config,
1274            &store,
1275            &subject.number.to_string(),
1276            "done",
1277            Some("abc"),
1278            None,
1279            true,
1280            None,
1281        )
1282        .unwrap();
1283
1284        let after = store.get(IssueSelector::Number(subject.number)).unwrap();
1285        let now: Vec<String> = after
1286            .history
1287            .events()
1288            .iter()
1289            .map(|e| format!("{e:?}"))
1290            .collect();
1291        assert_eq!(
1292            &now[..prior.len()],
1293            &prior[..],
1294            "prior entries must be byte-identical and in the same order"
1295        );
1296        // WP03: an acknowledged move appends **two** entries, not one — a
1297        // `transition_accepted` from the seam and a `blocked_move_acknowledged`
1298        // from the guard. They record two different decisions (the move was
1299        // permitted; the user chose to proceed past open blockers) and collapsing
1300        // them would lose one. The kinds are asserted, not just the count, because
1301        // a count of 2 would also be satisfied by a duplicated
1302        // acknowledgement and by nothing else being recorded at all.
1303        assert_eq!(
1304            now.len(),
1305            prior.len() + 2,
1306            "a transition decision and an acknowledgement, nothing else"
1307        );
1308        let appended: Vec<HistoryEventKind> = after.history.events()[prior.len()..]
1309            .iter()
1310            .map(|e| e.kind)
1311            .collect();
1312        assert_eq!(
1313            appended,
1314            vec![
1315                HistoryEventKind::TransitionAccepted,
1316                HistoryEventKind::BlockedMoveAcknowledged,
1317            ],
1318            "in that order: the seam writes first, then the guard appends its own"
1319        );
1320    }
1321
1322    #[test]
1323    fn t016_moving_to_a_non_completion_column_does_not_warn() {
1324        let (tmp, store) = make_store();
1325        let _ = tmp;
1326        let (_, subject, _) = seed_blocked_pair(&store);
1327        let issue = store.get(IssueSelector::Number(subject.number)).unwrap();
1328        // The default board's own columns, not invented ones — a test that names
1329        // a column the board does not have would be asserting the "unknown
1330        // column" error, not the guard.
1331        for column in ["todo", "in progress", "in review"] {
1332            assert!(
1333                store
1334                    .config
1335                    .default_config()
1336                    .columns
1337                    .iter()
1338                    .any(|c| c == column),
1339                "precondition: `{column}` is a real column"
1340            );
1341            let guard = blocked_move_guard(&store.config, &store, &issue, column).unwrap();
1342            assert!(
1343                !guard.needs_acknowledgement(),
1344                "blockers do not block a move to {column}"
1345            );
1346        }
1347        // And the move applies without an acknowledgement.
1348        let moved = apply_guarded(
1349            &store.config,
1350            &store,
1351            &subject.number.to_string(),
1352            "in progress",
1353            None,
1354            None,
1355            false,
1356            None,
1357        )
1358        .unwrap();
1359        assert!(!moved.acknowledged);
1360        assert_eq!(moved.issue.status.display(), "in progress");
1361    }
1362
1363    #[test]
1364    fn t016_a_custom_done_column_is_resolved_through_config() {
1365        // The test that catches a hard-coded `"done"` (T017's own note). A board
1366        // whose completion column is `shipped` has no `done` column at all, so an
1367        // implementation matching the literal would never guard `shipped`.
1368        let (tmp, store) = store_with_done_column("shipped");
1369        let _ = tmp;
1370        let (blocker, subject, _) = seed_blocked_pair(&store);
1371        let issue = store.get(IssueSelector::Number(subject.number)).unwrap();
1372
1373        assert_eq!(
1374            completion_column(&store.config).as_deref(),
1375            Some("shipped"),
1376            "precondition: the configured completion column"
1377        );
1378        let guard = blocked_move_guard(&store.config, &store, &issue, "shipped").unwrap();
1379        assert!(
1380            guard.needs_acknowledgement(),
1381            "moving to the configured completion column must be guarded"
1382        );
1383        assert!(
1384            guard
1385                .warning()
1386                .unwrap()
1387                .contains(&blocker.number.to_string())
1388        );
1389
1390        // And `done` is not a column on this board, so it cannot be the guard's
1391        // trigger by accident.
1392        let err = apply_guarded(
1393            &store.config,
1394            &store,
1395            &subject.number.to_string(),
1396            "shipped",
1397            None,
1398            None,
1399            false,
1400            None,
1401        )
1402        .unwrap_err();
1403        assert!(err.to_string().contains("blocked"), "got: {err}");
1404
1405        // An unblocked issue moves to `shipped` freely.
1406        let mut done_blocker = store.get(IssueSelector::Number(blocker.number)).unwrap();
1407        done_blocker.status = Status::new("shipped".to_string());
1408        store.update(&done_blocker.filename, &done_blocker).unwrap();
1409        let moved = apply_guarded(
1410            &store.config,
1411            &store,
1412            &subject.number.to_string(),
1413            "shipped",
1414            None,
1415            None,
1416            false,
1417            None,
1418        )
1419        .unwrap();
1420        assert!(
1421            !moved.acknowledged,
1422            "a blocker that reached the completion column does not block"
1423        );
1424    }
1425
1426    #[test]
1427    fn t016_an_unblocked_issue_moves_freely_and_records_nothing() {
1428        let (tmp, store) = make_store();
1429        let _ = tmp;
1430        let a = seed(&store);
1431        seed(&store);
1432        let moved = apply_guarded(
1433            &store.config,
1434            &store,
1435            &a.number.to_string(),
1436            "done",
1437            Some("abc"),
1438            None,
1439            false,
1440            None,
1441        )
1442        .unwrap();
1443        assert!(!moved.acknowledged);
1444        assert!(moved.warning.is_none());
1445        assert_eq!(
1446            acknowledged_events(&moved.issue),
1447            [] as [std::string::String; 0]
1448        );
1449    }
1450}