qubit-fs 0.2.1

Provider-neutral synchronous and asynchronous filesystem abstraction for Rust
Documentation
// =============================================================================
//    Copyright (c) 2025 - 2026 Haixing Hu.
//
//    SPDX-License-Identifier: Apache-2.0
//
//    Licensed under the Apache License, Version 2.0.
// =============================================================================

//! Provider-neutral logical paths.

use std::fmt::Display;
use std::fmt::Formatter;
use std::fmt::Result as FmtResult;

use super::PathComponent;
use super::PathComponents;
use super::PathSemantics;
use super::RelativePath;
use crate::error::FsError;
use crate::error::FsOperation;
use crate::error::FsResult;

/// A validated logical path independent of any provider-native representation.
///
/// # Examples
/// ```rust
/// use qubit_fs::Path;
/// use qubit_fs::path::RelativePath;
/// let root = Path::parse("/reports")?;
/// let report = root.join(&RelativePath::parse("daily.csv")?);
/// assert_eq!("/reports/daily.csv", report.as_str());
/// assert!(report.is_absolute());
/// # Ok::<(), qubit_fs::FsError>(())
/// ```
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct Path {
    /// Whether this logical path starts at a provider root.
    absolute: bool,
    /// Canonical normalized or provider-literal text.
    text: String,
    /// Whether component iteration must preserve literal slash boundaries.
    literal: bool,
    /// Semantics used to validate this spelling.
    semantics: PathSemantics,
}

impl Path {
    /// Creates the canonical hierarchical root path.
    #[inline]
    #[must_use]
    pub fn root() -> Self {
        Self {
            absolute: true,
            text: "/".to_owned(),
            literal: false,
            semantics: PathSemantics::Hierarchical,
        }
    }

    /// Constructs a hierarchical path from independently validated components.
    ///
    /// Each item is validated as one component without reparsing a joined path
    /// string. An empty absolute sequence produces the root; an empty relative
    /// sequence returns an invalid-path error.
    ///
    /// # Parameters
    /// - `absolute`: Whether the resulting path is rooted at the provider root.
    /// - `components`: Validated path-component text to join in order.
    ///
    /// # Errors
    /// Returns an invalid-path error when a component is empty, contains a
    /// separator or traversal marker, or when a relative sequence is empty.
    #[inline]
    pub fn from_components<I, S>(absolute: bool, components: I) -> FsResult<Self>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
    {
        let components = components
            .into_iter()
            .map(|value| PathComponent::parse(value.as_ref()))
            .collect::<FsResult<Vec<_>>>()?;
        if !absolute && components.is_empty() {
            return Err(invalid_path());
        }
        let joined = components
            .iter()
            .map(PathComponent::as_str)
            .collect::<Vec<_>>()
            .join("/");
        Ok(Self {
            absolute,
            text: if absolute {
                if joined.is_empty() {
                    "/".to_owned()
                } else {
                    format!("/{joined}")
                }
            } else {
                joined
            },
            literal: false,
            semantics: PathSemantics::Hierarchical,
        })
    }

    /// Parses a hierarchical logical path using normalized semantics.
    ///
    /// Returns an invalid-path error for empty input, NUL, or root escape.
    ///
    /// # Errors
    /// Returns [`FsError`] with an invalid-path kind when `text` is empty,
    /// contains NUL, or escapes above the hierarchical root.
    #[inline]
    pub fn parse(text: &str) -> FsResult<Self> {
        Self::parse_with_semantics(text, PathSemantics::Hierarchical)
    }

    /// Parses a provider-literal path without interpreting separators or dots.
    ///
    /// Returns an invalid-path error for empty input or NUL.
    ///
    /// # Errors
    /// Returns [`FsError`] with an invalid-path kind when `text` is empty or
    /// contains NUL.
    #[inline]
    pub fn parse_literal(text: &str) -> FsResult<Self> {
        Self::parse_with_semantics(text, PathSemantics::ObjectKey)
    }

    /// Parses `text` according to explicitly selected provider semantics.
    ///
    /// Hierarchical values normalize empty and dot components and reject root
    /// escapes. Object-key and provider-specific values preserve their text.
    ///
    /// # Parameters
    /// - `text`: Provider path text to validate and normalize.
    /// - `semantics`: Path semantics controlling normalization and root rules.
    ///
    /// # Errors
    /// Returns an invalid-path error when `text` is empty, contains NUL, or
    /// escapes above the hierarchical root.
    pub fn parse_with_semantics(text: &str, semantics: PathSemantics) -> FsResult<Self> {
        if text.is_empty() || text.contains('\0') {
            return Err(invalid_path());
        }
        if semantics != PathSemantics::Hierarchical {
            return Ok(Self {
                absolute: text.starts_with('/'),
                text: text.to_owned(),
                literal: true,
                semantics,
            });
        }
        let absolute = text.starts_with('/');
        let mut components = Vec::new();
        for component in text.split('/') {
            match component {
                "" | "." => {}
                ".." => {
                    if components.pop().is_none() {
                        return Err(invalid_path());
                    }
                }
                value => components.push(value),
            }
        }
        let text = if absolute {
            if components.is_empty() {
                "/".to_owned()
            } else {
                format!("/{}", components.join("/"))
            }
        } else {
            components.join("/")
        };
        if text.is_empty() {
            return Err(invalid_path());
        }
        Ok(Self {
            absolute,
            text,
            literal: false,
            semantics,
        })
    }

