Skip to main content

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}