Skip to main content

dev_sweep/
util.rs

1use anyhow::Result;
2
3/// Parse an age string like "30d", "3m", "1y" into a chrono TimeDelta.
4///
5/// Supported units:
6/// - `d` — days
7/// - `w` — weeks
8/// - `m` — months (30 days)
9/// - `y` — years (365 days)
10pub fn parse_age(s: &str) -> Result<chrono::TimeDelta> {
11    let s = s.trim().to_lowercase();
12    let (num_str, unit) = if s.ends_with('d') {
13        (&s[..s.len() - 1], 'd')
14    } else if s.ends_with('m') {
15        (&s[..s.len() - 1], 'm')
16    } else if s.ends_with('y') {
17        (&s[..s.len() - 1], 'y')
18    } else if s.ends_with('w') {
19        (&s[..s.len() - 1], 'w')
20    } else {
21        anyhow::bail!(
22            "Invalid age format '{}'. Use e.g. '30d' (days), '4w' (weeks), '3m' (months), '1y' (years)",
23            s
24        );
25    };
26
27    let num: i64 = num_str
28        .parse()
29        .map_err(|_| anyhow::anyhow!("Invalid number in age string: '{}'", num_str))?;
30
31    let days = match unit {
32        'd' => num,
33        'w' => num * 7,
34        'm' => num * 30,
35        'y' => num * 365,
36        _ => unreachable!(),
37    };
38
39    chrono::TimeDelta::try_days(days).ok_or_else(|| anyhow::anyhow!("Duration too large"))
40}
41
42/// Format a byte count into a human-readable string (e.g. "1.5 GB").
43pub fn format_bytes(bytes: u64) -> String {
44    const KB: u64 = 1024;
45    const MB: u64 = 1024 * KB;
46    const GB: u64 = 1024 * MB;
47    const TB: u64 = 1024 * GB;
48
49    if bytes >= TB {
50        format!("{:.1} TB", bytes as f64 / TB as f64)
51    } else if bytes >= GB {
52        format!("{:.1} GB", bytes as f64 / GB as f64)
53    } else if bytes >= MB {
54        format!("{:.1} MB", bytes as f64 / MB as f64)
55    } else if bytes >= KB {
56        format!("{:.1} KB", bytes as f64 / KB as f64)
57    } else {
58        format!("{bytes} B")
59    }
60}
61
62/// Visible length of a string (strips ANSI escape sequences).
63pub fn visible_len(s: &str) -> usize {
64    let mut len = 0;
65    let mut in_escape = false;
66    for ch in s.chars() {
67        if in_escape {
68            if ch == 'm' {
69                in_escape = false;
70            }
71        } else if ch == '\x1b' {
72            in_escape = true;
73        } else {
74            len += 1;
75        }
76    }
77    len
78}
79
80/// Pad a string to a given visible width (right-padded).
81pub fn pad_right(s: &str, width: usize) -> String {
82    let vis = visible_len(s);
83    if vis >= width {
84        s.to_string()
85    } else {
86        format!("{s}{}", " ".repeat(width - vis))
87    }
88}
89
90/// Pad a string to a given visible width (left-padded).
91pub fn pad_left(s: &str, width: usize) -> String {
92    let vis = visible_len(s);
93    if vis >= width {
94        s.to_string()
95    } else {
96        format!("{}{s}", " ".repeat(width - vis))
97    }
98}
99
100/// Format a duration into a human-readable age string.
101pub fn format_age(duration: chrono::TimeDelta) -> String {
102    let days = duration.num_days();
103    if days > 365 {
104        format!("{:.1}y ago", days as f64 / 365.0)
105    } else if days > 30 {
106        format!("{}mo ago", days / 30)
107    } else if days > 0 {
108        format!("{}d ago", days)
109    } else {
110        let hours = duration.num_hours();
111        if hours > 0 {
112            format!("{}h ago", hours)
113        } else {
114            "just now".to_string()
115        }
116    }
117}
118
119/// Truncate a string to a max visible width, appending "…" if truncated.
120pub fn truncate(s: &str, max_width: usize) -> String {
121    if s.len() <= max_width {
122        s.to_string()
123    } else if max_width > 1 {
124        format!("{}…", &s[..max_width - 1])
125    } else {
126        "…".to_string()
127    }
128}
129
130/// Shorten a path by replacing the home directory with ~.
131pub fn shorten_path(path: &str) -> String {
132    if let Some(home) = dirs::home_dir() {
133        let home_str = home.display().to_string();
134        if path.starts_with(&home_str) {
135            return path.replacen(&home_str, "~", 1);
136        }
137    }
138    path.to_string()
139}