1use std::fmt;
22
23use snafu::{ResultExt, Snafu, ensure};
24
25use crate::name::{NameError, ProjectName, TagName, TaskName};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum NameComponent {
34 Project,
36 Tag,
38 Task,
40}
41
42impl fmt::Display for NameComponent {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 let s = match self {
45 NameComponent::Project => "project",
46 NameComponent::Tag => "tag",
47 NameComponent::Task => "task",
48 };
49 f.write_str(s)
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
58pub enum TaskRefError {
59 #[snafu(display("task reference is empty"))]
61 EmptyInput,
62
63 #[snafu(display("task reference has no `:` separator"))]
66 MissingColon,
67
68 #[snafu(display("tag reference malformed: missing `]` or `:` after `]`"))]
72 MalformedTagBrackets,
73
74 #[snafu(display("invalid {component} name: {source}"))]
77 InvalidName {
78 component: NameComponent,
80 source: NameError,
82 },
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Hash)]
103pub enum TaskRef {
104 SameProject(TaskName),
106 Project(ProjectName, TaskName),
109 FanOut(TaskName),
112 SiblingFanOut(TaskName),
115 Tag(TagName, TaskName),
119 SiblingTag(TagName, TaskName),
123}
124
125impl TaskRef {
126 pub fn parse(s: &str) -> Result<Self, TaskRefError> {
166 ensure!(!s.is_empty(), EmptyInputSnafu);
167
168 if let Some(rest) = s.strip_prefix("~:") {
169 let task = TaskName::try_new(rest).context(InvalidNameSnafu {
170 component: NameComponent::Task,
171 })?;
172 return Ok(TaskRef::SameProject(task));
173 }
174
175 if let Some(rest) = s.strip_prefix("^~:") {
178 let task = TaskName::try_new(rest).context(InvalidNameSnafu {
179 component: NameComponent::Task,
180 })?;
181 return Ok(TaskRef::SiblingFanOut(task));
182 }
183
184 if let Some(rest) = s.strip_prefix("^:") {
185 let task = TaskName::try_new(rest).context(InvalidNameSnafu {
186 component: NameComponent::Task,
187 })?;
188 return Ok(TaskRef::FanOut(task));
189 }
190
191 if let Some(rest) = s.strip_prefix('[') {
192 let close_idx = rest.find(']').ok_or(TaskRefError::MalformedTagBrackets)?;
193 let (tag_str, after_close) = rest.split_at(close_idx);
194 let after_bracket = &after_close[']'.len_utf8()..];
195
196 let (task_str, is_sibling) = if let Some(rest) = after_bracket.strip_prefix("~:") {
201 (rest, true)
202 } else if let Some(rest) = after_bracket.strip_prefix(':') {
203 (rest, false)
204 } else {
205 return Err(TaskRefError::MalformedTagBrackets);
206 };
207
208 let tag = TagName::try_new(tag_str).context(InvalidNameSnafu {
209 component: NameComponent::Tag,
210 })?;
211 let task = TaskName::try_new(task_str).context(InvalidNameSnafu {
212 component: NameComponent::Task,
213 })?;
214 return Ok(if is_sibling {
215 TaskRef::SiblingTag(tag, task)
216 } else {
217 TaskRef::Tag(tag, task)
218 });
219 }
220
221 let colon_idx = s.find(':').ok_or(TaskRefError::MissingColon)?;
223 let (proj_str, after_colon) = s.split_at(colon_idx);
224 let task_str = &after_colon[':'.len_utf8()..];
225 let project = ProjectName::try_new(proj_str).context(InvalidNameSnafu {
226 component: NameComponent::Project,
227 })?;
228 let task = TaskName::try_new(task_str).context(InvalidNameSnafu {
229 component: NameComponent::Task,
230 })?;
231 Ok(TaskRef::Project(project, task))
232 }
233
234 #[must_use]
236 pub fn is_same_project(&self) -> bool {
237 matches!(self, TaskRef::SameProject(_))
238 }
239
240 #[must_use]
242 pub fn is_project(&self) -> bool {
243 matches!(self, TaskRef::Project(_, _))
244 }
245
246 #[must_use]
248 pub fn is_fan_out(&self) -> bool {
249 matches!(self, TaskRef::FanOut(_))
250 }
251
252 #[must_use]
254 pub fn is_sibling_fan_out(&self) -> bool {
255 matches!(self, TaskRef::SiblingFanOut(_))
256 }
257
258 #[must_use]
260 pub fn is_tag(&self) -> bool {
261 matches!(self, TaskRef::Tag(_, _))
262 }
263
264 #[must_use]
266 pub fn is_sibling_tag(&self) -> bool {
267 matches!(self, TaskRef::SiblingTag(_, _))
268 }
269}
270
271impl fmt::Display for TaskRef {
272 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273 match self {
274 TaskRef::SameProject(task) => write!(f, "~:{task}"),
275 TaskRef::Project(project, task) => write!(f, "{project}:{task}"),
276 TaskRef::FanOut(task) => write!(f, "^:{task}"),
277 TaskRef::SiblingFanOut(task) => write!(f, "^~:{task}"),
278 TaskRef::Tag(tag, task) => write!(f, "[{tag}]:{task}"),
279 TaskRef::SiblingTag(tag, task) => write!(f, "[{tag}]~:{task}"),
280 }
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use crate::name::NameError;
287 use crate::task_ref::{NameComponent, TaskRef, TaskRefError};
288
289 #[test]
293 fn ref_001_rejects_invalid_project_name() {
294 assert!(matches!(
295 TaskRef::parse("lib core:compile"),
296 Err(TaskRefError::InvalidName {
297 component: NameComponent::Project,
298 source: NameError::InvalidChar { c: ' ' },
299 })
300 ));
301 }
302
303 #[test]
304 fn ref_001_rejects_invalid_tag_name() {
305 assert!(matches!(
306 TaskRef::parse("[a b]:compile"),
307 Err(TaskRefError::InvalidName {
308 component: NameComponent::Tag,
309 source: NameError::InvalidChar { c: ' ' },
310 })
311 ));
312 }
313
314 #[test]
315 fn ref_001_rejects_invalid_task_name() {
316 assert!(matches!(
317 TaskRef::parse("~:foo bar"),
318 Err(TaskRefError::InvalidName {
319 component: NameComponent::Task,
320 source: NameError::InvalidChar { c: ' ' },
321 })
322 ));
323 }
324
325 #[test]
326 fn ref_001_rejects_empty_project_name() {
327 assert!(matches!(
328 TaskRef::parse(":compile"),
329 Err(TaskRefError::InvalidName {
330 component: NameComponent::Project,
331 source: NameError::Empty,
332 })
333 ));
334 }
335
336 #[test]
337 fn ref_001_rejects_empty_tag_name() {
338 assert!(matches!(
339 TaskRef::parse("[]:compile"),
340 Err(TaskRefError::InvalidName {
341 component: NameComponent::Tag,
342 source: NameError::Empty,
343 })
344 ));
345 }
346
347 #[test]
348 fn ref_001_rejects_empty_task_in_same_project() {
349 assert!(matches!(
350 TaskRef::parse("~:"),
351 Err(TaskRefError::InvalidName {
352 component: NameComponent::Task,
353 source: NameError::Empty,
354 })
355 ));
356 }
357
358 #[test]
359 fn ref_001_rejects_empty_task_in_fan_out() {
360 assert!(matches!(
361 TaskRef::parse("^:"),
362 Err(TaskRefError::InvalidName {
363 component: NameComponent::Task,
364 source: NameError::Empty,
365 })
366 ));
367 }
368
369 #[test]
370 fn ref_001_rejects_empty_task_in_project() {
371 assert!(matches!(
372 TaskRef::parse("lib:"),
373 Err(TaskRefError::InvalidName {
374 component: NameComponent::Task,
375 source: NameError::Empty,
376 })
377 ));
378 }
379
380 #[test]
381 fn ref_001_rejects_empty_task_in_tag() {
382 assert!(matches!(
383 TaskRef::parse("[typescript]:"),
384 Err(TaskRefError::InvalidName {
385 component: NameComponent::Task,
386 source: NameError::Empty,
387 })
388 ));
389 }
390
391 #[test]
394 fn ref_002_parses_same_project() {
395 let r = TaskRef::parse("~:compile").unwrap();
396 assert!(r.is_same_project());
397 assert_eq!(r.to_string(), "~:compile");
398 }
399
400 #[test]
401 fn ref_002_parses_same_project_with_underscore_and_dash() {
402 let r = TaskRef::parse("~:test_unit-1").unwrap();
403 assert!(matches!(r, TaskRef::SameProject(_)));
404 }
405
406 #[test]
409 fn ref_003_parses_project() {
410 let r = TaskRef::parse("lib_core:compile").unwrap();
411 assert!(r.is_project());
412 assert_eq!(r.to_string(), "lib_core:compile");
413 }
414
415 #[test]
416 fn ref_003_parses_project_with_dash() {
417 let r = TaskRef::parse("web-frontend:bundle").unwrap();
418 assert!(matches!(r, TaskRef::Project(_, _)));
419 }
420
421 #[test]
424 fn ref_004_parses_fan_out() {
425 let r = TaskRef::parse("^:compile").unwrap();
426 assert!(r.is_fan_out());
427 assert_eq!(r.to_string(), "^:compile");
428 }
429
430 #[test]
433 fn ref_005_parses_tag() {
434 let r = TaskRef::parse("[typescript]:compile").unwrap();
435 assert!(r.is_tag());
436 assert_eq!(r.to_string(), "[typescript]:compile");
437 }
438
439 #[test]
440 fn ref_005_parses_tag_with_dash() {
441 let r = TaskRef::parse("[release]:publish").unwrap();
442 assert!(matches!(r, TaskRef::Tag(_, _)));
443 }
444
445 #[test]
448 fn ref_008_parses_sibling_fan_out() {
449 let r = TaskRef::parse("^~:compile").unwrap();
450 assert!(r.is_sibling_fan_out());
451 assert_eq!(r.to_string(), "^~:compile");
452 }
453
454 #[test]
455 fn ref_008_parses_sibling_fan_out_with_underscore_and_dash() {
456 let r = TaskRef::parse("^~:test_unit-1").unwrap();
457 assert!(matches!(r, TaskRef::SiblingFanOut(_)));
458 }
459
460 #[test]
461 fn ref_008_parses_sibling_tag() {
462 let r = TaskRef::parse("[typescript]~:compile").unwrap();
463 assert!(r.is_sibling_tag());
464 assert_eq!(r.to_string(), "[typescript]~:compile");
465 }
466
467 #[test]
468 fn ref_008_parses_sibling_tag_with_dash() {
469 let r = TaskRef::parse("[release]~:publish").unwrap();
470 assert!(matches!(r, TaskRef::SiblingTag(_, _)));
471 }
472
473 #[test]
474 fn ref_008_rejects_empty_task_in_sibling_fan_out() {
475 assert!(matches!(
476 TaskRef::parse("^~:"),
477 Err(TaskRefError::InvalidName {
478 component: NameComponent::Task,
479 source: NameError::Empty,
480 })
481 ));
482 }
483
484 #[test]
485 fn ref_008_rejects_empty_task_in_sibling_tag() {
486 assert!(matches!(
487 TaskRef::parse("[typescript]~:"),
488 Err(TaskRefError::InvalidName {
489 component: NameComponent::Task,
490 source: NameError::Empty,
491 })
492 ));
493 }
494
495 #[test]
496 fn ref_008_rejects_invalid_task_in_sibling_fan_out() {
497 assert!(matches!(
498 TaskRef::parse("^~:foo bar"),
499 Err(TaskRefError::InvalidName {
500 component: NameComponent::Task,
501 source: NameError::InvalidChar { c: ' ' },
502 })
503 ));
504 }
505
506 #[test]
507 fn ref_008_rejects_invalid_tag_in_sibling_tag() {
508 assert!(matches!(
509 TaskRef::parse("[a b]~:compile"),
510 Err(TaskRefError::InvalidName {
511 component: NameComponent::Tag,
512 source: NameError::InvalidChar { c: ' ' },
513 })
514 ));
515 }
516
517 #[test]
518 fn ref_008_rejects_empty_tag_in_sibling_tag() {
519 assert!(matches!(
520 TaskRef::parse("[]~:compile"),
521 Err(TaskRefError::InvalidName {
522 component: NameComponent::Tag,
523 source: NameError::Empty,
524 })
525 ));
526 }
527
528 #[test]
531 fn ref_006_dispatch_same_project() {
532 let r = TaskRef::parse("~:compile").unwrap();
535 assert!(r.is_same_project());
536 }
537
538 #[test]
539 fn ref_006_dispatch_fan_out() {
540 let r = TaskRef::parse("^:compile").unwrap();
541 assert!(r.is_fan_out());
542 }
543
544 #[test]
545 fn ref_006_dispatch_sibling_fan_out() {
546 let r = TaskRef::parse("^~:compile").unwrap();
548 assert!(r.is_sibling_fan_out());
549 }
550
551 #[test]
552 fn ref_006_dispatch_tag() {
553 let r = TaskRef::parse("[t]:compile").unwrap();
554 assert!(r.is_tag());
555 }
556
557 #[test]
558 fn ref_006_dispatch_sibling_tag() {
559 let r = TaskRef::parse("[t]~:compile").unwrap();
562 assert!(r.is_sibling_tag());
563 }
564
565 #[test]
566 fn ref_006_dispatch_project_default() {
567 let r = TaskRef::parse("p:t").unwrap();
568 assert!(r.is_project());
569 }
570
571 #[test]
572 fn ref_006_rejects_tilde_without_colon() {
573 assert!(matches!(
576 TaskRef::parse("~compile"),
577 Err(TaskRefError::MissingColon)
578 ));
579 }
580
581 #[test]
582 fn ref_006_rejects_caret_without_colon() {
583 assert!(matches!(
584 TaskRef::parse("^compile"),
585 Err(TaskRefError::MissingColon)
586 ));
587 }
588
589 #[test]
590 fn ref_006_rejects_tag_missing_close_bracket() {
591 assert!(matches!(
592 TaskRef::parse("[tag"),
593 Err(TaskRefError::MalformedTagBrackets)
594 ));
595 }
596
597 #[test]
598 fn ref_006_rejects_tag_missing_colon_after_close() {
599 assert!(matches!(
600 TaskRef::parse("[tag]compile"),
601 Err(TaskRefError::MalformedTagBrackets)
602 ));
603 }
604
605 #[test]
606 fn ref_006_rejects_sibling_fan_out_without_colon() {
607 assert!(matches!(
610 TaskRef::parse("^~compile"),
611 Err(TaskRefError::MissingColon)
612 ));
613 }
614
615 #[test]
616 fn ref_006_rejects_sibling_tag_without_colon() {
617 assert!(matches!(
621 TaskRef::parse("[tag]~compile"),
622 Err(TaskRefError::MalformedTagBrackets)
623 ));
624 }
625
626 #[test]
627 fn ref_006_rejects_sibling_tag_bare_tilde_after_close() {
628 assert!(matches!(
630 TaskRef::parse("[tag]~"),
631 Err(TaskRefError::MalformedTagBrackets)
632 ));
633 }
634
635 #[test]
636 fn ref_006_rejects_tilde_suffix_on_project_name() {
637 assert!(matches!(
641 TaskRef::parse("lib~:compile"),
642 Err(TaskRefError::InvalidName {
643 component: NameComponent::Project,
644 source: NameError::InvalidChar { c: '~' },
645 })
646 ));
647 }
648
649 #[test]
650 fn ref_006_rejects_no_colon_at_all() {
651 assert!(matches!(
652 TaskRef::parse("compile"),
653 Err(TaskRefError::MissingColon)
654 ));
655 }
656
657 #[test]
658 fn ref_006_rejects_empty_input() {
659 assert!(matches!(TaskRef::parse(""), Err(TaskRefError::EmptyInput)));
660 }
661
662 #[test]
667 fn ref_007_rejects_space_after_same_project_sigil() {
668 assert!(matches!(
671 TaskRef::parse("~: foo"),
672 Err(TaskRefError::InvalidName {
673 component: NameComponent::Task,
674 source: NameError::InvalidLeadingChar { c: ' ' },
675 })
676 ));
677 }
678
679 #[test]
680 fn ref_007_rejects_non_ascii_in_task() {
681 assert!(matches!(
683 TaskRef::parse("~:caf\u{00E9}"),
684 Err(TaskRefError::InvalidName {
685 component: NameComponent::Task,
686 source: NameError::InvalidChar { c: '\u{00E9}' },
687 })
688 ));
689 }
690
691 #[test]
692 fn ref_007_rejects_tab_in_project() {
693 assert!(matches!(
694 TaskRef::parse("lib\tcore:compile"),
695 Err(TaskRefError::InvalidName {
696 component: NameComponent::Project,
697 source: NameError::InvalidChar { c: '\t' },
698 })
699 ));
700 }
701
702 #[test]
705 fn round_trip_same_project() {
706 let original = "~:compile";
707 let r = TaskRef::parse(original).unwrap();
708 assert_eq!(r.to_string(), original);
709 assert_eq!(TaskRef::parse(&r.to_string()).unwrap(), r);
710 }
711
712 #[test]
713 fn round_trip_project() {
714 let original = "lib_core:compile";
715 let r = TaskRef::parse(original).unwrap();
716 assert_eq!(r.to_string(), original);
717 assert_eq!(TaskRef::parse(&r.to_string()).unwrap(), r);
718 }
719
720 #[test]
721 fn round_trip_fan_out() {
722 let original = "^:test";
723 let r = TaskRef::parse(original).unwrap();
724 assert_eq!(r.to_string(), original);
725 assert_eq!(TaskRef::parse(&r.to_string()).unwrap(), r);
726 }
727
728 #[test]
729 fn round_trip_tag() {
730 let original = "[typescript]:compile";
731 let r = TaskRef::parse(original).unwrap();
732 assert_eq!(r.to_string(), original);
733 assert_eq!(TaskRef::parse(&r.to_string()).unwrap(), r);
734 }
735
736 #[test]
737 fn round_trip_sibling_fan_out() {
738 let original = "^~:test";
739 let r = TaskRef::parse(original).unwrap();
740 assert_eq!(r.to_string(), original);
741 assert_eq!(TaskRef::parse(&r.to_string()).unwrap(), r);
742 }
743
744 #[test]
745 fn round_trip_sibling_tag() {
746 let original = "[typescript]~:compile";
747 let r = TaskRef::parse(original).unwrap();
748 assert_eq!(r.to_string(), original);
749 assert_eq!(TaskRef::parse(&r.to_string()).unwrap(), r);
750 }
751
752 use proptest::prelude::*;
757
758 fn name_strategy() -> impl Strategy<Value = String> {
759 "[A-Za-z0-9][A-Za-z0-9_-]{0,15}"
760 }
761
762 proptest! {
763 #[test]
764 fn prop_same_project_round_trips(task in name_strategy()) {
765 let s = format!("~:{task}");
766 let r = TaskRef::parse(&s).unwrap();
767 prop_assert!(r.is_same_project());
768 prop_assert_eq!(r.to_string(), s);
769 }
770
771 #[test]
772 fn prop_project_round_trips(
773 project in name_strategy(),
774 task in name_strategy(),
775 ) {
776 let s = format!("{project}:{task}");
777 let r = TaskRef::parse(&s).unwrap();
778 prop_assert!(r.is_project());
779 prop_assert_eq!(r.to_string(), s);
780 }
781
782 #[test]
783 fn prop_fan_out_round_trips(task in name_strategy()) {
784 let s = format!("^:{task}");
785 let r = TaskRef::parse(&s).unwrap();
786 prop_assert!(r.is_fan_out());
787 prop_assert_eq!(r.to_string(), s);
788 }
789
790 #[test]
791 fn prop_sibling_fan_out_round_trips(task in name_strategy()) {
792 let s = format!("^~:{task}");
793 let r = TaskRef::parse(&s).unwrap();
794 prop_assert!(r.is_sibling_fan_out());
795 prop_assert_eq!(r.to_string(), s);
796 }
797
798 #[test]
799 fn prop_tag_round_trips(
800 tag in name_strategy(),
801 task in name_strategy(),
802 ) {
803 let s = format!("[{tag}]:{task}");
804 let r = TaskRef::parse(&s).unwrap();
805 prop_assert!(r.is_tag());
806 prop_assert_eq!(r.to_string(), s);
807 }
808
809 #[test]
810 fn prop_sibling_tag_round_trips(
811 tag in name_strategy(),
812 task in name_strategy(),
813 ) {
814 let s = format!("[{tag}]~:{task}");
815 let r = TaskRef::parse(&s).unwrap();
816 prop_assert!(r.is_sibling_tag());
817 prop_assert_eq!(r.to_string(), s);
818 }
819 }
820}