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/plans/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
37/// Minimal document trait for sort inference. Both src-tauri's
38/// ParsedDocument and the editor's in-memory document model implement this.
39pub 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    /// Whether this doc is a folder-index page. moss uses pretty URLs
46    /// (`<stem>/index.html`) for both articles and subfolder indexes, so
47    /// the URL pattern alone can't tell them apart. Implementors that
48    /// distinguish via metadata (e.g. `kind == Folder`) should override.
49    /// Default returns false — safe for callers that only ever pass
50    /// articles in.
51    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    // Exclude subfolder indexes via the kind-aware trait method.
63    // The legacy URL-pattern filter (`!url.ends_with("/index.html")`) is
64    // wrong under pretty URLs — every article also ends with
65    // `<stem>/index.html` — so we delegate to the impl. The default
66    // `is_folder_index() == false` keeps moss-core's existing single-file
67    // tests (`a.url = "a.html"`) green; src-tauri's `ParsedDocument`
68    // returns true when `kind == Folder`.
69    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            // Entries may be written as Obsidian `[[Wikilinks]]`, quoted refs, or
78            // paths (`travel/foo.md`). Normalize each to the bare filename stem so
79            // it matches `clean_stem()` at sort time — otherwise the explicit
80            // order is silently ignored and children fall back to the axis sort.
81            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        // Entries written as `[[Wikilinks]]`, quoted paths, or bare names must
192        // all collapse to the filename stem in `explicit_order`, so they match
193        // `clean_stem()` when `sort_by_resolved` partitions listed children.
194        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);  // 4/5 = 0.8 == threshold
233        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);  // 1/5 = 0.2 < 0.8
246    }
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
284/// Optional supplementary trait for label-based sorting.
285/// Implementations that want Title-axis support implement both
286/// SortableDoc and SortableLabel.
287pub 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::*;  // reuse TestDoc
347
348    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        // Folder declares an explicit order using `[[Wikilinks]]`; every child is
408        // dated, so the axis infers Date. The bracketed refs must normalize to
409        // stems, match the children, and the explicit order must win over
410        // date-descending. Regression guard for the two-part collection-order fix.
411        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");  // listed first
450        assert_eq!(sorted[1].clean_stem(), "a");      // newest in tail
451        assert_eq!(sorted[2].clean_stem(), "b");
452    }
453}