1use anyhow::Result;
2
3pub 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
42pub 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
62pub 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
80pub 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
90pub 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
100pub 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
119pub 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
130pub 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}