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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct FileStem {
17 pub value: String,
18}
19
20#[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#[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#[must_use]
45pub fn has_file_stem(input: &str) -> bool {
46 file_stem(input).is_some()
47}
48
49#[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#[must_use]
60pub fn append_to_file_stem(input: &str, suffix: &str) -> String {
61 replace_stem(input, |current| format!("{current}{suffix}"))
62}
63
64#[must_use]
66pub fn prepend_to_file_stem(input: &str, prefix: &str) -> String {
67 replace_stem(input, |current| format!("{prefix}{current}"))
68}
69
70#[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}