#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
const RESERVED_NAMES: [&str; 22] = [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FileName {
pub value: String,
}
#[must_use]
pub fn file_name(input: &str) -> Option<String> {
file_name_candidate(input).map(ToOwned::to_owned)
}
#[must_use]
pub fn is_hidden_file_name(input: &str) -> bool {
file_name(input).as_deref().is_some_and(|value| {
value.starts_with('.') && value.len() > 1 && !matches!(value, "." | "..")
})
}
#[must_use]
pub fn is_safe_file_name(input: &str) -> bool {
let Some(candidate) = file_name_candidate(input) else {
return false;
};
!candidate.is_empty()
&& !matches!(candidate, "." | "..")
&& !candidate.starts_with(' ')
&& !candidate.ends_with([' ', '.'])
&& !has_reserved_file_name(candidate)
&& !candidate.chars().any(is_unsafe_character)
}
#[must_use]
pub fn sanitize_file_name(input: &str) -> String {
let candidate = normalize_file_name(input);
let mut value = replace_unsafe_file_name_chars(candidate.as_str(), '-');
value = value.trim_matches(' ').trim_end_matches('.').to_string();
if value.is_empty() || matches!(value.as_str(), "." | "..") {
return String::from("file");
}
if has_reserved_file_name(&value) {
if let Some((base, rest)) = value.split_once('.') {
value = format!("{base}_.{rest}");
} else {
value.push('_');
}
}
value
}
#[must_use]
pub fn normalize_file_name(input: &str) -> String {
file_name_candidate(input).unwrap_or("").trim().to_string()
}
#[must_use]
pub fn has_reserved_file_name(input: &str) -> bool {
let Some(candidate) = file_name_candidate(input) else {
return false;
};
let trimmed = candidate.trim().trim_end_matches([' ', '.']);
if trimmed.is_empty() {
return false;
}
let base = trimmed
.split('.')
.next()
.unwrap_or(trimmed)
.trim_end_matches([' ', '.']);
RESERVED_NAMES.contains(&base.to_ascii_uppercase().as_str())
}
#[must_use]
pub fn remove_unsafe_file_name_chars(input: &str) -> String {
file_name_candidate(input)
.unwrap_or("")
.chars()
.filter(|character| !is_unsafe_character(*character))
.collect()
}
#[must_use]
pub fn replace_unsafe_file_name_chars(input: &str, replacement: char) -> String {
let replacement = if is_unsafe_character(replacement) {
'-'
} else {
replacement
};
file_name_candidate(input)
.unwrap_or("")
.chars()
.map(|character| match character {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => replacement,
character if character.is_control() => replacement,
_ => character,
})
.collect()
}
fn file_name_candidate(input: &str) -> Option<&str> {
if input.is_empty() {
return None;
}
let candidate = input.rsplit(['/', '\\']).next().unwrap_or(input);
(!candidate.is_empty()).then_some(candidate)
}
fn is_unsafe_character(character: char) -> bool {
matches!(
character,
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
) || character.is_control()
}