use std::error::Error;
use aimcal_core::{DateTimeAnchor, LooseDateTime};
use jiff::Zoned;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum OutputFormat {
Json,
Table,
}
pub fn parse_datetime(now: &Zoned, anchor: &str) -> Result<Option<LooseDateTime>, Box<dyn Error>> {
if anchor.is_empty() {
Ok(None)
} else {
let anchor: DateTimeAnchor = anchor.parse()?;
Ok(Some(anchor.resolve_since_zoned(now).map_err(|e| {
format!("Failed to resolve since zoned: {e}")
})?))
}
}
pub fn parse_datetime_range(
now: &Zoned,
start: &str,
end: &str,
) -> Result<(Option<LooseDateTime>, Option<LooseDateTime>), Box<dyn Error>> {
let start = parse_datetime(now, start)?;
if end.is_empty() {
Ok((start, None))
} else {
let anchor: DateTimeAnchor = end.parse()?;
let end = match start {
Some(ref s) => anchor
.resolve_since(s)
.map_err(|e| format!("Failed to resolve since: {e}"))?,
None => anchor
.resolve_since_zoned(now)
.map_err(|e| format!("Failed to resolve since zoned: {e}"))?,
};
Ok((start, Some(end)))
}
}
pub fn format_datetime(t: LooseDateTime) -> String {
match t {
LooseDateTime::DateOnly(d) => d.strftime("%Y-%m-%d"),
LooseDateTime::Floating(dt) => dt.strftime("%Y-%m-%d %H:%M"),
LooseDateTime::Local(dt) => dt.strftime("%Y-%m-%d %H:%M"),
}
.to_string()
}
pub fn unicode_width_of_slice(s: &str, first_n_chars: usize) -> usize {
if first_n_chars == 0 || s.is_empty() {
0
} else if let Some((idx, ch)) = s.char_indices().nth(first_n_chars - 1) {
let byte_idx = idx + ch.len_utf8();
s[..byte_idx].width()
} else {
s.width()
}
}
pub fn byte_range_of_grapheme_at(s: &str, g_idx: usize) -> Option<std::ops::Range<usize>> {
for (i, (byte_start, g)) in s.grapheme_indices(true).enumerate() {
if i == g_idx {
let byte_end = byte_start + g.len();
return Some(byte_start..byte_end);
}
}
None
}
#[cfg(test)]
mod tests {
use jiff::Zoned;
use jiff::civil::{DateTime, date, datetime, time};
use jiff::tz::TimeZone;
use super::*;
#[test]
fn calculates_width_for_ascii_only() {
let s = "hello world";
assert_eq!(unicode_width_of_slice(s, 100), 11);
assert_eq!(unicode_width_of_slice(s, 5), 5);
assert_eq!(unicode_width_of_slice(s, 0), 0);
}
#[test]
fn calculates_width_for_mixed_english_chinese() {
let s = "abcδΈζdef";
assert_eq!(unicode_width_of_slice(s, 4), "abcδΈ".width());
assert_eq!(unicode_width_of_slice(s, 8), s.width());
assert_eq!(unicode_width_of_slice(s, 9), s.width());
}
#[test]
fn calculates_width_for_emoji() {
let s = "aπb";
assert_eq!(unicode_width_of_slice(s, 2), "aπ".width());
}
#[test]
fn calculates_width_for_out_of_bounds_char_index() {
let s = "hi";
assert_eq!(unicode_width_of_slice(s, 10), s.width());
}
#[test]
fn calculates_width_for_empty_string() {
let s = "";
assert_eq!(unicode_width_of_slice(s, 0), 0);
}
#[test]
fn calculates_width_for_full_width_characters() {
let s = "οΌ‘οΌ’οΌ£"; assert_eq!(unicode_width_of_slice(s, 2), "οΌ‘οΌ’".width());
}
fn default_datetime() -> Zoned {
datetime(2025, 1, 1, 12, 0, 0, 0)
.to_zoned(TimeZone::system())
.unwrap()
}
#[test]
fn parses_empty_datetime() {
let now = default_datetime();
assert_eq!(parse_datetime(&now, "").unwrap(), None);
}
#[test]
fn parses_datetime_date_only() {
let now = default_datetime();
let result = parse_datetime(&now, "2023-12-25").unwrap().unwrap();
match result {
LooseDateTime::DateOnly(dt) => assert_eq!(dt, date(2023, 12, 25)),
_ => panic!("Expected DateOnly variant"),
}
}
#[test]
fn parses_datetime_date_and_time() {
let now = default_datetime();
let result = parse_datetime(&now, "2023-12-25 14:30").unwrap().unwrap();
match result {
LooseDateTime::Local(dt) => {
assert_eq!(dt.date(), date(2023, 12, 25));
assert_eq!(dt.time(), time(14, 30, 0, 0));
}
_ => panic!("Expected Local variant"),
}
}
#[test]
fn parses_datetime_time_only() {
let now = default_datetime();
let result = parse_datetime(&now, "20:30").unwrap().unwrap();
match result {
LooseDateTime::Local(dt) => {
assert_eq!(dt.date(), now.date());
assert_eq!(dt.time(), time(20, 30, 0, 0));
}
_ => panic!("Expected Local variant"),
}
}
#[test]
fn returns_error_for_invalid_datetime() {
let now = default_datetime();
assert!(parse_datetime(&now, "invalid").is_err());
assert!(parse_datetime(&now, "25:00").is_err());
assert!(parse_datetime(&now, "2023-13-01").is_err());
}
#[test]
fn parses_datetime_range_with_both_empty() {
let now = default_datetime();
let (start, end) = parse_datetime_range(&now, "", "").unwrap();
assert_eq!(start, None);
assert_eq!(end, None);
}
#[test]
fn parses_datetime_range_with_start_only() {
let now = default_datetime();
let (start, end) = parse_datetime_range(&now, "2023-12-25", "").unwrap();
assert!(start.is_some());
assert_eq!(end, None);
}
#[test]
fn parses_datetime_range_with_both_dates() {
let now = default_datetime();
let (start, end) = parse_datetime_range(&now, "2023-12-25", "2023-12-26").unwrap();
assert!(start.is_some());
assert!(end.is_some());
}
#[test]
fn parses_datetime_range_with_date_and_time() {
let now = default_datetime();
let (start, end) = parse_datetime_range(&now, "2023-12-25", "14:30").unwrap();
assert!(start.is_some());
assert!(end.is_some());
match start.unwrap() {
LooseDateTime::DateOnly(d) => assert_eq!(d, date(2023, 12, 25)),
_ => panic!("Expected DateOnly variant for start"),
}
match end.unwrap() {
LooseDateTime::Local(dt) => {
assert_eq!(dt.date(), date(2023, 12, 25));
assert_eq!(dt.time(), time(14, 30, 0, 0));
}
_ => panic!("Expected Floating variant for end"),
}
}
#[test]
fn parses_datetime_range_with_datetime_and_time() {
let now = default_datetime();
let (start, end) = parse_datetime_range(&now, "2023-12-25 14:00", "14:30").unwrap();
assert!(start.is_some());
assert!(end.is_some());
match start.unwrap() {
LooseDateTime::Local(dt) => {
assert_eq!(dt.date(), date(2023, 12, 25));
assert_eq!(dt.time(), time(14, 0, 0, 0));
}
_ => panic!("Expected Local variant for start"),
}
match end.unwrap() {
LooseDateTime::Local(dt) => {
assert_eq!(dt.date(), date(2023, 12, 25));
assert_eq!(dt.time(), time(14, 30, 0, 0));
}
_ => panic!("Expected Local variant for end"),
}
}
#[test]
fn parses_datetime_range_with_datetime_and_earlier_time() {
let now = default_datetime();
let (start, end) = parse_datetime_range(&now, "2023-12-25 14:00", "13:30").unwrap();
assert!(start.is_some());
assert!(end.is_some());
match start.unwrap() {
LooseDateTime::Local(dt) => {
assert_eq!(dt.date(), date(2023, 12, 25));
assert_eq!(dt.time(), time(14, 0, 0, 0));
}
_ => panic!("Expected Local variant for start"),
}
match end.unwrap() {
LooseDateTime::Local(dt) => {
assert_eq!(dt.date(), date(2023, 12, 26));
assert_eq!(dt.time(), time(13, 30, 0, 0));
}
_ => panic!("Expected Local variant for end"),
}
}
#[test]
fn formats_datetime_date_only() {
let date = date(2023, 12, 25);
let formatted = format_datetime(LooseDateTime::DateOnly(date));
assert_eq!(formatted, "2023-12-25");
}
#[test]
fn formats_datetime_floating() {
let date = date(2023, 12, 25);
let time = time(14, 30, 0, 0);
let dt = DateTime::from_parts(date, time);
let formatted = format_datetime(LooseDateTime::Floating(dt));
assert_eq!(formatted, "2023-12-25 14:30");
}
#[test]
fn formats_datetime_local() {
let dt = datetime(2023, 12, 25, 14, 30, 0, 0)
.to_zoned(TimeZone::system())
.unwrap();
let formatted = format_datetime(LooseDateTime::Local(dt));
assert_eq!(formatted, "2023-12-25 14:30");
}
#[test]
fn finds_byte_range_for_ascii_basic() {
let s = "hello";
assert_eq!(byte_range_of_grapheme_at(s, 0), Some(0..1)); assert_eq!(byte_range_of_grapheme_at(s, 4), Some(4..5)); assert_eq!(byte_range_of_grapheme_at(s, 5), None); }
#[test]
fn finds_byte_range_for_chinese_multibyte() {
let s = "aδΈb";
assert_eq!(byte_range_of_grapheme_at(s, 0), Some(0..1)); assert_eq!(byte_range_of_grapheme_at(s, 1), Some(1..4)); assert_eq!(byte_range_of_grapheme_at(s, 2), Some(4..5)); assert_eq!(byte_range_of_grapheme_at(s, 3), None); }
#[test]
fn finds_byte_range_for_emoji_with_skin_tone() {
let s = "ππ»a";
assert_eq!(byte_range_of_grapheme_at(s, 0), Some(0..8));
assert_eq!(byte_range_of_grapheme_at(s, 1), Some(8..9)); }
#[test]
fn finds_byte_range_for_emoji_family_zwj() {
let s = "π¨βπ©βπ§"; let len = s.len();
assert_eq!(byte_range_of_grapheme_at(s, 0), Some(0..len));
assert_eq!(byte_range_of_grapheme_at(s, 1), None); }
#[test]
fn finds_byte_range_for_combining_mark() {
let s = "e\u{0301}b";
assert_eq!(byte_range_of_grapheme_at(s, 0), Some(0..3));
assert_eq!(byte_range_of_grapheme_at(s, 1), Some(3..4)); assert_eq!(byte_range_of_grapheme_at(s, 2), None); }
#[test]
fn finds_byte_range_for_empty_string() {
let s = "";
assert_eq!(byte_range_of_grapheme_at(s, 0), None);
assert_eq!(byte_range_of_grapheme_at(s, 1), None);
}
}