1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[cfg_attr(feature = "specta", derive(specta::Type))]
13#[serde(rename_all = "lowercase")]
14pub enum SortAxis {
15 Date,
16 Weight,
17 Title,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[cfg_attr(feature = "specta", derive(specta::Type))]
22#[serde(untagged)]
23pub enum SortField {
24 Axis(SortAxis),
25 List(Vec<String>),
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[cfg_attr(feature = "specta", derive(specta::Type))]
30pub struct ResolvedSort {
31 pub axis: SortAxis,
32 pub explicit_order: Option<Vec<String>>,
33 pub series_default: bool,
35}
36
37impl ResolvedSort {
38 pub fn presentation_axis(&self) -> SortAxis {
43 if self.explicit_order.is_some() {
44 SortAxis::Weight
45 } else {
46 self.axis
47 }
48 }
49}
50
51pub trait SortableDoc {
54 fn url_path(&self) -> &str;
55 fn date(&self) -> Option<&str>;
56 fn weight(&self) -> Option<i32>;
57 fn declared_sort(&self) -> Option<&SortField>;
58 fn clean_stem(&self) -> &str;
59 fn is_folder_index(&self) -> bool {
66 false
67 }
68}
69
70const DATE_FRACTION_THRESHOLD: f32 = 0.8;
71
72pub fn resolve_folder_sort<D: SortableDoc>(
73 folder: &D,
74 children: &[&D],
75) -> ResolvedSort {
76 let article_children: Vec<&&D> = children
84 .iter()
85 .filter(|c| !c.is_folder_index())
86 .collect();
87
88 let (axis, explicit_order) = match folder.declared_sort() {
89 Some(SortField::Axis(a)) => (*a, None),
90 Some(SortField::List(items)) => {
91 let stems = items
96 .iter()
97 .map(|s| crate::frontmatter_typed::frontmatter_ref_to_stem(s))
98 .collect();
99 (infer_axis(&article_children), Some(stems))
100 }
101 None => (infer_axis(&article_children), None),
102 };
103
104 let series_default = matches!(axis, SortAxis::Weight) || explicit_order.is_some();
105
106 ResolvedSort { axis, explicit_order, series_default }
107}
108
109fn infer_axis<D: SortableDoc>(article_children: &[&&D]) -> SortAxis {
110 if article_children.is_empty() {
111 return SortAxis::Title;
112 }
113 if article_children.iter().any(|c| c.weight().is_some()) {
114 return SortAxis::Weight;
115 }
116 let total = article_children.len() as f32;
117 let dated = article_children.iter().filter(|c| c.date().is_some()).count() as f32;
118 if dated / total >= DATE_FRACTION_THRESHOLD {
119 return SortAxis::Date;
120 }
121 SortAxis::Title
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn sort_field_parses_axis_strings() {
130 assert!(matches!(serde_yaml::from_str::<SortField>("date").unwrap(), SortField::Axis(SortAxis::Date)));
131 assert!(matches!(serde_yaml::from_str::<SortField>("weight").unwrap(), SortField::Axis(SortAxis::Weight)));
132 assert!(matches!(serde_yaml::from_str::<SortField>("title").unwrap(), SortField::Axis(SortAxis::Title)));
133 }
134
135 #[test]
136 fn sort_field_parses_list() {
137 let f: SortField = serde_yaml::from_str("[intro, setup, advanced]").unwrap();
138 match f {
139 SortField::List(items) => assert_eq!(items, vec!["intro", "setup", "advanced"]),
140 _ => panic!("expected List"),
141 }
142 }
143
144 #[test]
145 fn sort_field_rejects_unknown_axis() {
146 assert!(serde_yaml::from_str::<SortField>("random").is_err());
147 }
148}
149
150#[cfg(test)]
151mod inference_tests {
152 use super::*;
153
154 #[derive(Debug, Default)]
155 pub(super) struct TestDoc {
156 url: String,
157 stem: String,
158 date_v: Option<String>,
159 weight_v: Option<i32>,
160 sort_v: Option<SortField>,
161 }
162 impl SortableDoc for TestDoc {
163 fn url_path(&self) -> &str { &self.url }
164 fn date(&self) -> Option<&str> { self.date_v.as_deref() }
165 fn weight(&self) -> Option<i32> { self.weight_v }
166 fn declared_sort(&self) -> Option<&SortField> { self.sort_v.as_ref() }
167 fn clean_stem(&self) -> &str { &self.stem }
168 }
169 pub(super) fn art(stem: &str, date: Option<&str>, w: Option<i32>) -> TestDoc {
170 TestDoc {
171 url: format!("{}.html", stem),
172 stem: stem.into(),
173 date_v: date.map(|s| s.into()),
174 weight_v: w,
175 sort_v: None,
176 }
177 }
178 pub(super) fn folder(url: &str, sort: Option<SortField>) -> TestDoc {
179 TestDoc { url: url.into(), sort_v: sort, ..Default::default() }
180 }
181
182 #[test]
183 fn explicit_axis_wins() {
184 let f = folder("blog/index.html", Some(SortField::Axis(SortAxis::Title)));
185 let a = art("a", Some("2025-01-01"), None);
186 let r = resolve_folder_sort(&f, &[&a]);
187 assert_eq!(r.axis, SortAxis::Title);
188 assert!(r.explicit_order.is_none());
189 assert!(!r.series_default);
190 }
191
192 #[test]
193 fn explicit_list_implies_chrome_on() {
194 let f = folder("blog/index.html", Some(SortField::List(vec!["a".into(), "b".into()])));
195 let a = art("a", None, None);
196 let b = art("b", None, None);
197 let r = resolve_folder_sort(&f, &[&a, &b]);
198 assert!(r.series_default, "explicit list-form sort implies series chrome on");
199 assert_eq!(r.explicit_order.as_ref().unwrap().len(), 2);
200 assert_eq!(r.axis, SortAxis::Title, "tail axis inferred from undated children");
201 }
202
203 #[test]
204 fn explicit_list_refs_normalized_to_stems() {
205 let f = folder(
209 "blog/index.html",
210 Some(SortField::List(vec![
211 "[[gamma]]".into(),
212 "posts/alpha.md".into(),
213 "beta".into(),
214 ])),
215 );
216 let a = art("alpha", None, None);
217 let b = art("beta", None, None);
218 let g = art("gamma", None, None);
219 let r = resolve_folder_sort(&f, &[&a, &b, &g]);
220 assert_eq!(
221 r.explicit_order.as_deref(),
222 Some(["gamma".to_string(), "alpha".to_string(), "beta".to_string()].as_slice()),
223 "wikilink/path/bare refs must all normalize to bare stems"
224 );
225 }
226
227 #[test]
228 fn weight_present_infers_weight() {
229 let f = folder("docs/index.html", None);
230 let a = art("intro", None, Some(10));
231 let b = art("advanced", None, Some(20));
232 let r = resolve_folder_sort(&f, &[&a, &b]);
233 assert_eq!(r.axis, SortAxis::Weight);
234 assert!(r.series_default, "weight axis implies series chrome on");
235 }
236
237 #[test]
238 fn dates_above_threshold_infer_date() {
239 let f = folder("blog/index.html", None);
240 let a = art("a", Some("2025-01-01"), None);
241 let b = art("b", Some("2025-02-01"), None);
242 let c = art("c", Some("2025-03-01"), None);
243 let d = art("d", Some("2025-04-01"), None);
244 let e = art("e", None, None);
245 let r = resolve_folder_sort(&f, &[&a, &b, &c, &d, &e]);
246 assert_eq!(r.axis, SortAxis::Date); assert!(!r.series_default);
248 }
249
250 #[test]
251 fn dates_below_threshold_fallback_to_title() {
252 let f = folder("projects/index.html", None);
253 let a = art("a", Some("2025-01-01"), None);
254 let b = art("b", None, None);
255 let c = art("c", None, None);
256 let d = art("d", None, None);
257 let e = art("e", None, None);
258 let r = resolve_folder_sort(&f, &[&a, &b, &c, &d, &e]);
259 assert_eq!(r.axis, SortAxis::Title); }
261
262 #[test]
263 fn weight_beats_date() {
264 let f = folder("hybrid/index.html", None);
265 let a = art("a", Some("2025-01-01"), Some(1));
266 let b = art("b", Some("2025-02-01"), None);
267 let r = resolve_folder_sort(&f, &[&a, &b]);
268 assert_eq!(r.axis, SortAxis::Weight);
269 }
270
271 #[test]
272 fn subfolders_excluded_from_inference() {
273 let f = folder("root/index.html", None);
274 let article = art("welcome", None, None);
275 let sub_a = folder("root/news/index.html", None);
276 let sub_b = folder("root/projects/index.html", None);
277 let r = resolve_folder_sort(&f, &[&article, &sub_a, &sub_b]);
278 assert_eq!(r.axis, SortAxis::Title);
279 }
280
281 #[test]
282 fn chps_style_root_with_only_subfolders_falls_to_title() {
283 let f = folder("root/index.html", None);
284 let sub_a = folder("root/news/index.html", None);
285 let sub_b = folder("root/projects/index.html", None);
286 let r = resolve_folder_sort(&f, &[&sub_a, &sub_b]);
287 assert_eq!(r.axis, SortAxis::Title);
288 }
289
290 #[test]
291 fn empty_folder_defaults_to_title() {
292 let f = folder("empty/index.html", None);
293 let r = resolve_folder_sort::<TestDoc>(&f, &[]);
294 assert_eq!(r.axis, SortAxis::Title);
295 }
296}
297
298pub trait SortableLabel {
302 fn label(&self) -> &str;
303}
304
305pub fn sort_by_resolved<'a, D>(
306 docs: &[&'a D],
307 resolved: &ResolvedSort,
308) -> Vec<&'a D>
309where
310 D: SortableDoc + SortableLabel,
311{
312 let axis_cmp = |a: &&'a D, b: &&'a D| -> std::cmp::Ordering {
313 match resolved.axis {
314 SortAxis::Date => {
315 let ad = a.date().unwrap_or("");
316 let bd = b.date().unwrap_or("");
317 bd.cmp(ad)
318 }
319 SortAxis::Weight => match (a.weight(), b.weight()) {
320 (Some(aw), Some(bw)) => aw.cmp(&bw),
321 (Some(_), None) => std::cmp::Ordering::Less,
322 (None, Some(_)) => std::cmp::Ordering::Greater,
323 (None, None) => a.clean_stem().cmp(b.clean_stem()),
324 },
325 SortAxis::Title => a.label().cmp(b.label()),
326 }
327 };
328
329 match &resolved.explicit_order {
330 Some(order) => {
331 let order_lower: Vec<String> = order.iter().map(|s| s.to_lowercase()).collect();
332 let order_map: std::collections::HashMap<&str, usize> = order_lower
333 .iter()
334 .enumerate()
335 .map(|(i, s)| (s.as_str(), i))
336 .collect();
337 let (mut listed, mut unlisted): (Vec<_>, Vec<_>) = docs.iter().copied().partition(|d| {
338 order_map.contains_key(d.clean_stem().to_lowercase().as_str())
339 });
340 listed.sort_by(|a, b| {
341 let ai = order_map.get(a.clean_stem().to_lowercase().as_str()).copied().unwrap_or(usize::MAX);
342 let bi = order_map.get(b.clean_stem().to_lowercase().as_str()).copied().unwrap_or(usize::MAX);
343 ai.cmp(&bi)
344 });
345 unlisted.sort_by(axis_cmp);
346 listed.extend(unlisted);
347 listed
348 }
349 None => {
350 let mut sorted: Vec<&'a D> = docs.to_vec();
351 sorted.sort_by(axis_cmp);
352 sorted
353 }
354 }
355}
356
357#[cfg(test)]
358mod sort_dispatch_tests {
359 use super::*;
360 use super::inference_tests::*; fn doc_with_label(stem: &str, date: Option<&str>, w: Option<i32>, label: &str) -> TestDocWithLabel {
363 TestDocWithLabel {
364 base: art(stem, date, w),
365 label_v: label.into(),
366 }
367 }
368
369 #[derive(Debug)]
370 struct TestDocWithLabel {
371 base: TestDoc,
372 label_v: String,
373 }
374 impl SortableDoc for TestDocWithLabel {
375 fn url_path(&self) -> &str { self.base.url_path() }
376 fn date(&self) -> Option<&str> { self.base.date() }
377 fn weight(&self) -> Option<i32> { self.base.weight() }
378 fn declared_sort(&self) -> Option<&SortField> { self.base.declared_sort() }
379 fn clean_stem(&self) -> &str { self.base.clean_stem() }
380 }
381 impl SortableLabel for TestDocWithLabel {
382 fn label(&self) -> &str { &self.label_v }
383 }
384
385 #[test]
386 fn date_desc() {
387 let a = doc_with_label("a", Some("2025-01-01"), None, "A");
388 let b = doc_with_label("b", Some("2025-03-01"), None, "B");
389 let c = doc_with_label("c", Some("2025-02-01"), None, "C");
390 let r = ResolvedSort { axis: SortAxis::Date, explicit_order: None, series_default: false };
391 let sorted = sort_by_resolved(&[&a, &b, &c], &r);
392 assert_eq!(sorted[0].clean_stem(), "b");
393 assert_eq!(sorted[2].clean_stem(), "a");
394 }
395
396 #[test]
397 fn weight_asc_unweighted_last() {
398 let a = doc_with_label("a", None, Some(2), "A");
399 let b = doc_with_label("b", None, None, "B");
400 let c = doc_with_label("c", None, Some(1), "C");
401 let r = ResolvedSort { axis: SortAxis::Weight, explicit_order: None, series_default: true };
402 let sorted = sort_by_resolved(&[&a, &b, &c], &r);
403 assert_eq!(sorted[0].clean_stem(), "c");
404 assert_eq!(sorted[1].clean_stem(), "a");
405 assert_eq!(sorted[2].clean_stem(), "b");
406 }
407
408 #[test]
409 fn title_alpha() {
410 let a = doc_with_label("zebra", None, None, "Zebra");
411 let b = doc_with_label("apple", None, None, "Apple");
412 let c = doc_with_label("mango", None, None, "Mango");
413 let r = ResolvedSort { axis: SortAxis::Title, explicit_order: None, series_default: false };
414 let sorted = sort_by_resolved(&[&a, &b, &c], &r);
415 assert_eq!(sorted[0].clean_stem(), "apple");
416 assert_eq!(sorted[2].clean_stem(), "zebra");
417 }
418
419 #[test]
420 fn explicit_wikilink_order_normalized_and_beats_date_axis() {
421 let f = TestDocWithLabel {
426 base: folder(
427 "blog/index.html",
428 Some(SortField::List(vec![
429 "[[gamma]]".into(),
430 "[[alpha]]".into(),
431 "[[beta]]".into(),
432 ])),
433 ),
434 label_v: "Blog".into(),
435 };
436 let alpha = doc_with_label("alpha", Some("2025-01-01"), None, "Alpha");
437 let beta = doc_with_label("beta", Some("2025-03-01"), None, "Beta");
438 let gamma = doc_with_label("gamma", Some("2025-02-01"), None, "Gamma");
439
440 let r = resolve_folder_sort(&f, &[&alpha, &beta, &gamma]);
441 assert_eq!(r.axis, SortAxis::Date, "all children dated => Date axis inferred");
442
443 let sorted = sort_by_resolved(&[&alpha, &beta, &gamma], &r);
444 let order: Vec<&str> = sorted.iter().map(|d| d.clean_stem()).collect();
445 assert_eq!(
446 order,
447 vec!["gamma", "alpha", "beta"],
448 "explicit [[wikilink]] order must beat date-desc (beta, gamma, alpha) after stem normalization"
449 );
450 }
451
452 #[test]
453 fn explicit_list_with_tail() {
454 let a = doc_with_label("a", Some("2025-03-01"), None, "A");
455 let b = doc_with_label("b", Some("2025-02-01"), None, "B");
456 let intro = doc_with_label("intro", Some("2025-01-01"), None, "Intro");
457 let r = ResolvedSort {
458 axis: SortAxis::Date,
459 explicit_order: Some(vec!["intro".into()]),
460 series_default: true,
461 };
462 let sorted = sort_by_resolved(&[&a, &b, &intro], &r);
463 assert_eq!(sorted[0].clean_stem(), "intro"); assert_eq!(sorted[1].clean_stem(), "a"); assert_eq!(sorted[2].clean_stem(), "b");
466 }
467}