trailcache_core/utils/
format.rs1use std::cmp::Ordering;
2
3use chrono::{NaiveDate, Utc};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ExpirationStatus {
12 Active,
13 ExpiringSoon,
14 Expired,
15}
16
17impl ExpirationStatus {
18 pub fn format_expiry(&self, formatted_date: &str) -> String {
20 match self {
21 ExpirationStatus::Expired => format!("Expired {}", formatted_date),
22 _ => format!("Expires {}", formatted_date),
23 }
24 }
25
26 pub fn format_ypt(&self, formatted_date: &str) -> String {
28 match self {
29 ExpirationStatus::Expired => format!("Expired {}", formatted_date),
30 ExpirationStatus::ExpiringSoon => format!("Expires {}", formatted_date),
31 ExpirationStatus::Active => format!("Current ({})", formatted_date),
32 }
33 }
34
35 pub fn style_class(&self) -> &'static str {
37 match self {
38 ExpirationStatus::Expired => "expired",
39 ExpirationStatus::ExpiringSoon => "expiring",
40 ExpirationStatus::Active => "active",
41 }
42 }
43
44 pub fn membership_style_class(&self) -> &'static str {
46 match self {
47 ExpirationStatus::Active => "current",
48 other => other.style_class(),
49 }
50 }
51}
52
53pub const EXPIRING_SOON_DAYS: i64 = 90;
55
56pub fn check_expiration(date_str: &str) -> Option<(ExpirationStatus, String)> {
62 let date_part = &date_str[..10.min(date_str.len())];
64 let date = NaiveDate::parse_from_str(date_part, "%Y-%m-%d").ok()?;
65 let today = Utc::now().date_naive();
66 let formatted = date.format("%b %d, %Y").to_string();
67
68 let status = if date < today {
69 ExpirationStatus::Expired
70 } else if date <= today + chrono::Duration::days(EXPIRING_SOON_DAYS) {
71 ExpirationStatus::ExpiringSoon
72 } else {
73 ExpirationStatus::Active
74 };
75
76 Some((status, formatted))
77}
78
79pub fn contains_ignore_case(haystack: &str, needle_lowercase: &str) -> bool {
86 if needle_lowercase.is_empty() {
87 return true;
88 }
89 haystack
90 .char_indices()
91 .any(|(i, _)| {
92 haystack[i..]
93 .chars()
94 .zip(needle_lowercase.chars())
95 .all(|(h, n)| h.to_ascii_lowercase() == n)
96 && haystack[i..].chars().count() >= needle_lowercase.chars().count()
97 })
98}
99
100pub fn cmp_ignore_case(a: &str, b: &str) -> Ordering {
102 a.chars()
103 .map(|c| c.to_ascii_lowercase())
104 .cmp(b.chars().map(|c| c.to_ascii_lowercase()))
105}
106
107pub fn format_phone(phone: &str) -> String {
114 let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect();
116
117 match digits.len() {
118 10 => format!(
119 "({}) {}-{}",
120 &digits[0..3],
121 &digits[3..6],
122 &digits[6..10]
123 ),
124 11 if digits.starts_with('1') => format!(
125 "({}) {}-{}",
126 &digits[1..4],
127 &digits[4..7],
128 &digits[7..11]
129 ),
130 _ => phone.to_string(), }
132}
133
134pub fn strip_html(s: &str) -> String {
137 let mut result = String::new();
138 let mut in_tag = false;
139 for c in s.chars() {
140 match c {
141 '<' => in_tag = true,
142 '>' => in_tag = false,
143 _ if !in_tag => result.push(c),
144 _ => {}
145 }
146 }
147 result.split_whitespace().collect::<Vec<_>>().join(" ")
149}
150
151pub fn truncate(s: &str, max_len: usize) -> String {
154 let cleaned: String = s.replace('\t', " ").trim().to_string();
156 if cleaned.len() <= max_len {
157 cleaned
158 } else if max_len <= 1 {
159 cleaned.chars().take(max_len).collect()
160 } else {
161 format!("{}…", &cleaned[..max_len.saturating_sub(1)])
162 }
163}
164
165pub fn wrap_text(text: &str, width: usize) -> Vec<String> {
167 if width == 0 {
168 return vec![text.to_string()];
169 }
170 let mut result = Vec::new();
171 let mut current_line = String::new();
172
173 for word in text.split_whitespace() {
174 if current_line.is_empty() {
175 if word.len() > width {
176 let mut remaining = word;
177 while remaining.len() > width {
178 result.push(remaining[..width].to_string());
179 remaining = &remaining[width..];
180 }
181 current_line = remaining.to_string();
182 } else {
183 current_line = word.to_string();
184 }
185 } else if current_line.len() + 1 + word.len() > width {
186 result.push(current_line);
187 if word.len() > width {
188 let mut remaining = word;
189 while remaining.len() > width {
190 result.push(remaining[..width].to_string());
191 remaining = &remaining[width..];
192 }
193 current_line = remaining.to_string();
194 } else {
195 current_line = word.to_string();
196 }
197 } else {
198 current_line.push(' ');
199 current_line.push_str(word);
200 }
201 }
202 if !current_line.is_empty() {
203 result.push(current_line);
204 }
205 if result.is_empty() {
206 result.push(String::new());
207 }
208 result
209}
210
211pub fn strip_url_scheme(url: &str) -> &str {
213 url.strip_prefix("https://")
214 .or_else(|| url.strip_prefix("http://"))
215 .unwrap_or(url)
216}
217
218#[allow(dead_code)]
220pub fn format_optional(value: &Option<String>, default: &str) -> String {
221 value.as_deref().unwrap_or(default).to_string()
222}
223
224#[allow(dead_code)]
226pub fn format_date(date: &str) -> String {
227 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(date) {
229 dt.format("%b %d, %Y").to_string()
230 } else if date.len() >= 10 {
231 date.chars().take(10).collect()
233 } else {
234 date.to_string()
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn test_format_phone() {
244 assert_eq!(format_phone("5551234567"), "(555) 123-4567");
245 assert_eq!(format_phone("15551234567"), "(555) 123-4567");
246 assert_eq!(format_phone("555-123-4567"), "(555) 123-4567");
247 assert_eq!(format_phone("(555) 123-4567"), "(555) 123-4567");
248 assert_eq!(format_phone("123"), "123"); }
250
251 #[test]
252 fn test_check_expiration_expired() {
253 let past = "2020-01-01";
254 let (status, formatted) = check_expiration(past).unwrap();
255 assert_eq!(status, ExpirationStatus::Expired);
256 assert_eq!(formatted, "Jan 01, 2020");
257 }
258
259 #[test]
260 fn test_check_expiration_active() {
261 let future = "2099-12-31";
263 let (status, _) = check_expiration(future).unwrap();
264 assert_eq!(status, ExpirationStatus::Active);
265 }
266
267 #[test]
268 fn test_check_expiration_invalid() {
269 assert!(check_expiration("not-a-date").is_none());
270 assert!(check_expiration("").is_none());
271 }
272
273 #[test]
274 fn test_check_expiration_with_timestamp_suffix() {
275 let (status, _) = check_expiration("2020-01-01T00:00:00Z").unwrap();
276 assert_eq!(status, ExpirationStatus::Expired);
277 }
278
279 #[test]
280 fn test_truncate() {
281 assert_eq!(truncate("Hello", 10), "Hello");
282 assert_eq!(truncate("Hello World", 8), "Hello W…");
283 assert_eq!(truncate("Hi", 2), "Hi");
284 assert_eq!(truncate("Hello\tWorld", 20), "Hello World");
286 assert_eq!(truncate(" Hello ", 10), "Hello");
288 }
289}