Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 4 | use rand::Rng; |
| 5 |
| 6 | /// Change IDs are letters only (no vowels, no `l`), so they can't be |
| 7 | /// confused with version numbers or spell words. |
| 8 | pub const LETTERS: &[u8] = b"bcdfghjkmnpqrstvwxyz"; |
| 9 | pub const CHANGE_ID_LEN: usize = 12; |
| 10 | /// The shortest prefix shown or accepted. |
| 11 | pub const MIN_PREFIX: usize = 4; |
| 12 |
| 13 | pub fn new_change_id() -> String { |
| 14 | let mut rng = rand::rng(); |
| 15 | (0..CHANGE_ID_LEN) |
| 16 | .map(|_| LETTERS[rng.random_range(0..LETTERS.len())] as char) |
| 17 | .collect() |
| 18 | } |
| 19 |
| 20 | pub fn new_workspace_id() -> String { |
| 21 | let mut rng = rand::rng(); |
| 22 | (0..16) |
| 23 | .map(|_| b"0123456789abcdef"[rng.random_range(0..16)] as char) |
| 24 | .collect() |
| 25 | } |
| 26 |
| 27 | pub fn looks_like_change(text: &str) -> bool { |
| 28 | text.len() >= MIN_PREFIX && text.len() <= CHANGE_ID_LEN && text.bytes().all(|b| LETTERS.contains(&b)) |
| 29 | } |
| 30 |
| 31 | /// The shortest prefix of `id` (at least four letters) that no other ID in |
| 32 | /// `others` shares. |
| 33 | pub fn short(id: &str, others: &[String]) -> String { |
| 34 | for len in MIN_PREFIX..id.len() { |
| 35 | let prefix = &id[..len]; |
| 36 | if !others.iter().any(|o| o != id && o.starts_with(prefix)) { |
| 37 | return prefix.to_string(); |
| 38 | } |
| 39 | } |
| 40 | id.to_string() |
| 41 | } |
| 42 |
| 43 | pub fn now() -> i64 { |
| 44 | chrono::Utc::now().timestamp() |
| 45 | } |
| 46 |
| 47 | /// "3s ago", "12 min ago", "2h ago", "3 days ago". |
| 48 | pub fn ago(at: i64) -> String { |
| 49 | let secs = (now() - at).max(0); |
| 50 | match secs { |
| 51 | 0..=59 => format!("{secs}s ago"), |
| 52 | 60..=3599 => format!("{} min ago", secs / 60), |
| 53 | 3600..=86_399 => format!("{}h ago", secs / 3600), |
| 54 | _ => { |
| 55 | let days = secs / 86_400; |
| 56 | if days == 1 { "1 day ago".into() } else { format!("{days} days ago") } |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 |
| 61 | pub fn clock(at: i64) -> String { |
| 62 | match Local.timestamp_opt(at, 0).single() { |
| 63 | Some(time) => time.format("%Y-%m-%d %H:%M").to_string(), |
| 64 | None => at.to_string(), |
| 65 | } |
| 66 | } |
| 67 |
| 68 | /// Parses `--at` words: "10 min ago", "2h ago", "90s ago", "3 days ago", |
| 69 | /// "15:30" (today, or yesterday if that's still ahead), "2026-10-27T15:30", |
| 70 | /// "2026-10-27 15:30" or "2026-10-27". Returns seconds since the epoch. |
| 71 | pub fn parse_time(text: &str) -> Option<i64> { |
| 72 | let t = text.trim().to_ascii_lowercase(); |
| 73 | if let Some(rest) = t.strip_suffix("ago") { |
| 74 | let rest = rest.trim(); |
| 75 | let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); |
| 76 | let unit = rest[digits.len()..].trim(); |
| 77 | let n: i64 = digits.parse().ok()?; |
| 78 | let secs = match unit { |
| 79 | "s" | "sec" | "secs" | "second" | "seconds" => n, |
| 80 | "m" | "min" | "mins" | "minute" | "minutes" => n * 60, |
| 81 | "h" | "hr" | "hrs" | "hour" | "hours" => n * 3600, |
| 82 | "d" | "day" | "days" => n * 86_400, |
| 83 | "w" | "week" | "weeks" => n * 7 * 86_400, |
| 84 | _ => return None, |
| 85 | }; |
| 86 | return Some(now() - secs); |
| 87 | } |
| 88 | if t == "yesterday" { |
| 89 | return Some(now() - 86_400); |
| 90 | } |
| 91 | if let Ok(time) = NaiveTime::parse_from_str(&t, "%H:%M") { |
| 92 | let today = Local::now().date_naive().and_time(time); |
| 93 | let mut at = Local.from_local_datetime(&today).earliest()?; |
| 94 | if at > Local::now() { |
| 95 | at -= Duration::days(1); |
| 96 | } |
| 97 | return Some(at.timestamp()); |
| 98 | } |
| 99 | 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"] { |
| 100 | if let Ok(dt) = NaiveDateTime::parse_from_str(&t, format) { |
| 101 | return Local.from_local_datetime(&dt).earliest().map(|d| d.timestamp()); |
| 102 | } |
| 103 | } |
| 104 | if let Ok(date) = NaiveDate::parse_from_str(&t, "%Y-%m-%d") { |
| 105 | let dt = date.and_hms_opt(0, 0, 0)?; |
| 106 | return Local.from_local_datetime(&dt).earliest().map(|d| d.timestamp()); |
| 107 | } |
| 108 | None |
| 109 | } |
| 110 |
| 111 | #[cfg(test)] |
| 112 | mod tests { |
| 113 | use super::*; |
| 114 |
| 115 | #[test] |
| 116 | fn ids_are_letters() { |
| 117 | let id = new_change_id(); |
| 118 | assert_eq!(id.len(), CHANGE_ID_LEN); |
| 119 | assert!(looks_like_change(&id)); |
| 120 | assert!(!looks_like_change("48213")); |
| 121 | assert!(!looks_like_change("main")); |
| 122 | } |
| 123 |
| 124 | #[test] |
| 125 | fn short_prefixes() { |
| 126 | let ids = vec!["bcdfghjk".to_string(), "bcdfzzzz".to_string(), "xyzxyzxy".to_string()]; |
| 127 | assert_eq!(short(&ids[0], &ids), "bcdfg"); |
| 128 | assert_eq!(short(&ids[2], &ids), "xyzx"); |
| 129 | } |
| 130 |
| 131 | #[test] |
| 132 | fn times() { |
| 133 | let n = now(); |
| 134 | assert!((parse_time("10 min ago").unwrap() - (n - 600)).abs() < 3); |
| 135 | assert!((parse_time("2h ago").unwrap() - (n - 7200)).abs() < 3); |
| 136 | assert!((parse_time("90s ago").unwrap() - (n - 90)).abs() < 3); |
| 137 | assert!(parse_time("15:30").is_some()); |
| 138 | assert!(parse_time("2026-10-27T15:30").is_some()); |
| 139 | assert!(parse_time("soon").is_none()); |
| 140 | } |
| 141 | } |
| 142 |