Skip to main content

haz_domain/
task_ref.rs

1//! Task references: the textual form used to identify one or more
2//! tasks across the workspace.
3//!
4//! Six syntactic forms exist (per `REF-002`..`REF-005`, `REF-008`):
5//!
6//! - `~:<task>`: same-project reference,
7//! - `<project>:<task>`: project reference,
8//! - `^:<task>`: fan-out reference,
9//! - `^~:<task>`: sibling-fan-out reference,
10//! - `[<tag>]:<task>`: tag reference,
11//! - `[<tag>]~:<task>`: sibling-tag reference.
12//!
13//! The sibling-modifier forms (`^~:` and `[<tag>]~:`) exclude the
14//! bearing project from the otherwise-included fan-out resolution.
15//!
16//! Each name component (project, tag, task) is a validated
17//! identifier per [`crate::name`]. Parsing checks only syntactic
18//! validity (`REF-001`..`REF-008`); resolution against the
19//! workspace happens later.
20
21use std::fmt;
22
23use snafu::{ResultExt, Snafu, ensure};
24
25use crate::name::{NameError, ProjectName, TagName, TaskName};
26
27/// Which name component of a task reference failed validation.
28///
29/// Used as a field of [`TaskRefError::InvalidName`] so a single
30/// variant covers `REF-001` violations across all four reference
31/// forms without sacrificing diagnostic detail.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum NameComponent {
34    /// The project name in a [`TaskRef::Project`] reference.
35    Project,
36    /// The tag name in a [`TaskRef::Tag`] reference.
37    Tag,
38    /// The task name in any reference form.
39    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/// Error produced when parsing a [`TaskRef`].
54///
55/// Each variant maps to either the grammar (`REF-002`..`REF-006`)
56/// or to identifier inheritance (`REF-001`).
57#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
58pub enum TaskRefError {
59    /// Violates the grammar: the input is empty.
60    #[snafu(display("task reference is empty"))]
61    EmptyInput,
62
63    /// Violates `REF-006` for the project-reference form: the input
64    /// has no leading sigil and contains no `:` separator.
65    #[snafu(display("task reference has no `:` separator"))]
66    MissingColon,
67
68    /// Violates the grammar for the tag-reference form (`REF-005` /
69    /// `REF-006`): the input begins with `[` but is missing the
70    /// closing `]` or the `:` that must follow it.
71    #[snafu(display("tag reference malformed: missing `]` or `:` after `]`"))]
72    MalformedTagBrackets,
73
74    /// Violates `REF-001`: a name component fails the identifier
75    /// rules `ID-001`..`ID-003`.
76    #[snafu(display("invalid {component} name: {source}"))]
77    InvalidName {
78        /// Which component (project, tag, or task) failed.
79        component: NameComponent,
80        /// The underlying identifier error.
81        source: NameError,
82    },
83}
84
85/// A validated task reference.
86///
87/// Construction is via [`TaskRef::parse`]. The [`fmt::Display`]
88/// implementation produces a string that round-trips through
89/// [`TaskRef::parse`].
90///
91/// # Examples
92///
93/// ```
94/// use haz_domain::task_ref::TaskRef;
95///
96/// let r = TaskRef::parse("~:compile").unwrap();
97/// assert_eq!(r.to_string(), "~:compile");
98///
99/// let r = TaskRef::parse("[typescript]:compile").unwrap();
100/// assert_eq!(r.to_string(), "[typescript]:compile");
101/// ```
102#[derive(Debug, Clone, PartialEq, Eq, Hash)]
103pub enum TaskRef {
104    /// `~:<task>`. Resolves within the bearing project (`REF-002`).
105    SameProject(TaskName),
106    /// `<project>:<task>`. Resolves to a task in the named project
107    /// (`REF-003`).
108    Project(ProjectName, TaskName),
109    /// `^:<task>`. Resolves to every task with this name across the
110    /// workspace, including the bearing project (`REF-004`).
111    FanOut(TaskName),
112    /// `^~:<task>`. Resolves to every task with this name across the
113    /// workspace *except the bearing project* (`REF-008`).
114    SiblingFanOut(TaskName),
115    /// `[<tag>]:<task>`. Resolves to every task with this name
116    /// whose project carries the named tag, including the bearing
117    /// project (`REF-005`).
118    Tag(TagName, TaskName),
119    /// `[<tag>]~:<task>`. Resolves to every task with this name
120    /// whose project carries the named tag, *excluding the bearing
121    /// project* even if it carries the named tag (`REF-008`).
122    SiblingTag(TagName, TaskName),
123}
124
125impl TaskRef {
126    /// Parse `s` as a [`TaskRef`].
127    ///
128    /// Dispatch follows `REF-006`: at most three characters of
129    /// lookahead for `^`-prefixed forms, plus scanning past the
130    /// closing `]` for tag forms.
131    ///
132    /// # Errors
133    ///
134    /// Returns a [`TaskRefError`] when `s` violates the grammar
135    /// (`REF-002`..`REF-006`, `REF-008`) or when any name component
136    /// violates the identifier rules (`REF-001`, delegating to
137    /// `ID-001`..`ID-003`):
138    ///
139    /// - [`TaskRefError::EmptyInput`] if `s` is the empty string.
140    /// - [`TaskRefError::MissingColon`] if `s` is a project
141    ///   reference with no `:` separator.
142    /// - [`TaskRefError::MalformedTagBrackets`] if `s` begins with
143    ///   `[` but is not a well-formed `[<tag>]:<task>` or
144    ///   `[<tag>]~:<task>`.
145    /// - [`TaskRefError::InvalidName`] if any name component is
146    ///   not a valid identifier.
147    ///
148    /// # Examples
149    ///
150    /// ```
151    /// use haz_domain::task_ref::TaskRef;
152    ///
153    /// assert!(TaskRef::parse("~:compile").is_ok());
154    /// assert!(TaskRef::parse("lib_core:compile").is_ok());
155    /// assert!(TaskRef::parse("^:compile").is_ok());
156    /// assert!(TaskRef::parse("^~:compile").is_ok());
157    /// assert!(TaskRef::parse("[typescript]:compile").is_ok());
158    /// assert!(TaskRef::parse("[typescript]~:compile").is_ok());
159    ///
160    /// assert!(TaskRef::parse("").is_err());
161    /// assert!(TaskRef::parse("compile").is_err());
162    /// assert!(TaskRef::parse("[tag]compile").is_err());
163    /// assert!(TaskRef::parse("[tag]~compile").is_err());
164    /// ```
165    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        // The sibling-fan-out form `^~:` must be tested before the
176        // plain fan-out form `^:` (longer prefix wins).
177        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            // The character(s) following `]` decide between the
197            // plain tag form `:` and the sibling-tag form `~:`.
198            // The sibling-tag form must be tested first (longer
199            // prefix wins).
200            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        // Default: project reference `<project>:<task>` (REF-006).
222        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    /// `true` if this is a same-project reference (`~:<task>`).
235    #[must_use]
236    pub fn is_same_project(&self) -> bool {
237        matches!(self, TaskRef::SameProject(_))
238    }
239
240    /// `true` if this is a project reference (`<project>:<task>`).
241    #[must_use]
242    pub fn is_project(&self) -> bool {
243        matches!(self, TaskRef::Project(_, _))
244    }
245
246    /// `true` if this is a fan-out reference (`^:<task>`).
247    #[must_use]
248    pub fn is_fan_out(&self) -> bool {
249        matches!(self, TaskRef::FanOut(_))
250    }
251
252    /// `true` if this is a sibling-fan-out reference (`^~:<task>`).
253    #[must_use]
254    pub fn is_sibling_fan_out(&self) -> bool {
255        matches!(self, TaskRef::SiblingFanOut(_))
256    }
257
258    /// `true` if this is a tag reference (`[<tag>]:<task>`).
259    #[must_use]
260    pub fn is_tag(&self) -> bool {
261        matches!(self, TaskRef::Tag(_, _))
262    }
263
264    /// `true` if this is a sibling-tag reference (`[<tag>]~:<task>`).
265    #[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    // REF-001: identifier inheritance. A few representative
290    // violations across the four forms.
291
292    #[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    // REF-002: same-project reference.
392
393    #[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    // REF-003: project reference.
407
408    #[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    // REF-004: fan-out reference.
422
423    #[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    // REF-005: tag reference.
431
432    #[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    // REF-008: sibling-fan-out and sibling-tag references.
446
447    #[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    // REF-006: dispatch by leading characters.
529
530    #[test]
531    fn ref_006_dispatch_same_project() {
532        // `~:` always wins, even when the rest looks tag-like
533        // (which would also fail ID-001 anyway).
534        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        // `^~:` must beat `^:` (longer prefix wins).
547        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        // The `~:` after `]` must beat the bare `:` (longer prefix
560        // wins).
561        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        // `~compile` has no `:` at all; it falls through to the
574        // project-reference branch which then fails MissingColon.
575        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        // `^~compile` has no `:` after `^~`; falls through to the
608        // project-reference branch and fails MissingColon.
609        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        // `[tag]~compile` has `~` after `]` but no `:` after it;
618        // neither `~:` nor `:` strip-prefix matches, so it falls
619        // through to MalformedTagBrackets.
620        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        // `[tag]~` ends right after `~`; no `:` follows.
629        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        // `lib~:compile` is not a sibling form; `~` is not in the
638        // identifier alphabet, so the project-name validation
639        // rejects it.
640        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    // REF-007: no whitespace, no extras. Inherited from ID-001
663    // for chars inside identifier slots; checked here for the
664    // characters directly after a sigil.
665
666    #[test]
667    fn ref_007_rejects_space_after_same_project_sigil() {
668        // `~: foo` keeps the leading space inside what would be
669        // the task name, which then fails ID-001.
670        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        // U+00E9 (LATIN SMALL LETTER E WITH ACUTE) is not ASCII.
682        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    // Round-trip parse -> Display -> parse.
703
704    #[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    // Properties: every reference produced by the six valid
753    // strategies parses, displays back to itself, and is recognised
754    // by the right `is_*` predicate.
755
756    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}