#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DirectoryPath {
pub value: String,
}
#[must_use]
pub fn dir_name(input: &str) -> Option<String> {
let normalized = normalize_path_like(input);
let trimmed = trim_trailing_dir_separator(&normalized);
let (_, segments) = split_root_and_segments(&trimmed);
let last = segments.last()?.to_string();
if normalized.ends_with('/') || !looks_like_file_name(last.as_str()) {
return Some(last);
}
(segments.len() >= 2).then(|| segments[segments.len() - 2].to_string())
}
#[must_use]
pub fn is_root_dir(input: &str) -> bool {
let normalized = normalize_path_like(input);
let trimmed = trim_trailing_dir_separator(&normalized);
let (root, segments) = split_root_and_segments(&trimmed);
root.is_some() && segments.is_empty()
}
#[must_use]
pub fn is_current_dir(input: &str) -> bool {
trim_trailing_dir_separator(&normalize_path_like(input)) == "."
}
#[must_use]
pub fn is_parent_dir(input: &str) -> bool {
trim_trailing_dir_separator(&normalize_path_like(input)) == ".."
}
#[must_use]
pub fn normalize_dir_path(input: &str) -> String {
trim_trailing_dir_separator(&normalize_path_like(input))
}
#[must_use]
pub fn ensure_dir_trailing_separator(input: &str) -> String {
let normalized = normalize_path_like(input);
if normalized.is_empty() || normalized.ends_with('/') {
return normalized;
}
format!("{normalized}/")
}
#[must_use]
pub fn strip_dir_trailing_separator(input: &str) -> String {
trim_trailing_dir_separator(&normalize_path_like(input))
}
#[must_use]
pub fn path_depth(input: &str) -> usize {
let normalized = trim_trailing_dir_separator(&normalize_path_like(input));
let (_, segments) = split_root_and_segments(&normalized);
segments.len()
}
#[must_use]
pub fn starts_with_dir(input: &str, dir: &str) -> bool {
let input_normalized = normalize_dir_path(input);
let dir_normalized = normalize_dir_path(dir);
if dir_normalized.is_empty() {
return false;
}
let (input_root, input_segments) = split_root_and_segments(&input_normalized);
let (dir_root, dir_segments) = split_root_and_segments(&dir_normalized);
if input_root != dir_root || dir_segments.len() > input_segments.len() {
return false;
}
input_segments.starts_with(&dir_segments)
}
#[must_use]
pub fn relative_to_dir(path: &str, base: &str) -> Option<String> {
let normalized_path = normalize_dir_path(path);
let normalized_base = normalize_dir_path(base);
if normalized_base.is_empty() {
return None;
}
let (path_root, path_segments) = split_root_and_segments(&normalized_path);
let (base_root, base_segments) = split_root_and_segments(&normalized_base);
if path_root != base_root || base_segments.len() > path_segments.len() {
return None;
}
if !path_segments.starts_with(&base_segments) {
return None;
}
let remainder = &path_segments[base_segments.len()..];
if remainder.is_empty() {
Some(String::from("."))
} else {
Some(remainder.join("/"))
}
}
fn normalize_path_like(input: &str) -> String {
input.replace('\\', "/")
}
fn trim_trailing_dir_separator(input: &str) -> String {
let mut value = input.to_string();
while value.ends_with('/') && !is_root_like(&value) {
value.pop();
}
value
}
fn is_root_like(input: &str) -> bool {
if matches!(input, "/" | "//") {
return true;
}
if drive_root_prefix(input).is_some() && input.len() == 3 {
return true;
}
if let Some(remainder) = input.strip_prefix("//") {
let segments: Vec<_> = remainder
.split('/')
.filter(|segment| !segment.is_empty())
.collect();
return segments.len() == 2;
}
false
}
fn drive_root_prefix(input: &str) -> Option<&str> {
let bytes = input.as_bytes();
if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
Some(&input[..3])
} else {
None
}
}
fn split_root_and_segments(input: &str) -> (Option<String>, Vec<String>) {
if let Some(remainder) = input.strip_prefix("//") {
let segments: Vec<_> = remainder
.split('/')
.filter(|segment| !segment.is_empty())
.collect();
if segments.len() >= 2 {
let root = format!("//{}/{}", segments[0], segments[1]);
let rest = segments[2..]
.iter()
.map(|segment| (*segment).to_string())
.collect();
return (Some(root), rest);
}
}
if let Some(root) = drive_root_prefix(input) {
let rest = input[root.len()..]
.split('/')
.filter(|segment| !segment.is_empty())
.map(ToOwned::to_owned)
.collect();
return (Some(root.to_string()), rest);
}
if let Some(remainder) = input.strip_prefix('/') {
let rest = remainder
.split('/')
.filter(|segment| !segment.is_empty())
.map(ToOwned::to_owned)
.collect();
return (Some(String::from("/")), rest);
}
let segments = input
.split('/')
.filter(|segment| !segment.is_empty())
.map(ToOwned::to_owned)
.collect();
(None, segments)
}
fn looks_like_file_name(segment: &str) -> bool {
if matches!(segment, "." | "..") || segment.is_empty() {
return false;
}
if segment.starts_with('.') {
return true;
}
match segment.rfind('.') {
Some(index) if index > 0 && index + 1 < segment.len() => true,
_ => false,
}
}