Skip to main content

specfold_core/model/
stamp.rs

1//! UTC timestamps used as document keys, and ISO dates used in frontmatter.
2
3use time::{OffsetDateTime, format_description::FormatItem, macros::format_description};
4
5const STAMP: &[FormatItem<'static>] = format_description!("[year][month][day][hour][minute][second]");
6const DATE: &[FormatItem<'static>] = format_description!("[year]-[month]-[day]");
7
8pub fn now_stamp() -> String {
9    OffsetDateTime::now_utc().format(STAMP).unwrap_or_default()
10}
11
12pub fn today() -> String {
13    OffsetDateTime::now_utc().format(DATE).unwrap_or_default()
14}
15
16/// The stamp one second after `stamp`, used when two documents would share a key.
17pub fn next_stamp(stamp: &str) -> Option<String> {
18    let parsed = time::PrimitiveDateTime::parse(stamp, STAMP).ok()?;
19    (parsed + time::Duration::seconds(1)).format(STAMP).ok()
20}
21
22pub fn is_stamp(s: &str) -> bool {
23    s.len() == 14 && s.bytes().all(|b| b.is_ascii_digit()) && time::PrimitiveDateTime::parse(s, STAMP).is_ok()
24}
25
26pub fn is_date(s: &str) -> bool {
27    time::Date::parse(s, DATE).is_ok()
28}
29
30/// Accepts `YYYYMMDD`, `YYYYMMDDHHMMSS` and ISO dates; returns the ISO date.
31pub fn normalize_date(s: &str) -> Option<String> {
32    let s = s.trim().trim_matches('"').trim_matches('\'');
33    if is_date(s) {
34        return Some(s.to_string());
35    }
36    let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
37    if digits.len() >= 8 {
38        let d = format!("{}-{}-{}", &digits[0..4], &digits[4..6], &digits[6..8]);
39        if is_date(&d) {
40            return Some(d);
41        }
42    }
43    if s.len() >= 10 && is_date(&s[0..10]) {
44        return Some(s[0..10].to_string());
45    }
46    None
47}
48
49pub fn is_slug(s: &str) -> bool {
50    !s.is_empty()
51        && s.len() <= 80
52        && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
53        && !s.starts_with('-')
54        && !s.ends_with('-')
55        && !s.contains("--")
56}
57
58/// Turns a title into a slug: ASCII letters and digits, words joined by `-`, at most 60 bytes.
59pub fn slugify(title: &str) -> String {
60    let mut out = String::new();
61    let mut dash = false;
62    for c in title.chars() {
63        if c.is_ascii_alphanumeric() {
64            out.push(c.to_ascii_lowercase());
65            dash = false;
66        } else if !out.is_empty() && !dash {
67            out.push('-');
68            dash = true;
69        }
70    }
71    let mut s = out.trim_matches('-').to_string();
72    if s.len() > 60 {
73        s.truncate(60);
74        s = s.trim_end_matches('-').to_string();
75    }
76    s
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn stamps_and_dates() {
85        assert!(is_stamp("20261027073000"));
86        assert!(!is_stamp("20261327073000"));
87        assert_eq!(next_stamp("20261027235959").as_deref(), Some("20261028000000"));
88        assert_eq!(normalize_date("20261027").as_deref(), Some("2026-10-27"));
89        assert_eq!(normalize_date("\"2026-10-27\"").as_deref(), Some("2026-10-27"));
90        assert_eq!(normalize_date("2026-10-27T07:00:00Z").as_deref(), Some("2026-10-27"));
91    }
92
93    #[test]
94    fn slugs() {
95        assert_eq!(
96            slugify("Keep a general core, take behaviour by config!"),
97            "keep-a-general-core-take-behaviour-by-config"
98        );
99        assert!(is_slug("guard-policy"));
100        assert!(!is_slug("Guard"));
101        assert!(!is_slug("a--b"));
102    }
103}