Skip to main content

tix/commands/
mod.rs

1//! Thin dispatch: route a parsed CLI [`Command`] to its per-command handler
2//! module. Also owns the shared `<issue>` selector resolution and priority
3//! validation that several commands reuse.
4//!
5//! Selector precedence follows `contracts/cli.md` §Selection grammar: a bare
6//! number wins, then a unique filename fragment, then a title fragment that
7//! matches exactly one issue. Non-unique matches are a hard error listing
8//! the candidates (exit 2 like target-not-found, per the exit-code table).
9
10use std::collections::HashSet;
11use std::path::Path;
12
13use crate::cli::Command;
14use crate::config::Config;
15use crate::error::{Result, TixError};
16use crate::model::Priority;
17use crate::storage::{IssueSelector, Store};
18
19pub mod agents;
20pub mod analytics;
21pub mod archive;
22pub mod beads;
23pub mod bulk;
24pub mod capacity;
25pub mod clean;
26pub mod create;
27pub mod cycle;
28pub mod delete;
29pub mod edit;
30pub mod epic;
31pub mod export;
32pub mod health;
33pub mod import;
34pub mod init;
35pub mod layout;
36pub mod link;
37pub mod lint;
38pub mod list;
39pub mod mentions;
40pub mod migrate;
41pub mod milestone;
42pub mod mv;
43pub mod parent;
44pub mod project;
45pub mod recurring;
46pub mod render;
47pub mod roadmap;
48pub mod rollover;
49pub mod search;
50pub mod status;
51pub mod templates;
52pub mod time;
53pub mod triage;
54pub mod version;
55pub mod view;
56pub mod views;
57pub mod wip;
58pub mod workflow;
59
60/// Message prefix shared with `cli::exit_code_for` so selection ambiguity is
61/// routed to the `2` exit bucket alongside target-not-found.
62pub const AMBIGUITY_PREFIX: &str = "ambiguous selector";
63
64/// Insert or replace a marker-delimited managed block in `existing`.
65///
66/// Shared by `tix init-agents` (`AGENTS.md`, `<!-- tix:start -->` /
67/// `<!-- tix:end -->`) and `tix init` (`.gitignore`, `# tix:start` / `# tix:end`)
68/// so the two never drift into two block formats:
69///
70/// - both markers found → replace everything between them, inclusive;
71/// - an already byte-identical block (ignoring surrounding whitespace) → return
72///   `existing` unchanged, so re-runs are fully idempotent;
73/// - only one marker → treat the file as unrelated and append a fresh block;
74/// - no markers → append a fresh block.
75pub fn upsert_block(existing: &str, block: &str, start_marker: &str, end_marker: &str) -> String {
76    let has_start = existing.contains(start_marker);
77    let has_end = existing.contains(end_marker);
78
79    match (has_start, has_end) {
80        (true, true) => {
81            let start = existing.find(start_marker).unwrap();
82            let end = existing.find(end_marker).unwrap() + end_marker.len();
83            if existing[start..end].trim() == block.trim() {
84                return existing.to_string();
85            }
86            format!("{}{}{}", &existing[..start], block, &existing[end..])
87        }
88        (true, false) | (false, true) => {
89            format!("{}\n{}\n", existing.trim_end(), block)
90        }
91        (false, false) => {
92            let trimmed = existing.trim_end();
93            if trimmed.is_empty() {
94                format!("{block}\n")
95            } else {
96                format!("{trimmed}\n\n{block}\n")
97            }
98        }
99    }
100}
101
102/// Atomic file write: write `content` to a sibling temp file, then rename it
103/// over `path`, so a crash mid-write cannot leave a truncated file.
104pub fn atomic_write(path: &Path, content: &str) -> Result<()> {
105    let parent = path.parent().unwrap_or_else(|| Path::new("."));
106    let file_name = path.file_name().map_or_else(
107        || ".tix-tmp".to_string(),
108        |name| format!(".{}.tix-tmp", name.to_string_lossy()),
109    );
110    let tmp = parent.join(file_name);
111    std::fs::write(&tmp, content)?;
112    std::fs::rename(&tmp, path)?;
113    Ok(())
114}
115
116// ---------------------------------------------------------------------------
117// Shared project refusals (WP09 item E)
118// ---------------------------------------------------------------------------
119//
120// Three user-facing refusals are spelled in both the CLI and MCP. Each predicate
121// and its message live here, in one place, and both sides call them, so a
122// rewording cannot leave one surface saying something the other does not. This is
123// the same discipline `projects::ProjectError` follows for the service refusals;
124// these three are *command-level* rules the service never sees.
125
126/// The refusal `tix project edit` prints when no field flag was given.
127///
128/// `--projects` is not one of the edit flags — it belongs to `tix edit` — so a
129/// caller with no field change has asked for nothing and is told which flags
130/// exist rather than silently rewriting the record.
131pub fn project_edit_requires_a_field() -> TixError {
132    TixError::InvalidInput(
133        "no fields to change; pass at least one of --name, --description, --status, \
134         --lead, --start-date or --due-date"
135            .to_string(),
136    )
137}
138
139/// Whether a `--projects` value on `tix edit` is the **clear-everything**
140/// spelling (blank after trimming), per the empty-string-clears convention.
141pub fn edit_projects_clears(value: &str) -> bool {
142    value.trim().is_empty()
143}
144
145/// The refusal for a non-blank `--projects` on `tix edit`.
146///
147/// Setting associations has exactly one spelling (`--add-project` /
148/// `--remove-project`); a non-blank list here is refused rather than guessed at,
149/// so a typo cannot silently replace an association set.
150pub fn edit_projects_must_be_clear(value: &str) -> TixError {
151    TixError::InvalidInput(format!(
152        "--projects on `tix edit` only clears associations (`--projects \"\"`); use \
153         --add-project / --remove-project to change them, not `--projects {value}`"
154    ))
155}
156
157/// The refusal for `--projects` beside `--template`.
158///
159/// Returns `Some` only for a non-blank value; an omitted or blank `--projects`
160/// narrows nothing and is allowed. Refused rather than dropped: silently ignoring
161/// it would create an issue the caller believes is associated, and the template
162/// surface (source issue #174) owns wiring the field through its own crate.
163pub fn projects_beside_template_refused(projects: Option<&str>) -> Option<TixError> {
164    projects.filter(|value| !value.trim().is_empty()).map(|_| {
165        TixError::InvalidInput(
166            "--projects cannot be combined with --template yet; create without \
167                 the template and add the association with `tix project add`"
168                .to_string(),
169        )
170    })
171}
172
173/// Whether an `--epics` value on `tix edit` is the **clear-everything** spelling
174/// (blank after trimming), per the empty-string-clears convention.
175pub fn edit_epics_clears(value: &str) -> bool {
176    value.trim().is_empty()
177}
178
179/// The refusal for a non-blank `--epics` on `tix edit`.
180///
181/// Setting references has exactly one spelling (`--add-epic` / `--remove-epic`);
182/// a non-blank list here is refused rather than guessed at, so a typo cannot
183/// silently replace a reference set.
184pub fn edit_epics_must_be_clear(value: &str) -> TixError {
185    TixError::InvalidInput(format!(
186        "--epics on `tix edit` only clears references (`--epics \"\"`); use \
187         --add-epic / --remove-epic to change them, not `--epics {value}`"
188    ))
189}
190
191/// The refusal for `--epics` beside `--template`.
192///
193/// Mirrors [`projects_beside_template_refused`] and is refused rather than dropped
194/// for the same reason: silently ignoring it would create an issue the caller
195/// believes references the epic, and the template surface (source issue #174) owns
196/// wiring the field through its own crate.
197pub fn epics_beside_template_refused(epics: Option<&str>) -> Option<TixError> {
198    epics.filter(|value| !value.trim().is_empty()).map(|_| {
199        TixError::InvalidInput(
200            "--epics cannot be combined with --template yet; create without \
201                 the template and add the reference with `tix epic add`"
202                .to_string(),
203        )
204    })
205}
206
207/// One argument per `tix.toml`-backed field; the flag count mirrors the
208/// frozen command surface in `contracts/cli.md`.
209#[allow(clippy::too_many_arguments)]
210/// The data-level action for one `tix mentions` verb.
211///
212/// A projection, not a second definition: the enum in `cli.rs` is the flag grammar
213/// and this module owns the behavior, so the two cannot drift into a verb that parses
214/// and does nothing.
215fn mentions_action(action: &crate::cli::MentionsAction) -> mentions::Action<'_> {
216    use crate::cli::MentionsAction as Cli;
217    match action {
218        Cli::List => mentions::Action::List,
219        Cli::Show { id } => mentions::Action::Show(id),
220        Cli::Read { id } => mentions::Action::Read(id),
221        Cli::Ignore { id } => mentions::Action::Ignore(id),
222        Cli::Jump { id } => mentions::Action::Jump(id),
223    }
224}
225
226#[allow(clippy::too_many_lines)]
227pub fn dispatch(cmd: &Command, config: &Config, store: &Store) -> Result<()> {
228    match cmd {
229        Command::Init { beads: _ } => unreachable!("init is handled before dispatch"),
230        Command::InitAgents { yes: _ } => unreachable!("init-agents is handled before dispatch"),
231        Command::Triage { .. } => unreachable!("triage is handled before dispatch"),
232        Command::Version => unreachable!("version is handled before dispatch"),
233        // `tix beads` reaches the `bd` CLI, writes its own state file and refuses
234        // writes under `mirror`, so it is routed in `cli::run` where the exit code
235        // is decided — the same boundary `triage` and `bulk` use.
236        Command::Beads { .. } => unreachable!("beads is handled before dispatch"),
237        // `tix mentions` reads and writes only the mention store, so it fits
238        // `dispatch`'s `&Config` borrow. Its verbs carry no batch status: one mention
239        // is one record, and an unknown id is a plain `not found`.
240        Command::Mentions { action } => mentions::run(store, &mentions_action(action)),
241        // `tix analytics` is read-only over the shared series/projection DTOs and the
242        // chart document WP04 owns, so it fits `dispatch`'s `&Config` borrow. Its
243        // `burndown`/`projection` verbs print and write nothing at all; its `export`
244        // verb writes only the caller's own `--output` path, and never an issue, a
245        // cycle, a milestone or a snapshot.
246        Command::Analytics { command } => analytics::run(config, store, command),
247        Command::Health { action } => health::run(config, store, action),
248        // `tix roadmap` is read-only, so it fits `dispatch`'s `&Config` borrow and is
249        // routed here. `tix initiative` mutates `tix.toml` and so is routed by
250        // `cli::run`, where the mutable config is owned — the arm is here anyway so
251        // the match stays exhaustive and a caller reaching `dispatch` with one is
252        // told why rather than being quietly dropped.
253        Command::Roadmap { command } => roadmap::run_roadmap(config, store, command).map(|_| ()),
254        Command::Initiative { .. } => unreachable!("initiative is handled before dispatch"),
255        Command::Mcp => unreachable!("mcp is handled before dispatch"),
256        Command::New => unreachable!("new is handled before dispatch"),
257        Command::Create {
258            title,
259            body,
260            status,
261            priority,
262            assignee,
263            labels,
264            files,
265            estimate,
266            cycles,
267            milestones,
268            projects,
269            epics,
270            reviewer,
271            start_date,
272            due_date,
273            parent,
274            related_issues,
275            r#type,
276            template,
277            scope,
278        } => {
279            // One branch, and the no-template branch is the untouched
280            // `create::run` call: `tix create` without `--template` behaves
281            // exactly as before (byte-identical output and files). `--scope`
282            // cannot reach the else branch with a value — clap requires
283            // `--template` when `--scope` is present.
284            if let Some(template_id) = template {
285                // `--projects` is deliberately refused beside `--template` rather
286                // than dropped: silently ignoring it would create an issue the user
287                // believes is associated, and the template surface (source issue
288                // #174) owns wiring the field through its own crate. The predicate
289                // and wording live in one shared helper so MCP cannot diverge.
290                if let Some(error) = projects_beside_template_refused(projects.as_deref()) {
291                    return Err(error);
292                }
293                if let Some(error) = epics_beside_template_refused(epics.as_deref()) {
294                    return Err(error);
295                }
296                templates::create_from_template(
297                    config,
298                    store,
299                    template_id,
300                    scope.as_deref(),
301                    &templates::CreateFields {
302                        title: title.as_deref(),
303                        body: body.as_deref(),
304                        status: status.as_deref(),
305                        priority: priority.as_deref(),
306                        assignee: assignee.as_deref(),
307                        labels: labels.as_deref(),
308                        files: files.as_deref(),
309                        estimate: estimate.as_deref(),
310                        cycles: cycles.as_deref(),
311                        milestones: milestones.as_deref(),
312                        reviewer: reviewer.as_deref(),
313                        start_date: start_date.as_deref(),
314                        due_date: due_date.as_deref(),
315                        parent: parent.as_deref(),
316                        related_issues: related_issues.as_deref(),
317                        type_: r#type.as_deref(),
318                    },
319                )
320            } else {
321                create::run(
322                    config,
323                    store,
324                    title.as_deref(),
325                    body.as_deref(),
326                    status.as_deref(),
327                    priority.as_deref(),
328                    assignee.as_deref(),
329                    labels.as_deref(),
330                    files.as_deref(),
331                    estimate.as_deref(),
332                    cycles.as_deref(),
333                    milestones.as_deref(),
334                    projects.as_deref(),
335                    epics.as_deref(),
336                    reviewer.as_deref(),
337                    start_date.as_deref(),
338                    due_date.as_deref(),
339                    parent.as_deref(),
340                    related_issues.as_deref(),
341                    r#type.as_deref(),
342                )
343            }
344        }
345        Command::Parent { action } => parent::run(store, action),
346        Command::Link {
347            action,
348            from,
349            to,
350            r#type,
351        } => link::run(
352            store,
353            action.as_ref(),
354            from.as_deref(),
355            to.as_deref(),
356            r#type.as_deref(),
357        ),
358        Command::Unlink { from, to, r#type } => {
359            link::unlink_cli(store, from, to, r#type.as_deref())
360        }
361        Command::Chain { issue } => link::chain(store, issue),
362        // Routed by `cli::run`, not here: a bulk batch's exit code is a **result**,
363        // and every other arm of this match is `Result<()>`. The arm is here so the
364        // match stays exhaustive and so a caller that reaches `dispatch` with a bulk
365        // command is told why rather than being quietly dropped.
366        Command::Bulk { .. } => Err(TixError::InvalidInput(
367            "`tix bulk` is routed by the process entry, not by dispatch — a partial \
368             batch reports a non-zero status rather than an error"
369                .to_string(),
370        )),
371        Command::Edit {
372            issue,
373            title,
374            body,
375            status,
376            priority,
377            assignee,
378            labels,
379            files,
380            estimate,
381            cycles,
382            milestones,
383            add_project,
384            remove_project,
385            projects,
386            add_epic,
387            remove_epic,
388            epics,
389            reviewer,
390            parent,
391            start_date,
392            due_date,
393            commit,
394            reason,
395            related_issues,
396            r#type,
397        } => edit::run(
398            config,
399            store,
400            issue,
401            title.as_deref(),
402            body.as_deref(),
403            status.as_deref(),
404            priority.as_deref(),
405            assignee.as_deref(),
406            labels.as_deref(),
407            files.as_deref(),
408            estimate.as_deref(),
409            cycles.as_deref(),
410            milestones.as_deref(),
411            add_project,
412            remove_project,
413            projects.as_deref(),
414            add_epic,
415            remove_epic,
416            epics.as_deref(),
417            reviewer.as_deref(),
418            start_date.as_deref(),
419            due_date.as_deref(),
420            commit.as_deref(),
421            parent.as_deref(),
422            // `--reason` is not an editable field — it only qualifies a
423            // `--status` transition — so it is kept out of the editable-field
424            // ordering and `edit::run`'s "was any field supplied" check.
425            reason.as_deref(),
426            related_issues.as_deref(),
427            r#type.as_deref(),
428        ),
429        Command::Move {
430            args,
431            select,
432            filter,
433            search,
434            dry_run,
435            commit,
436            reason,
437            yes,
438            r#override,
439        } => mv::run_batch(
440            config,
441            store,
442            args,
443            select.as_deref(),
444            filter.as_deref(),
445            search.as_deref(),
446            commit.as_deref(),
447            reason.as_deref(),
448            *yes,
449            *r#override,
450            *dry_run,
451        )
452        // The binary routes `tix move` through `cli::run` so a partial batch's
453        // exit status survives; dispatch cannot carry that status. Reaching this
454        // arm means an in-process caller asked for the move and only wants
455        // whether it ran at all, so the status is dropped here.
456        .map(|_| ()),
457        // `tix wip` mutates `tix.toml`, so it is routed by `cli::run_subcommand`,
458        // which owns the mutable `Config` this dispatch cannot borrow. The arm is
459        // here so the match stays exhaustive and a caller that reaches `dispatch`
460        // with it is told why.
461        // `tix project` mutates `tix.toml` (create/edit/delete) and carries a
462        // partial-batch **status** for `add`/`remove`/`bulk-add`, so it is routed by
463        // `cli::run`, which owns the mutable config and decides the exit code. The
464        // arm is here so the match stays exhaustive and a caller that reaches
465        // `dispatch` with it is told why.
466        Command::Project { .. } => Err(TixError::InvalidInput(
467            "`tix project` is routed by the process entry, not by dispatch".to_string(),
468        )),
469        // `tix epic` shares `tix project`'s routing reason: its lifecycle verbs
470        // mutate `tix.toml` and its batch verbs carry a partial-batch status, so
471        // `cli::run` owns both the mutable config and the exit code.
472        Command::Epic { .. } => Err(TixError::InvalidInput(
473            "`tix epic` is routed by the process entry, not by dispatch".to_string(),
474        )),
475        Command::Wip { .. } => Err(TixError::InvalidInput(
476            "`tix wip` is routed by the process entry, not by dispatch".to_string(),
477        )),
478        Command::View {
479            action,
480            issue,
481            show,
482        } => match (action, issue) {
483            // `tix view <subcommand>` is routed by `cli::run_subcommand`, which
484            // owns the mutable `Config` a view mutation needs. The arm is here so
485            // the match stays exhaustive and a caller that reaches `dispatch` with
486            // a saved-view subcommand is told why rather than being quietly
487            // treated as an issue selector.
488            (Some(_), _) => Err(TixError::InvalidInput(
489                "`tix view <subcommand>` is routed by the process entry, not by dispatch"
490                    .to_string(),
491            )),
492            (None, Some(issue)) => view::run(store, issue, show.as_deref()),
493            (None, None) => Err(TixError::InvalidInput(
494                "`tix view` needs an issue selector or a subcommand; run `tix view --help`"
495                    .to_string(),
496            )),
497        },
498        Command::List {
499            status,
500            filter,
501            project,
502            epic,
503            sort,
504            group,
505            columns,
506            show,
507            with_archived,
508            all,
509            format,
510        } => list::run(
511            store,
512            status.as_deref(),
513            filter.as_deref(),
514            project.as_deref(),
515            epic.as_deref(),
516            sort.as_deref(),
517            group.as_deref(),
518            columns.as_deref(),
519            show.as_deref(),
520            *with_archived,
521            *all,
522            format.as_deref(),
523            None,
524        ),
525        Command::Ready {
526            columns,
527            limit,
528            project,
529            epic,
530            format,
531        } => list::ready(
532            store,
533            columns.as_deref(),
534            *limit,
535            project.as_deref(),
536            epic.as_deref(),
537            format.as_deref(),
538        ),
539        Command::Workflow { action } => match action {
540            crate::cli::WorkflowAction::Categories => workflow::categories(config),
541            crate::cli::WorkflowAction::Show { board } => workflow::show(config, board.as_deref()),
542            crate::cli::WorkflowAction::Transitions => workflow::transitions(config),
543        },
544        Command::Status {
545            filter,
546            sort,
547            group,
548            health,
549            json,
550        } => status::run(
551            config,
552            store,
553            filter.as_deref(),
554            sort.as_deref(),
555            group.as_deref(),
556            *health,
557            *json,
558        ),
559        // `tix time` reads and writes the issue store only, so it fits this dispatch: a
560        // mutation goes through the shared service, and none of the verbs needs the
561        // mutable config the process entry owns or a partial-batch status.
562        Command::Time { command } => time::run(store, command),
563        Command::Delete { issue, yes } => delete::run(store, issue, *yes),
564        // The recurrence surface: a thin adapter over `recurrence::{series,detect,
565        // generate,lifecycle}`. `create` needs `&Config` (it goes through the shared
566        // create core), which `dispatch` already borrows immutably.
567        Command::Recurring { action } => recurring::run(config, store, action),
568        // Routed by `cli::run`, not here: an archive/restore batch reports one
569        // outcome per item and exits non-zero if any failed, which is a
570        // *status* rather than a `Result<()>` (`contracts/cli-surface.md`, the
571        // same shape `tix bulk` and `tix triage` use). The arms keep the match
572        // exhaustive and tell a caller that reaches `dispatch` why.
573        Command::Archive { .. } | Command::Restore { .. } => Err(TixError::InvalidInput(
574            "`tix archive`/`tix restore` are routed by the process entry, not by dispatch — \
575             a partial batch reports a non-zero status rather than an error"
576                .to_string(),
577        )),
578        // Routed by `cli::run`, not here: management mutates `tix.toml` and so
579        // needs a mutable config and the path it was read from, which
580        // `dispatch`'s `&Config` cannot provide. The arm keeps the match
581        // exhaustive and explains why rather than dropping the command.
582        Command::Template { .. } => Err(TixError::InvalidInput(
583            "`tix template` is routed by the process entry, not by dispatch — management \
584             writes `tix.toml` through a mutable config"
585                .to_string(),
586        )),
587        // The frozen legacy `tix cycle create|list|edit|delete` verbs route here,
588        // exactly as before. Only the new `schedule` namespace mutates `tix.toml`
589        // and is routed by `cli::run`; a schedule command can never reach this arm.
590        Command::Cycle { command } => cycle::run_legacy(config, store, command),
591        // Routed by `cli::run`, not here: `set`/`delete` mutate `tix.toml` and need the
592        // mutable config only the process entry owns. The arm is here so the match
593        // stays exhaustive and a caller that reaches `dispatch` with one is told why
594        // rather than being quietly dropped.
595        Command::Capacity { .. } => unreachable!("capacity is handled before dispatch"),
596        Command::Milestone { command } => milestone::run(config, store, command),
597        Command::Export {
598            target,
599            dry_run,
600            project,
601            cycle,
602            output,
603            verbose,
604        } => export::run(
605            config,
606            store,
607            target,
608            *dry_run,
609            project.as_deref(),
610            cycle.as_deref(),
611            output.as_deref(),
612            *verbose,
613        )
614        .map(|_| ()),
615        Command::Import {
616            target,
617            triage_policy,
618        } => {
619            // Target first, then the policy — the order WP07 froze (T036): a bad
620            // `--target` must still report what an older `tix` reported and never
621            // a triage diagnostic. `Tool::from_target` is the exact call
622            // `run_core_with_policy` makes a line later, so this cannot accept a
623            // target that one would reject; it only fixes which of two bad flags
624            // is named first. `speckit` and `beads` are **local** readers rather
625            // than remote `Tool`s — they shell out to no tracker API and hold no
626            // token — so both are exempt here and validate themselves.
627            if target != "speckit" && target != "beads" {
628                crate::sync::Tool::from_target(target)?;
629            }
630            let policy = triage_policy
631                .as_deref()
632                .map(crate::triage::import_policy::ImportPolicy::parse)
633                .transpose()?
634                .unwrap_or_default();
635            import::run_with_policy(config, store, target, policy)
636        }
637        Command::Rollover { cycle, yes } => rollover::run(config, store, cycle.as_deref(), *yes),
638        Command::Search {
639            string,
640            with_archived,
641        } => search::run(store, string, *with_archived),
642        Command::Render => render::run(config, store),
643        Command::Clean { force, keep } => clean::run(store, *force, *keep),
644        Command::Lint { no_foreign } => lint::run_with(config, store, !no_foreign).map(|_| ()),
645        Command::Layout { action } => layout::run(config, store, action),
646        Command::Migrate { action } => migrate::run(store, action),
647    }
648}
649
650/// The one sentence every surface uses to say an issue is blocked (FR-003,
651/// FR-004).
652///
653/// `contracts/cli.md` and `contracts/surface-parity.md` §2 require the CLI
654/// warning, the TUI warning, `tix list --show relationships`, and the detail view
655/// to carry the **same** text, and the service's [`BlockedState::reason`] is the
656/// only string that is allowed to produce it. Each surface composing its own
657/// phrasing is how four surfaces come to say four different things about one
658/// graph.
659///
660/// Returns `None` when the issue is not blocked, so a caller cannot accidentally
661/// print an empty "blocked: " prefix.
662pub fn blocked_note(state: &crate::relationships::BlockedState) -> Option<String> {
663    if !state.is_blocked {
664        return None;
665    }
666    Some(format!("blocked: {}", state.reason))
667}
668
669/// The issue set plus the board's configured completion column, with a
670/// [`RelationshipGraph`] over the pair.
671///
672/// One place for every surface to obtain a graph (C-003, parity §3). A surface
673/// that reached past the service to re-derive direction handling, cycle
674/// detection, or blocked state would be the defect this contract exists to
675/// prevent, and giving each surface its own graph construction is how that starts
676/// to happen.
677pub fn graph_over<'a>(
678    issues: &'a [crate::model::Issue],
679    done_column: &str,
680) -> crate::relationships::RelationshipGraph<'a> {
681    use crate::model::Issue;
682    use crate::relationships::IssueView;
683    let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
684    crate::relationships::RelationshipGraph::new(&views, done_column)
685}
686
687/// Resolve an `<issue>` selector string per `contracts/cli.md` §Selection
688/// grammar (number → unique filename fragment → unique title fragment).
689pub fn parse_selector(store: &Store, raw: &str) -> Result<IssueSelector> {
690    if let Ok(number) = raw
691        .trim()
692        .strip_prefix('#')
693        .unwrap_or(raw.trim())
694        .parse::<u32>()
695    {
696        let matched: Vec<crate::model::Issue> = store
697            .list_all()?
698            .into_iter()
699            .filter(|issue| issue.number == number)
700            .collect();
701        return match matched.as_slice() {
702            [_] => Ok(IssueSelector::Number(number)),
703            [] => Err(TixError::NotFound(format!("issue #{number}"))),
704            many => Err(TixError::InvalidInput(format!(
705                "{AMBIGUITY_PREFIX} #{number}: {}",
706                many.iter()
707                    .map(|issue| crate::id::canonical_ref(issue).to_string())
708                    .collect::<Vec<_>>()
709                    .join(", ")
710            ))),
711        };
712    }
713    // An 8-character Crockford base32 id is the canonical identity. It is never
714    // all digits (see [`crate::id`]), so this can only be reached after the
715    // numeric fast path above has declined, and it is resolved *before* the
716    // filename/title fragment scan so a selector that is exactly an id resolves
717    // that issue rather than merely matching its (id-prefixed) filename.
718    if crate::id::is_issue_id(raw) {
719        let issues = store.list_all()?;
720        let matched: Vec<&crate::model::Issue> = issues
721            .iter()
722            .filter(|i| {
723                i.id.as_deref()
724                    .is_some_and(|id| id.eq_ignore_ascii_case(raw))
725            })
726            .collect();
727        return match matched.as_slice() {
728            [one] => Ok(IssueSelector::Filename(one.filename.clone())),
729            [] => Err(TixError::NotFound(format!("issue with id '{raw}'"))),
730            _ => Err(TixError::InvalidInput(format!(
731                "{AMBIGUITY_PREFIX} id '{raw}': {}",
732                matched
733                    .iter()
734                    .map(|i| format!("#{} ({})", i.number, i.filename))
735                    .collect::<Vec<_>>()
736                    .join(", ")
737            ))),
738        };
739    }
740    // `list_all`: a selector must resolve an archived issue too (issue #1492).
741    // Hiding archived issues here would make `tix show <fragment>` and
742    // `tix archive <fragment>` fail on an issue the user can name.
743    let issues = store.list_all()?;
744    let by_filename: Vec<&String> = issues
745        .iter()
746        .filter(|i| i.filename.contains(raw))
747        .map(|i| &i.filename)
748        .collect();
749    if by_filename.len() == 1 {
750        return Ok(IssueSelector::Filename(by_filename[0].clone()));
751    }
752    let by_title: Vec<&crate::model::Issue> =
753        issues.iter().filter(|i| i.title.contains(raw)).collect();
754    if by_title.len() == 1 {
755        return Ok(IssueSelector::Filename(by_title[0].filename.clone()));
756    }
757    if by_filename.len() > 1 || by_title.len() > 1 {
758        let mut seen: HashSet<u32> = HashSet::new();
759        let candidates: Vec<String> = issues
760            .iter()
761            .filter(|i| i.filename.contains(raw) || i.title.contains(raw))
762            .filter(|i| seen.insert(i.number))
763            .map(|i| format!("#{} ({})", i.number, i.filename))
764            .collect();
765        return Err(TixError::InvalidInput(format!(
766            "{AMBIGUITY_PREFIX} '{raw}': {}",
767            candidates.join(", ")
768        )));
769    }
770    Err(TixError::NotFound(format!(
771        "issue matching '{raw}' by number, filename or title"
772    )))
773}
774
775/// Strict priority validation for user input: only `low`/`medium`/`high`
776/// are accepted (`contracts/cli.md` enumerates exactly these three).
777pub fn parse_priority(raw: &str) -> Result<Priority> {
778    match raw.to_ascii_lowercase().as_str() {
779        "low" => Ok(Priority::Low),
780        "medium" => Ok(Priority::Medium),
781        "high" => Ok(Priority::High),
782        // An explicitly unset priority. Without this, the state `tix lint` accepts
783        // could only be reached by hand-editing a file, which would make the
784        // lint exemption a read-only fiction. Omitting `--priority` entirely is a
785        // different thing and still yields the default `medium`, because the CLI
786        // argument is `Option<String>` and `None` never reaches here.
787        "" => Ok(Priority::Other(String::new())),
788        _ => Err(TixError::InvalidInput(format!(
789            "invalid priority '{raw}' — use '' (unset), low, medium or high"
790        ))),
791    }
792}
793
794/// Split a comma-separated list, trimming whitespace and dropping empty
795/// entries (shared by `labels`, `links` and `files` parsing; D3).
796pub fn parse_csv_list(raw: &str) -> Vec<String> {
797    raw.split(',')
798        .map(|s| s.trim().to_string())
799        .filter(|s| !s.is_empty())
800        .collect()
801}
802
803/// Resolve a `--commit auto` request for `issue`: the commit that last
804/// touched its file, falling back to `HEAD` (see [`crate::git`]). This lets a
805/// closure hash be back-filled after the closing commit has landed. Errors
806/// when neither is available (not a git repository, or the file has never
807/// been committed).
808pub fn resolve_auto_commit(store: &Store, issue: &crate::model::Issue) -> Result<String> {
809    let cwd = std::env::current_dir().unwrap_or_default();
810    let path = store.issues_dir.join(&issue.filename);
811    crate::git::resolve_closing_commit(&cwd, &path).ok_or_else(|| {
812        TixError::InvalidInput(
813            "could not resolve a commit hash for `auto` — not a git repository or the issue file is uncommitted"
814                .to_string(),
815        )
816    })
817}
818
819#[cfg(test)]
820mod tests {
821    #[allow(clippy::wildcard_imports)]
822    use super::*;
823    use std::fs;
824    use tempfile::TempDir;
825
826    fn make_store() -> (TempDir, Store) {
827        let tmp = TempDir::new().unwrap();
828        let dir = tmp.path().join("issues");
829        fs::create_dir_all(&dir).unwrap();
830        let config_path = tmp.path().join("tix.toml");
831        let config = crate::config::Config {
832            issues_dir: dir.clone(),
833            ..crate::config::Config::default()
834        };
835        config.save(&config_path).unwrap();
836        let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
837        (
838            tmp,
839            Store {
840                issues_dir: dir,
841                config,
842            },
843        )
844    }
845
846    fn write_issue(store: &Store, name: &str, number: u32, title: &str) {
847        let contents = format!(
848            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody"
849        );
850        fs::write(store.issues_dir.join(name), contents).unwrap();
851    }
852
853    /// Like [`write_issue`], with an `id:` line (the new schema).
854    fn write_issue_with_id(store: &Store, name: &str, number: u32, title: &str, id: &str) {
855        let contents = format!(
856            "---\nnumber: {number}\nid: {id}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody"
857        );
858        fs::write(store.issues_dir.join(name), contents).unwrap();
859    }
860
861    #[test]
862    fn parse_priority_accepts_an_unset_value_and_still_rejects_typos() {
863        // Without the empty case, the state `tix lint` accepts could only be
864        // reached by hand-editing a file — the exemption would be read-only.
865        let undefined = parse_priority("").unwrap();
866        assert!(undefined.is_undefined());
867        assert_eq!(undefined.to_string(), "");
868
869        // Omitting `--priority` never reaches here at all (the CLI argument is
870        // `Option<String>` and `create` uses `unwrap_or_default()`), so accepting
871        // `''` here does not make "unset" the default.
872        assert_eq!(parse_priority("medium").unwrap(), Priority::default());
873
874        for typo in ["hih", "urgent", "0", "lowest"] {
875            assert!(
876                parse_priority(typo).is_err(),
877                "{typo:?} must still be rejected"
878            );
879        }
880    }
881
882    #[test]
883    fn selector_bare_number_wins() {
884        let (tmp, store) = make_store();
885        let _ = tmp;
886        write_issue(&store, "2026-01-02-ticket-55.md", 55, "Ticket 55");
887        assert_eq!(
888            parse_selector(&store, "55").unwrap(),
889            IssueSelector::Number(55)
890        );
891    }
892
893    #[test]
894    fn selector_unique_filename_fragment() {
895        let (tmp, store) = make_store();
896        let _ = tmp;
897        write_issue(&store, "2026-05-10-add-docs.md", 1, "Add documentation");
898        assert_eq!(
899            parse_selector(&store, "add-doc").unwrap(),
900            IssueSelector::Filename("2026-05-10-add-docs.md".into())
901        );
902    }
903
904    #[test]
905    fn selector_unique_title_fragment() {
906        let (tmp, store) = make_store();
907        let _ = tmp;
908        write_issue(&store, "2026-01-01-fix-login.md", 1, "Fix login bug");
909        assert_eq!(
910            parse_selector(&store, "login").unwrap(),
911            IssueSelector::Filename("2026-01-01-fix-login.md".into())
912        );
913    }
914
915    #[test]
916    fn selector_unique_filename_fragment_matches_grouped_relative_path() {
917        let (tmp, store) = make_store();
918        let _ = tmp;
919        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
920        write_issue(
921            &store,
922            "alice/2026-05-10-add-docs.md",
923            1,
924            "Add documentation",
925        );
926        assert_eq!(
927            parse_selector(&store, "add-doc").unwrap(),
928            IssueSelector::Filename("alice/2026-05-10-add-docs.md".into())
929        );
930    }
931
932    #[test]
933    fn selector_full_grouped_relative_path_resolves() {
934        let (tmp, store) = make_store();
935        let _ = tmp;
936        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
937        write_issue(
938            &store,
939            "alice/2026-05-10-add-docs.md",
940            1,
941            "Add documentation",
942        );
943        assert_eq!(
944            parse_selector(&store, "alice/2026-05-10-add-docs.md").unwrap(),
945            IssueSelector::Filename("alice/2026-05-10-add-docs.md".into())
946        );
947    }
948
949    #[test]
950    fn selector_ambiguous_lists_candidates() {
951        let (tmp, store) = make_store();
952        let _ = tmp;
953        write_issue(&store, "2026-01-01-a.md", 1, "Fix bug A");
954        write_issue(&store, "2026-01-02-b.md", 2, "Fix bug B");
955        let err = parse_selector(&store, "bug").unwrap_err();
956        assert!(
957            matches!(&err, TixError::InvalidInput(m) if m.starts_with(AMBIGUITY_PREFIX)),
958            "got {err:?}"
959        );
960        assert!(err.to_string().contains("#1"));
961        assert!(err.to_string().contains("#2"));
962    }
963
964    #[test]
965    fn selector_missing_is_not_found() {
966        let (tmp, store) = make_store();
967        let _ = tmp;
968        write_issue(&store, "2026-01-01-a.md", 1, "Fix bug A");
969        assert!(matches!(
970            parse_selector(&store, "zzz-nonexistent").unwrap_err(),
971            TixError::NotFound(_)
972        ));
973    }
974
975    #[test]
976    fn selector_resolves_an_issue_id_case_insensitively() {
977        let (tmp, store) = make_store();
978        let _ = tmp;
979        write_issue_with_id(
980            &store,
981            "2026-01-01-A1B2C3D4-fix-login.md",
982            1,
983            "Fix login",
984            "A1B2C3D4",
985        );
986        for raw in ["A1B2C3D4", "a1b2c3d4", "a1B2c3D4"] {
987            assert_eq!(
988                parse_selector(&store, raw).unwrap(),
989                IssueSelector::Filename("2026-01-01-A1B2C3D4-fix-login.md".into()),
990                "{raw} must resolve the id"
991            );
992        }
993    }
994
995    #[test]
996    fn selector_unknown_wellformed_id_is_not_found() {
997        let (tmp, store) = make_store();
998        let _ = tmp;
999        write_issue_with_id(
1000            &store,
1001            "2026-01-01-A1B2C3D4-fix-login.md",
1002            1,
1003            "Fix login",
1004            "A1B2C3D4",
1005        );
1006        // A well-formed id that matches nothing is a not-found, not a fragment scan.
1007        assert!(matches!(
1008            parse_selector(&store, "ZZZZZZZZ").unwrap_err(),
1009            TixError::NotFound(_)
1010        ));
1011    }
1012
1013    #[test]
1014    fn an_all_digits_selector_stays_a_number_not_an_id() {
1015        // Ids are never all digits (see `crate::id`), so an 8-digit selector must
1016        // keep meaning the number, and must not scan for an id.
1017        let (tmp, store) = make_store();
1018        let _ = tmp;
1019        write_issue(&store, "2026-01-01-a.md", 12_345_678, "Big number");
1020        assert_eq!(
1021            parse_selector(&store, "12345678").unwrap(),
1022            IssueSelector::Number(12_345_678)
1023        );
1024    }
1025
1026    #[test]
1027    fn priority_accepts_the_three_named_values_and_an_explicit_unset() {
1028        assert_eq!(parse_priority("low").unwrap(), Priority::Low);
1029        assert_eq!(parse_priority("HIGH").unwrap(), Priority::High);
1030        assert_eq!(parse_priority("medium").unwrap(), Priority::Medium);
1031        assert!(parse_priority("urgent").is_err());
1032        // The empty string is the TUI form's own default (`ui/form.rs` seeds
1033        // `priority: String::new()`), so rejecting it flagged every issue the
1034        // TUI created. `parse_priority_accepts_an_unset_value_and_still_rejects_typos`
1035        // covers this case in more depth.
1036        assert!(parse_priority("").unwrap().is_undefined());
1037    }
1038}