atuin_common/string/
mod.rs1use std::borrow::Cow;
4use std::fmt::{self, Write as _};
5
6#[cfg(feature = "unicode")]
7use unicode_width::UnicodeWidthStr;
8use url::{Position, Url};
9
10#[cfg(feature = "unicode")]
11pub mod align;
12pub mod bounded_buffer;
13#[cfg(feature = "unicode")]
14pub mod ellipsis;
15pub mod highlighted;
16pub mod trim;
17
18mod escape_non_printable_posix_ext;
19mod non_blank;
20mod non_nul_str;
21
22#[allow(clippy::manual_range_contains, clippy::must_use_candidate, reason = "vendored file")]
23mod normalize;
24
25#[cfg(feature = "unicode")]
26pub use align::{AlignExt, Alignment};
27pub use bounded_buffer::BoundedBuffer;
28#[cfg(feature = "unicode")]
29pub use ellipsis::EllipsizeExt;
30pub use escape_non_printable_posix_ext::EscapeNonPrintablePosixExt;
31pub use non_blank::{Blank, NonBlank, NonBlankString};
32pub use non_nul_str::{ContainsNul, NonNulStr};
33pub use normalize::normalize;
34pub use trim::TrimExt;
35
36pub trait TruncateCharsExt: AsRef<str> {
37 fn truncate_chars(&self, max_chars: usize) -> &str {
38 let s = self.as_ref();
39 if s.len() <= max_chars {
40 return s;
41 }
42
43 match s.char_indices().nth(max_chars) {
44 Some((end, _)) => &s[..end],
45 None => s,
46 }
47 }
48}
49
50impl<T: AsRef<str> + ?Sized> TruncateCharsExt for T {}
51
52pub trait NormalizeDiacriticsExt: AsRef<str> {
54 fn normalize_diacritics(&self) -> Cow<'_, str> {
56 let s = self.as_ref();
57 if s.is_ascii() || !s.chars().any(|c| normalize(c) != c) {
58 return Cow::Borrowed(s);
59 }
60 Cow::Owned(s.chars().map(normalize).collect())
61 }
62}
63
64impl<T: AsRef<str> + ?Sized> NormalizeDiacriticsExt for T {}
65
66pub trait NonEmptyOrExt: Sized {
68 #[must_use]
70 fn nonempty_or(self, value: Self) -> Self {
71 self.nonempty_or_else(|| value)
72 }
73
74 #[must_use]
76 fn nonempty_or_else(self, default: impl FnOnce() -> Self) -> Self;
77}
78
79impl NonEmptyOrExt for String {
80 fn nonempty_or_else(self, default: impl FnOnce() -> Self) -> Self {
81 if self.is_empty() {
82 default()
83 } else {
84 self
85 }
86 }
87}
88
89impl NonEmptyOrExt for &str {
90 fn nonempty_or_else(self, default: impl FnOnce() -> Self) -> Self {
91 if self.is_empty() {
92 default()
93 } else {
94 self
95 }
96 }
97}
98
99impl<T> NonEmptyOrExt for &[T] {
100 fn nonempty_or_else(self, default: impl FnOnce() -> Self) -> Self {
101 if self.is_empty() {
102 default()
103 } else {
104 self
105 }
106 }
107}
108
109impl<T> NonEmptyOrExt for Vec<T> {
110 fn nonempty_or_else(self, default: impl FnOnce() -> Self) -> Self {
111 if self.is_empty() {
112 default()
113 } else {
114 self
115 }
116 }
117}
118
119pub trait FormatSafeUrlExt {
122 fn format_safe(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result;
124}
125
126impl FormatSafeUrlExt for Url {
127 fn format_safe(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 if self.password().is_none() {
129 return fmt::Debug::fmt(self.as_str(), f);
130 }
131
132 f.write_char('"')?;
133 for c in self[..Position::BeforePassword].escape_debug() {
134 f.write_char(c)?;
135 }
136 f.write_str("****")?;
137 for c in self[Position::AfterPassword..].escape_debug() {
138 f.write_char(c)?;
139 }
140 f.write_char('"')
141 }
142}
143
144#[cfg(feature = "unicode")]
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum Measure {
148 Bytes(usize),
150 Columns(usize),
153}
154
155#[cfg(feature = "unicode")]
156impl Measure {
157 pub(crate) fn amount(self) -> usize {
159 match self {
160 Self::Bytes(n) | Self::Columns(n) => n,
161 }
162 }
163
164 pub(crate) fn cost(self, s: &str) -> usize {
166 match self {
167 Self::Bytes(_) => s.len(),
168 Self::Columns(_) => s.width(),
169 }
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use rstest::rstest;
176 use url::Url;
177
178 use super::{FormatSafeUrlExt, NonEmptyOrExt, NormalizeDiacriticsExt, TruncateCharsExt};
179
180 #[rstest]
181 #[case::empty("", "")]
182 #[case::ascii_unchanged("hello world", "hello world")]
183 #[case::accented("café", "cafe")]
184 #[case::keeps_unmappable("naïve Æ", "naive Æ")] #[case::position_preserved("élève", "eleve")]
186 fn normalizes_diacritics(#[case] input: &str, #[case] expected: &str) {
187 assert_eq!(input.normalize_diacritics(), expected);
188 }
189
190 #[rstest]
191 #[case::empty("")]
192 #[case::plain_ascii("just ascii text")]
193 #[case::unmappable("日本語")]
194 fn normalize_diacritics_borrows_when_unchanged(#[case] input: &str) {
195 assert!(matches!(input.normalize_diacritics(), std::borrow::Cow::Borrowed(_)));
196 }
197
198 #[rstest]
199 #[case::under_budget("hello", 10, "hello")]
200 #[case::exact_budget("hello", 5, "hello")]
201 #[case::over_budget("hello", 3, "hel")] #[case::zero("hello", 0, "")]
203 #[case::empty("", 5, "")]
204 #[case::multibyte_cut("café", 3, "caf")] #[case::multibyte_kept("café", 4, "café")]
206 fn truncates_by_char_count(#[case] input: &str, #[case] max: usize, #[case] expected: &str) {
207 let out = input.truncate_chars(max);
208 assert_eq!(out, expected);
209 assert!(out.chars().count() <= max);
210 }
211
212 #[rstest]
213 #[case::empty("", "fallback")]
214 #[case::non_empty("value", "value")]
215 fn nonempty_or_falls_back_only_when_empty(#[case] input: &str, #[case] expected: &str) {
216 assert_eq!(input.to_string().nonempty_or_else(|| "fallback".into()), expected);
217 }
218
219 #[rstest]
220 fn truncate_chars_returns_the_original_slice_when_it_fits() {
221 let s = "borrow me";
222 assert!(std::ptr::eq(s.truncate_chars(100), s));
223 }
224
225 struct Safe<'a>(&'a Url);
226
227 impl std::fmt::Debug for Safe<'_> {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 self.0.format_safe(f)
230 }
231 }
232
233 fn safe(url: &str) -> String {
234 format!("{:?}", Safe(&Url::parse(url).unwrap()))
235 }
236
237 #[rstest]
238 #[case::redacts_password(
239 "postgres://user:hunter2@localhost/db",
240 r#""postgres://user:****@localhost/db""#
241 )]
242 #[case::passwordless_unchanged(
243 "postgres://user@localhost/db",
244 r#""postgres://user@localhost/db""#
245 )]
246 #[case::empty_password_dropped("mysql://user:@localhost/db", r#""mysql://user@localhost/db""#)]
247 fn format_safe_redacts(#[case] url: &str, #[case] expected: &str) {
248 assert_eq!(safe(url), expected);
249 }
250}