Skip to main content

haz_domain/path/
mod.rs

1//! Path types: validated [`HazPath`] (workspace-absolute or
2//! project-relative) and [`PathSegment`].
3//!
4//! Canonicalisation lives in [`canonical`]: a [`HazPath`] resolves
5//! to a [`canonical::CanonicalPath`] given the project root that
6//! owns the bearing field.
7
8pub mod canonical;
9pub mod input_spec;
10pub mod output_spec;
11pub mod pattern;
12pub mod project_root;
13pub mod segment;
14pub mod workspace_root;
15
16use std::fmt;
17
18use nonempty::NonEmpty;
19use snafu::{ResultExt, Snafu, ensure};
20
21pub use canonical::{CanonicalPath, ParseAbsoluteError};
22pub use input_spec::InputSpec;
23pub use output_spec::OutputSpec;
24pub use pattern::{GlobPattern, GlobSegmentError, PathAnchor, PathPattern, PathPatternError};
25pub use project_root::ProjectRoot;
26pub use segment::{ForbiddenCategory, PathSegment, SegmentError};
27pub use workspace_root::{WorkspaceRootPath, WorkspaceRootPathError};
28
29/// Error produced when parsing a [`HazPath`].
30///
31/// Each variant maps to one of the normative path rules
32/// (`PATH-002`, `PATH-003`).
33#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
34pub enum PathError {
35    /// Violates `PATH-002`: the input is empty.
36    #[snafu(display("path is empty"))]
37    Empty,
38
39    /// Violates `PATH-003`: the input contains the empty
40    /// separator `//`.
41    #[snafu(display("path contains the empty segment `//`"))]
42    EmptySegment,
43
44    /// Violates `PATH-003`: the workspace-absolute path is the
45    /// single character `/` and has no segments.
46    #[snafu(display("workspace-absolute path has no segments"))]
47    OnlySlash,
48
49    /// Violates `PATH-003`: the input ends with `/`.
50    #[snafu(display("path ends with trailing `/`"))]
51    TrailingSlash,
52
53    /// Violates `PATH-002`: a segment failed validation.
54    #[snafu(display("invalid segment {segment:?} at position {position}: {source}"))]
55    InvalidSegment {
56        /// The offending segment text.
57        segment: String,
58        /// Zero-based index of the segment in the path body.
59        position: usize,
60        /// Underlying segment-level error.
61        source: SegmentError,
62    },
63}
64
65/// A validated path. Either a [workspace-absolute] form (anchored
66/// at the workspace root) or a [project-relative] form (anchored
67/// at the bearing field's project root).
68///
69/// [workspace-absolute]: HazPath::WorkspaceAbsolute
70/// [project-relative]: HazPath::ProjectRelative
71///
72/// Construction is via [`HazPath::parse`]. The parsed value carries
73/// at least one [`PathSegment`]; `.` and `..` are not valid
74/// segments, so a project-relative path cannot escape its owning
75/// project (see `PATH-005` and `PATH-006`).
76///
77/// # Examples
78///
79/// ```
80/// use haz_domain::path::HazPath;
81///
82/// let p = HazPath::parse("src/lib.rs").unwrap();
83/// assert!(matches!(p, HazPath::ProjectRelative(_)));
84///
85/// let q = HazPath::parse("/lib_core/src/main.rs").unwrap();
86/// assert!(matches!(q, HazPath::WorkspaceAbsolute(_)));
87/// ```
88#[derive(Debug, Clone, PartialEq, Eq, Hash)]
89pub enum HazPath {
90    /// A path beginning with `/`, resolved against the workspace
91    /// root (`PATH-004`).
92    WorkspaceAbsolute(NonEmpty<PathSegment>),
93    /// A path that does not begin with `/`, resolved against the
94    /// owning project's root (`PATH-005`).
95    ProjectRelative(NonEmpty<PathSegment>),
96}
97
98impl HazPath {
99    /// Parse `s` as a [`HazPath`].
100    ///
101    /// # Errors
102    ///
103    /// Returns a [`PathError`] when `s` violates a path rule:
104    ///
105    /// - [`PathError::Empty`] if `s` is the empty string.
106    /// - [`PathError::OnlySlash`] if `s` is `"/"` (workspace-
107    ///   absolute with no segments).
108    /// - [`PathError::EmptySegment`] if `s` contains `//`.
109    /// - [`PathError::TrailingSlash`] if `s` ends with `/`.
110    /// - [`PathError::InvalidSegment`] if any segment violates
111    ///   [`PATH-002`](crate::path::SegmentError).
112    ///
113    /// # Panics
114    ///
115    /// Does not panic on any path of execution reachable from valid
116    /// or invalid input: an internal invariant assertion exists
117    /// (the non-emptiness of `segments` after the leading-`/`,
118    /// `//`, and trailing-`/` checks have rejected the degenerate
119    /// cases) but is structurally unreachable.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// use haz_domain::path::HazPath;
125    ///
126    /// assert!(HazPath::parse("src/main.rs").is_ok());
127    /// assert!(HazPath::parse("/lib_core/src/main.rs").is_ok());
128    /// assert!(HazPath::parse("./foo").is_err());
129    /// assert!(HazPath::parse("a//b").is_err());
130    /// assert!(HazPath::parse("foo/").is_err());
131    /// ```
132    pub fn parse(s: &str) -> Result<Self, PathError> {
133        ensure!(!s.is_empty(), EmptySnafu);
134
135        let (is_absolute, body) = if let Some(rest) = s.strip_prefix('/') {
136            (true, rest)
137        } else {
138            (false, s)
139        };
140
141        ensure!(!body.is_empty(), OnlySlashSnafu);
142        ensure!(!body.contains("//"), EmptySegmentSnafu);
143        ensure!(!body.ends_with('/'), TrailingSlashSnafu);
144
145        let segments: Vec<PathSegment> = body
146            .split('/')
147            .enumerate()
148            .map(|(position, part)| {
149                PathSegment::try_new(part).context(InvalidSegmentSnafu {
150                    segment: part.to_owned(),
151                    position,
152                })
153            })
154            .collect::<Result<_, _>>()?;
155
156        let nonempty = NonEmpty::from_vec(segments)
157            .expect("body is non-empty and has no `//`, so segments is non-empty");
158
159        Ok(if is_absolute {
160            HazPath::WorkspaceAbsolute(nonempty)
161        } else {
162            HazPath::ProjectRelative(nonempty)
163        })
164    }
165
166    /// Returns the segments of this path (always at least one).
167    #[must_use]
168    pub fn segments(&self) -> &NonEmpty<PathSegment> {
169        match self {
170            HazPath::WorkspaceAbsolute(s) | HazPath::ProjectRelative(s) => s,
171        }
172    }
173
174    /// `true` if this is a workspace-absolute path.
175    #[must_use]
176    pub fn is_workspace_absolute(&self) -> bool {
177        matches!(self, HazPath::WorkspaceAbsolute(_))
178    }
179
180    /// `true` if this is a project-relative path.
181    #[must_use]
182    pub fn is_project_relative(&self) -> bool {
183        matches!(self, HazPath::ProjectRelative(_))
184    }
185}
186
187impl fmt::Display for HazPath {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        if self.is_workspace_absolute() {
190            f.write_str("/")?;
191        }
192        let mut first = true;
193        for segment in self.segments().iter() {
194            if !first {
195                f.write_str("/")?;
196            }
197            f.write_str(segment.as_str())?;
198            first = false;
199        }
200        Ok(())
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use crate::path::{HazPath, PathError, SegmentError};
207
208    // PATH-001: two forms.
209
210    #[test]
211    fn path_001_recognises_project_relative() {
212        let p = HazPath::parse("src/lib.rs").unwrap();
213        assert!(matches!(p, HazPath::ProjectRelative(_)));
214    }
215
216    #[test]
217    fn path_001_recognises_workspace_absolute() {
218        let p = HazPath::parse("/lib_core/src/main.rs").unwrap();
219        assert!(matches!(p, HazPath::WorkspaceAbsolute(_)));
220    }
221
222    // PATH-002: segment alphabet inherited from PathSegment.
223
224    #[test]
225    fn path_002_rejects_dot_segment() {
226        assert!(matches!(
227            HazPath::parse("./foo"),
228            Err(PathError::InvalidSegment {
229                source: SegmentError::Dot,
230                ..
231            })
232        ));
233    }
234
235    #[test]
236    fn path_002_rejects_dotdot_segment() {
237        assert!(matches!(
238            HazPath::parse("../shared/lib.rs"),
239            Err(PathError::InvalidSegment {
240                source: SegmentError::DotDot,
241                ..
242            })
243        ));
244    }
245
246    #[test]
247    fn path_002_rejects_internal_dotdot() {
248        assert!(matches!(
249            HazPath::parse("a/../b"),
250            Err(PathError::InvalidSegment {
251                source: SegmentError::DotDot,
252                ..
253            })
254        ));
255    }
256
257    #[test]
258    fn path_002_rejects_control_char_inside_segment() {
259        // `\t` denotes U+0009 (TAB), Cc.
260        assert!(matches!(
261            HazPath::parse("src/foo\tbar.rs"),
262            Err(PathError::InvalidSegment {
263                source: SegmentError::ContainsForbidden {
264                    c: '\t',
265                    category: crate::path::ForbiddenCategory::Control,
266                },
267                ..
268            })
269        ));
270    }
271
272    #[test]
273    fn path_002_rejects_zero_width_space_inside_segment() {
274        // U+200B (ZERO WIDTH SPACE), Cf.
275        assert!(matches!(
276            HazPath::parse("src/foo\u{200B}bar.rs"),
277            Err(PathError::InvalidSegment {
278                source: SegmentError::ContainsForbidden {
279                    category: crate::path::ForbiddenCategory::Format,
280                    ..
281                },
282                ..
283            })
284        ));
285    }
286
287    // PATH-003: empty segments, trailing /, only-slash.
288
289    #[test]
290    fn path_003_rejects_double_slash() {
291        assert!(matches!(
292            HazPath::parse("src//lib.rs"),
293            Err(PathError::EmptySegment)
294        ));
295    }
296
297    #[test]
298    fn path_003_rejects_trailing_slash_relative() {
299        assert!(matches!(
300            HazPath::parse("src/"),
301            Err(PathError::TrailingSlash)
302        ));
303    }
304
305    #[test]
306    fn path_003_rejects_trailing_slash_absolute() {
307        assert!(matches!(
308            HazPath::parse("/src/"),
309            Err(PathError::TrailingSlash)
310        ));
311    }
312
313    #[test]
314    fn path_003_rejects_only_slash() {
315        assert!(matches!(HazPath::parse("/"), Err(PathError::OnlySlash)));
316    }
317
318    #[test]
319    fn path_003_rejects_empty_string() {
320        assert!(matches!(HazPath::parse(""), Err(PathError::Empty)));
321    }
322
323    // PATH-004 / PATH-005: variant invariants verified by parse output.
324
325    #[test]
326    fn path_004_workspace_absolute_segments_count() {
327        let p = HazPath::parse("/a/b/c").unwrap();
328        let segs = p.segments();
329        assert_eq!(segs.len(), 3);
330        assert_eq!(segs.first().as_str(), "a");
331    }
332
333    #[test]
334    fn path_005_project_relative_segments_count() {
335        let p = HazPath::parse("a/b/c").unwrap();
336        let segs = p.segments();
337        assert_eq!(segs.len(), 3);
338    }
339
340    // PATH-007: case sensitivity.
341
342    #[test]
343    fn path_007_case_sensitive_equality() {
344        let upper = HazPath::parse("Src/Lib.rs").unwrap();
345        let lower = HazPath::parse("src/lib.rs").unwrap();
346        assert_ne!(upper, lower);
347    }
348
349    // Round-trip: parse -> Display -> parse.
350
351    #[test]
352    fn round_trip_project_relative() {
353        let original = "src/lib.rs";
354        let p = HazPath::parse(original).unwrap();
355        assert_eq!(p.to_string(), original);
356        let reparsed = HazPath::parse(&p.to_string()).unwrap();
357        assert_eq!(p, reparsed);
358    }
359
360    #[test]
361    fn round_trip_workspace_absolute() {
362        let original = "/lib_core/src/main.rs";
363        let p = HazPath::parse(original).unwrap();
364        assert_eq!(p.to_string(), original);
365        let reparsed = HazPath::parse(&p.to_string()).unwrap();
366        assert_eq!(p, reparsed);
367    }
368
369    // Properties.
370
371    use proptest::prelude::*;
372
373    fn segment_strategy() -> impl Strategy<Value = String> {
374        // Conservative alphabet for proptest: avoids reserved
375        // segments like "." / ".." and any control characters.
376        "[a-zA-Z0-9][a-zA-Z0-9._-]{0,15}"
377    }
378
379    proptest! {
380        #[test]
381        fn prop_valid_relative_path_round_trips(
382            segs in proptest::collection::vec(segment_strategy(), 1..=5)
383        ) {
384            let s = segs.join("/");
385            let parsed = HazPath::parse(&s).unwrap();
386            prop_assert_eq!(parsed.to_string(), s);
387        }
388
389        #[test]
390        fn prop_valid_absolute_path_round_trips(
391            segs in proptest::collection::vec(segment_strategy(), 1..=5)
392        ) {
393            let s = format!("/{}", segs.join("/"));
394            let parsed = HazPath::parse(&s).unwrap();
395            prop_assert_eq!(parsed.to_string(), s);
396        }
397    }
398}