//! Change and workspace IDs, and the time words `--at` accepts. use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone}; use rand::Rng; /// Change IDs are letters only (no vowels, no `l`), so they can't be /// confused with version numbers or spell words. pub const LETTERS: &[u8] = b"bcdfghjkmnpqrstvwxyz"; pub const CHANGE_ID_LEN: usize = 12; /// The shortest prefix shown or accepted. pub const MIN_PREFIX: usize = 4; pub fn new_change_id() -> String { let mut rng = rand::rng(); (0..CHANGE_ID_LEN) .map(|_| LETTERS[rng.random_range(0..LETTERS.len())] as char) .collect() } pub fn new_workspace_id() -> String { let mut rng = rand::rng(); (0..16) .map(|_| b"0123456789abcdef"[rng.random_range(0..16)] as char) .collect() } pub fn looks_like_change(text: &str) -> bool { text.len() >= MIN_PREFIX && text.len() <= CHANGE_ID_LEN && text.bytes().all(|b| LETTERS.contains(&b)) } /// The shortest prefix of `id` (at least four letters) that no other ID in /// `others` shares. pub fn short(id: &str, others: &[String]) -> String { for len in MIN_PREFIX..id.len() { let prefix = &id[..len]; if !others.iter().any(|o| o != id && o.starts_with(prefix)) { return prefix.to_string(); } } id.to_string() } pub fn now() -> i64 { chrono::Utc::now().timestamp() } /// "3s ago", "12 min ago", "2h ago", "3 days ago". pub fn ago(at: i64) -> String { let secs = (now() - at).max(0); match secs { 0..=59 => format!("{secs}s ago"), 60..=3599 => format!("{} min ago", secs / 60), 3600..=86_399 => format!("{}h ago", secs / 3600), _ => { let days = secs / 86_400; if days == 1 { "1 day ago".into() } else { format!("{days} days ago") } } } } pub fn clock(at: i64) -> String { match Local.timestamp_opt(at, 0).single() { Some(time) => time.format("%Y-%m-%d %H:%M").to_string(), None => at.to_string(), } } /// Parses `--at` words: "10 min ago", "2h ago", "90s ago", "3 days ago", /// "15:30" (today, or yesterday if that's still ahead), "2026-10-27T15:30", /// "2026-10-27 15:30" or "2026-10-27". Returns seconds since the epoch. pub fn parse_time(text: &str) -> Option { let t = text.trim().to_ascii_lowercase(); if let Some(rest) = t.strip_suffix("ago") { let rest = rest.trim(); let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); let unit = rest[digits.len()..].trim(); let n: i64 = digits.parse().ok()?; let secs = match unit { "s" | "sec" | "secs" | "second" | "seconds" => n, "m" | "min" | "mins" | "minute" | "minutes" => n * 60, "h" | "hr" | "hrs" | "hour" | "hours" => n * 3600, "d" | "day" | "days" => n * 86_400, "w" | "week" | "weeks" => n * 7 * 86_400, _ => return None, }; return Some(now() - secs); } if t == "yesterday" { return Some(now() - 86_400); } if let Ok(time) = NaiveTime::parse_from_str(&t, "%H:%M") { let today = Local::now().date_naive().and_time(time); let mut at = Local.from_local_datetime(&today).earliest()?; if at > Local::now() { at -= Duration::days(1); } return Some(at.timestamp()); } for format in ["%Y-%m-%dt%H:%M:%S", "%Y-%m-%dt%H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"] { if let Ok(dt) = NaiveDateTime::parse_from_str(&t, format) { return Local.from_local_datetime(&dt).earliest().map(|d| d.timestamp()); } } if let Ok(date) = NaiveDate::parse_from_str(&t, "%Y-%m-%d") { let dt = date.and_hms_opt(0, 0, 0)?; return Local.from_local_datetime(&dt).earliest().map(|d| d.timestamp()); } None } /// Parses `land --at` words for a time ahead: "in 30 min", "in 2h", /// "15:30" (today, or tomorrow once that's passed), "tomorrow 9:00", /// "fri 9:00" (the next Friday at 9:00), "2026-11-02 09:00" or "2026-11-02". /// Returns seconds since the epoch. pub fn parse_future(text: &str) -> Option { let t = text.trim().to_ascii_lowercase(); if let Some(rest) = t.strip_prefix("in ") { let rest = rest.trim(); let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); let n: i64 = digits.parse().ok()?; let secs = match rest[digits.len()..].trim() { "s" | "sec" | "secs" | "second" | "seconds" => n, "m" | "min" | "mins" | "minute" | "minutes" => n * 60, "h" | "hr" | "hrs" | "hour" | "hours" => n * 3600, "d" | "day" | "days" => n * 86_400, "w" | "week" | "weeks" => n * 7 * 86_400, _ => return None, }; return Some(now() + secs); } const DAYS: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; let day_of = |word: &str| -> Option { let today = Local::now().date_naive(); match word { "today" => Some(0), "tomorrow" => Some(1), _ => { let want = DAYS.iter().position(|d| word.starts_with(d))? as i64; let now_day = today.weekday().num_days_from_monday() as i64; Some((want - now_day).rem_euclid(7)) } } }; let (ahead, clock) = match t.split_once(' ') { Some((word, rest)) if day_of(word).is_some() => (day_of(word), rest.trim()), _ if day_of(&t).is_some() => (day_of(&t), "00:00"), _ => (None, t.as_str()), }; if let Ok(time) = NaiveTime::parse_from_str(clock, "%H:%M") { let today = Local::now().date_naive(); let mut days = ahead.unwrap_or(0); let at = |days: i64| Local.from_local_datetime(&(today + Duration::days(days)).and_time(time)).earliest(); // A time that's passed means the next one: tomorrow, or next week for a weekday. if at(days)? <= Local::now() { days += if ahead.is_some() && !matches!(t.split(' ').next(), Some("today") | Some("tomorrow")) { 7 } else { 1 }; } return Some(at(days)?.timestamp()); } if ahead.is_some() { return None; } for format in ["%Y-%m-%dt%H:%M:%S", "%Y-%m-%dt%H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"] { if let Ok(dt) = NaiveDateTime::parse_from_str(&t, format) { return Local.from_local_datetime(&dt).earliest().map(|d| d.timestamp()); } } if let Ok(date) = NaiveDate::parse_from_str(&t, "%Y-%m-%d") { return Local.from_local_datetime(&date.and_hms_opt(0, 0, 0)?).earliest().map(|d| d.timestamp()); } None } #[cfg(test)] mod tests { use super::*; #[test] fn future_times() { let n = now(); assert!((parse_future("in 30 min").unwrap() - (n + 1800)).abs() < 3); assert!((parse_future("in 2h").unwrap() - (n + 7200)).abs() < 3); for words in ["15:30", "tomorrow 9:00", "fri 9:00", "friday 09:00", "tomorrow"] { let at = parse_future(words).unwrap(); assert!(at > n && at <= n + 8 * 86_400, "{words}"); } assert!(parse_future("2030-01-02 09:00").is_some()); assert!(parse_future("soon").is_none()); assert!(parse_future("fri lunch").is_none()); } #[test] fn ids_are_letters() { let id = new_change_id(); assert_eq!(id.len(), CHANGE_ID_LEN); assert!(looks_like_change(&id)); assert!(!looks_like_change("48213")); assert!(!looks_like_change("main")); } #[test] fn short_prefixes() { let ids = vec!["bcdfghjk".to_string(), "bcdfzzzz".to_string(), "xyzxyzxy".to_string()]; assert_eq!(short(&ids[0], &ids), "bcdfg"); assert_eq!(short(&ids[2], &ids), "xyzx"); } #[test] fn times() { let n = now(); assert!((parse_time("10 min ago").unwrap() - (n - 600)).abs() < 3); assert!((parse_time("2h ago").unwrap() - (n - 7200)).abs() < 3); assert!((parse_time("90s ago").unwrap() - (n - 90)).abs() < 3); assert!(parse_time("15:30").is_some()); assert!(parse_time("2026-10-27T15:30").is_some()); assert!(parse_time("soon").is_none()); } }