1use 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
60pub const AMBIGUITY_PREFIX: &str = "ambiguous selector";
63
64pub 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
102pub 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
116pub 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
139pub fn edit_projects_clears(value: &str) -> bool {
142 value.trim().is_empty()
143}
144
145pub 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
157pub 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
173pub fn edit_epics_clears(value: &str) -> bool {
176 value.trim().is_empty()
177}
178
179pub 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
191pub 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#[allow(clippy::too_many_arguments)]
210fn 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 Command::Beads { .. } => unreachable!("beads is handled before dispatch"),
237 Command::Mentions { action } => mentions::run(store, &mentions_action(action)),
241 Command::Analytics { command } => analytics::run(config, store, command),
247 Command::Health { action } => health::run(config, store, action),
248 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 if let Some(template_id) = template {
285 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 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.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 .map(|_| ()),
457 Command::Project { .. } => Err(TixError::InvalidInput(
467 "`tix project` is routed by the process entry, not by dispatch".to_string(),
468 )),
469 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 (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 Command::Time { command } => time::run(store, command),
563 Command::Delete { issue, yes } => delete::run(store, issue, *yes),
564 Command::Recurring { action } => recurring::run(config, store, action),
568 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 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 Command::Cycle { command } => cycle::run_legacy(config, store, command),
591 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 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
650pub 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
669pub 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
687pub 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 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 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
775pub 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 "" => Ok(Priority::Other(String::new())),
788 _ => Err(TixError::InvalidInput(format!(
789 "invalid priority '{raw}' — use '' (unset), low, medium or high"
790 ))),
791 }
792}
793
794pub 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
803pub 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 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 let undefined = parse_priority("").unwrap();
866 assert!(undefined.is_undefined());
867 assert_eq!(undefined.to_string(), "");
868
869 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 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 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 assert!(parse_priority("").unwrap().is_undefined());
1037 }
1038}