use chrono::Duration;
use serde::{Deserialize, Serialize};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormattedEvent {
pub id: i32,
pub start: String,
pub end: String,
pub duration: String,
}
pub fn format_duration(duration: &Duration) -> String {
let hours = duration.num_hours();
let mins = duration.num_minutes() % 60;
format!("{:02}:{:02}", hours.max(0), mins.max(0))
}
pub fn terminal_cols() -> usize {
terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.filter(|&cols| cols > 0)
.unwrap_or(100)
}
pub fn truncate_to_width(s: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if s.width() <= max_width {
return s.to_string();
}
const ELLIPSIS: &str = "…";
let ellipsis_width = ELLIPSIS.width();
if max_width <= ellipsis_width {
return ELLIPSIS.chars().take(max_width).collect();
}
let target = max_width - ellipsis_width;
let mut used = 0;
let mut end = 0;
for (idx, ch) in s.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > target {
break;
}
used += ch_width;
end = idx + ch.len_utf8();
}
format!("{}{}", &s[..end], ELLIPSIS)
}
pub fn parse_date(date_str: &str) -> anyhow::Result<chrono::NaiveDate> {
if date_str.to_lowercase() == "today" {
Ok(chrono::Local::now().date_naive())
} else {
Ok(chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")?)
}
}
pub fn wrap_to_width(s: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
let mut lines: Vec<String> = Vec::new();
let mut line = String::new();
let mut line_width = 0;
for word in s.split_whitespace() {
let word_width = word.width();
if word_width > max_width {
if !line.is_empty() {
lines.push(std::mem::take(&mut line));
}
let mut chunk = String::new();
let mut chunk_width = 0;
for c in word.chars() {
let c_width = c.width().unwrap_or(0);
if chunk_width + c_width > max_width {
lines.push(std::mem::take(&mut chunk));
chunk_width = 0;
}
chunk.push(c);
chunk_width += c_width;
}
line = chunk;
line_width = chunk_width;
continue;
}
let needed = if line.is_empty() { word_width } else { line_width + 1 + word_width };
if needed > max_width {
lines.push(std::mem::take(&mut line));
line_width = 0;
}
if !line.is_empty() {
line.push(' ');
line_width += 1;
}
line.push_str(word);
line_width += word_width;
}
if !line.is_empty() {
lines.push(line);
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_leaves_short_strings_unchanged() {
assert_eq!(truncate_to_width("hello", 10), "hello");
assert_eq!(truncate_to_width("task name", 20), "task name");
}
#[test]
fn truncate_ascii_adds_ellipsis_within_budget() {
let truncated = truncate_to_width("abcdefghij", 7);
assert_eq!(truncated, "abcdef…");
assert_eq!(truncated.width(), 7);
}
#[test]
fn truncate_fullwidth_respects_display_width() {
let truncated = truncate_to_width("ABCDEF", 7);
assert_eq!(truncated, "ABC…");
assert_eq!(truncated.width(), 7);
}
#[test]
fn truncate_zero_width_returns_empty() {
assert_eq!(truncate_to_width("hello", 0), "");
}
#[test]
fn wrap_breaks_between_words_and_keeps_every_one() {
let wrapped = wrap_to_width("the list is ordered by it, highest first", 12);
for line in wrapped.lines() {
assert!(line.width() <= 12, "line over budget: {line:?}");
}
assert_eq!(
wrapped.split_whitespace().collect::<Vec<_>>(),
"the list is ordered by it, highest first".split_whitespace().collect::<Vec<_>>()
);
}
#[test]
fn wrap_breaks_a_word_too_long_to_fit() {
let wrapped = wrap_to_width("supercalifragilistic", 6);
for line in wrapped.lines() {
assert!(line.width() <= 6, "line over budget: {line:?}");
}
assert_eq!(wrapped.replace('\n', ""), "supercalifragilistic");
}
#[test]
fn wrap_respects_display_width() {
let wrapped = wrap_to_width("ABC DEF", 6);
assert_eq!(wrapped, "ABC\nDEF");
for line in wrapped.lines() {
assert!(line.width() <= 6);
}
}
#[test]
fn wrap_zero_width_returns_empty() {
assert_eq!(wrap_to_width("hello", 0), "");
}
}