1use serde::{Deserialize, Serialize};
12use serde_yaml::Value;
13use std::collections::HashMap;
14
15#[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
29pub 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
61fn 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
81fn date_from_filename_prefix(filename: &str) -> Option<String> {
86 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
112fn 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 #[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 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 #[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 #[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 #[test]
273 fn filename_cjk_only_is_rejected_without_panic() {
274 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 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 let r = resolve_publish_date(&fm(&[]), &["é025-11-15.md"], None);
308 assert_eq!(r, (None, DateSource::None));
309 }
310
311 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 let _ = resolve_publish_date(&HashMap::new(), &[name.as_str()], ctime.as_deref());
325 }
326 }
327}