Skip to main content

use_file_name/
lib.rs

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/// A string-backed file name.
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct FileName {
12    pub value: String,
13}
14
15/// Extracts the final file-name segment from a path-like input.
16#[must_use]
17pub fn file_name(input: &str) -> Option<String> {
18    file_name_candidate(input).map(ToOwned::to_owned)
19}
20
21/// Returns `true` when the extracted file name is a hidden dotfile.
22#[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/// Returns `true` when the extracted file name avoids reserved names and unsafe characters.
30#[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/// Sanitizes a file name by trimming, replacing unsafe characters, and disarming reserved names.
45#[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/// Normalizes a file name by trimming surrounding whitespace.
67#[must_use]
68pub fn normalize_file_name(input: &str) -> String {
69    file_name_candidate(input).unwrap_or("").trim().to_string()
70}
71
72/// Returns `true` when the extracted file name is a reserved Windows name.
73#[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/// Removes unsafe characters and control characters from a file name.
93#[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/// Replaces unsafe characters and control characters with a safe replacement.
103#[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}