//! Change and workspace IDs, and the time words `--at` accepts. use chrono::{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 } #[cfg(test)] mod tests { use super::*; #[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()); } }