Skip to main content

moss_core/
date.rs

1//! Publish-date resolution for moss content.
2//!
3//! Single public entry point: [`resolve_publish_date`]. Returns the file's
4//! canonical `YYYY-MM-DD` date plus the [`DateSource`] that produced it.
5//!
6//! Returns canonicalized `YYYY-MM-DD` strings so lexicographic comparison
7//! is correct chronological comparison. No `chrono` dep — moss-core's
8//! existing convention is `Option<String>` for dates (see
9//! `frontmatter::FrontMatter::date`).
10
11use serde::{Deserialize, Serialize};
12use serde_yaml::Value;
13use std::collections::HashMap;
14
15/// Provenance of a resolved publish date. Distinguishes explicit dates
16/// (`Frontmatter`, `FilenamePrefix`) from implicit fallbacks (`Ctime`),
17/// so consumers that care (e.g. file-tree zoning, RSS feed pubDate)
18/// can treat them differently.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[cfg_attr(feature = "specta", derive(specta::Type))]
21#[serde(rename_all = "snake_case")]
22pub enum DateSource {
23    Frontmatter,
24    FilenamePrefix,
25    Ctime,
26    None,
27}
28
29/// Resolve the publish date for a markdown file.
30///
31/// Precedence (first match wins):
32///   1. Frontmatter `date` field.
33///   2. `YYYY-MM-DD-…` prefix on any of the supplied `filenames`, in order.
34///      The slice lets callers pass multiple candidate names — e.g. a build
35///      pipeline that has both a slugified `url_path` and a source filename
36///      can pass them both without composing its own precedence here.
37///   3. `fallback_ctime`, used as-is (caller is responsible for canonical form).
38///
39/// Returns `(None, DateSource::None)` only when nothing matches.
40///
41/// Pure function — no I/O.
42pub fn resolve_publish_date(
43    frontmatter: &HashMap<String, Value>,
44    filenames: &[&str],
45    fallback_ctime: Option<&str>,
46) -> (Option<String>, DateSource) {
47    if let Some(d) = date_from_frontmatter(frontmatter) {
48        return (Some(d), DateSource::Frontmatter);
49    }
50    for fname in filenames {
51        if let Some(d) = date_from_filename_prefix(fname) {
52            return (Some(d), DateSource::FilenamePrefix);
53        }
54    }
55    if let Some(d) = fallback_ctime {
56        return (Some(d.to_string()), DateSource::Ctime);
57    }
58    (None, DateSource::None)
59}
60
61// ── Private helpers ───────────────────────────────────────────────────────
62
63/// Read the frontmatter `date` field and normalize to `YYYY-MM-DD`.
64///
65/// Accepts:
66/// - ISO date: `2025-11-15`
67/// - ISO timestamp: `2025-11-15T10:30:00Z`, `2025-11-15T10:30:00`
68/// - Slash form: `2025/11/15`
69fn date_from_frontmatter(frontmatter: &HashMap<String, Value>) -> Option<String> {
70    let raw = match frontmatter.get("date")? {
71        Value::String(s) => s.trim().to_string(),
72        Value::Number(n) => n.to_string(),
73        _ => return None,
74    };
75    if raw.is_empty() {
76        return None;
77    }
78    normalize_date(&raw)
79}
80
81/// Parse a `YYYY-MM-DD-…` prefix from a filename.
82///
83/// The filename must START with the date in the exact form, optionally
84/// followed by `-rest` and an extension. `news-2025-11-15.md` does NOT match.
85fn date_from_filename_prefix(filename: &str) -> Option<String> {
86    // Operate on bytes throughout: `&filename[..10]` panics when byte 10 lands
87    // inside a multi-byte char (e.g. `纽约诸法门.md`). All bytes we *match*
88    // against (digits, `-`, `.`) are ASCII, so byte comparisons are
89    // semantically equivalent to char comparisons here.
90    let bytes = filename.as_bytes();
91    if bytes.len() < 10 {
92        return None;
93    }
94    if bytes[4] != b'-' || bytes[7] != b'-' {
95        return None;
96    }
97    let y = std::str::from_utf8(&bytes[0..4]).ok()?.parse::<u32>().ok()?;
98    let m = std::str::from_utf8(&bytes[5..7]).ok()?.parse::<u32>().ok()?;
99    let d = std::str::from_utf8(&bytes[8..10]).ok()?.parse::<u32>().ok()?;
100    if !(1..=12).contains(&m) || !(1..=31).contains(&d) || y == 0 {
101        return None;
102    }
103
104    match bytes.get(10) {
105        None | Some(b'-') | Some(b'.') => {}
106        _ => return None,
107    }
108
109    Some(format!("{:04}-{:02}-{:02}", y, m, d))
110}
111
112/// Normalize a date-ish string to `YYYY-MM-DD`.
113fn normalize_date(s: &str) -> Option<String> {
114    let s = s.trim();
115    let date_part = s.split('T').next().unwrap_or(s);
116    let normalized: String = date_part.replace('/', "-");
117    let mut parts = normalized.split('-');
118    let y = parts.next()?.parse::<u32>().ok()?;
119    let m = parts.next()?.parse::<u32>().ok()?;
120    let d = parts.next()?.parse::<u32>().ok()?;
121    if parts.next().is_some() {
122        return None;
123    }
124    if !(1..=9999).contains(&y) || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
125        return None;
126    }
127    Some(format!("{:04}-{:02}-{:02}", y, m, d))
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use serde_yaml::Value;
134    use std::collections::HashMap;
135
136    fn fm(pairs: &[(&str, &str)]) -> HashMap<String, Value> {
137        pairs
138            .iter()
139            .map(|(k, v)| (k.to_string(), Value::String(v.to_string())))
140            .collect()
141    }
142
143    // ── resolve_publish_date — the public API ────────────────────────────
144
145    #[test]
146    fn resolve_uses_frontmatter_first() {
147        let r = resolve_publish_date(
148            &fm(&[("date", "2025-03-01")]),
149            &["2024-01-01-old.md"],
150            Some("2023-01-01"),
151        );
152        assert_eq!(r, (Some("2025-03-01".to_string()), DateSource::Frontmatter));
153    }
154
155    #[test]
156    fn resolve_falls_through_to_filename() {
157        let r = resolve_publish_date(
158            &fm(&[("title", "x")]),
159            &["2024-01-01-old.md"],
160            Some("2023-01-01"),
161        );
162        assert_eq!(
163            r,
164            (Some("2024-01-01".to_string()), DateSource::FilenamePrefix)
165        );
166    }
167
168    #[test]
169    fn resolve_tries_each_filename_in_order() {
170        // First filename has no prefix; second one matches.
171        let r = resolve_publish_date(
172            &fm(&[]),
173            &["no-date.md", "2024-05-15-from-second.md"],
174            None,
175        );
176        assert_eq!(
177            r,
178            (Some("2024-05-15".to_string()), DateSource::FilenamePrefix)
179        );
180    }
181
182    #[test]
183    fn resolve_falls_through_to_ctime() {
184        let r = resolve_publish_date(&fm(&[]), &["no-date.md"], Some("2023-01-01"));
185        assert_eq!(r, (Some("2023-01-01".to_string()), DateSource::Ctime));
186    }
187
188    #[test]
189    fn resolve_returns_none_when_nothing_available() {
190        let r = resolve_publish_date(&fm(&[]), &["no-date.md"], None);
191        assert_eq!(r, (None, DateSource::None));
192    }
193
194    #[test]
195    fn resolve_handles_empty_filenames_slice() {
196        let r = resolve_publish_date(&fm(&[]), &[], Some("2023-01-01"));
197        assert_eq!(r, (Some("2023-01-01".to_string()), DateSource::Ctime));
198    }
199
200    // ── frontmatter forms (covered through the public API) ──────────────
201
202    #[test]
203    fn frontmatter_iso_date_passes_through() {
204        let r = resolve_publish_date(&fm(&[("date", "2025-11-15")]), &[], None);
205        assert_eq!(r.0, Some("2025-11-15".to_string()));
206    }
207
208    #[test]
209    fn frontmatter_iso_timestamp_truncated_to_date() {
210        let r = resolve_publish_date(&fm(&[("date", "2025-11-15T10:30:00Z")]), &[], None);
211        assert_eq!(r.0, Some("2025-11-15".to_string()));
212    }
213
214    #[test]
215    fn frontmatter_slash_form_normalized() {
216        let r = resolve_publish_date(&fm(&[("date", "2025/11/15")]), &[], None);
217        assert_eq!(r.0, Some("2025-11-15".to_string()));
218    }
219
220    #[test]
221    fn frontmatter_malformed_returns_none() {
222        let r = resolve_publish_date(&fm(&[("date", "not a date")]), &[], None);
223        assert_eq!(r, (None, DateSource::None));
224    }
225
226    #[test]
227    fn frontmatter_empty_string_returns_none() {
228        let r = resolve_publish_date(&fm(&[("date", "")]), &[], None);
229        assert_eq!(r, (None, DateSource::None));
230    }
231
232    // ── filename forms (covered through the public API) ─────────────────
233
234    #[test]
235    fn filename_with_dated_slug() {
236        let r = resolve_publish_date(&fm(&[]), &["2025-11-15-research-proposals.md"], None);
237        assert_eq!(r.0, Some("2025-11-15".to_string()));
238    }
239
240    #[test]
241    fn filename_bare_date_md() {
242        let r = resolve_publish_date(&fm(&[]), &["2025-11-15.md"], None);
243        assert_eq!(r.0, Some("2025-11-15".to_string()));
244    }
245
246    #[test]
247    fn filename_no_prefix() {
248        let r = resolve_publish_date(&fm(&[]), &["research-proposals.md"], None);
249        assert_eq!(r, (None, DateSource::None));
250    }
251
252    #[test]
253    fn filename_date_in_middle_does_not_match() {
254        let r = resolve_publish_date(&fm(&[]), &["news-2025-11-15.md"], None);
255        assert_eq!(r, (None, DateSource::None));
256    }
257
258    #[test]
259    fn filename_invalid_date_returns_none() {
260        let r = resolve_publish_date(&fm(&[]), &["2025-13-99-foo.md"], None);
261        assert_eq!(r, (None, DateSource::None));
262    }
263
264    #[test]
265    fn filename_extensionless() {
266        let r = resolve_publish_date(&fm(&[]), &["2025-11-15-foo"], None);
267        assert_eq!(r.0, Some("2025-11-15".to_string()));
268    }
269
270    // ── non-ASCII filenames must not panic (regression: #date-utf8-panic) ──
271
272    #[test]
273    fn filename_cjk_only_is_rejected_without_panic() {
274        // The original report: opening this file in the editor crashed moss
275        // because `&filename[..10]` cut inside the third byte of `法`.
276        let r = resolve_publish_date(&fm(&[]), &["纽约诸法门.md"], None);
277        assert_eq!(r, (None, DateSource::None));
278    }
279
280    #[test]
281    fn filename_dated_then_cjk_slug_matches() {
282        let r = resolve_publish_date(&fm(&[]), &["2025-11-15-纽约诸法门.md"], None);
283        assert_eq!(r.0, Some("2025-11-15".to_string()));
284    }
285
286    #[test]
287    fn filename_emoji_only_is_rejected_without_panic() {
288        let r = resolve_publish_date(&fm(&[]), &["📝-notes.md"], None);
289        assert_eq!(r, (None, DateSource::None));
290    }
291
292    #[test]
293    fn filename_date_followed_by_cjk_without_separator_is_rejected() {
294        // Byte 10 is the first byte of `春` (0xE6) — not `-` or `.`, so the
295        // function must reject the filename rather than slice through `春`.
296        let r = resolve_publish_date(&fm(&[]), &["2025-11-15春节.md"], None);
297        assert_eq!(r, (None, DateSource::None));
298    }
299
300    #[test]
301    fn filename_with_non_ascii_inside_date_window_is_rejected() {
302        // `é` (2 bytes) at position 0 means bytes[4] lands on a digit, not
303        // `-` — the date prefix is broken before we reach the boundary check.
304        // Earlier code panicked before getting this far for some inputs in
305        // this shape; the test pins the no-panic invariant for the whole
306        // pre-position-10 window.
307        let r = resolve_publish_date(&fm(&[]), &["é025-11-15.md"], None);
308        assert_eq!(r, (None, DateSource::None));
309    }
310
311    // ── property test: pure functions in moss-core must not panic on user input ──
312
313    proptest::proptest! {
314        #![proptest_config(proptest::prelude::ProptestConfig::with_cases(2048))]
315
316        #[test]
317        fn resolve_publish_date_never_panics_on_arbitrary_input(
318            name in ".*",
319            ctime in proptest::option::of(".*"),
320        ) {
321            // The contract: moss-core takes untrusted user strings (filenames,
322            // frontmatter values) and must return a value, not abort the host
323            // process. Anything that reaches a Tauri command must satisfy this.
324            let _ = resolve_publish_date(&HashMap::new(), &[name.as_str()], ctime.as_deref());
325        }
326    }
327}