···4646 datetime_until: &Option<String>,
4747) -> Result<DateRange> {
4848 let start = if let Some(datetime_str) = datetime_from {
4949- parse_datetime_to_timestamp(datetime_str)?
4949+ parse_datetime_to_timestamp(datetime_str, true)?
5050 } else {
5151 Local::now()
5252 };
53535454 let end = if let Some(datetime_str) = datetime_until {
5555- parse_datetime_to_timestamp(datetime_str)?
5555+ parse_datetime_to_timestamp(datetime_str, false)?
5656 } else {
5757 start + chrono::Duration::days(60)
5858 };
···6161}
62626363/// Parses a datetime string (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS) to a DateTime
6464-fn parse_datetime_to_timestamp(datetime_str: &str) -> Result<chrono::DateTime<Local>> {
6464+/// If `is_start` is true and only date is provided, uses 00:00:00 of that day
6565+/// If `is_start` is false and only date is provided, uses 00:00:00 of the next day
6666+/// (to be used with strict < comparison)
6767+fn parse_datetime_to_timestamp(
6868+ datetime_str: &str,
6969+ is_start: bool,
7070+) -> Result<chrono::DateTime<Local>> {
6571 // Try parsing as full datetime first
6672 if let Ok(datetime) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") {
6773 return datetime.and_local_timezone(Local).single().ok_or_else(|| {
···7278 });
7379 }
74807575- // Fall back to date only (with time 00:00:00)
8181+ // Fall back to date only
7682 let date = NaiveDate::parse_from_str(datetime_str, "%Y-%m-%d")?;
7777- let datetime =
8383+ let datetime = if is_start {
8484+ // For start datetime, use 00:00:00 of that day
7885 date.and_hms_opt(0, 0, 0)
7979- .ok_or_else(|| alarma_core::AlarmaError::InvalidDateTime {
8080- input: datetime_str.to_string(),
8181- expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS".to_string()),
8282- })?;
8686+ } else {
8787+ // For end datetime, use 00:00:00 of the next day
8888+ // (to be used with strict < comparison, catching all events on the given day)
8989+ let next_day =
9090+ date.succ_opt()
9191+ .ok_or_else(|| alarma_core::AlarmaError::InvalidDateTime {
9292+ input: datetime_str.to_string(),
9393+ expected_format: Some("Date overflow".to_string()),
9494+ })?;
9595+ next_day.and_hms_opt(0, 0, 0)
9696+ }
9797+ .ok_or_else(|| alarma_core::AlarmaError::InvalidDateTime {
9898+ input: datetime_str.to_string(),
9999+ expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS".to_string()),
100100+ })?;
8310184102 datetime.and_local_timezone(Local).single().ok_or_else(|| {
85103 alarma_core::AlarmaError::InvalidDateTime {
···92110#[cfg(test)]
93111mod tests {
94112 use super::*;
113113+ use chrono::{Datelike, Timelike};
9511496115 #[test]
97116 fn test_parse_date() {
9898- let result = parse_datetime_to_timestamp("2026-01-15");
117117+ let result = parse_datetime_to_timestamp("2026-01-15", true);
99118 assert!(result.is_ok());
100119 }
101120102121 #[test]
103122 fn test_parse_datetime() {
104104- let result = parse_datetime_to_timestamp("2026-01-15T14:30:00");
123123+ let result = parse_datetime_to_timestamp("2026-01-15T14:30:00", true);
105124 assert!(result.is_ok());
106125 }
107126108127 #[test]
109109- fn test_parse_invalid_date() {
110110- let result = parse_datetime_to_timestamp("invalid-date");
128128+ fn test_parse_invalid_datetime() {
129129+ let result = parse_datetime_to_timestamp("invalid-date", true);
111130 assert!(result.is_err());
131131+ }
132132+133133+ #[test]
134134+ fn test_parse_date_start_time() {
135135+ let result = parse_datetime_to_timestamp("2026-01-15", true).unwrap();
136136+ assert_eq!(result.time().hour(), 0);
137137+ assert_eq!(result.time().minute(), 0);
138138+ assert_eq!(result.time().second(), 0);
139139+ }
140140+141141+ #[test]
142142+ fn test_parse_date_end_time() {
143143+ let result = parse_datetime_to_timestamp("2026-01-15", false).unwrap();
144144+ // Should be 00:00:00 of the next day (2026-01-16)
145145+ assert_eq!(result.date_naive().day(), 16);
146146+ assert_eq!(result.time().hour(), 0);
147147+ assert_eq!(result.time().minute(), 0);
148148+ assert_eq!(result.time().second(), 0);
149149+ }
150150+151151+ #[test]
152152+ fn test_date_end_time_boundary_condition() {
153153+ // For --datetime-until with date-only input, we use next day at 00:00:00
154154+ let end = parse_datetime_to_timestamp("2026-01-15", false).unwrap();
155155+156156+ // Create a theoretical event at 23:59:59.999... on 2026-01-15
157157+ let late_event = chrono::NaiveDate::from_ymd_opt(2026, 1, 15)
158158+ .unwrap()
159159+ .and_hms_opt(23, 59, 59)
160160+ .unwrap()
161161+ .and_local_timezone(chrono::Local)
162162+ .single()
163163+ .unwrap();
164164+165165+ // With strict < comparison, this event should be included
166166+ assert!(late_event.timestamp() < end.timestamp());
167167+168168+ // And an event exactly at midnight of next day should be excluded
169169+ let next_day_event = chrono::NaiveDate::from_ymd_opt(2026, 1, 16)
170170+ .unwrap()
171171+ .and_hms_opt(0, 0, 0)
172172+ .unwrap()
173173+ .and_local_timezone(chrono::Local)
174174+ .single()
175175+ .unwrap();
176176+177177+ assert!(next_day_event.timestamp() >= end.timestamp());
112178 }
113179}
+4-2
crates/alarma-cli/tests/output_tests.rs
···5050 "source-1".to_string(),
5151 )];
52525353- let result = print_events(&events, OutputFormat::Table);
5353+ let source_names = std::collections::HashMap::new();
5454+ let result = print_events(&events, &source_names, OutputFormat::Table);
5455 assert!(result.is_ok());
5556}
5657···6465 "source-1".to_string(),
6566 )];
66676767- let result = print_events(&events, OutputFormat::Json);
6868+ let source_names = std::collections::HashMap::new();
6969+ let result = print_events(&events, &source_names, OutputFormat::Json);
6870 assert!(result.is_ok());
6971}
7072