Skip to main content

haz_domain/path/
pattern.rs

1//! `PathPattern`: a literal [`HazPath`] or a glob pattern.
2//!
3//! A path pattern (per `PATH-008`) is what appears in `inputs` and
4//! `outputs`. It is either:
5//!
6//! - a literal path (no wildcards, no `\` escapes): the existing
7//!   [`HazPath`] type, reused as the [`PathPattern::Literal`]
8//!   variant; or
9//! - a [`GlobPattern`]: a glob conforming to `PATH-009`..`PATH-016`,
10//!   compiled by `globset` 0.4 with `literal_separator(true)` and
11//!   `case_insensitive(false)`.
12//!
13//! The classification is decided by [`PathPattern::parse`] from the
14//! presence of any of the glob meta-characters `*`, `?`, `[`, `{`,
15//! or the escape character `\`. A string with `\` is always
16//! classified as a glob (per `PATH-015`), even if every wildcard
17//! in it is escaped, because the `\` itself is glob syntax that
18//! does not appear in literal paths.
19//!
20//! The canonical text of a `PathPattern` (the form produced by
21//! [`fmt::Display`]) is what determines equality and hashing. The
22//! compiled `globset::Glob` is derived state, produced on demand
23//! by [`GlobPattern::compile`].
24
25use std::fmt;
26
27use snafu::{ResultExt, Snafu, ensure};
28
29use crate::path::segment::{ForbiddenCategory, forbidden_category};
30use crate::path::{HazPath, PathError};
31
32/// Where a path pattern resolves from.
33///
34/// Mirrors the workspace-absolute / project-relative dichotomy of
35/// [`HazPath`], but is exposed as its own type so the
36/// [`GlobPattern`] variant can carry it without conflating with the
37/// [`HazPath`] variants.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum PathAnchor {
40    /// Resolves against the workspace root (`PATH-004`).
41    WorkspaceAbsolute,
42    /// Resolves against the bearing field's project root
43    /// (`PATH-005`).
44    ProjectRelative,
45}
46
47/// Error produced when validating a single glob segment.
48///
49/// Each variant maps to one of the glob-pattern rules
50/// (`PATH-002` inheritance for forbidden codepoints, `PATH-011`
51/// for the `**` placement constraint).
52#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
53pub enum GlobSegmentError {
54    /// Violates `PATH-003`: the segment is empty.
55    #[snafu(display("glob segment is empty"))]
56    Empty,
57
58    /// Violates `PATH-002`: the segment is the literal `.`.
59    #[snafu(display("glob segment is the literal `.`"))]
60    Dot,
61
62    /// Violates `PATH-002`: the segment is the literal `..`.
63    #[snafu(display("glob segment is the literal `..`"))]
64    DotDot,
65
66    /// Violates `PATH-002` (inherited): the segment contains a
67    /// codepoint in a forbidden Unicode General Category.
68    #[snafu(display(
69        "glob segment contains forbidden {category} codepoint U+{:04X}",
70        u32::from(*c)
71    ))]
72    ContainsForbidden {
73        /// The offending codepoint.
74        c: char,
75        /// The Unicode General Category that disqualifies it.
76        category: ForbiddenCategory,
77    },
78
79    /// Violates `PATH-011`: `**` MUST appear only as a complete
80    /// segment, never adjacent to other characters within the same
81    /// segment.
82    #[snafu(display("glob segment contains `**` adjacent to other characters"))]
83    AdjacentDoubleStar,
84}
85
86/// Error produced when parsing a [`PathPattern`].
87#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
88pub enum PathPatternError {
89    /// Violates `PATH-002`: the input is empty.
90    #[snafu(display("path pattern is empty"))]
91    EmptyInput,
92
93    /// Violates `PATH-003`: the input contains the empty
94    /// separator `//`.
95    #[snafu(display("path pattern contains the empty segment `//`"))]
96    EmptySegment,
97
98    /// Violates `PATH-003`: the workspace-absolute pattern is the
99    /// single character `/` and has no segments.
100    #[snafu(display("workspace-absolute glob has no segments"))]
101    OnlySlash,
102
103    /// Violates `PATH-003`: the input ends with `/`.
104    #[snafu(display("path pattern ends with trailing `/`"))]
105    TrailingSlash,
106
107    /// The literal sub-form (`PATH-001`..`PATH-007`) failed.
108    #[snafu(display("invalid literal path: {source}"))]
109    LiteralPath {
110        /// Underlying [`HazPath`] error.
111        source: PathError,
112    },
113
114    /// A glob segment failed validation.
115    #[snafu(display("invalid glob segment {segment:?} at position {position}: {source}"))]
116    InvalidGlobSegment {
117        /// The offending segment text.
118        segment: String,
119        /// Zero-based index of the segment in the pattern body.
120        position: usize,
121        /// Underlying segment-level error.
122        source: GlobSegmentError,
123    },
124
125    /// Violates `PATH-014`: a brace-alternation block is nested
126    /// inside another brace-alternation block.
127    ///
128    /// `globset` 0.4 accepts nested alternation, so the constraint
129    /// is enforced separately at parse time.
130    #[snafu(display("nested alternation is forbidden"))]
131    NestedAlternation,
132
133    /// `globset` rejected the structural form of the pattern (for
134    /// example, an unclosed `[`).
135    #[snafu(display("globset rejected pattern: {message}"))]
136    InvalidGlob {
137        /// Diagnostic text returned by `globset`.
138        message: String,
139    },
140}
141
142/// A validated glob pattern (`PATH-009`..`PATH-016`).
143///
144/// The canonical body is stored as a string without the leading
145/// `/`; the leading `/` is reintroduced by [`fmt::Display`] when
146/// the anchor is workspace-absolute. This keeps `Eq`/`Hash` keyed
147/// on a single canonical spelling.
148#[derive(Debug, Clone, PartialEq, Eq, Hash)]
149pub struct GlobPattern {
150    anchor: PathAnchor,
151    body: String,
152}
153
154impl GlobPattern {
155    /// The anchor (workspace-absolute or project-relative).
156    #[must_use]
157    pub fn anchor(&self) -> PathAnchor {
158        self.anchor
159    }
160
161    /// The canonical body (segments joined by `/`), without the
162    /// leading `/`.
163    #[must_use]
164    pub fn body(&self) -> &str {
165        &self.body
166    }
167
168    /// Compile this pattern into a `globset::Glob` for matching.
169    ///
170    /// The pattern was validated at construction, so compilation
171    /// always succeeds.
172    ///
173    /// # Panics
174    ///
175    /// Does not panic on any path of execution reachable from valid
176    /// input: an internal invariant assertion exists (the success
177    /// of `globset::GlobBuilder::build` after the same builder
178    /// already accepted the pattern at parse time) but is
179    /// structurally unreachable.
180    #[must_use]
181    pub fn compile(&self) -> globset::Glob {
182        let canonical = self.to_string();
183        globset::GlobBuilder::new(&canonical)
184            .literal_separator(true)
185            .case_insensitive(false)
186            .build()
187            .expect("validated at construction")
188    }
189}
190
191impl fmt::Display for GlobPattern {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        if matches!(self.anchor, PathAnchor::WorkspaceAbsolute) {
194            f.write_str("/")?;
195        }
196        f.write_str(&self.body)
197    }
198}
199
200/// A validated path pattern: either a literal [`HazPath`] or a
201/// [`GlobPattern`].
202///
203/// Construction is via [`PathPattern::parse`]. The
204/// [`fmt::Display`] implementation produces the canonical text,
205/// which round-trips through [`PathPattern::parse`].
206///
207/// # Examples
208///
209/// ```
210/// use haz_domain::path::PathPattern;
211///
212/// let p = PathPattern::parse("src/lib.rs").unwrap();
213/// assert!(matches!(p, PathPattern::Literal(_)));
214///
215/// let g = PathPattern::parse("src/**/*.rs").unwrap();
216/// assert!(matches!(g, PathPattern::Glob(_)));
217/// ```
218#[derive(Debug, Clone, PartialEq, Eq, Hash)]
219pub enum PathPattern {
220    /// A literal path (no wildcards, no `\` escapes).
221    Literal(HazPath),
222    /// A glob pattern.
223    Glob(GlobPattern),
224}
225
226impl PathPattern {
227    /// Parse `s` as a [`PathPattern`].
228    ///
229    /// Classification rule: if `s` contains any of `*`, `?`, `[`,
230    /// `{`, or `\`, the input is a glob; otherwise it is parsed as
231    /// a literal [`HazPath`]. The escape character `\` is glob
232    /// syntax (`PATH-015`), so its mere presence is enough to
233    /// classify the input as a glob.
234    ///
235    /// # Errors
236    ///
237    /// Returns a [`PathPatternError`] when `s` violates path or
238    /// glob rules:
239    ///
240    /// - [`PathPatternError::EmptyInput`] if `s` is the empty string.
241    /// - [`PathPatternError::OnlySlash`] if `s` is `"/"`.
242    /// - [`PathPatternError::EmptySegment`] if `s` contains `//`.
243    /// - [`PathPatternError::TrailingSlash`] if `s` ends with `/`.
244    /// - [`PathPatternError::LiteralPath`] if the literal sub-form
245    ///   ([`HazPath`]) rejects the input.
246    /// - [`PathPatternError::InvalidGlobSegment`] if a glob segment
247    ///   violates `PATH-002` or `PATH-011`.
248    /// - [`PathPatternError::InvalidGlob`] if `globset` rejects the
249    ///   structural form (for example, an unclosed `[` or nested
250    ///   `{}`).
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// use haz_domain::path::PathPattern;
256    ///
257    /// assert!(PathPattern::parse("src/lib.rs").is_ok());
258    /// assert!(PathPattern::parse("/lib_core/src/main.rs").is_ok());
259    /// assert!(PathPattern::parse("src/**/*.rs").is_ok());
260    /// assert!(PathPattern::parse("/lib_core/src/**/*.rs").is_ok());
261    /// assert!(PathPattern::parse("with\\*literal.txt").is_ok());
262    ///
263    /// assert!(PathPattern::parse("").is_err());
264    /// assert!(PathPattern::parse("a**b/foo.rs").is_err());
265    /// ```
266    pub fn parse(s: &str) -> Result<Self, PathPatternError> {
267        ensure!(!s.is_empty(), EmptyInputSnafu);
268
269        if !has_glob_metacharacter(s) {
270            return HazPath::parse(s)
271                .context(LiteralPathSnafu)
272                .map(PathPattern::Literal);
273        }
274
275        let (anchor, body) = if let Some(rest) = s.strip_prefix('/') {
276            (PathAnchor::WorkspaceAbsolute, rest)
277        } else {
278            (PathAnchor::ProjectRelative, s)
279        };
280
281        ensure!(!body.is_empty(), OnlySlashSnafu);
282        ensure!(!body.contains("//"), EmptySegmentSnafu);
283        ensure!(!body.ends_with('/'), TrailingSlashSnafu);
284
285        for (position, segment) in body.split('/').enumerate() {
286            validate_glob_segment(segment).context(InvalidGlobSegmentSnafu {
287                segment: segment.to_owned(),
288                position,
289            })?;
290        }
291
292        ensure!(!has_nested_alternation(body), NestedAlternationSnafu);
293
294        let canonical = match anchor {
295            PathAnchor::WorkspaceAbsolute => format!("/{body}"),
296            PathAnchor::ProjectRelative => body.to_owned(),
297        };
298        globset::GlobBuilder::new(&canonical)
299            .literal_separator(true)
300            .case_insensitive(false)
301            .build()
302            .map_err(|e| PathPatternError::InvalidGlob {
303                message: e.to_string(),
304            })?;
305
306        Ok(PathPattern::Glob(GlobPattern {
307            anchor,
308            body: body.to_owned(),
309        }))
310    }
311
312    /// `true` if this pattern is a literal path (no wildcards).
313    #[must_use]
314    pub fn is_literal(&self) -> bool {
315        matches!(self, PathPattern::Literal(_))
316    }
317
318    /// `true` if this pattern is a glob.
319    #[must_use]
320    pub fn is_glob(&self) -> bool {
321        matches!(self, PathPattern::Glob(_))
322    }
323
324    /// The anchor (workspace-absolute or project-relative).
325    #[must_use]
326    pub fn anchor(&self) -> PathAnchor {
327        match self {
328            PathPattern::Literal(p) => {
329                if p.is_workspace_absolute() {
330                    PathAnchor::WorkspaceAbsolute
331                } else {
332                    PathAnchor::ProjectRelative
333                }
334            }
335            PathPattern::Glob(g) => g.anchor(),
336        }
337    }
338}
339
340impl fmt::Display for PathPattern {
341    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342        match self {
343            PathPattern::Literal(p) => p.fmt(f),
344            PathPattern::Glob(g) => g.fmt(f),
345        }
346    }
347}
348
349fn has_glob_metacharacter(s: &str) -> bool {
350    s.chars().any(|c| matches!(c, '*' | '?' | '[' | '{' | '\\'))
351}
352
353/// `true` if any `{` opens an alternation while another is already
354/// open. Respects `\` escapes and `[...]` character classes (where
355/// `{` is a literal).
356fn has_nested_alternation(s: &str) -> bool {
357    let mut chars = s.chars();
358    let mut brace_depth: u32 = 0;
359    let mut in_class = false;
360    while let Some(c) = chars.next() {
361        match c {
362            '\\' => {
363                chars.next();
364            }
365            '[' if !in_class => in_class = true,
366            ']' if in_class => in_class = false,
367            '{' if !in_class => {
368                if brace_depth > 0 {
369                    return true;
370                }
371                brace_depth += 1;
372            }
373            '}' if !in_class => {
374                brace_depth = brace_depth.saturating_sub(1);
375            }
376            _ => {}
377        }
378    }
379    false
380}
381
382fn validate_glob_segment(s: &str) -> Result<(), GlobSegmentError> {
383    if s == "**" {
384        return Ok(());
385    }
386    ensure!(!s.is_empty(), EmptySnafu);
387    ensure!(s != ".", DotSnafu);
388    ensure!(s != "..", DotDotSnafu);
389    ensure!(!s.contains("**"), AdjacentDoubleStarSnafu);
390
391    for c in s.chars() {
392        if let Some(category) = forbidden_category(c) {
393            return ContainsForbiddenSnafu { c, category }.fail();
394        }
395    }
396    Ok(())
397}
398
399#[cfg(test)]
400mod tests {
401    use crate::path::pattern::{
402        GlobSegmentError, PathAnchor, PathPattern, PathPatternError, has_glob_metacharacter,
403    };
404    use crate::path::segment::ForbiddenCategory;
405    use crate::path::{HazPath, PathError, SegmentError};
406
407    // PATH-008: literal vs glob classification.
408
409    #[test]
410    fn path_008_classifies_literal_no_metacharacters() {
411        let p = PathPattern::parse("src/lib.rs").unwrap();
412        assert!(p.is_literal());
413        assert!(matches!(
414            p,
415            PathPattern::Literal(HazPath::ProjectRelative(_))
416        ));
417    }
418
419    #[test]
420    fn path_008_classifies_workspace_absolute_literal() {
421        let p = PathPattern::parse("/lib_core/src/main.rs").unwrap();
422        assert!(p.is_literal());
423        assert_eq!(p.anchor(), PathAnchor::WorkspaceAbsolute);
424    }
425
426    #[test]
427    fn path_008_classifies_glob_with_star() {
428        let p = PathPattern::parse("src/*.rs").unwrap();
429        assert!(p.is_glob());
430    }
431
432    #[test]
433    fn path_008_classifies_glob_with_question() {
434        let p = PathPattern::parse("src/lib?.rs").unwrap();
435        assert!(p.is_glob());
436    }
437
438    #[test]
439    fn path_008_classifies_glob_with_charclass() {
440        let p = PathPattern::parse("dist/[!_]a/index.html").unwrap();
441        assert!(p.is_glob());
442    }
443
444    #[test]
445    fn path_008_classifies_glob_with_alternation() {
446        let p = PathPattern::parse("assets/x.{png,jpg}").unwrap();
447        assert!(p.is_glob());
448    }
449
450    #[test]
451    fn path_008_classifies_escape_as_glob() {
452        // Per PATH-015, `\` is glob syntax. A pattern containing `\`
453        // is therefore a glob, not a literal, even if every wildcard
454        // is escaped.
455        let p = PathPattern::parse("with\\*literal.txt").unwrap();
456        assert!(p.is_glob());
457    }
458
459    // Detection helper itself.
460
461    #[test]
462    fn detection_recognises_all_metacharacters() {
463        assert!(!has_glob_metacharacter("src/lib.rs"));
464        assert!(has_glob_metacharacter("src/*.rs"));
465        assert!(has_glob_metacharacter("src/lib?.rs"));
466        assert!(has_glob_metacharacter("src/[a].rs"));
467        assert!(has_glob_metacharacter("src/{a,b}.rs"));
468        assert!(has_glob_metacharacter("with\\*.rs"));
469    }
470
471    // PATH-009: globset baseline. We exercise this implicitly by
472    // checking that valid globset patterns parse and that obviously
473    // invalid ones (unclosed bracket) are rejected.
474
475    #[test]
476    fn path_009_rejects_unclosed_charclass() {
477        assert!(matches!(
478            PathPattern::parse("src/[abc.rs"),
479            Err(PathPatternError::InvalidGlob { .. })
480        ));
481    }
482
483    // PATH-010: `*` matches within a single segment. (Behavioural
484    // guarantee delegated to globset; here we verify acceptance.)
485
486    #[test]
487    fn path_010_accepts_single_star_in_segment() {
488        assert!(PathPattern::parse("src/*.rs").is_ok());
489    }
490
491    // PATH-011: `**` placement.
492
493    #[test]
494    fn path_011_accepts_doublestar_as_complete_segment() {
495        assert!(PathPattern::parse("src/**/main.rs").is_ok());
496        assert!(PathPattern::parse("tests/**").is_ok());
497        assert!(PathPattern::parse("**/foo.rs").is_ok());
498    }
499
500    #[test]
501    fn path_011_rejects_doublestar_adjacent_to_other_chars() {
502        assert!(matches!(
503            PathPattern::parse("a**b/foo.rs"),
504            Err(PathPatternError::InvalidGlobSegment {
505                source: GlobSegmentError::AdjacentDoubleStar,
506                ..
507            })
508        ));
509    }
510
511    #[test]
512    fn path_011_rejects_triple_star_segment() {
513        // `***` collapses to `**` adjacent to `*`, which is rejected.
514        assert!(matches!(
515            PathPattern::parse("foo/***/bar.rs"),
516            Err(PathPatternError::InvalidGlobSegment {
517                source: GlobSegmentError::AdjacentDoubleStar,
518                ..
519            })
520        ));
521    }
522
523    // PATH-012: `?`. Acceptance is the only thing we verify here;
524    // the `not /` constraint is a globset behavioural guarantee.
525
526    #[test]
527    fn path_012_accepts_question_mark() {
528        assert!(PathPattern::parse("file?.rs").is_ok());
529    }
530
531    // PATH-013: character class.
532
533    #[test]
534    fn path_013_accepts_charclass() {
535        assert!(PathPattern::parse("dist/[abc]/index.html").is_ok());
536    }
537
538    #[test]
539    fn path_013_accepts_negated_charclass() {
540        assert!(PathPattern::parse("dist/[!_]a/index.html").is_ok());
541    }
542
543    #[test]
544    fn path_013_accepts_range_charclass() {
545        assert!(PathPattern::parse("v[0-9].txt").is_ok());
546    }
547
548    // PATH-014: alternation, no nesting.
549
550    #[test]
551    fn path_014_accepts_flat_alternation() {
552        assert!(PathPattern::parse("assets/x.{png,jpg}").is_ok());
553    }
554
555    #[test]
556    fn path_014_rejects_nested_alternation() {
557        assert!(matches!(
558            PathPattern::parse("src/{a,{b,c}}/foo.rs"),
559            Err(PathPatternError::NestedAlternation)
560        ));
561    }
562
563    #[test]
564    fn path_014_rejects_deeper_nesting() {
565        assert!(matches!(
566            PathPattern::parse("a/{x,{y,{z,w}}}/b"),
567            Err(PathPatternError::NestedAlternation)
568        ));
569    }
570
571    #[test]
572    fn path_014_accepts_brace_in_charclass() {
573        // `{` inside `[...]` is a literal character class member,
574        // not an alternation opener; so it does not count toward
575        // brace depth.
576        assert!(PathPattern::parse("foo/[{abc]/bar").is_ok());
577    }
578
579    #[test]
580    fn path_014_accepts_escaped_brace_inside_alternation() {
581        // The inner `{` is escaped, so it does not nest.
582        assert!(PathPattern::parse("foo/{a,\\{lit\\}}.txt").is_ok());
583    }
584
585    // PATH-015: escape.
586
587    #[test]
588    fn path_015_accepts_escaped_star() {
589        let p = PathPattern::parse("with\\*literal.txt").unwrap();
590        assert!(p.is_glob());
591        assert_eq!(p.to_string(), "with\\*literal.txt");
592    }
593
594    #[test]
595    fn path_015_accepts_escaped_brace() {
596        assert!(PathPattern::parse("opt\\{a\\}.txt").is_ok());
597    }
598
599    // PATH-002 inheritance for glob segments.
600
601    #[test]
602    fn path_002_glob_rejects_dot_segment() {
603        assert!(matches!(
604            PathPattern::parse("./foo*.rs"),
605            Err(PathPatternError::InvalidGlobSegment {
606                source: GlobSegmentError::Dot,
607                ..
608            })
609        ));
610    }
611
612    #[test]
613    fn path_002_glob_rejects_dotdot_segment() {
614        assert!(matches!(
615            PathPattern::parse("../foo*.rs"),
616            Err(PathPatternError::InvalidGlobSegment {
617                source: GlobSegmentError::DotDot,
618                ..
619            })
620        ));
621    }
622
623    #[test]
624    fn path_002_glob_rejects_zero_width_space_in_segment() {
625        // U+200B inside a glob segment.
626        assert!(matches!(
627            PathPattern::parse("src/foo\u{200B}*.rs"),
628            Err(PathPatternError::InvalidGlobSegment {
629                source: GlobSegmentError::ContainsForbidden {
630                    category: ForbiddenCategory::Format,
631                    ..
632                },
633                ..
634            })
635        ));
636    }
637
638    #[test]
639    fn path_002_glob_rejects_tab_in_segment() {
640        assert!(matches!(
641            PathPattern::parse("src/foo\t*.rs"),
642            Err(PathPatternError::InvalidGlobSegment {
643                source: GlobSegmentError::ContainsForbidden {
644                    c: '\t',
645                    category: ForbiddenCategory::Control,
646                },
647                ..
648            })
649        ));
650    }
651
652    // PATH-003 reuse for globs.
653
654    #[test]
655    fn path_003_glob_rejects_double_slash() {
656        assert!(matches!(
657            PathPattern::parse("src//*.rs"),
658            Err(PathPatternError::EmptySegment)
659        ));
660    }
661
662    #[test]
663    fn path_003_glob_rejects_trailing_slash() {
664        assert!(matches!(
665            PathPattern::parse("src/*/"),
666            Err(PathPatternError::TrailingSlash)
667        ));
668    }
669
670    #[test]
671    fn path_003_glob_rejects_only_slash_workspace_absolute() {
672        // `/` alone is literal; tested via the literal sub-path
673        // (HazPath returns OnlySlash).
674        assert!(matches!(
675            PathPattern::parse("/"),
676            Err(PathPatternError::LiteralPath {
677                source: PathError::OnlySlash,
678            })
679        ));
680    }
681
682    #[test]
683    fn pathpattern_rejects_empty_string() {
684        assert!(matches!(
685            PathPattern::parse(""),
686            Err(PathPatternError::EmptyInput)
687        ));
688    }
689
690    // Literal sub-form propagates HazPath errors.
691
692    #[test]
693    fn literal_propagates_hazpath_dot_segment() {
694        assert!(matches!(
695            PathPattern::parse("./foo.rs"),
696            Err(PathPatternError::LiteralPath {
697                source: PathError::InvalidSegment {
698                    source: SegmentError::Dot,
699                    ..
700                },
701            })
702        ));
703    }
704
705    // PATH-016: case sensitivity is implicit (we configure
706    // `case_insensitive(false)` and `PATH-007` already mandates
707    // case sensitivity for literal paths).
708
709    #[test]
710    fn path_016_case_sensitive_glob_equality() {
711        let upper = PathPattern::parse("Src/*.rs").unwrap();
712        let lower = PathPattern::parse("src/*.rs").unwrap();
713        assert_ne!(upper, lower);
714    }
715
716    // Round-trip parse -> Display -> parse.
717
718    #[test]
719    fn round_trip_literal_relative() {
720        let original = "src/lib.rs";
721        let p = PathPattern::parse(original).unwrap();
722        assert_eq!(p.to_string(), original);
723        assert_eq!(PathPattern::parse(&p.to_string()).unwrap(), p);
724    }
725
726    #[test]
727    fn round_trip_literal_absolute() {
728        let original = "/lib_core/src/main.rs";
729        let p = PathPattern::parse(original).unwrap();
730        assert_eq!(p.to_string(), original);
731        assert_eq!(PathPattern::parse(&p.to_string()).unwrap(), p);
732    }
733
734    #[test]
735    fn round_trip_glob_relative() {
736        let original = "src/**/*.rs";
737        let p = PathPattern::parse(original).unwrap();
738        assert_eq!(p.to_string(), original);
739        assert_eq!(PathPattern::parse(&p.to_string()).unwrap(), p);
740    }
741
742    #[test]
743    fn round_trip_glob_absolute() {
744        let original = "/lib_core/src/**/*.rs";
745        let p = PathPattern::parse(original).unwrap();
746        assert_eq!(p.to_string(), original);
747        assert_eq!(PathPattern::parse(&p.to_string()).unwrap(), p);
748    }
749
750    // Glob compilation produces a working matcher.
751
752    #[test]
753    fn glob_compile_matches_expected_paths() {
754        let p = PathPattern::parse("src/**/*.rs").unwrap();
755        let glob = match p {
756            PathPattern::Glob(ref g) => g.compile(),
757            PathPattern::Literal(_) => unreachable!(),
758        };
759        let matcher = glob.compile_matcher();
760        assert!(matcher.is_match("src/lib.rs"));
761        assert!(matcher.is_match("src/foo/bar.rs"));
762        assert!(!matcher.is_match("src/lib.txt"));
763        assert!(!matcher.is_match("other/lib.rs"));
764    }
765
766    #[test]
767    fn glob_star_does_not_cross_separator() {
768        // PATH-010 / globset literal_separator(true).
769        let p = PathPattern::parse("src/*.rs").unwrap();
770        let glob = match p {
771            PathPattern::Glob(ref g) => g.compile(),
772            PathPattern::Literal(_) => unreachable!(),
773        };
774        let matcher = glob.compile_matcher();
775        assert!(matcher.is_match("src/lib.rs"));
776        assert!(!matcher.is_match("src/foo/bar.rs"));
777    }
778
779    // Properties.
780
781    use proptest::prelude::*;
782
783    fn segment_strategy() -> impl Strategy<Value = String> {
784        "[a-zA-Z0-9][a-zA-Z0-9._-]{0,15}"
785    }
786
787    proptest! {
788        #[test]
789        fn prop_literal_relative_round_trips(
790            segs in proptest::collection::vec(segment_strategy(), 1..=5)
791        ) {
792            let s = segs.join("/");
793            let parsed = PathPattern::parse(&s).unwrap();
794            prop_assert!(parsed.is_literal());
795            prop_assert_eq!(parsed.to_string(), s);
796        }
797
798        #[test]
799        fn prop_literal_absolute_round_trips(
800            segs in proptest::collection::vec(segment_strategy(), 1..=5)
801        ) {
802            let s = format!("/{}", segs.join("/"));
803            let parsed = PathPattern::parse(&s).unwrap();
804            prop_assert!(parsed.is_literal());
805            prop_assert_eq!(parsed.to_string(), s);
806        }
807
808        #[test]
809        fn prop_simple_star_glob_round_trips(
810            segs in proptest::collection::vec(segment_strategy(), 1..=4)
811        ) {
812            let s = format!("{}/*.rs", segs.join("/"));
813            let parsed = PathPattern::parse(&s).unwrap();
814            prop_assert!(parsed.is_glob());
815            prop_assert_eq!(parsed.to_string(), s);
816        }
817    }
818}