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 current time as written into frontmatter: RFC 3339 in UTC, `2026-10-30T02:21:00Z`.
17pub fn now_value() -> String {
18    let t = OffsetDateTime::now_utc().replace_nanosecond(0).unwrap_or_else(|_| OffsetDateTime::now_utc());
19    t.format(&time::format_description::well_known::Rfc3339).unwrap_or_default()
20}
21
22/// The stamp one second after `stamp`, used when two documents would share a key.
23pub fn next_stamp(stamp: &str) -> Option<String> {
24    let parsed = time::PrimitiveDateTime::parse(stamp, STAMP).ok()?;
25    (parsed + time::Duration::seconds(1)).format(STAMP).ok()
26}
27
28pub fn is_stamp(s: &str) -> bool {
29    s.len() == 14 && s.bytes().all(|b| b.is_ascii_digit()) && time::PrimitiveDateTime::parse(s, STAMP).is_ok()
30}
31
32pub fn is_date(s: &str) -> bool {
33    time::Date::parse(s, DATE).is_ok()
34}
35
36/// A frontmatter date as written: `YYYY-MM-DD`, or an RFC 3339 time with its offset
37/// (`2026-10-30T02:21:00Z`, `...+09:00`). specfold writes the plain date; both are read.
38pub fn is_date_value(s: &str) -> bool {
39    is_date(s) || instant(s).is_some()
40}
41
42/// Zone abbreviations read in `YYYY-MM-DD HH:MM[:SS] ABBR`. Only ones with a single fixed
43/// meaning; CST, IST, BST and the like name several zones and are refused.
44const ZONES: &[(&str, i8)] = &[("UTC", 0), ("GMT", 0), ("Z", 0), ("KST", 9), ("JST", 9)];
45pub const AMBIGUOUS_ZONES: &[&str] = &["CST", "IST", "BST", "AST", "EST", "MST", "PST", "ECT", "SST"];
46
47/// Why a date-like value with a zone abbreviation is refused, if it is.
48pub fn zone_problem(s: &str) -> Option<String> {
49    let abbr = s.rsplit(' ').next()?;
50    AMBIGUOUS_ZONES.contains(&abbr).then(|| format!("zone '{abbr}' is ambiguous; write an offset such as +09:00 or Z"))
51}
52
53/// An RFC 3339 time, or `YYYY-MM-DD HH:MM[:SS] ABBR` with a known abbreviation.
54fn instant(s: &str) -> Option<OffsetDateTime> {
55    if let Ok(t) = OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339) {
56        return Some(t);
57    }
58    let (at, abbr) = s.rsplit_once(' ')?;
59    let hours = ZONES.iter().find(|(z, _)| *z == abbr)?.1;
60    let p = time::PrimitiveDateTime::parse(at, format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"))
61        .or_else(|_| time::PrimitiveDateTime::parse(at, format_description!("[year]-[month]-[day] [hour]:[minute]")))
62        .ok()?;
63    Some(p.assume_offset(time::UtcOffset::from_hms(hours, 0, 0).ok()?))
64}
65
66/// The UTC calendar day of a frontmatter date, so ages and order compare like with like.
67pub fn utc_day(s: &str) -> Option<String> {
68    if is_date(s) {
69        return Some(s.to_string());
70    }
71    instant(s)?.to_offset(time::UtcOffset::UTC).format(DATE).ok()
72}
73
74/// Accepts `YYYYMMDD`, `YYYYMMDDHHMMSS` and ISO dates; returns the ISO date.
75pub fn normalize_date(s: &str) -> Option<String> {
76    let s = s.trim().trim_matches('"').trim_matches('\'');
77    if is_date(s) {
78        return Some(s.to_string());
79    }
80    let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
81    if digits.len() >= 8 {
82        let d = format!("{}-{}-{}", &digits[0..4], &digits[4..6], &digits[6..8]);
83        if is_date(&d) {
84            return Some(d);
85        }
86    }
87    if s.len() >= 10 && is_date(&s[0..10]) {
88        return Some(s[0..10].to_string());
89    }
90    None
91}
92
93pub fn is_slug(s: &str) -> bool {
94    !s.is_empty()
95        && s.len() <= 80
96        && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
97        && !s.starts_with('-')
98        && !s.ends_with('-')
99        && !s.contains("--")
100}
101
102/// Turns a title into a slug: ASCII letters and digits, words joined by `-`, at most 60 bytes.
103pub fn slugify(title: &str) -> String {
104    let mut out = String::new();
105    let mut dash = false;
106    for c in title.chars() {
107        if c.is_ascii_alphanumeric() {
108            out.push(c.to_ascii_lowercase());
109            dash = false;
110        } else if !out.is_empty() && !dash {
111            out.push('-');
112            dash = true;
113        }
114    }
115    let mut s = out.trim_matches('-').to_string();
116    if s.len() > 60 {
117        // Cut at the last word boundary that fits, so no word is left half-written.
118        s = match s[..61].rfind('-') {
119            Some(i) if i > 0 => s[..i].to_string(),
120            _ => s[..60].to_string(),
121        };
122    }
123    s
124}
125
126#[cfg(test)]
127mod tests {
128    #[test]
129    fn reads_dates_with_an_offset_as_their_utc_day() {
130        assert_eq!(super::utc_day("2026-10-30").as_deref(), Some("2026-10-30"));
131        assert_eq!(super::utc_day("2026-10-30T02:21:00Z").as_deref(), Some("2026-10-30"));
132        assert_eq!(super::utc_day("2026-10-30T02:21:00+09:00").as_deref(), Some("2026-10-29"));
133        assert!(!super::is_date_value("2026-10-30 02:21"));
134        assert_eq!(super::utc_day("2026-10-30 08:00 KST").as_deref(), Some("2026-10-29"));
135        assert_eq!(super::utc_day("2026-10-30 08:00:05 UTC").as_deref(), Some("2026-10-30"));
136        assert!(!super::is_date_value("2026-10-30 08:00 CST"));
137        assert!(super::zone_problem("2026-10-30 08:00 CST").is_some());
138    }
139
140    use super::*;
141
142    #[test]
143    fn stamps_and_dates() {
144        assert!(is_stamp("20261027073000"));
145        assert!(!is_stamp("20261327073000"));
146        assert_eq!(next_stamp("20261027235959").as_deref(), Some("20261028000000"));
147        assert_eq!(normalize_date("20261027").as_deref(), Some("2026-10-27"));
148        assert_eq!(normalize_date("\"2026-10-27\"").as_deref(), Some("2026-10-27"));
149        assert_eq!(normalize_date("2026-10-27T07:00:00Z").as_deref(), Some("2026-10-27"));
150    }
151
152    #[test]
153    fn slugs() {
154        assert_eq!(
155            slugify("Keep a general core, take behaviour by config!"),
156            "keep-a-general-core-take-behaviour-by-config"
157        );
158        let long = slugify("A very long title that keeps on going and going beyond the limit words");
159        assert_eq!(long, "a-very-long-title-that-keeps-on-going-and-going-beyond-the");
160        assert!(slugify(&"x".repeat(70)).len() == 60);
161        assert!(is_slug("guard-policy"));
162        assert!(!is_slug("Guard"));
163        assert!(!is_slug("a--b"));
164    }
165}