Skip to main content

use_file_stem/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4const COMPOUND_EXTENSIONS: [&str; 7] = [
5    "tar.gz",
6    "tar.bz2",
7    "tar.xz",
8    "d.ts",
9    "module.css",
10    "test.ts",
11    "spec.ts",
12];
13
14/// A string-backed file stem.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct FileStem {
17    pub value: String,
18}
19
20/// Extracts the simple file stem from a file name or path-like input.
21#[must_use]
22pub fn file_stem(input: &str) -> Option<String> {
23    let file_name = file_name_segment(input)?;
24    match split_simple_extension(file_name) {
25        Some((stem, _)) => Some(stem.to_string()),
26        None => Some(file_name.to_string()),
27    }
28}
29
30/// Extracts the file stem while removing a supported compound extension when present.
31#[must_use]
32pub fn file_stem_without_compound_extension(input: &str) -> Option<String> {
33    let file_name = file_name_segment(input)?;
34
35    if let Some(extension) = compound_extension(file_name) {
36        let stem = &file_name[..file_name.len() - extension.len() - 1];
37        return (!stem.is_empty()).then(|| stem.to_string());
38    }
39
40    file_stem(file_name)
41}
42
43/// Returns `true` when the input has a recoverable file stem.
44#[must_use]
45pub fn has_file_stem(input: &str) -> bool {
46    file_stem(input).is_some()
47}
48
49/// Replaces the file stem while preserving the original extension suffix.
50#[must_use]
51pub fn with_file_stem(input: &str, stem: &str) -> String {
52    replace_stem(input, |current| {
53        let _ = current;
54        stem.to_string()
55    })
56}
57
58/// Appends a suffix to the file stem while preserving the original extension suffix.
59#[must_use]
60pub fn append_to_file_stem(input: &str, suffix: &str) -> String {
61    replace_stem(input, |current| format!("{current}{suffix}"))
62}
63
64/// Prepends a prefix to the file stem while preserving the original extension suffix.
65#[must_use]
66pub fn prepend_to_file_stem(input: &str, prefix: &str) -> String {
67    replace_stem(input, |current| format!("{prefix}{current}"))
68}
69
70/// Converts a file stem into a conservative ASCII slug.
71#[must_use]
72pub fn slug_file_stem_basic(input: &str) -> Option<String> {
73    let stem = file_stem_without_compound_extension(input).or_else(|| file_stem(input))?;
74    let mut slug = String::new();
75    let mut previous_was_separator = false;
76
77    for character in stem.trim().chars() {
78        let lowered = character.to_ascii_lowercase();
79
80        if lowered.is_ascii_alphanumeric() {
81            slug.push(lowered);
82            previous_was_separator = false;
83        } else if !slug.is_empty() && !previous_was_separator {
84            slug.push('-');
85            previous_was_separator = true;
86        }
87    }
88
89    while slug.ends_with('-') {
90        slug.pop();
91    }
92
93    (!slug.is_empty()).then_some(slug)
94}
95
96fn normalize_path_like(input: &str) -> String {
97    input.replace('\\', "/")
98}
99
100fn file_name_segment(input: &str) -> Option<&str> {
101    let candidate = input.rsplit(['/', '\\']).next().unwrap_or(input);
102    (!candidate.is_empty()).then_some(candidate)
103}
104
105fn split_directory_and_file_name(input: &str) -> (&str, Option<&str>) {
106    match input.rfind('/') {
107        Some(index) => {
108            let file_name = (index + 1 < input.len()).then(|| &input[index + 1..]);
109            (&input[..=index], file_name)
110        }
111        None => ("", (!input.is_empty()).then_some(input)),
112    }
113}
114
115fn split_simple_extension(file_name: &str) -> Option<(&str, &str)> {
116    let dot_index = file_name.rfind('.')?;
117    if dot_index == file_name.len() - 1 {
118        return None;
119    }
120
121    if dot_index == 0 {
122        let nested_dot = file_name[1..].rfind('.')? + 1;
123        if nested_dot == file_name.len() - 1 {
124            return None;
125        }
126
127        return Some((&file_name[..nested_dot], &file_name[nested_dot + 1..]));
128    }
129
130    Some((&file_name[..dot_index], &file_name[dot_index + 1..]))
131}
132
133fn compound_extension(file_name: &str) -> Option<&'static str> {
134    let normalized = file_name.to_ascii_lowercase();
135
136    for candidate in COMPOUND_EXTENSIONS {
137        let suffix = format!(".{candidate}");
138        if normalized.ends_with(&suffix) && normalized.len() > suffix.len() {
139            return Some(candidate);
140        }
141    }
142
143    None
144}
145
146fn extension_suffix(file_name: &str) -> String {
147    if let Some(extension) = compound_extension(file_name) {
148        return format!(".{extension}");
149    }
150
151    split_simple_extension(file_name)
152        .map(|(_, extension)| format!(".{extension}"))
153        .unwrap_or_default()
154}
155
156fn operation_stem(file_name: &str) -> String {
157    file_stem_without_compound_extension(file_name)
158        .or_else(|| file_stem(file_name))
159        .unwrap_or_default()
160}
161
162fn replace_stem(input: &str, update: impl FnOnce(&str) -> String) -> String {
163    let normalized = normalize_path_like(input);
164    let (prefix, file_name) = split_directory_and_file_name(&normalized);
165    let Some(file_name) = file_name else {
166        return normalized;
167    };
168
169    let suffix = extension_suffix(file_name);
170    let next_stem = update(operation_stem(file_name).as_str());
171
172    format!("{prefix}{next_stem}{suffix}")
173}