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//! ## 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#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn truncate_leaves_short_strings_unchanged() {
161        assert_eq!(truncate_to_width("hello", 10), "hello");
162        assert_eq!(truncate_to_width("task name", 20), "task name");
163    }
164
165    #[test]
166    fn truncate_ascii_adds_ellipsis_within_budget() {
167        let truncated = truncate_to_width("abcdefghij", 7);
168        assert_eq!(truncated, "abcdef…");
169        assert_eq!(truncated.width(), 7);
170    }
171
172    #[test]
173    fn truncate_fullwidth_respects_display_width() {
174        // Fullwidth letters have display width 2 each
175        let truncated = truncate_to_width("ABCDEF", 7);
176        assert_eq!(truncated, "ABC…");
177        assert_eq!(truncated.width(), 7);
178    }
179
180    #[test]
181    fn truncate_zero_width_returns_empty() {
182        assert_eq!(truncate_to_width("hello", 0), "");
183    }
184}