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)) => (infer_axis(&article_children), Some(items.clone())),
77 None => (infer_axis(&article_children), None),
78 };
79
80 let series_default = matches!(axis, SortAxis::Weight) || explicit_order.is_some();
81
82 ResolvedSort { axis, explicit_order, series_default }
83}
84
85fn infer_axis<D: SortableDoc>(article_children: &[&&D]) -> SortAxis {
86 if article_children.is_empty() {
87 return SortAxis::Title;
88 }
89 if article_children.iter().any(|c| c.weight().is_some()) {
90 return SortAxis::Weight;
91 }
92 let total = article_children.len() as f32;
93 let dated = article_children.iter().filter(|c| c.date().is_some()).count() as f32;
94 if dated / total >= DATE_FRACTION_THRESHOLD {
95 return SortAxis::Date;
96 }
97 SortAxis::Title
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn sort_field_parses_axis_strings() {
106 assert!(matches!(serde_yaml::from_str::<SortField>("date").unwrap(), SortField::Axis(SortAxis::Date)));
107 assert!(matches!(serde_yaml::from_str::<SortField>("weight").unwrap(), SortField::Axis(SortAxis::Weight)));
108 assert!(matches!(serde_yaml::from_str::<SortField>("title").unwrap(), SortField::Axis(SortAxis::Title)));
109 }
110
111 #[test]
112 fn sort_field_parses_list() {
113 let f: SortField = serde_yaml::from_str("[intro, setup, advanced]").unwrap();
114 match f {
115 SortField::List(items) => assert_eq!(items, vec!["intro", "setup", "advanced"]),
116 _ => panic!("expected List"),
117 }
118 }
119
120 #[test]
121 fn sort_field_rejects_unknown_axis() {
122 assert!(serde_yaml::from_str::<SortField>("random").is_err());
123 }
124}
125
126#[cfg(test)]
127mod inference_tests {
128 use super::*;
129
130 #[derive(Debug, Default)]
131 pub(super) struct TestDoc {
132 url: String,
133 stem: String,
134 date_v: Option<String>,
135 weight_v: Option<i32>,
136 sort_v: Option<SortField>,
137 }
138 impl SortableDoc for TestDoc {
139 fn url_path(&self) -> &str { &self.url }
140 fn date(&self) -> Option<&str> { self.date_v.as_deref() }
141 fn weight(&self) -> Option<i32> { self.weight_v }
142 fn declared_sort(&self) -> Option<&SortField> { self.sort_v.as_ref() }
143 fn clean_stem(&self) -> &str { &self.stem }
144 }
145 pub(super) fn art(stem: &str, date: Option<&str>, w: Option<i32>) -> TestDoc {
146 TestDoc {
147 url: format!("{}.html", stem),
148 stem: stem.into(),
149 date_v: date.map(|s| s.into()),
150 weight_v: w,
151 sort_v: None,
152 }
153 }
154 pub(super) fn folder(url: &str, sort: Option<SortField>) -> TestDoc {
155 TestDoc { url: url.into(), sort_v: sort, ..Default::default() }
156 }
157
158 #[test]
159 fn explicit_axis_wins() {
160 let f = folder("blog/index.html", Some(SortField::Axis(SortAxis::Title)));
161 let a = art("a", Some("2025-01-01"), None);
162 let r = resolve_folder_sort(&f, &[&a]);
163 assert_eq!(r.axis, SortAxis::Title);
164 assert!(r.explicit_order.is_none());
165 assert!(!r.series_default);
166 }
167
168 #[test]
169 fn explicit_list_implies_chrome_on() {
170 let f = folder("blog/index.html", Some(SortField::List(vec!["a".into(), "b".into()])));
171 let a = art("a", None, None);
172 let b = art("b", None, None);
173 let r = resolve_folder_sort(&f, &[&a, &b]);
174 assert!(r.series_default, "explicit list-form sort implies series chrome on");
175 assert_eq!(r.explicit_order.as_ref().unwrap().len(), 2);
176 assert_eq!(r.axis, SortAxis::Title, "tail axis inferred from undated children");
177 }
178
179 #[test]
180 fn weight_present_infers_weight() {
181 let f = folder("docs/index.html", None);
182 let a = art("intro", None, Some(10));
183 let b = art("advanced", None, Some(20));
184 let r = resolve_folder_sort(&f, &[&a, &b]);
185 assert_eq!(r.axis, SortAxis::Weight);
186 assert!(r.series_default, "weight axis implies series chrome on");
187 }
188
189 #[test]
190 fn dates_above_threshold_infer_date() {
191 let f = folder("blog/index.html", None);
192 let a = art("a", Some("2025-01-01"), None);
193 let b = art("b", Some("2025-02-01"), None);
194 let c = art("c", Some("2025-03-01"), None);
195 let d = art("d", Some("2025-04-01"), None);
196 let e = art("e", None, None);
197 let r = resolve_folder_sort(&f, &[&a, &b, &c, &d, &e]);
198 assert_eq!(r.axis, SortAxis::Date); assert!(!r.series_default);
200 }
201
202 #[test]
203 fn dates_below_threshold_fallback_to_title() {
204 let f = folder("projects/index.html", None);
205 let a = art("a", Some("2025-01-01"), None);
206 let b = art("b", None, None);
207 let c = art("c", None, None);
208 let d = art("d", None, None);
209 let e = art("e", None, None);
210 let r = resolve_folder_sort(&f, &[&a, &b, &c, &d, &e]);
211 assert_eq!(r.axis, SortAxis::Title); }
213
214 #[test]
215 fn weight_beats_date() {
216 let f = folder("hybrid/index.html", None);
217 let a = art("a", Some("2025-01-01"), Some(1));
218 let b = art("b", Some("2025-02-01"), None);
219 let r = resolve_folder_sort(&f, &[&a, &b]);
220 assert_eq!(r.axis, SortAxis::Weight);
221 }
222
223 #[test]
224 fn subfolders_excluded_from_inference() {
225 let f = folder("root/index.html", None);
226 let article = art("welcome", None, None);
227 let sub_a = folder("root/news/index.html", None);
228 let sub_b = folder("root/projects/index.html", None);
229 let r = resolve_folder_sort(&f, &[&article, &sub_a, &sub_b]);
230 assert_eq!(r.axis, SortAxis::Title);
231 }
232
233 #[test]
234 fn chps_style_root_with_only_subfolders_falls_to_title() {
235 let f = folder("root/index.html", None);
236 let sub_a = folder("root/news/index.html", None);
237 let sub_b = folder("root/projects/index.html", None);
238 let r = resolve_folder_sort(&f, &[&sub_a, &sub_b]);
239 assert_eq!(r.axis, SortAxis::Title);
240 }
241
242 #[test]
243 fn empty_folder_defaults_to_title() {
244 let f = folder("empty/index.html", None);
245 let r = resolve_folder_sort::<TestDoc>(&f, &[]);
246 assert_eq!(r.axis, SortAxis::Title);
247 }
248}
249
250pub trait SortableLabel {
254 fn label(&self) -> &str;
255}
256
257pub fn sort_by_resolved<'a, D>(
258 docs: &[&'a D],
259 resolved: &ResolvedSort,
260) -> Vec<&'a D>
261where
262 D: SortableDoc + SortableLabel,
263{
264 let axis_cmp = |a: &&'a D, b: &&'a D| -> std::cmp::Ordering {
265 match resolved.axis {
266 SortAxis::Date => {
267 let ad = a.date().unwrap_or("");
268 let bd = b.date().unwrap_or("");
269 bd.cmp(ad)
270 }
271 SortAxis::Weight => match (a.weight(), b.weight()) {
272 (Some(aw), Some(bw)) => aw.cmp(&bw),
273 (Some(_), None) => std::cmp::Ordering::Less,
274 (None, Some(_)) => std::cmp::Ordering::Greater,
275 (None, None) => a.clean_stem().cmp(b.clean_stem()),
276 },
277 SortAxis::Title => a.label().cmp(b.label()),
278 }
279 };
280
281 match &resolved.explicit_order {
282 Some(order) => {
283 let order_lower: Vec<String> = order.iter().map(|s| s.to_lowercase()).collect();
284 let order_map: std::collections::HashMap<&str, usize> = order_lower
285 .iter()
286 .enumerate()
287 .map(|(i, s)| (s.as_str(), i))
288 .collect();
289 let (mut listed, mut unlisted): (Vec<_>, Vec<_>) = docs.iter().copied().partition(|d| {
290 order_map.contains_key(d.clean_stem().to_lowercase().as_str())
291 });
292 listed.sort_by(|a, b| {
293 let ai = order_map.get(a.clean_stem().to_lowercase().as_str()).copied().unwrap_or(usize::MAX);
294 let bi = order_map.get(b.clean_stem().to_lowercase().as_str()).copied().unwrap_or(usize::MAX);
295 ai.cmp(&bi)
296 });
297 unlisted.sort_by(axis_cmp);
298 listed.extend(unlisted);
299 listed
300 }
301 None => {
302 let mut sorted: Vec<&'a D> = docs.to_vec();
303 sorted.sort_by(axis_cmp);
304 sorted
305 }
306 }
307}
308
309#[cfg(test)]
310mod sort_dispatch_tests {
311 use super::*;
312 use super::inference_tests::*; fn doc_with_label(stem: &str, date: Option<&str>, w: Option<i32>, label: &str) -> TestDocWithLabel {
315 TestDocWithLabel {
316 base: art(stem, date, w),
317 label_v: label.into(),
318 }
319 }
320
321 #[derive(Debug)]
322 struct TestDocWithLabel {
323 base: TestDoc,
324 label_v: String,
325 }
326 impl SortableDoc for TestDocWithLabel {
327 fn url_path(&self) -> &str { self.base.url_path() }
328 fn date(&self) -> Option<&str> { self.base.date() }
329 fn weight(&self) -> Option<i32> { self.base.weight() }
330 fn declared_sort(&self) -> Option<&SortField> { self.base.declared_sort() }
331 fn clean_stem(&self) -> &str { self.base.clean_stem() }
332 }
333 impl SortableLabel for TestDocWithLabel {
334 fn label(&self) -> &str { &self.label_v }
335 }
336
337 #[test]
338 fn date_desc() {
339 let a = doc_with_label("a", Some("2025-01-01"), None, "A");
340 let b = doc_with_label("b", Some("2025-03-01"), None, "B");
341 let c = doc_with_label("c", Some("2025-02-01"), None, "C");
342 let r = ResolvedSort { axis: SortAxis::Date, explicit_order: None, series_default: false };
343 let sorted = sort_by_resolved(&[&a, &b, &c], &r);
344 assert_eq!(sorted[0].clean_stem(), "b");
345 assert_eq!(sorted[2].clean_stem(), "a");
346 }
347
348 #[test]
349 fn weight_asc_unweighted_last() {
350 let a = doc_with_label("a", None, Some(2), "A");
351 let b = doc_with_label("b", None, None, "B");
352 let c = doc_with_label("c", None, Some(1), "C");
353 let r = ResolvedSort { axis: SortAxis::Weight, explicit_order: None, series_default: true };
354 let sorted = sort_by_resolved(&[&a, &b, &c], &r);
355 assert_eq!(sorted[0].clean_stem(), "c");
356 assert_eq!(sorted[1].clean_stem(), "a");
357 assert_eq!(sorted[2].clean_stem(), "b");
358 }
359
360 #[test]
361 fn title_alpha() {
362 let a = doc_with_label("zebra", None, None, "Zebra");
363 let b = doc_with_label("apple", None, None, "Apple");
364 let c = doc_with_label("mango", None, None, "Mango");
365 let r = ResolvedSort { axis: SortAxis::Title, explicit_order: None, series_default: false };
366 let sorted = sort_by_resolved(&[&a, &b, &c], &r);
367 assert_eq!(sorted[0].clean_stem(), "apple");
368 assert_eq!(sorted[2].clean_stem(), "zebra");
369 }
370
371 #[test]
372 fn explicit_list_with_tail() {
373 let a = doc_with_label("a", Some("2025-03-01"), None, "A");
374 let b = doc_with_label("b", Some("2025-02-01"), None, "B");
375 let intro = doc_with_label("intro", Some("2025-01-01"), None, "Intro");
376 let r = ResolvedSort {
377 axis: SortAxis::Date,
378 explicit_order: Some(vec!["intro".into()]),
379 series_default: true,
380 };
381 let sorted = sort_by_resolved(&[&a, &b, &intro], &r);
382 assert_eq!(sorted[0].clean_stem(), "intro"); assert_eq!(sorted[1].clean_stem(), "a"); assert_eq!(sorted[2].clean_stem(), "b");
385 }
386}