pub mod canonical;
pub mod input_spec;
pub mod output_spec;
pub mod pattern;
pub mod project_root;
pub mod segment;
pub mod workspace_root;
use std::fmt;
use nonempty::NonEmpty;
use snafu::{ResultExt, Snafu, ensure};
pub use canonical::{CanonicalPath, ParseAbsoluteError};
pub use input_spec::InputSpec;
pub use output_spec::OutputSpec;
pub use pattern::{GlobPattern, GlobSegmentError, PathAnchor, PathPattern, PathPatternError};
pub use project_root::ProjectRoot;
pub use segment::{ForbiddenCategory, PathSegment, SegmentError};
pub use workspace_root::{WorkspaceRootPath, WorkspaceRootPathError};
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum PathError {
#[snafu(display("path is empty"))]
Empty,
#[snafu(display("path contains the empty segment `//`"))]
EmptySegment,
#[snafu(display("workspace-absolute path has no segments"))]
OnlySlash,
#[snafu(display("path ends with trailing `/`"))]
TrailingSlash,
#[snafu(display("invalid segment {segment:?} at position {position}: {source}"))]
InvalidSegment {
segment: String,
position: usize,
source: SegmentError,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum HazPath {
WorkspaceAbsolute(NonEmpty<PathSegment>),
ProjectRelative(NonEmpty<PathSegment>),
}
impl HazPath {
pub fn parse(s: &str) -> Result<Self, PathError> {
ensure!(!s.is_empty(), EmptySnafu);
let (is_absolute, body) = if let Some(rest) = s.strip_prefix('/') {
(true, rest)
} else {
(false, s)
};
ensure!(!body.is_empty(), OnlySlashSnafu);
ensure!(!body.contains("//"), EmptySegmentSnafu);
ensure!(!body.ends_with('/'), TrailingSlashSnafu);
let segments: Vec<PathSegment> = body
.split('/')
.enumerate()
.map(|(position, part)| {
PathSegment::try_new(part).context(InvalidSegmentSnafu {
segment: part.to_owned(),
position,
})
})
.collect::<Result<_, _>>()?;
let nonempty = NonEmpty::from_vec(segments)
.expect("body is non-empty and has no `//`, so segments is non-empty");
Ok(if is_absolute {
HazPath::WorkspaceAbsolute(nonempty)
} else {
HazPath::ProjectRelative(nonempty)
})
}
#[must_use]
pub fn segments(&self) -> &NonEmpty<PathSegment> {
match self {
HazPath::WorkspaceAbsolute(s) | HazPath::ProjectRelative(s) => s,
}
}
#[must_use]
pub fn is_workspace_absolute(&self) -> bool {
matches!(self, HazPath::WorkspaceAbsolute(_))
}
#[must_use]
pub fn is_project_relative(&self) -> bool {
matches!(self, HazPath::ProjectRelative(_))
}
}
impl fmt::Display for HazPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_workspace_absolute() {
f.write_str("/")?;
}
let mut first = true;
for segment in self.segments().iter() {
if !first {
f.write_str("/")?;
}
f.write_str(segment.as_str())?;
first = false;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::path::{HazPath, PathError, SegmentError};
#[test]
fn path_001_recognises_project_relative() {
let p = HazPath::parse("src/lib.rs").unwrap();
assert!(matches!(p, HazPath::ProjectRelative(_)));
}
#[test]
fn path_001_recognises_workspace_absolute() {
let p = HazPath::parse("/lib_core/src/main.rs").unwrap();
assert!(matches!(p, HazPath::WorkspaceAbsolute(_)));
}
#[test]
fn path_002_rejects_dot_segment() {
assert!(matches!(
HazPath::parse("./foo"),
Err(PathError::InvalidSegment {
source: SegmentError::Dot,
..
})
));
}
#[test]
fn path_002_rejects_dotdot_segment() {
assert!(matches!(
HazPath::parse("../shared/lib.rs"),
Err(PathError::InvalidSegment {
source: SegmentError::DotDot,
..
})
));
}
#[test]
fn path_002_rejects_internal_dotdot() {
assert!(matches!(
HazPath::parse("a/../b"),
Err(PathError::InvalidSegment {
source: SegmentError::DotDot,
..
})
));
}
#[test]
fn path_002_rejects_control_char_inside_segment() {
assert!(matches!(
HazPath::parse("src/foo\tbar.rs"),
Err(PathError::InvalidSegment {
source: SegmentError::ContainsForbidden {
c: '\t',
category: crate::path::ForbiddenCategory::Control,
},
..
})
));
}
#[test]
fn path_002_rejects_zero_width_space_inside_segment() {
assert!(matches!(
HazPath::parse("src/foo\u{200B}bar.rs"),
Err(PathError::InvalidSegment {
source: SegmentError::ContainsForbidden {
category: crate::path::ForbiddenCategory::Format,
..
},
..
})
));
}
#[test]
fn path_003_rejects_double_slash() {
assert!(matches!(
HazPath::parse("src//lib.rs"),
Err(PathError::EmptySegment)
));
}
#[test]
fn path_003_rejects_trailing_slash_relative() {
assert!(matches!(
HazPath::parse("src/"),
Err(PathError::TrailingSlash)
));
}
#[test]
fn path_003_rejects_trailing_slash_absolute() {
assert!(matches!(
HazPath::parse("/src/"),
Err(PathError::TrailingSlash)
));
}
#[test]
fn path_003_rejects_only_slash() {
assert!(matches!(HazPath::parse("/"), Err(PathError::OnlySlash)));
}
#[test]
fn path_003_rejects_empty_string() {
assert!(matches!(HazPath::parse(""), Err(PathError::Empty)));
}
#[test]
fn path_004_workspace_absolute_segments_count() {
let p = HazPath::parse("/a/b/c").unwrap();
let segs = p.segments();
assert_eq!(segs.len(), 3);
assert_eq!(segs.first().as_str(), "a");
}
#[test]
fn path_005_project_relative_segments_count() {
let p = HazPath::parse("a/b/c").unwrap();
let segs = p.segments();
assert_eq!(segs.len(), 3);
}
#[test]
fn path_007_case_sensitive_equality() {
let upper = HazPath::parse("Src/Lib.rs").unwrap();
let lower = HazPath::parse("src/lib.rs").unwrap();
assert_ne!(upper, lower);
}
#[test]
fn round_trip_project_relative() {
let original = "src/lib.rs";
let p = HazPath::parse(original).unwrap();
assert_eq!(p.to_string(), original);
let reparsed = HazPath::parse(&p.to_string()).unwrap();
assert_eq!(p, reparsed);
}
#[test]
fn round_trip_workspace_absolute() {
let original = "/lib_core/src/main.rs";
let p = HazPath::parse(original).unwrap();
assert_eq!(p.to_string(), original);
let reparsed = HazPath::parse(&p.to_string()).unwrap();
assert_eq!(p, reparsed);
}
use proptest::prelude::*;
fn segment_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9][a-zA-Z0-9._-]{0,15}"
}
proptest! {
#[test]
fn prop_valid_relative_path_round_trips(
segs in proptest::collection::vec(segment_strategy(), 1..=5)
) {
let s = segs.join("/");
let parsed = HazPath::parse(&s).unwrap();
prop_assert_eq!(parsed.to_string(), s);
}
#[test]
fn prop_valid_absolute_path_round_trips(
segs in proptest::collection::vec(segment_strategy(), 1..=5)
) {
let s = format!("/{}", segs.join("/"));
let parsed = HazPath::parse(&s).unwrap();
prop_assert_eq!(parsed.to_string(), s);
}
}
}