kasl/libs/formatter.rs
1//! Time duration formatting utilities for user-friendly display.
2//!
3//! Provides formatting functions and types for converting time durations into
4//! human-readable string representations used throughout the application.
5//!
6//! ## Usage
7//!
8//! ```rust
9//! use kasl::libs::formatter::{format_duration, FormattedEvent};
10//! use chrono::Duration;
11//!
12//! let duration = Duration::hours(2) + Duration::minutes(30);
13//! let formatted = format_duration(&duration);
14//! assert_eq!(formatted, "02:30");
15//! ```
16
17use chrono::Duration;
18use serde::{Deserialize, Serialize};
19use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
20
21/// Represents a formatted time-based event for display purposes.
22///
23/// ## Examples
24///
25/// ```rust
26/// use kasl::libs::formatter::FormattedEvent;
27///
28/// // Work interval representation
29/// let interval = FormattedEvent {
30/// id: 1,
31/// start: "09:00".to_string(),
32/// end: "12:00".to_string(),
33/// duration: "03:00".to_string(),
34/// };
35///
36/// // Pause representation
37/// let pause = FormattedEvent {
38/// id: 2,
39/// start: "12:00".to_string(),
40/// end: "12:30".to_string(),
41/// duration: "00:30".to_string(),
42/// };
43/// ```
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct FormattedEvent {
46 /// The sequential identifier of the event.
47 ///
48 /// Used for ordering events chronologically and providing reference
49 /// numbers in display tables. Typically starts from 1 and increments
50 /// for each event in a sequence.
51 pub id: i32,
52
53 /// The formatted start time (e.g., "09:00", "14:30").
54 ///
55 /// Represents when the event began, typically formatted as "HH:MM"
56 /// in 24-hour format. For work intervals, this is when work started.
57 /// For pauses, this is when the break began.
58 pub start: String,
59
60 /// The formatted end time (e.g., "17:00", "15:15").
61 ///
62 /// Represents when the event ended, typically formatted as "HH:MM"
63 /// in 24-hour format. May be "-" or empty if the event is ongoing
64 /// or has no defined end time.
65 pub end: String,
66
67 /// The formatted duration (e.g., "08:00", "00:45").
68 ///
69 /// Represents the total length of the event, formatted as "HH:MM".
70 /// This is calculated from the difference between start and end times.
71 /// May be "--:--" if the duration cannot be determined.
72 pub duration: String,
73}
74
75/// Formats a chrono::Duration into a standardized "HH:MM" string.
76///
77/// # Examples
78///
79/// ```rust
80/// use kasl::libs::formatter::format_duration;
81/// use chrono::Duration;
82///
83/// // Standard durations
84/// assert_eq!(format_duration(&Duration::hours(8)), "08:00");
85/// assert_eq!(format_duration(&Duration::minutes(90)), "01:30");
86/// assert_eq!(format_duration(&Duration::minutes(45)), "00:45");
87///
88/// // Edge cases
89/// assert_eq!(format_duration(&Duration::zero()), "00:00");
90/// assert_eq!(format_duration(&Duration::hours(-1)), "00:00");
91/// assert_eq!(format_duration(&Duration::hours(24)), "24:00");
92/// ```
93pub fn format_duration(duration: &Duration) -> String {
94 // Extract hours and minutes from the duration
95 let hours = duration.num_hours();
96 let mins = duration.num_minutes() % 60;
97
98 // Ensure we don't display negative durations by clamping to zero
99 // This handles edge cases where calculations might result in negative values
100 format!("{:02}:{:02}", hours.max(0), mins.max(0))
101}
102
103/// Returns the current terminal width in columns, or `100` when unknown.
104pub fn terminal_cols() -> usize {
105 terminal_size::terminal_size()
106 .map(|(w, _)| w.0 as usize)
107 .filter(|&cols| cols > 0)
108 .unwrap_or(100)
109}
110
111/// Truncates `s` to at most `max_width` display columns, appending `…` when cut.
112///
113/// Uses Unicode display width so Cyrillic and ASCII share the same budget.
114pub fn truncate_to_width(s: &str, max_width: usize) -> String {
115 if max_width == 0 {
116 return String::new();
117 }
118
119 if s.width() <= max_width {
120 return s.to_string();
121 }
122
123 const ELLIPSIS: &str = "…";
124 let ellipsis_width = ELLIPSIS.width();
125 if max_width <= ellipsis_width {
126 return ELLIPSIS.chars().take(max_width).collect();
127 }
128
129 let target = max_width - ellipsis_width;
130 let mut used = 0;
131 let mut end = 0;
132 for (idx, ch) in s.char_indices() {
133 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
134 if used + ch_width > target {
135 break;
136 }
137 used += ch_width;
138 end = idx + ch.len_utf8();
139 }
140
141 format!("{}{}", &s[..end], ELLIPSIS)
142}
143
144/// Parses `YYYY-MM-DD` or the (case-insensitive) keyword `today` into a date.
145///
146/// The shared parser behind every command's `--date` argument.
147pub fn parse_date(date_str: &str) -> anyhow::Result<chrono::NaiveDate> {
148 if date_str.to_lowercase() == "today" {
149 Ok(chrono::Local::now().date_naive())
150 } else {
151 Ok(chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")?)
152 }
153}
154
155/// Wraps text to a display width, breaking between words.
156///
157/// The counterpart to [`truncate_to_width`], and the choice between them is
158/// about what the column is for. A list of many rows is scanned, so a long
159/// summary is cut and the eye moves on; a sentence the user asked to read is
160/// ruined by being cut, so it wraps instead.
161///
162/// Width is display width, not bytes, so CJK and fullwidth text wrap where
163/// they look like they should. A single word longer than the budget is broken
164/// rather than allowed to overflow the column.
165pub fn wrap_to_width(s: &str, max_width: usize) -> String {
166 if max_width == 0 {
167 return String::new();
168 }
169
170 let mut lines: Vec<String> = Vec::new();
171 let mut line = String::new();
172 let mut line_width = 0;
173
174 for word in s.split_whitespace() {
175 let word_width = word.width();
176
177 // A word that cannot fit on any line is broken across lines; leaving
178 // it whole would push the column past the terminal.
179 if word_width > max_width {
180 if !line.is_empty() {
181 lines.push(std::mem::take(&mut line));
182 }
183 let mut chunk = String::new();
184 let mut chunk_width = 0;
185 for c in word.chars() {
186 let c_width = c.width().unwrap_or(0);
187 if chunk_width + c_width > max_width {
188 lines.push(std::mem::take(&mut chunk));
189 chunk_width = 0;
190 }
191 chunk.push(c);
192 chunk_width += c_width;
193 }
194 line = chunk;
195 line_width = chunk_width;
196 continue;
197 }
198
199 let needed = if line.is_empty() { word_width } else { line_width + 1 + word_width };
200 if needed > max_width {
201 lines.push(std::mem::take(&mut line));
202 line_width = 0;
203 }
204 if !line.is_empty() {
205 line.push(' ');
206 line_width += 1;
207 }
208 line.push_str(word);
209 line_width += word_width;
210 }
211
212 if !line.is_empty() {
213 lines.push(line);
214 }
215 lines.join("\n")
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn truncate_leaves_short_strings_unchanged() {
224 assert_eq!(truncate_to_width("hello", 10), "hello");
225 assert_eq!(truncate_to_width("task name", 20), "task name");
226 }
227
228 #[test]
229 fn truncate_ascii_adds_ellipsis_within_budget() {
230 let truncated = truncate_to_width("abcdefghij", 7);
231 assert_eq!(truncated, "abcdef…");
232 assert_eq!(truncated.width(), 7);
233 }
234
235 #[test]
236 fn truncate_fullwidth_respects_display_width() {
237 // Fullwidth letters have display width 2 each
238 let truncated = truncate_to_width("ABCDEF", 7);
239 assert_eq!(truncated, "ABC…");
240 assert_eq!(truncated.width(), 7);
241 }
242
243 #[test]
244 fn truncate_zero_width_returns_empty() {
245 assert_eq!(truncate_to_width("hello", 0), "");
246 }
247
248 #[test]
249 fn wrap_breaks_between_words_and_keeps_every_one() {
250 let wrapped = wrap_to_width("the list is ordered by it, highest first", 12);
251 for line in wrapped.lines() {
252 assert!(line.width() <= 12, "line over budget: {line:?}");
253 }
254 // Wrapping is not truncation: nothing may be dropped.
255 assert_eq!(
256 wrapped.split_whitespace().collect::<Vec<_>>(),
257 "the list is ordered by it, highest first".split_whitespace().collect::<Vec<_>>()
258 );
259 }
260
261 #[test]
262 fn wrap_breaks_a_word_too_long_to_fit() {
263 // Left whole it would overflow the column and break the table.
264 let wrapped = wrap_to_width("supercalifragilistic", 6);
265 for line in wrapped.lines() {
266 assert!(line.width() <= 6, "line over budget: {line:?}");
267 }
268 assert_eq!(wrapped.replace('\n', ""), "supercalifragilistic");
269 }
270
271 #[test]
272 fn wrap_respects_display_width() {
273 // Fullwidth letters are two columns each, so three fit in six.
274 let wrapped = wrap_to_width("ABC DEF", 6);
275 assert_eq!(wrapped, "ABC\nDEF");
276 for line in wrapped.lines() {
277 assert!(line.width() <= 6);
278 }
279 }
280
281 #[test]
282 fn wrap_zero_width_returns_empty() {
283 assert_eq!(wrap_to_width("hello", 0), "");
284 }
285}