Skip to main content

moss_core/
sort.rs

1//! Folder-listing sort: types and inference cascade.
2//!
3//! Pure Rust, zero I/O. Consumed by:
4//!   - the build pipeline (scan pass, card renderer, series-nav)
5//!   - the editor form (to show "inferred: date" next to undeclared sort:)
6//!
7//! See docs/archive/2026-05-17-listing-sort-and-embeds-design.md.
8
9use 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    /// Default value of `series:` chrome. True iff axis == Weight OR explicit_order is Some.
34    pub series_default: bool,
35}
36
37impl ResolvedSort {
38    /// Axis driving card *presentation* (the meta slot), as opposed to
39    /// ordering. An explicit-order listing is a curated sequence, not a
40    /// chronological feed, so its cards present like a Weight listing —
41    /// no per-card date — even when the ordering axis is Date.
42    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
51/// Minimal document trait for sort inference. Both src-tauri's
52/// ParsedDocument and the editor's in-memory document model implement this.
53pub 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    /// Whether this doc is a folder-index page. moss uses pretty URLs
60    /// (`<stem>/index.html`) for both articles and subfolder indexes, so
61    /// the URL pattern alone can't tell them apart. Implementors that
62    /// distinguish via metadata (e.g. `kind == Folder`) should override.
63    /// Default returns false — safe for callers that only ever pass
64    /// articles in.
65    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    // Exclude subfolder indexes via the kind-aware trait method.
77    // The legacy URL-pattern filter (`!url.ends_with("/index.html")`) is
78    // wrong under pretty URLs — every article also ends with
79    // `<stem>/index.html` — so we delegate to the impl. The default
80    // `is_folder_index() == false` keeps moss-core's existing single-file
81    // tests (`a.url = "a.html"`) green; src-tauri's `ParsedDocument`
82    // returns true when `kind == Folder`.
83    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            // Entries may be written as Obsidian `[[Wikilinks]]`, quoted refs, or
92            // paths (`travel/foo.md`). Normalize each to the bare filename stem so
93            // it matches `clean_stem()` at sort time — otherwise the explicit
94            // order is silently ignored and children fall back to the axis sort.
95            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        // Entries written as `[[Wikilinks]]`, quoted paths, or bare names must
206        // all collapse to the filename stem in `explicit_order`, so they match
207        // `clean_stem()` when `sort_by_resolved` partitions listed children.
208        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);  // 4/5 = 0.8 == threshold
247        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);  // 1/5 = 0.2 < 0.8
260    }
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
298/// Optional supplementary trait for label-based sorting.
299/// Implementations that want Title-axis support implement both
300/// SortableDoc and SortableLabel.
301pub 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::*;  // reuse TestDoc
361
362    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        // Folder declares an explicit order using `[[Wikilinks]]`; every child is
422        // dated, so the axis infers Date. The bracketed refs must normalize to
423        // stems, match the children, and the explicit order must win over
424        // date-descending. Regression guard for the two-part collection-order fix.
425        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");  // listed first
464        assert_eq!(sorted[1].clean_stem(), "a");      // newest in tail
465        assert_eq!(sorted[2].clean_stem(), "b");
466    }
467}