use std::fmt;
use nonempty::NonEmpty;
use snafu::{ResultExt, Snafu};
use crate::path::{HazPath, PathError, PathSegment};
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum ParseAbsoluteError {
#[snafu(display("invalid workspace-absolute path: {source}"))]
Invalid {
source: PathError,
},
#[snafu(display(
"path is project-relative; expected workspace-absolute (must start with `/`)"
))]
NotWorkspaceAbsolute,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CanonicalPath(NonEmpty<PathSegment>);
impl CanonicalPath {
#[must_use]
pub fn from_absolute(haz_path: &HazPath) -> Option<Self> {
match haz_path {
HazPath::WorkspaceAbsolute(segments) => Some(Self(segments.clone())),
HazPath::ProjectRelative(_) => None,
}
}
pub fn parse_workspace_absolute(s: &str) -> Result<Self, ParseAbsoluteError> {
let parsed = HazPath::parse(s).context(InvalidSnafu)?;
Self::from_absolute(&parsed).ok_or(ParseAbsoluteError::NotWorkspaceAbsolute)
}
#[must_use]
pub fn segments(&self) -> &NonEmpty<PathSegment> {
&self.0
}
}
impl fmt::Display for CanonicalPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("/")?;
let mut first = true;
for segment in self.0.iter() {
if !first {
f.write_str("/")?;
}
f.write_str(segment.as_str())?;
first = false;
}
Ok(())
}
}
impl HazPath {
#[must_use]
pub fn resolve(&self, project_root: &CanonicalPath) -> CanonicalPath {
match self {
HazPath::WorkspaceAbsolute(segments) => CanonicalPath(segments.clone()),
HazPath::ProjectRelative(segments) => {
let mut combined: Vec<PathSegment> = project_root.0.iter().cloned().collect();
combined.extend(segments.iter().cloned());
let nonempty =
NonEmpty::from_vec(combined).expect("project_root segments are non-empty");
CanonicalPath(nonempty)
}
}
}
}
#[cfg(test)]
mod tests {
use crate::path::{CanonicalPath, HazPath};
fn project_root(absolute: &str) -> CanonicalPath {
let parsed = HazPath::parse(absolute).unwrap();
CanonicalPath::from_absolute(&parsed)
.expect("test helper expects a workspace-absolute path")
}
#[test]
fn from_absolute_accepts_workspace_absolute() {
let p = HazPath::parse("/lib_core").unwrap();
assert!(CanonicalPath::from_absolute(&p).is_some());
}
#[test]
fn from_absolute_rejects_project_relative() {
let p = HazPath::parse("src/lib.rs").unwrap();
assert!(CanonicalPath::from_absolute(&p).is_none());
}
#[test]
fn resolve_workspace_absolute_ignores_project_root() {
let root = project_root("/lib_core");
let p = HazPath::parse("/other/file.txt").unwrap();
assert_eq!(p.resolve(&root).to_string(), "/other/file.txt");
}
#[test]
fn resolve_project_relative_appends_to_project_root() {
let root = project_root("/lib_core");
let p = HazPath::parse("src/main.rs").unwrap();
assert_eq!(p.resolve(&root).to_string(), "/lib_core/src/main.rs");
}
#[test]
fn resolve_project_relative_deep() {
let root = project_root("/web/frontend");
let p = HazPath::parse("dist/index.html").unwrap();
assert_eq!(
p.resolve(&root).to_string(),
"/web/frontend/dist/index.html"
);
}
#[test]
fn canonical_eq_for_same_logical_path() {
let root = project_root("/lib_core");
let rel = HazPath::parse("src/main.rs").unwrap().resolve(&root);
let abs = HazPath::parse("/lib_core/src/main.rs")
.unwrap()
.resolve(&root);
assert_eq!(rel, abs);
}
#[test]
fn canonical_ne_for_different_paths() {
let root = project_root("/lib_core");
let a = HazPath::parse("src/main.rs").unwrap().resolve(&root);
let b = HazPath::parse("src/lib.rs").unwrap().resolve(&root);
assert_ne!(a, b);
}
#[test]
fn canonical_path_display_round_trip() {
let root = project_root("/web/frontend");
let resolved = HazPath::parse("dist/index.html").unwrap().resolve(&root);
assert_eq!(resolved.to_string(), "/web/frontend/dist/index.html");
}
use crate::path::{ParseAbsoluteError, PathError, SegmentError};
#[test]
fn parse_workspace_absolute_accepts_valid_path() {
let p = CanonicalPath::parse_workspace_absolute("/lib_core/src/main.rs").unwrap();
assert_eq!(p.to_string(), "/lib_core/src/main.rs");
}
#[test]
fn parse_workspace_absolute_rejects_project_relative() {
let err = CanonicalPath::parse_workspace_absolute("src/main.rs").unwrap_err();
assert!(matches!(err, ParseAbsoluteError::NotWorkspaceAbsolute));
}
#[test]
fn parse_workspace_absolute_rejects_traversal() {
let err = CanonicalPath::parse_workspace_absolute("/foo/../bar").unwrap_err();
assert!(matches!(
err,
ParseAbsoluteError::Invalid {
source: PathError::InvalidSegment {
source: SegmentError::DotDot,
..
}
}
));
}
#[test]
fn parse_workspace_absolute_rejects_dot_segment() {
let err = CanonicalPath::parse_workspace_absolute("/foo/./bar").unwrap_err();
assert!(matches!(
err,
ParseAbsoluteError::Invalid {
source: PathError::InvalidSegment {
source: SegmentError::Dot,
..
}
}
));
}
#[test]
fn parse_workspace_absolute_rejects_bare_root() {
let err = CanonicalPath::parse_workspace_absolute("/").unwrap_err();
assert!(matches!(
err,
ParseAbsoluteError::Invalid {
source: PathError::OnlySlash
}
));
}
#[test]
fn parse_workspace_absolute_rejects_empty() {
let err = CanonicalPath::parse_workspace_absolute("").unwrap_err();
assert!(matches!(
err,
ParseAbsoluteError::Invalid {
source: PathError::Empty
}
));
}
#[test]
fn parse_workspace_absolute_rejects_double_slash() {
let err = CanonicalPath::parse_workspace_absolute("/foo//bar").unwrap_err();
assert!(matches!(
err,
ParseAbsoluteError::Invalid {
source: PathError::EmptySegment
}
));
}
#[test]
fn parse_workspace_absolute_rejects_trailing_slash() {
let err = CanonicalPath::parse_workspace_absolute("/foo/bar/").unwrap_err();
assert!(matches!(
err,
ParseAbsoluteError::Invalid {
source: PathError::TrailingSlash
}
));
}
}