feat(cli) parse event (#125)

* feat(cli) parse `event`

* check possible `Event` for `mode`

* m.bros
This commit is contained in:
Jens Krause 2025-10-13 11:54:06 +02:00 committed by GitHub
parent 758a72fbf6
commit 56e6ce66fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 199 additions and 18 deletions

View File

@ -90,6 +90,7 @@ Options:
-c, --countdown <COUNTDOWN> Countdown time to start from. Formats: 'Yy Dd hh:mm:ss', 'Dd hh:mm:ss', 'Yy mm:ss', 'Dd mm:ss', 'Yy ss', 'Dd ss', 'hh:mm:ss', 'mm:ss', 'ss'. Examples: '1y 5d 10:30:00', '2d 4:00', '1d 10', '5:03'.
-w, --work <WORK> Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
-p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
-e, --event <EVENT> Event date time and title (optional). Format: 'YYYY-MM-DD HH:MM:SS' or 'time=YYYY-MM-DD HH:MM:SS[,title=...]'. Examples: '2025-10-10 14:30:00' or 'time=2025-10-10 14:30:00,title=My Event'.
-d, --decis Show deciseconds.
-m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro, event, localtime]
-s, --style <STYLE> Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille]

View File

@ -2,6 +2,7 @@ use crate::{
args::Args,
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle},
constants::TICK_VALUE_MS,
event::{Event, get_default_event},
events::{self, TuiEventHandler},
storage::AppStorage,
terminal::Terminal,
@ -29,7 +30,6 @@ use ratatui::{
};
use std::path::PathBuf;
use std::time::Duration;
use time::macros::format_description;
use tracing::{debug, error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -75,6 +75,7 @@ pub struct AppArgs {
pub current_value_countdown: Duration,
pub elapsed_value_countdown: Duration,
pub current_value_timer: Duration,
pub event: Event,
pub app_tx: events::AppEventTx,
pub sound_path: Option<PathBuf>,
pub footer_toggle_app_time: Toggle,
@ -107,6 +108,8 @@ impl From<FromAppArgs> for App {
Content::Pomodoro
} else if args.countdown.is_some() {
Content::Countdown
} else if args.event.is_some() {
Content::Event
}
// in other case just use latest stored state
else {
@ -132,6 +135,7 @@ impl From<FromAppArgs> for App {
None => stg.elapsed_value_countdown,
},
current_value_timer: stg.current_value_timer,
event: args.event.unwrap_or_else(get_default_event),
app_tx,
#[cfg(feature = "sound")]
sound_path: args.sound,
@ -160,6 +164,7 @@ impl App {
with_decis,
pomodoro_mode,
pomodoro_round,
event,
notification,
blink,
sound_path,
@ -212,12 +217,7 @@ impl App {
}),
event: EventState::new(EventStateArgs {
app_time,
event_time: time::PrimitiveDateTime::parse(
"2025-10-10 10:30:30",
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"),
)
.unwrap(),
event_title: "My event".to_owned(),
event,
with_decis,
app_tx: app_tx.clone(),
}),

View File

@ -1,6 +1,7 @@
use crate::{
common::{Content, Style, Toggle},
duration,
event::{Event, parse_event},
};
#[cfg(feature = "sound")]
use crate::{sound, sound::SoundError};
@ -28,6 +29,14 @@ pub struct Args {
)]
pub pause: Option<Duration>,
#[arg(
long,
short = 'e',
value_parser = parse_event,
help = "Event date time and title (optional). Format: 'YYYY-MM-DD HH:MM:SS' or 'time=YYYY-MM-DD HH:MM:SS[,title=...]'. Examples: '2025-10-10 14:30:00' or 'time=2025-10-10 14:30:00,title=My Event'."
)]
pub event: Option<Event>,
#[arg(long, short = 'd', help = "Show deciseconds.")]
pub decis: bool,

170
src/event.rs Normal file
View File

