use std::fmt;
use std::io;
use std::path::PathBuf;
pub type Result<T> = std::result::Result<T, Error>;
pub const PREVIEW_CHARS: usize = 96;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
InvalidName {
name: String,
reason: Reason,
},
InvalidPath {
path: String,
reason: Reason,
},
Unresolved {
dir: &'static str,
},
AlreadyExists {
path: PathBuf,
},
NotFound {
path: PathBuf,
},
Refused {
path: PathBuf,
reason: &'static str,
},
Io {
op: &'static str,
path: PathBuf,
source: io::Error,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Reason {
Empty,
Traversal,
Absolute,
HomeShortcut,
ReservedChar(char),
ControlChar,
InvisibleChar(char),
ReservedName,
TrailingDotOrSpace,
SegmentTooLong,
TooLong,
TooDeep,
Separator,
NotAbsolute,
SharedDir,
OtherUser,
}
impl Error {
pub(crate) fn invalid_path(input: &str, reason: Reason) -> Self {
Self::InvalidPath {
path: preview(input),
reason,
}
}
pub(crate) fn invalid_name(input: &str, reason: Reason) -> Self {
Self::InvalidName {
name: preview(input),
reason,
}
}
pub(crate) fn io(op: &'static str, path: impl Into<PathBuf>, source: io::Error) -> Self {
Self::Io {
op,
path: path.into(),
source,
}
}
#[must_use]
pub fn reason(&self) -> Option<Reason> {
match self {
Self::InvalidName { reason, .. } | Self::InvalidPath { reason, .. } => Some(*reason),
_ => None,
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidName { name, reason } => {
write!(f, "invalid application name {name:?}: {reason}")
}
Self::InvalidPath { path, reason } => write!(f, "invalid path {path:?}: {reason}"),
Self::Unresolved { dir } => {
write!(f, "the operating system did not report the {dir} directory")
}
Self::AlreadyExists { path } => write!(f, "{} already exists", path.display()),
Self::NotFound { path } => write!(f, "{} does not exist", path.display()),
Self::Refused { path, reason } => {
write!(f, "refused to act on {}: {reason}", path.display())
}
Self::Io { op, path, source } => {
write!(f, "cannot {op} {}: {source}", path.display())
}
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
impl fmt::Display for Reason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("must not be empty"),
Self::Traversal => f.write_str("`..` would leave the base directory"),
Self::Absolute => f.write_str("must be relative (no root, drive, or UNC prefix)"),
Self::HomeShortcut => f.write_str("`~` is a shell shortcut, not a directory"),
Self::ReservedChar(c) => write!(f, "character {c:?} is reserved on some platforms"),
Self::ControlChar => f.write_str("contains a control character"),
Self::InvisibleChar(c) => {
write!(
f,
"contains invisible formatting character U+{:04X}",
u32::from(*c)
)
}
Self::ReservedName => f.write_str("is a reserved Windows device name"),
Self::TrailingDotOrSpace => f.write_str("a segment ends with `.` or a space"),
Self::SegmentTooLong => f.write_str("a segment is longer than 255 bytes"),
Self::TooLong => f.write_str("is longer than 4096 bytes"),
Self::TooDeep => f.write_str("has more than 128 segments"),
Self::Separator => f.write_str("must be a single name without separators"),
Self::NotAbsolute => f.write_str("must be an absolute path"),
Self::SharedDir => f.write_str("is already the directory of another kind"),
Self::OtherUser => f.write_str("only `~` for the current user is supported"),
}
}
}
fn preview(input: &str) -> String {
match input.char_indices().nth(PREVIEW_CHARS) {
Some((cut, _)) => {
let mut s = String::with_capacity(cut + 3);
s.push_str(&input[..cut]);
s.push_str("...");
s
}
None => input.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_preview_short_input_is_unchanged() {
assert_eq!(preview("abc"), "abc");
assert_eq!(preview(""), "");
}
#[test]
fn test_preview_long_input_is_truncated_on_char_boundary() {
let long = "é".repeat(PREVIEW_CHARS + 10);
let p = preview(&long);
assert_eq!(p.chars().count(), PREVIEW_CHARS + 3);
assert!(p.ends_with("..."));
}
#[test]
fn test_display_escapes_control_characters() {
let err = Error::invalid_path("a\nb\u{1b}[31m", Reason::ControlChar);
let shown = err.to_string();
assert!(!shown.contains('\n'));
assert!(!shown.contains('\u{1b}'));
assert!(shown.contains("\\n"));
}
#[test]
fn test_reason_is_exposed_only_for_input_errors() {
assert_eq!(
Error::invalid_name("x", Reason::Empty).reason(),
Some(Reason::Empty)
);
assert_eq!(Error::Unresolved { dir: "home" }.reason(), None);
}
#[test]
fn test_display_every_variant_names_its_subject() {
use std::path::Path;
let cases: [(Error, &str); 7] = [
(
Error::invalid_name("x/y", Reason::Separator),
"invalid application name",
),
(
Error::invalid_path("../a", Reason::Traversal),
"invalid path",
),
(Error::Unresolved { dir: "home" }, "home directory"),
(
Error::AlreadyExists {
path: Path::new("p").into(),
},
"already exists",
),
(
Error::NotFound {
path: Path::new("p").into(),
},
"does not exist",
),
(
Error::Refused {
path: Path::new("p").into(),
reason: "because",
},
"refused to act on p: because",
),
(
Error::io("create file", "p", io::Error::other("boom")),
"cannot create file p: boom",
),
];
for (err, needle) in cases {
let shown = err.to_string();
assert!(
shown.contains(needle),
"{shown:?} should contain {needle:?}"
);
}
}
#[test]
fn test_source_present_only_for_io() {
use std::error::Error as _;
let io = Error::io("read", "/x", io::Error::other("boom"));
assert!(io.source().is_some());
assert!(Error::Unresolved { dir: "home" }.source().is_none());
}
#[test]
fn test_every_reason_has_a_message() {
let all = [
Reason::Empty,
Reason::Traversal,
Reason::Absolute,
Reason::HomeShortcut,
Reason::ReservedChar(':'),
Reason::ControlChar,
Reason::InvisibleChar('\u{202e}'),
Reason::ReservedName,
Reason::TrailingDotOrSpace,
Reason::SegmentTooLong,
Reason::TooLong,
Reason::TooDeep,
Reason::Separator,
Reason::NotAbsolute,
Reason::SharedDir,
Reason::OtherUser,
];
for r in all {
assert!(!r.to_string().is_empty());
}
}
}