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