Skip to main content

trailcache_core/utils/
format.rs

1use std::cmp::Ordering;
2
3use chrono::{NaiveDate, Utc};
4
5// ============================================================================
6// Expiration Status
7// ============================================================================
8
9/// Classification of a date-based expiration relative to today.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ExpirationStatus {
12    Active,
13    ExpiringSoon,
14    Expired,
15}
16
17impl ExpirationStatus {
18    /// Format as display text, e.g. "Expired Mar 15, 2026" or "Expires Mar 15, 2026".
19    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    /// Format for YPT status (uses "Current" instead of "Expires" for active).
27    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    /// CSS-style class name for this status.
36    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    /// For membership: "current" instead of "active" style class.
45    pub fn membership_style_class(&self) -> &'static str {
46        match self {
47            ExpirationStatus::Active => "current",
48            other => other.style_class(),
49        }
50    }
51}
52
53/// Number of days before expiration to flag as "expiring soon".
54pub const EXPIRING_SOON_DAYS: i64 = 90;
55
56/// Parse a YYYY-MM-DD date string and classify it as expired / expiring soon / active.
57///
58/// Returns `(status, formatted_date)` where formatted_date is e.g. "Mar 15, 2026".
59/// The "expiring soon" threshold is [`EXPIRING_SOON_DAYS`] days (inclusive).
60/// Returns `None` if the date string cannot be parsed.
61pub fn check_expiration(date_str: &str) -> Option<(ExpirationStatus, String)> {
62    // Take first 10 chars to handle timestamps or extra suffixes
63    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
79// ============================================================================
80// String Comparison Utilities
81// ============================================================================
82
83/// Case-insensitive substring check without allocation.
84/// Assumes `needle` is already lowercase.
85pub 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
100/// Case-insensitive string comparison for sorting (no allocation).
101pub 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
107// ============================================================================
108// Phone Number Formatting
109// ============================================================================
110
111/// Format a phone number for display
112/// Handles various input formats and normalizes to (XXX) XXX-XXXX
113pub fn format_phone(phone: &str) -> String {
114    // Extract just the digits
115    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(), // Return original if can't format
131    }
132}
133
134/// Strip HTML tags from a string.
135/// Useful for cleaning up requirement text from the API.
136pub 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    // Normalize whitespace
148    result.split_whitespace().collect::<Vec<_>>().join(" ")
149}
150
151/// Truncate a string to a maximum length, adding ellipsis if needed.
152/// Handles tabs by replacing with spaces and trims whitespace.
153pub fn truncate(s: &str, max_len: usize) -> String {
154    // Replace tabs with spaces and trim to avoid display width issues
155    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
165/// Word-wrap text into lines of at most `width` characters, breaking at word boundaries.
166pub 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
211/// Strip the URL scheme (http:// or https://) from a URL for display.
212pub 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/// Format an optional string, returning a default if None
219#[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/// Format a date string to a more readable format
225#[allow(dead_code)]
226pub fn format_date(date: &str) -> String {
227    // Try to parse ISO format and convert to readable
228    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        // Try to parse YYYY-MM-DD format
232        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"); // Too short, return as-is
249    }
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        // Far future date
262        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        // Test tab handling
285        assert_eq!(truncate("Hello\tWorld", 20), "Hello World");
286        // Test trimming
287        assert_eq!(truncate("  Hello  ", 10), "Hello");
288    }
289}