    /// Returns the validated logical path text.
    #[inline]
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.text
    }

    /// Returns the final non-empty path component, when one is present.
    ///
    /// A root path and a literal path ending in a separator have no file
    /// name. Hierarchical paths are canonicalized during parsing, so their
    /// final component is always non-empty.
    ///
    /// # Returns
    /// `Some` with the final non-empty component, or `None` for a root or a
    /// literal path ending in a separator.
    #[inline]
    #[must_use]
    pub fn file_name(&self) -> Option<&str> {
        if self.text == "/" || (self.literal && self.text.ends_with('/')) {
            return None;
        }
        self.text.rsplit('/').find(|component| !component.is_empty())
    }

    /// Returns whether this path is absolute.
    #[inline]
    #[must_use]
    pub const fn is_absolute(&self) -> bool {
        self.absolute
    }

    /// Returns the semantics used to validate this logical path.
    #[inline]
    #[must_use]
    pub const fn semantics(&self) -> PathSemantics {
        self.semantics
    }

    /// Iterates lexical component boundaries without using an empty root value.
    #[inline]
    #[must_use]
    pub fn components(&self) -> PathComponents<'_> {
        PathComponents::new(&self.text, self.absolute, self.literal)
    }

    /// Appends one validated component without re-parsing provider text.
    #[inline]
    #[must_use]
    pub fn child(&self, component: &PathComponent) -> Self {
        self.append(component.as_str())
    }

    /// Appends a safe normalized relative path without re-parsing provider
    /// text.
    #[inline]
    #[must_use]
    pub fn join(&self, relative: &RelativePath) -> Self {
        self.append(relative.as_str())
    }

    /// Joins an already validated suffix to this path.
    fn append(&self, suffix: &str) -> Self {
        let text = if self.text == "/" {
            format!("/{suffix}")
        } else {
            format!("{}/{}", self.text, suffix)
        };
        Self {
            absolute: self.absolute,
            text,
            literal: self.literal,
            semantics: self.semantics,
        }
    }
}

impl Display for Path {
    /// Formats the validated logical spelling.
    #[inline]
    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
        formatter.write_str(self.as_str())
    }
}

impl AsRef<str> for Path {
    /// Returns the logical path text for generic text consumers.
    #[inline]
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

/// Builds the shared logical path validation failure.
fn invalid_path() -> FsError {
    FsError::invalid_path(
        FsOperation::ParsePath,
        "path must be non-empty, NUL-free, and remain within its root",
    )
}

#[cfg(test)]
mod tests {
    use std::hint::black_box;

    use super::Path;
    use crate::path::PathComponent;
    use crate::path::PathSemantics;
    use crate::path::RelativePath;

    #[test]
    fn path_accessors_and_constructors_are_executed_at_runtime() {
        let root: fn() -> Path = black_box(Path::root);
        let parse_literal: fn(&str) -> crate::error::FsResult<Path> = black_box(Path::parse_literal);
        let parse_with_semantics: fn(&str, PathSemantics) -> crate::error::FsResult<Path> =
            black_box(Path::parse_with_semantics);
        let as_str: for<'a> fn(&'a Path) -> &'a str = black_box(Path::as_str);
        let file_name: for<'a> fn(&'a Path) -> Option<&'a str> = black_box(Path::file_name);
        let is_absolute: fn(&Path) -> bool = black_box(Path::is_absolute);
        let semantics: fn(&Path) -> PathSemantics = black_box(Path::semantics);
        let components = black_box(Path::components);
        let child: fn(&Path, &PathComponent) -> Path = black_box(Path::child);
        let join: fn(&Path, &RelativePath) -> Path = black_box(Path::join);
        let as_ref: for<'a> fn(&'a Path) -> &'a str = black_box(<Path as AsRef<str>>::as_ref);

        let built = Path::from_components(true, vec!["reports", "daily.csv"]).expect("components should form a path");
        assert!(Path::from_components(false, Vec::<&str>::new()).is_err());
        let literal = parse_literal("bucket/key").expect("literal path should parse");
        let provider = parse_with_semantics("bucket/key", PathSemantics::ProviderSpecific)
            .expect("provider-specific path should parse");
        let component = PathComponent::parse("archive").expect("component should parse");
        let relative = RelativePath::parse("daily.csv").expect("relative path should parse");

        assert_eq!("/", as_str(&root()));
        assert_eq!(Some("daily.csv"), file_name(&built));
        assert!(is_absolute(&built));
        assert_eq!(PathSemantics::ObjectKey, semantics(&literal));
        assert_eq!(PathSemantics::ProviderSpecific, semantics(&provider));
        let parent = Path::parse("/reports").expect("parent path should parse");
        assert_eq!("/reports/daily.csv", as_str(&join(&parent, &relative)));
        assert_eq!(
            "/reports/archive",
            as_str(&child(&Path::parse("/reports").unwrap(), &component))
        );
        assert_eq!("reports/daily.csv", components(&built).collect::<Vec<_>>().join("/"));
        assert_eq!(as_str(&built), as_ref(&built));
    }
}