use anyhow::{Result, anyhow};
use regex::Regex;
use std::cmp::Ordering;
use std::convert::Into;
use std::env;
use std::fmt::{self, Display};
use std::fs::Metadata;
use std::hash::{Hash, Hasher};
use std::ops::Deref;
use std::path::{Component, Path, PathBuf};
use std::sync::LazyLock;
use yansi::{Paint, Style};
#[derive(Debug, Clone, Eq)] pub struct Entry {
path: PathBuf,
is_dir: bool,
}
impl TryFrom<PathBuf> for Entry {
type Error = (PathBuf, anyhow::Error);
fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
let path_err = |err: anyhow::Error| (path.clone(), err);
let is_dir = path
.metadata()
.map_err(Into::into)
.map_err(path_err)?
.is_dir(); if is_dir {
path.file_name()
.unwrap_or_default() .to_str()
.ok_or_else(|| anyhow!("no UTF-8 dir name: {path:?}"))
.map_err(path_err)?;
} else {
path.file_stem()
.ok_or_else(|| anyhow!("no file stem: {path:?}"))
.map_err(path_err)?
.to_str()
.ok_or_else(|| anyhow!("no UTF-8 file stem: {path:?}"))
.map_err(path_err)?;
path.extension()
.unwrap_or_default()
.to_str()
.ok_or_else(|| anyhow!("no UTF-8 file extension: {path:?}"))
.map_err(path_err)?;
}
if let Some(pp) = path.parent() {
pp.to_str()
.ok_or_else(|| anyhow!("no UTF-8 parent: {pp:?}"))
.map_err(path_err)?;
}
Ok(Entry { path, is_dir })
}
}
pub static ROOT: LazyLock<Entry> = LazyLock::new(|| Entry::try_new("/", true).unwrap());
impl Entry {
pub fn try_new(path: impl Into<PathBuf>, is_dir: bool) -> Result<Self> {
let path = path.into();
if path.to_str().is_none() {
return Err(anyhow!("invalid UTF-8 path: {path:?}"));
}
match path.try_exists() {
Ok(true) => assert_eq!(path.is_dir(), is_dir, "is_dir error in {path:?}: {is_dir}"),
Ok(false) => {} Err(err) => println!("warning: couldn't verify {path:?}: {err}"),
}
Ok(Entry { path, is_dir })
}
pub fn join(&self, name: impl AsRef<str>) -> Entry {
let path = self.path.join(name.as_ref());
let is_dir = path.is_dir();
Entry { path, is_dir }
}
pub fn with_file_name(&self, name: impl AsRef<str>) -> Entry {
let path = self.path.with_file_name(name.as_ref());
let is_dir = path.is_dir();
Entry { path, is_dir }
}
pub fn filename_parts(&self) -> (&str, &str) {
match self.is_dir {
true => (self.file_name(), ""),
false => (
self.path.file_stem().unwrap().to_str().unwrap(),
self.path.extension().unwrap_or_default().to_str().unwrap(),
),
}
}
pub fn collection_parts(&self) -> (&str, Option<&str>, Option<usize>, &str, &str) {
static RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(\w+)(?:\+(\w+))?~(\d+)(.*)$").unwrap());
let (stem, ext) = self.filename_parts();
let Some(caps) = RE.captures(stem) else {
return (stem, None, None, "", ext);
};
let canonical = caps.get(1).unwrap().as_str(); let alias = caps.get(2).map(|m| m.as_str());
let seq = caps.get(3).and_then(|m| m.as_str().parse().ok());
let comment = caps.get(4).map_or("", |m| m.as_str());
(canonical, alias, seq, comment, ext)
}
pub fn is_dir(&self) -> bool {
self.is_dir
}
pub fn file_name(&self) -> &str {
self.path
.file_name()
.map(|n| n.to_str().unwrap())
.unwrap_or_default()
}
pub fn to_str(&self) -> &str {
self.path.to_str().unwrap()
}
pub fn parent(&self) -> Option<Entry> {
self.path.parent().map(|p| Entry {
path: p.to_owned(),
is_dir: true,
})
}
pub fn metadata(&self) -> Result<Metadata> {
self.path.metadata().map_err(Into::into)
}
pub fn display_path(&self) -> impl Display {
DisplayPath(self)
}
pub fn display_filename(&self) -> impl Display {
DisplayFilename(self)
}
pub fn resolve(&self) -> Result<Entry> {
let mut it = self.path.components();
let mut res = match it.next().unwrap() {
Component::Normal(x) if x == "~" => {
dirs::home_dir().ok_or_else(|| anyhow!("no home dir"))?
}
Component::Normal(x) => {
let mut dir = env::current_dir()?;
dir.push(x);
dir
}
Component::CurDir => env::current_dir()?,
Component::ParentDir => {
let mut dir = env::current_dir()?;
dir.pop();
dir
}
x => PathBuf::from(x.as_os_str()),
};
for comp in it {
match comp {
Component::RootDir => res.push(comp), Component::Normal(_) => res.push(comp),
Component::ParentDir => {
if !res.pop() {
return Err(anyhow!("invalid path: {self}"));
}
}
_ => unreachable!(),
}
}
Entry::try_new(res, self.is_dir) }
}
#[derive(Debug)]
pub struct DisplayPath<'a>(&'a Entry);
#[derive(Debug)]
pub struct DisplayFilename<'a>(&'a Entry);
const DIR_STYLE: (Style, Style) = {
let parent_dir: Style = Style::new().yellow();
(parent_dir, parent_dir.bold())
};
const FILE_STYLE: (Style, Style) = {
let parent_file = Style::new().cyan();
(parent_file, parent_file.bold())
};
impl Display for DisplayPath<'_> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let entry = self.0;
let (parent, name, symbol) = display_parts(entry);
let (p_style, n_style) = if entry.is_dir { DIR_STYLE } else { FILE_STYLE };
write!(
f,
"{}{}{}",
parent.paint(p_style),
name.paint(n_style),
symbol.paint(n_style)
)
}
}
impl Display for DisplayFilename<'_> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let entry = self.0;
let (_, name, symbol) = display_parts(entry);
let (_, style) = if entry.is_dir { DIR_STYLE } else { FILE_STYLE };
write!(f, "{}{}", name.paint(style), symbol.paint(style))
}
}
fn display_parts(entry: &Entry) -> (&str, &str, &str) {
let full = entry.to_str();
let (parent, name) = match entry.path.file_name().map(|s| s.to_str().unwrap()) {
Some(name) => {
let pos = full.rfind(name).unwrap();
(&full[..pos], name)
}
None => ("", full),
};
let dir_id = match entry.is_dir && !name.ends_with('/') {
true => "/",
false => "",
};
(parent, name, dir_id)
}
impl Display for Entry {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.display_path().fmt(f)
}
}
impl Deref for Entry {
type Target = Path;
fn deref(&self) -> &Self::Target {
&self.path
}
}
impl AsRef<Path> for Entry {
fn as_ref(&self) -> &Path {
&self.path
}
}
impl Hash for Entry {
fn hash<H: Hasher>(&self, state: &mut H) {
self.path.hash(state)
}
}
impl Ord for Entry {
fn cmp(&self, other: &Self) -> Ordering {
self.path.cmp(&other.path)
}
}
impl PartialOrd for Entry {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for Entry {
fn eq(&self, other: &Self) -> bool {
self.path == other.path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filename_parts() {
#[track_caller]
fn case(p: impl Into<PathBuf>, is_dir: bool, out: (&str, &str)) {
let entry = Entry {
path: p.into(),
is_dir,
};
assert_eq!(out, entry.filename_parts())
}
case("foo", false, ("foo", ""));
case("foo.bar", false, ("foo", "bar"));
case("foo.bar.baz", false, ("foo.bar", "baz"));
case(".foo", false, (".foo", ""));
case(".foo.bar", false, (".foo", "bar"));
case(".foo.bar.baz", false, (".foo.bar", "baz"));
case("foo", true, ("foo", ""));
case("foo.bar", true, ("foo.bar", ""));
case("foo.bar.baz", true, ("foo.bar.baz", ""));
case(".foo", true, (".foo", ""));
case(".foo.bar", true, (".foo.bar", ""));
case(".foo.bar.baz", true, (".foo.bar.baz", ""));
}
#[test]
fn collection_parts() {
#[track_caller]
fn case(base: &str, out: (&str, Option<&str>, Option<usize>, &str)) {
let (name, alias, seq, comment) = out;
let entry = Entry::try_new(format!("{base}.ext"), false).unwrap();
let out = (name, alias, seq, comment, "ext");
assert_eq!(out, entry.collection_parts());
}
case("foo", ("foo", None, None, ""));
case("foo bar", ("foo bar", None, None, ""));
case("foo bar - baz", ("foo bar - baz", None, None, ""));
case("foo - 2025 - 24", ("foo - 2025 - 24", None, None, ""));
case("_foo_-24", ("_foo_-24", None, None, ""));
case("foo ~ 24", ("foo ~ 24", None, None, ""));
case("foo~ 24", ("foo~ 24", None, None, ""));
case("foo+bar", ("foo+bar", None, None, ""));
case("foo+bar,baz", ("foo+bar,baz", None, None, ""));
case("foo+bar ~ 24", ("foo+bar ~ 24", None, None, ""));
case("foo ~24", ("foo ~24", None, None, ""));
case("foo bar~24", ("foo bar~24", None, None, ""));
case("foo bar ~24", ("foo bar ~24", None, None, ""));
case("_foo_ ~24", ("_foo_ ~24", None, None, ""));
case("foo - 33~24", ("foo - 33~24", None, None, ""));
case("foo+ ~24", ("foo+ ~24", None, None, ""));
case("foo+ asd~24", ("foo+ asd~24", None, None, ""));
case("foo+asd ~24", ("foo+asd ~24", None, None, ""));
case("foo+~24", ("foo+~24", None, None, ""));
case(",~24", (",~24", None, None, ""));
case("foo+,~24", ("foo+,~24", None, None, ""));
case("foo+bar,~24", ("foo+bar,~24", None, None, ""));
case("foo+bar,~24 cool", ("foo+bar,~24 cool", None, None, ""));
case("foo~24", ("foo", None, Some(24), ""));
case("foo_~24", ("foo_", None, Some(24), ""));
case("__foo~24", ("__foo", None, Some(24), ""));
case("_foo__~24", ("_foo__", None, Some(24), ""));
case("foo+bar~24", ("foo", Some("bar"), Some(24), ""));
case(
"foo_bar__+_baz__~24",
("foo_bar__", Some("_baz__"), Some(24), ""),
);
case("foo~24cool", ("foo", None, Some(24), "cool"));
case("foo~24 cool", ("foo", None, Some(24), " cool"));
case("foo_~24-nice!", ("foo_", None, Some(24), "-nice!"));
case("__foo~24 ?why?", ("__foo", None, Some(24), " ?why?"));
case("_foo__~24 - cut", ("_foo__", None, Some(24), " - cut"));
case(
"foo+bar~24 seen 3 times",
("foo", Some("bar"), Some(24), " seen 3 times"),
);
case(
"_foo+__bar_~24 with comment!",
("_foo", Some("__bar_"), Some(24), " with comment!"),
);
}
#[test]
fn fn_display_parts() {
#[track_caller]
fn case(p: impl Into<PathBuf>, is_dir: bool, out: (&str, &str, &str)) {
let entry = Entry {
path: p.into(),
is_dir,
};
assert_eq!(out, display_parts(&entry));
}
case(".", true, ("", ".", "/"));
case("..", true, ("", "..", "/"));
case("/", true, ("", "/", ""));
case("./", true, ("", "./", ""));
case("../", true, ("", "../", ""));
case("dir", true, ("", "dir", "/"));
case("dir/", true, ("", "dir", "/"));
case("dir/.", true, ("", "dir", "/"));
case("./dir", true, ("./", "dir", "/"));
case("./dir/", true, ("./", "dir", "/"));
case("./dir/.", true, ("./", "dir", "/"));
case("file.txt", false, ("", "file.txt", ""));
case("./file.txt", false, ("./", "file.txt", ""));
case("dir/file.txt", false, ("dir/", "file.txt", ""));
case("./dir/file.txt", false, ("./dir/", "file.txt", ""));
case(".hidden", false, ("", ".hidden", ""));
case("./dir/.hidden", false, ("./dir/", ".hidden", ""));
}
}