1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4const RESERVED_NAMES: [&str; 22] = [
5 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
6 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
7];
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct FileName {
12 pub value: String,
13}
14
15#[must_use]
17pub fn file_name(input: &str) -> Option<String> {
18 file_name_candidate(input).map(ToOwned::to_owned)
19}
20
21#[must_use]
23pub fn is_hidden_file_name(input: &str) -> bool {
24 file_name(input).as_deref().is_some_and(|value| {
25 value.starts_with('.') && value.len() > 1 && !matches!(value, "." | "..")
26 })
27}
28
29#[must_use]
31pub fn is_safe_file_name(input: &str) -> bool {
32 let Some(candidate) = file_name_candidate(input) else {
33 return false;
34 };
35
36 !candidate.is_empty()
37 && !matches!(candidate, "." | "..")
38 && !candidate.starts_with(' ')
39 && !candidate.ends_with([' ', '.'])
40 && !has_reserved_file_name(candidate)
41 && !candidate.chars().any(is_unsafe_character)
42}
43
44#[must_use]
46pub fn sanitize_file_name(input: &str) -> String {
47 let candidate = normalize_file_name(input);
48 let mut value = replace_unsafe_file_name_chars(candidate.as_str(), '-');
49 value = value.trim_matches(' ').trim_end_matches('.').to_string();
50
51 if value.is_empty() || matches!(value.as_str(), "." | "..") {
52 return String::from("file");
53 }
54
55 if has_reserved_file_name(&value) {
56 if let Some((base, rest)) = value.split_once('.') {
57 value = format!("{base}_.{rest}");
58 } else {
59 value.push('_');
60 }
61 }
62
63 value
64}
65
66#[must_use]
68pub fn normalize_file_name(input: &str) -> String {
69 file_name_candidate(input).unwrap_or("").trim().to_string()
70}
71
72#[must_use]
74pub fn has_reserved_file_name(input: &str) -> bool {
75 let Some(candidate) = file_name_candidate(input) else {
76 return false;
77 };
78
79 let trimmed = candidate.trim().trim_end_matches([' ', '.']);
80 if trimmed.is_empty() {
81 return false;
82 }
83
84 let base = trimmed
85 .split('.')
86 .next()
87 .unwrap_or(trimmed)
88 .trim_end_matches([' ', '.']);
89 RESERVED_NAMES.contains(&base.to_ascii_uppercase().as_str())
90}
91
92#[must_use]
94pub fn remove_unsafe_file_name_chars(input: &str) -> String {
95 file_name_candidate(input)
96 .unwrap_or("")
97 .chars()
98 .filter(|character| !is_unsafe_character(*character))
99 .collect()
100}
101
102#[must_use]
104pub fn replace_unsafe_file_name_chars(input: &str, replacement: char) -> String {
105 let replacement = if is_unsafe_character(replacement) {
106 '-'
107 } else {
108 replacement
109 };
110
111 file_name_candidate(input)
112 .unwrap_or("")
113 .chars()
114 .map(|character| match character {
115 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => replacement,
116 character if character.is_control() => replacement,
117 _ => character,
118 })
119 .collect()
120}
121
122fn file_name_candidate(input: &str) -> Option<&str> {
123 if input.is_empty() {
124 return None;
125 }
126
127 let candidate = input.rsplit(['/', '\\']).next().unwrap_or(input);
128 (!candidate.is_empty()).then_some(candidate)
129}
130
131fn is_unsafe_character(character: char) -> bool {
132 matches!(
133 character,
134 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
135 ) || character.is_control()
136}