180 lines
5.6 KiB
Rust
180 lines
5.6 KiB
Rust
|
|
//! Pure parsing logic for timer bot-commands and time display formatting.
|
||
|
|
//!
|
||
|
|
//! No side effects: no filesystem access, no clock reads.
|
||
|
|
|
||
|
|
use chrono::{DateTime, Local, Utc};
|
||
|
|
use chrono_tz::Tz;
|
||
|
|
|
||
|
|
use crate::chat::util::strip_bot_mention;
|
||
|
|
|
||
|
|
// ── Command types ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// A parsed `timer` command.
|
||
|
|
#[derive(Debug, PartialEq)]
|
||
|
|
pub enum TimerCommand {
|
||
|
|
/// `timer <story_id_or_number> <HH:MM>` — schedule a deferred start.
|
||
|
|
Schedule {
|
||
|
|
story_number_or_id: String,
|
||
|
|
hhmm: String,
|
||
|
|
},
|
||
|
|
/// `timer list` — list all pending timers.
|
||
|
|
List,
|
||
|
|
/// `timer cancel <story_id_or_number>` — remove a pending timer.
|
||
|
|
Cancel { story_number_or_id: String },
|
||
|
|
/// Malformed arguments.
|
||
|
|
BadArgs,
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Command extraction ─────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Parse a `timer` command from a raw message body.
|
||
|
|
///
|
||
|
|
/// Strips the bot mention prefix and matches the `timer` keyword.
|
||
|
|
/// Returns `None` when the message is not a timer command at all.
|
||
|
|
pub fn extract_timer_command(
|
||
|
|
message: &str,
|
||
|
|
bot_name: &str,
|
||
|
|
bot_user_id: &str,
|
||
|
|
) -> Option<TimerCommand> {
|
||
|
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||
|
|
let trimmed = stripped
|
||
|
|
.trim()
|
||
|
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||
|
|
|
||
|
|
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
|
||
|
|
Some((c, a)) => (c, a.trim()),
|
||
|
|
None => (trimmed, ""),
|
||
|
|
};
|
||
|
|
|
||
|
|
if !cmd.eq_ignore_ascii_case("timer") {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
|
||
|
|
// `timer` with no args or `timer list`
|
||
|
|
if args.is_empty() || args.eq_ignore_ascii_case("list") {
|
||
|
|
return Some(TimerCommand::List);
|
||
|
|
}
|
||
|
|
|
||
|
|
let (sub, rest) = match args.split_once(char::is_whitespace) {
|
||
|
|
Some((s, r)) => (s, r.trim()),
|
||
|
|
None => (args, ""),
|
||
|
|
};
|
||
|
|
|
||
|
|
// `timer cancel <id>`
|
||
|
|
if sub.eq_ignore_ascii_case("cancel") {
|
||
|
|
if rest.is_empty() {
|
||
|
|
return Some(TimerCommand::BadArgs);
|
||
|
|
}
|
||
|
|
return Some(TimerCommand::Cancel {
|
||
|
|
story_number_or_id: rest.to_string(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// `timer <id> <HH:MM>`
|
||
|
|
if rest.is_empty() {
|
||
|
|
return Some(TimerCommand::BadArgs);
|
||
|
|
}
|
||
|
|
|
||
|
|
Some(TimerCommand::Schedule {
|
||
|
|
story_number_or_id: sub.to_string(),
|
||
|
|
hhmm: rest.to_string(),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Display helpers ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Format a UTC instant for display in the given timezone (or local time when
|
||
|
|
/// `timezone` is `None`). Returns `(formatted_string, label)` where `label`
|
||
|
|
/// is either the IANA timezone name or `"server local time"`.
|
||
|
|
pub(super) fn format_in_timezone(dt: DateTime<Utc>, timezone: Option<&str>) -> (String, String) {
|
||
|
|
match timezone.and_then(|s| s.parse::<Tz>().ok()) {
|
||
|
|
Some(tz) => {
|
||
|
|
let tz_time = dt.with_timezone(&tz);
|
||
|
|
(tz_time.format("%Y-%m-%d %H:%M").to_string(), tz.to_string())
|
||
|
|
}
|
||
|
|
None => {
|
||
|
|
let local_time = dt.with_timezone(&Local);
|
||
|
|
(
|
||
|
|
local_time.format("%Y-%m-%d %H:%M").to_string(),
|
||
|
|
"server local time".to_string(),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn non_timer_command_returns_none() {
|
||
|
|
assert!(extract_timer_command("Timmy help", "Timmy", "@bot:home").is_none());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn timer_list_no_args() {
|
||
|
|
assert_eq!(
|
||
|
|
extract_timer_command("Timmy timer", "Timmy", "@bot:home"),
|
||
|
|
Some(TimerCommand::List)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn timer_list_explicit() {
|
||
|
|
assert_eq!(
|
||
|
|
extract_timer_command("Timmy timer list", "Timmy", "@bot:home"),
|
||
|
|
Some(TimerCommand::List)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn timer_cancel_story_id() {
|
||
|
|
assert_eq!(
|
||
|
|
extract_timer_command("Timmy timer cancel 421_story_foo", "Timmy", "@bot:home"),
|
||
|
|
Some(TimerCommand::Cancel {
|
||
|
|
story_number_or_id: "421_story_foo".to_string()
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn timer_cancel_no_arg_is_bad_args() {
|
||
|
|
assert_eq!(
|
||
|
|
extract_timer_command("Timmy timer cancel", "Timmy", "@bot:home"),
|
||
|
|
Some(TimerCommand::BadArgs)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn timer_schedule_with_story_id() {
|
||
|
|
assert_eq!(
|
||
|
|
extract_timer_command("Timmy timer 421_story_foo 14:30", "Timmy", "@bot:home"),
|
||
|
|
Some(TimerCommand::Schedule {
|
||
|
|
story_number_or_id: "421_story_foo".to_string(),
|
||
|
|
hhmm: "14:30".to_string(),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn timer_schedule_with_number() {
|
||
|
|
assert_eq!(
|
||
|
|
extract_timer_command("Timmy timer 421 14:30", "Timmy", "@bot:home"),
|
||
|
|
Some(TimerCommand::Schedule {
|
||
|
|
story_number_or_id: "421".to_string(),
|
||
|
|
hhmm: "14:30".to_string(),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn timer_schedule_missing_time_is_bad_args() {
|
||
|
|
assert_eq!(
|
||
|
|
extract_timer_command("Timmy timer 421_story_foo", "Timmy", "@bot:home"),
|
||
|
|
Some(TimerCommand::BadArgs)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|