@ -0,0 +1,170 @@
use time::macros::format_description;
#[derive(Debug, Clone)]
pub struct Event {
pub date_time: time::PrimitiveDateTime,
pub title: Option<String>,
}
pub fn get_default_event() -> Event {
Event {
date_time: time::PrimitiveDateTime::parse(
// Mario Bros "...entered mass production in Japan on June 21" 1983
// https://en.wikipedia.org/wiki/Mario_Bros.#Release
"1983-06-21 00:00",
format_description!("[year]-[month]-[day] [hour]:[minute]"),
)
.unwrap(),
title: Some("Release date of Mario Bros in Japan".into()),
}
}
/// Parses an `Event`
/// Supports two formats:
/// (1) "YYYY-MM-DD HH:MM:SS"
/// (2) "time=YYYY-MM-DD HH:MM:SS,title=my event"
pub fn parse_event(s: &str) -> Result<Event, String> {
let s = s.trim();
// check + parse (2)
if s.contains('=') {
parse_event_key_value(s)
} else {
// parse (1)
parse_event_date_time(s)
}
}
/// Parses an `Event` based on "YYYY-MM-DD HH:MM:SS" format
fn parse_event_date_time(s: &str) -> Result<Event, String> {
let time = time::PrimitiveDateTime::parse(
s,
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"),
)
.map_err(|e| {
format!(
"Failed to parse event date_time '{}': {}. Expected format: 'YYYY-MM-DD HH:MM:SS'",
s, e
)
})?;
Ok(Event {
date_time: time,
title: None,
})
}
/// Parses an `Event` defined by a `key=value` pair.
/// Valid keys: `time` and `title`.
/// Format: "time=YYYY-MM-DD HH:MM:SS,title=my event"
fn parse_event_key_value(s: &str) -> Result<Event, String> {
let mut time_str = None;
let mut title_str = None;
// k/v pairs are splitted by commas
for part in s.split(',') {
let part = part.trim();
if let Some((key, value)) = part.split_once('=') {
match key.trim() {
"time" => time_str = Some(value.trim()),
"title" => title_str = Some(value.trim()),
unknown => {
return Err(format!(
"Unknown key '{}'. Valid keys: 'time', 'title'",
unknown
));
}
}
} else {
return Err(format!(
"Invalid key=value pair: '{}'. Expected format: 'key=value'",
part
));
}
}
let time_str = time_str.ok_or(
"Missing required 'time' field. Expected format: 'time=YYYY-MM-DD HH:MM:SS[,title=...]'",
)?;
let time = time::PrimitiveDateTime::parse(
time_str,
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"),
)
.map_err(|e| {
format!(
"Failed to parse event time '{}': {}. Expected format: 'YYYY-MM-DD HH:MM:SS'",
time_str, e
)
})?;
let title = title_str.filter(|t| !t.is_empty()).map(|t| t.to_string());
Ok(Event {
date_time: time,
title,
})
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
#[test]
fn test_parse_event() {
// Simple format: time only
let result = parse_event("2024-01-01 14:30:00").unwrap();
assert_eq!(result.date_time, datetime!(2024-01-01 14:30:00));
assert_eq!(result.title, None);
// Simple format: with leading/trailing whitespace (outer trim works)
let result = parse_event(" 2025-12-25 12:30:00 ").unwrap();
assert_eq!(result.date_time, datetime!(2025-12-25 12:30:00));
assert_eq!(result.title, None);
// Key=value format: time only
let result = parse_event("time=2025-10-10 14:30:00").unwrap();
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
assert_eq!(result.title, None);
// Key=value format: time and title
let result = parse_event("time=2025-10-10 14:30:00,title=Team Meeting").unwrap();
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
assert_eq!(result.title, Some("Team Meeting".to_string()));
// Key=value format: order independent
let result = parse_event("title=Stand-up,time=2025-10-10 09:00:00").unwrap();
assert_eq!(result.date_time, datetime!(2025-10-10 09:00:00));
assert_eq!(result.title, Some("Stand-up".to_string()));
// Key=value format: title with spaces and special chars
let result =
parse_event("time=2025-10-10 14:30:00,title=Sprint Planning: Q1 Review").unwrap();
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
assert_eq!(result.title, Some("Sprint Planning: Q1 Review".to_string()));
// Key=value format: empty title treated as None
let result = parse_event("time=2025-10-10 14:30:00,title=").unwrap();
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
assert_eq!(result.title, None);
// Key=value format: whitespace handling
let result = parse_event(" time = 2025-10-10 14:30:00 , title = My Event ").unwrap();
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
assert_eq!(result.title, Some("My Event".to_string()));
// Error cases: invalid time format
assert!(parse_event("2025-13-01 00:00:00").is_err());
assert!(parse_event("invalid").is_err());
assert!(parse_event("2025/10/10 14:30:00").is_err());
// Error cases: missing time in key=value format
assert!(parse_event("title=My Event").is_err());
// Error cases: unknown key
assert!(parse_event("time=2025-10-10 14:30:00,foo=bar").is_err());
// Error cases: malformed key=value pair
assert!(parse_event("time=2025-10-10 14:30:00,notapair").is_err());
}
}

