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}