1pub 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#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
34pub enum PathError {
35 #[snafu(display("path is empty"))]
37 Empty,
38
39 #[snafu(display("path contains the empty segment `//`"))]
42 EmptySegment,
43
44 #[snafu(display("workspace-absolute path has no segments"))]
47 OnlySlash,
48
49 #[snafu(display("path ends with trailing `/`"))]
51 TrailingSlash,
52
53 #[snafu(display("invalid segment {segment:?} at position {position}: {source}"))]
55 InvalidSegment {
56 segment: String,
58 position: usize,
60 source: SegmentError,
62 },
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
89pub enum HazPath {
90 WorkspaceAbsolute(NonEmpty<PathSegment>),
93 ProjectRelative(NonEmpty<PathSegment>),
96}
97
98impl HazPath {
99 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 #[must_use]
168 pub fn segments(&self) -> &NonEmpty<PathSegment> {
169 match self {
170 HazPath::WorkspaceAbsolute(s) | HazPath::ProjectRelative(s) => s,
171 }
172 }
173
174 #[must_use]
176 pub fn is_workspace_absolute(&self) -> bool {
177 matches!(self, HazPath::WorkspaceAbsolute(_))
178 }
179
180 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 use proptest::prelude::*;
372
373 fn segment_strategy() -> impl Strategy<Value = String> {
374 "[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}