View File

@ -2,6 +2,7 @@ mod app;
mod common;
mod config;
mod constants;
mod event;
mod events;
mod logging;

View File

@ -9,6 +9,7 @@ use time::{OffsetDateTime, macros::format_description};
use crate::{
common::{AppTime, ClockTypeId, Style},
duration::CalendarDuration,
event::Event,
events::{AppEvent, AppEventTx, TuiEvent, TuiEventHandler},
utils::center,
widgets::{clock, clock_elements::DIGIT_HEIGHT},
@ -17,7 +18,7 @@ use std::{cmp::max, time::Duration};
/// State for `EventWidget`
pub struct EventState {
title: String,
title: Option<String>,
event_time: OffsetDateTime,
app_time: OffsetDateTime,
start_time: OffsetDateTime,
@ -30,8 +31,7 @@ pub struct EventState {
pub struct EventStateArgs {
pub app_time: AppTime,
pub event_time: time::PrimitiveDateTime,
pub event_title: String,
pub event: Event,
pub with_decis: bool,
pub app_tx: AppEventTx,
}
@ -40,18 +40,17 @@ impl EventState {
pub fn new(args: EventStateArgs) -> Self {
let EventStateArgs {
app_time,
event_time,
event_title,
event,
with_decis,
app_tx,
} = args;
let app_datetime = OffsetDateTime::from(app_time);
// assume event has as same `offset` as `app_time`
let event_offset = event_time.assume_offset(app_datetime.offset());
let event_offset = event.date_time.assume_offset(app_datetime.offset());
Self {
title: event_title,
title: event.title,
event_time: event_offset,
app_time: app_datetime,
start_time: app_datetime,
@ -92,9 +91,10 @@ impl EventState {
// reset `done_count`
self.done_count = Some(clock::MAX_DONE_COUNT);
// send notification
_ = self
.app_tx
.send(AppEvent::ClockDone(ClockTypeId::Event, self.title.clone()));
_ = self.app_tx.send(AppEvent::ClockDone(
ClockTypeId::Event,
self.title.clone().unwrap_or("".into()),
));
}
// count (possible) `done`
self.done_count = clock::count_clock_done(self.done_count);
@ -140,7 +140,7 @@ impl StatefulWidget for EventWidget {
let clock_widths = clock::clock_horizontal_lengths(&clock_format, with_decis);
let clock_width = clock_widths.iter().sum();
let label_event = Line::raw(state.title.to_uppercase());
let label_event = Line::raw(state.title.clone().unwrap_or("".into()).to_uppercase());
let time_str = state
.event_time
.format(&format_description!(