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//! ## Features
7//!
8//! - **Consistent Formatting**: All time durations use the same "HH:MM" format
9//! - **Safety**: Handles negative durations gracefully by treating them as zero
10//! - **Performance**: Lightweight formatting with minimal allocations
11//! - **Integration**: Works seamlessly with `chrono::Duration` types
12//!
13//! ## Usage
14//!
15//! ```rust
16//! use kasl::libs::formatter::{format_duration, FormattedEvent};
17//! use chrono::Duration;
18//!
19//! let duration = Duration::hours(2) + Duration::minutes(30);
20//! let formatted = format_duration(&duration);
21//! assert_eq!(formatted, "02:30");
22//! ```
23
24use chrono::Duration;
25use serde::{Deserialize, Serialize};
26use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
27
28/// Represents a formatted time-based event for display purposes.
29///
30/// This structure holds string representations of event properties, making it
31/// suitable for direct use with table-rendering libraries and data export
32/// systems. All time values are pre-formatted for consistent display.
33///
34/// ## Design Rationale
35///
36/// Rather than storing raw time values and formatting them at display time,
37/// this structure pre-formats all values to strings. This approach provides:
38///
39/// - **Performance**: No repeated formatting calculations
40/// - **Consistency**: All instances use identical formatting
41/// - **Simplicity**: Direct use in templates and display systems
42/// - **Serialization**: Easy JSON/CSV export without custom formatters
43///
44/// ## Usage Context
45///
46/// This structure is primarily used for:
47/// - Console table display of work intervals
48/// - CSV export of time-based data
49/// - JSON serialization for API responses
50/// - Report generation and data visualization
51///
52/// ## Field Descriptions
53///
54/// - `id`: Sequential number for ordering and reference
55/// - `start`: Formatted start time (typically "HH:MM")
56/// - `end`: Formatted end time (typically "HH:MM")
57/// - `duration`: Formatted duration (typically "HH:MM")
58///
59/// ## Examples
60///
61/// ```rust
62/// use kasl::libs::formatter::FormattedEvent;
63///
64/// // Work interval representation
65/// let interval = FormattedEvent {
66/// id: 1,
67/// start: "09:00".to_string(),
68/// end: "12:00".to_string(),
69/// duration: "03:00".to_string(),
70/// };
71///
72/// // Pause representation
73/// let pause = FormattedEvent {
74/// id: 2,
75/// start: "12:00".to_string(),
76/// end: "12:30".to_string(),
77/// duration: "00:30".to_string(),
78/// };
79/// ```
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct FormattedEvent {
82 /// The sequential identifier of the event.
83 ///
84 /// Used for ordering events chronologically and providing reference
85 /// numbers in display tables. Typically starts from 1 and increments
86 /// for each event in a sequence.
87 pub id: i32,
88
89 /// The formatted start time (e.g., "09:00", "14:30").
90 ///
91 /// Represents when the event began, typically formatted as "HH:MM"
92 /// in 24-hour format. For work intervals, this is when work started.
93 /// For pauses, this is when the break began.
94 pub start: String,
95
96 /// The formatted end time (e.g., "17:00", "15:15").
97 ///
98 /// Represents when the event ended, typically formatted as "HH:MM"
99 /// in 24-hour format. May be "-" or empty if the event is ongoing
100 /// or has no defined end time.
101 pub end: String,
102
103 /// The formatted duration (e.g., "08:00", "00:45").
104 ///
105 /// Represents the total length of the event, formatted as "HH:MM".
106 /// This is calculated from the difference between start and end times.
107 /// May be "--:--" if the duration cannot be determined.
108 pub duration: String,
109}
110
111/// Formats a chrono::Duration into a standardized "HH:MM" string.
112///
113/// This function converts a time duration into a human-readable format
114/// suitable for display in reports, tables, and user interfaces. It ensures
115/// consistent formatting across the entire application.
116///
117/// ## Formatting Rules
118///
119/// - **Hours**: Always displayed with at least 2 digits (zero-padded)
120/// - **Minutes**: Always displayed with exactly 2 digits (zero-padded)
121/// - **Seconds**: Not displayed (rounded to nearest minute)
122/// - **Negative**: Treated as zero duration ("00:00")
123/// - **Overflow**: Large durations handled gracefully
124///
125/// ## Algorithm
126///
127/// 1. Extract total hours from the duration
128/// 2. Extract remaining minutes (after removing full hours)
129/// 3. Clamp negative values to zero
130/// 4. Format with zero-padding
131///
132/// # Arguments
133///
134/// * `duration` - A reference to the chrono::Duration to format
135///
136/// # Returns
137///
138/// A String in "HH:MM" format representing the duration.
139///
140/// # Examples
141///
142/// ```rust
143/// use kasl::libs::formatter::format_duration;
144/// use chrono::Duration;
145///
146/// // Standard durations
147/// assert_eq!(format_duration(&Duration::hours(8)), "08:00");
148/// assert_eq!(format_duration(&Duration::minutes(90)), "01:30");
149/// assert_eq!(format_duration(&Duration::minutes(45)), "00:45");
150///
151/// // Edge cases
152/// assert_eq!(format_duration(&Duration::zero()), "00:00");
153/// assert_eq!(format_duration(&Duration::hours(-1)), "00:00");
154/// assert_eq!(format_duration(&Duration::hours(24)), "24:00");
155/// ```
156///
157/// ## Performance Notes
158///
159/// This function is designed for frequent use and has minimal overhead:
160/// - Single allocation for the result string
161/// - Simple arithmetic operations only
162/// - No complex parsing or validation
163///
164/// ## Thread Safety
165///
166/// This function is pure and thread-safe. It can be called concurrently
167/// from multiple threads without synchronization.
168pub fn format_duration(duration: &Duration) -> String {
169 // Extract hours and minutes from the duration
170 let hours = duration.num_hours();
171 let mins = duration.num_minutes() % 60;
172
173 // Ensure we don't display negative durations by clamping to zero
174 // This handles edge cases where calculations might result in negative values
175 format!("{:02}:{:02}", hours.max(0), mins.max(0))
176}
177
178/// Returns the current terminal width in columns, or `100` when unknown.
179pub fn terminal_cols() -> usize {
180 terminal_size::terminal_size()
181 .map(|(w, _)| w.0 as usize)
182 .filter(|&cols| cols > 0)
183 .unwrap_or(100)
184}
185
186/// Truncates `s` to at most `max_width` display columns, appending `…` when cut.
187///
188/// Uses Unicode display width so Cyrillic and ASCII share the same budget.
189pub fn truncate_to_width(s: &str, max_width: usize) -> String {
190 if max_width == 0 {
191 return String::new();
192 }
193
194 if s.width() <= max_width {
195 return s.to_string();
196 }
197
198 const ELLIPSIS: &str = "…";
199 let ellipsis_width = ELLIPSIS.width();
200 if max_width <= ellipsis_width {
201 return ELLIPSIS.chars().take(max_width).collect();
202 }
203
204 let target = max_width - ellipsis_width;
205 let mut used = 0;
206 let mut end = 0;
207 for (idx, ch) in s.char_indices() {
208 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
209 if used + ch_width > target {
210 break;
211 }
212 used += ch_width;
213 end = idx + ch.len_utf8();
214 }
215
216 format!("{}{}", &s[..end], ELLIPSIS)
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn truncate_leaves_short_strings_unchanged() {
225 assert_eq!(truncate_to_width("hello", 10), "hello");
226 assert_eq!(truncate_to_width("task name", 20), "task name");
227 }
228
229 #[test]
230 fn truncate_ascii_adds_ellipsis_within_budget() {
231 let truncated = truncate_to_width("abcdefghij", 7);
232 assert_eq!(truncated, "abcdef…");
233 assert_eq!(truncated.width(), 7);
234 }
235
236 #[test]
237 fn truncate_fullwidth_respects_display_width() {
238 // Fullwidth letters have display width 2 each
239 let truncated = truncate_to_width("ABCDEF", 7);
240 assert_eq!(truncated, "ABC…");
241 assert_eq!(truncated.width(), 7);
242 }
243
244 #[test]
245 fn truncate_zero_width_returns_empty() {
246 assert_eq!(truncate_to_width("hello", 0), "");
247 }
248}