use crate::{Error, Result, SMeta, reshape};
use camino::{Utf8Path, Utf8PathBuf};
use core::fmt;
use pathdiff::diff_utf8_paths;
use std::fs::{self, Metadata};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct SPath {
pub(crate) path_buf: Utf8PathBuf,
}
impl SPath {
pub fn new(path: impl Into<Utf8PathBuf>) -> Self {
let path_buf = path.into();
let path_buf = reshape::into_normalized(path_buf);
Self { path_buf }
}
pub fn from_std_path_buf(path_buf: PathBuf) -> Result<Self> {
let path_buf = validate_spath_for_result(path_buf)?;
Ok(SPath::new(path_buf))
}
pub fn from_std_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let path_buf = validate_spath_for_result(path)?;
Ok(SPath::new(path_buf))
}
pub fn from_walkdir_entry(wd_entry: walkdir::DirEntry) -> Result<Self> {
let path = wd_entry.into_path();
let path_buf = validate_spath_for_result(path)?;
Ok(SPath::new(path_buf))
}
pub fn from_fs_entry(fs_entry: fs::DirEntry) -> Result<Self> {
let path = fs_entry.path();
let path_buf = validate_spath_for_result(path)?;
Ok(SPath::new(path_buf))
}
pub fn from_std_path_ok(path: impl AsRef<Path>) -> Option<Self> {
let path = path.as_ref();
let path_buf = validate_spath_for_option(path)?;
Some(SPath::new(path_buf))
}
pub fn from_std_path_buf_ok(path_buf: PathBuf) -> Option<Self> {
let path_buf = validate_spath_for_option(&path_buf)?;
Some(SPath::new(path_buf))
}
pub fn from_fs_entry_ok(fs_entry: fs::DirEntry) -> Option<Self> {
let path_buf = fs_entry.path();
let path_buf = validate_spath_for_option(&path_buf)?;
Some(SPath::new(path_buf))
}
pub fn from_walkdir_entry_ok(wd_entry: walkdir::DirEntry) -> Option<Self> {
let path_buf = validate_spath_for_option(wd_entry.path())?;
Some(SPath::new(path_buf))
}
}
impl SPath {
pub fn into_std_path_buf(self) -> PathBuf {
self.path_buf.into()
}
pub fn std_path(&self) -> &Path {
self.path_buf.as_std_path()
}
pub fn path(&self) -> &Utf8Path {
&self.path_buf
}
}
impl SPath {
pub fn as_str(&self) -> &str {
self.path_buf.as_str()
}
pub fn file_name(&self) -> Option<&str> {
self.path_buf.file_name()
}
pub fn name(&self) -> &str {
self.file_name().unwrap_or_default()
}
pub fn parent_name(&self) -> &str {
self.path_buf.parent().and_then(|p| p.file_name()).unwrap_or_default()
}
pub fn file_stem(&self) -> Option<&str> {
self.path_buf.file_stem()
}
pub fn stem(&self) -> &str {
self.file_stem().unwrap_or_default()
}
pub fn extension(&self) -> Option<&str> {
self.path_buf.extension()
}
pub fn ext(&self) -> &str {
self.extension().unwrap_or_default()
}
pub fn is_dir(&self) -> bool {
self.path_buf.is_dir()
}
pub fn is_file(&self) -> bool {
self.path_buf.is_file()
}
pub fn exists(&self) -> bool {
self.path_buf.exists()
}
pub fn is_absolute(&self) -> bool {
self.path_buf.is_absolute()
}
pub fn is_relative(&self) -> bool {
self.path_buf.is_relative()
}
}
impl SPath {
pub fn mime_type(&self) -> Option<&'static str> {
mime_guess::from_path(self.path()).first_raw()
}
pub fn is_likely_text(&self) -> bool {
if let Some(ext) = self.extension() {
let known_text_ext =
matches!(
ext,
"txt"
| "text" | "md" | "markdown"
| "csv" | "toml" | "yaml"
| "yml" | "json" | "jsonc"
| "json5" | "jsonl"
| "ndjson" | "jsonlines"
| "ldjson" | "xml" | "html"
| "htm" | "css" | "scss"
| "sass" | "less" | "js"
| "mjs" | "cjs" | "ts"
| "tsx" | "jsx" | "rs"
| "dart" | "py" | "rb"
| "go" | "java" | "c"
| "cpp" | "h" | "hpp"
| "sh" | "bash" | "zsh"
| "fish" | "php" | "lua"
| "ini" | "cfg" | "conf"
| "sql" | "graphql"
| "gql" | "svg" | "log"
| "env" | "tex" | "aip"
);
if known_text_ext {
return true;
}
let known_binary_ext = matches!(ext, "lockb");
if known_binary_ext {
return false;
}
}
let mimes = mime_guess::from_path(self.path());
if mimes.is_empty() {
return true;
}
mimes.into_iter().any(|mime| {
let mime = mime.essence_str();
mime.starts_with("text/")
|| mime == "application/json"
|| mime == "application/javascript"
|| mime == "application/x-javascript"
|| mime == "application/ecmascript"
|| mime == "application/x-python"
|| mime == "application/xml"
|| mime == "application/toml"
|| mime == "application/x-toml"
|| mime == "application/x-yaml"
|| mime == "application/yaml"
|| mime == "application/sql"
|| mime == "application/graphql"
|| mime == "application/xml-dtd"
|| mime == "application/x-qml"
|| mime == "application/ini"
|| mime == "application/x-ini"
|| mime == "application/x-sh"
|| mime == "application/x-httpd-php"
|| mime == "application/x-lua"
|| mime == "application/vnd.dart"
|| mime.ends_with("+json")
|| mime.ends_with("+xml")
|| mime.ends_with("+yaml")
})
}
}
impl SPath {
#[allow(clippy::fn_to_numeric_cast)]
pub fn meta(&self) -> Result<SMeta> {
let path = self;
let metadata = self.metadata()?;
let modified = metadata.modified().map_err(|ex| Error::CantGetMetadata((path, ex).into()))?;
let modified_epoch_us: i64 = modified
.duration_since(UNIX_EPOCH)
.map_err(|ex| Error::CantGetMetadata((path, ex).into()))?
.as_micros()
.min(i64::MAX as u128) as i64;
let created_epoch_us = metadata
.modified()
.ok()
.and_then(|c| c.duration_since(UNIX_EPOCH).ok())
.map(|c| c.as_micros().min(i64::MAX as u128) as i64);
let created_epoch_us = created_epoch_us.unwrap_or(modified_epoch_us);
let size = if metadata.is_file() { metadata.len() } else { 0 };
Ok(SMeta {
created_epoch_us,
modified_epoch_us,
size,
is_file: metadata.is_file(),
is_dir: metadata.is_dir(),
})
}
pub fn metadata(&self) -> Result<Metadata> {
fs::metadata(self).map_err(|ex| Error::CantGetMetadata((self, ex).into()))
}
}
impl SPath {
pub fn canonicalize(&self) -> Result<SPath> {
let path = self
.path_buf
.canonicalize_utf8()
.map_err(|err| Error::CannotCanonicalize((self.std_path(), err).into()))?;
Ok(SPath::new(path))
}
pub fn collapse(&self) -> SPath {
let path_buf = crate::into_collapsed(self.path_buf.clone());
SPath::new(path_buf)
}
pub fn into_collapsed(self) -> SPath {
if self.is_collapsed() { self } else { self.collapse() }
}
pub fn is_collapsed(&self) -> bool {
crate::is_collapsed(self)
}
pub fn parent(&self) -> Option<SPath> {
self.path_buf.parent().map(SPath::from)
}
pub fn append_suffix(&self, suffix: &str) -> SPath {
SPath::new(format!("{self}{suffix}"))
}
pub fn join(&self, leaf_path: impl Into<Utf8PathBuf>) -> SPath {
let path_buf = self.path_buf.join(leaf_path.into());
SPath::from(path_buf)
}
pub fn join_std_path(&self, leaf_path: impl AsRef<Path>) -> Result<SPath> {
let leaf_path = leaf_path.as_ref();
let joined = self.std_path().join(leaf_path);
let path_buf = validate_spath_for_result(joined)?;
Ok(SPath::from(path_buf))
}
pub fn new_sibling(&self, leaf_path: impl AsRef<str>) -> SPath {
let leaf_path = leaf_path.as_ref();
match self.path_buf.parent() {
Some(parent_dir) => SPath::new(parent_dir.join(leaf_path)),
None => SPath::new(leaf_path),
}
}
pub fn new_sibling_std_path(&self, leaf_path: impl AsRef<Path>) -> Result<SPath> {
let leaf_path = leaf_path.as_ref();
match self.std_path().parent() {
Some(parent_dir) => SPath::from_std_path(parent_dir.join(leaf_path)),
None => SPath::from_std_path(leaf_path),
}
}
pub fn diff(&self, base: impl AsRef<Utf8Path>) -> Option<SPath> {
let base = base.as_ref();
let diff_path = diff_utf8_paths(self, base);
diff_path.map(SPath::from)
}
pub fn try_diff(&self, base: impl AsRef<Utf8Path>) -> Result<SPath> {
self.diff(&base).ok_or_else(|| Error::CannotDiff {
path: self.to_string(),
base: base.as_ref().to_string(),
})
}
pub fn replace_prefix(&self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
let base = base.as_ref();
let with = with.as_ref();
let s = self.as_str();
if let Some(stripped) = s.strip_prefix(base) {
let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
format!("{with}{stripped}")
} else {
format!("{with}/{stripped}")
};
SPath::new(joined)
} else {
self.clone()
}
}
pub fn into_replace_prefix(self, base: impl AsRef<str>, with: impl AsRef<str>) -> SPath {
let base = base.as_ref();
let with = with.as_ref();
let s = self.as_str();
if let Some(stripped) = s.strip_prefix(base) {
let joined = if with.is_empty() || with.ends_with('/') || stripped.starts_with('/') {
format!("{with}{stripped}")
} else {
format!("{with}/{stripped}")
};
SPath::new(joined)
} else {
self
}
}
}
impl SPath {
pub fn as_std_path(&self) -> &Path {
self.std_path()
}
pub fn strip_prefix(&self, prefix: impl AsRef<str>) -> Result<SPath> {
let prefix = prefix.as_ref();
let new_path = self.path_buf.strip_prefix(prefix).map_err(|_| Error::StripPrefix {
prefix: prefix.to_string(),
path: self.to_string(),
})?;
Ok(new_path.into())
}
pub fn starts_with(&self, base: impl AsRef<Path>) -> bool {
self.path_buf.starts_with(base)
}
pub fn starts_with_prefix(&self, base: impl AsRef<str>) -> bool {
self.path_buf.starts_with(base.as_ref())
}
}
impl SPath {
pub fn into_ensure_extension(mut self, ext: &str) -> Self {
if self.extension() != Some(ext) {
self.path_buf.set_extension(ext);
}
self
}
pub fn ensure_extension(&self, ext: &str) -> Self {
self.clone().into_ensure_extension(ext)
}
pub fn append_extension(&self, ext: &str) -> Self {
SPath::new(format!("{self}.{ext}"))
}
}
impl SPath {
pub fn dir_before_glob(&self) -> Option<SPath> {
let path_str = self.as_str();
let mut last_slash_idx = None;
for (i, c) in path_str.char_indices() {
if c == '/' {
last_slash_idx = Some(i);
} else if matches!(c, '*' | '?' | '[' | '{') {
return Some(SPath::from(&path_str[..last_slash_idx.unwrap_or(0)]));
}
}
None
}
}
impl fmt::Display for SPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl AsRef<SPath> for SPath {
fn as_ref(&self) -> &SPath {
self
}
}
impl AsRef<Path> for SPath {
fn as_ref(&self) -> &Path {
self.path_buf.as_ref()
}
}
impl AsRef<Utf8Path> for SPath {
fn as_ref(&self) -> &Utf8Path {
self.path_buf.as_ref()
}
}
impl AsRef<str> for SPath {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl From<SPath> for String {
fn from(val: SPath) -> Self {
val.as_str().to_string()
}
}
impl From<&SPath> for String {
fn from(val: &SPath) -> Self {
val.as_str().to_string()
}
}
impl From<SPath> for PathBuf {
fn from(val: SPath) -> Self {
val.into_std_path_buf()
}
}
impl From<&SPath> for PathBuf {
fn from(val: &SPath) -> Self {
val.path_buf.clone().into()
}
}
impl From<SPath> for Utf8PathBuf {
fn from(val: SPath) -> Self {
val.path_buf
}
}
impl From<&SPath> for SPath {
fn from(path: &SPath) -> Self {
path.clone()
}
}
impl From<Utf8PathBuf> for SPath {
fn from(path_buf: Utf8PathBuf) -> Self {
SPath::new(path_buf)
}
}
impl From<&Utf8Path> for SPath {
fn from(path: &Utf8Path) -> Self {
SPath::new(path)
}
}
impl From<String> for SPath {
fn from(path: String) -> Self {
SPath::new(path)
}
}
impl From<&String> for SPath {
fn from(path: &String) -> Self {
SPath::new(path)
}
}
impl From<&str> for SPath {
fn from(path: &str) -> Self {
SPath::new(path)
}
}
impl TryFrom<PathBuf> for SPath {
type Error = Error;
fn try_from(path_buf: PathBuf) -> Result<SPath> {
SPath::from_std_path_buf(path_buf)
}
}
impl TryFrom<fs::DirEntry> for SPath {
type Error = Error;
fn try_from(fs_entry: fs::DirEntry) -> Result<SPath> {
SPath::from_std_path_buf(fs_entry.path())
}
}
impl TryFrom<walkdir::DirEntry> for SPath {
type Error = Error;
fn try_from(wd_entry: walkdir::DirEntry) -> Result<SPath> {
SPath::from_std_path(wd_entry.path())
}
}
pub(crate) fn validate_spath_for_result(path: impl Into<PathBuf>) -> Result<Utf8PathBuf> {
let path = path.into();
let path_buf =
Utf8PathBuf::from_path_buf(path).map_err(|err| Error::PathNotUtf8(err.to_string_lossy().to_string()))?;
Ok(path_buf)
}
pub(crate) fn validate_spath_for_option(path: impl Into<PathBuf>) -> Option<Utf8PathBuf> {
Utf8PathBuf::from_path_buf(path.into()).ok()
}
#[cfg(test)]
#[path = "_tests/tests_spath.rs"]
mod tests_spath;