Skip to main content

mbr_core/
sorting.rs

1//! Configurable file sorting for mbr.
2//!
3//! Provides multi-level sorting by any field (title, filename, date, frontmatter),
4//! with configurable order (ascending/descending) and comparison type (string/numeric).
5//!
6//! Uses a Schwartzian transform (decorate-sort-undecorate) to pre-extract sort keys,
7//! avoiding repeated field lookups and string allocations during comparisons.
8
9use crate::config::SortField;
10use serde_json::Value;
11use std::cmp::Ordering;
12
13/// A pre-extracted sort key for a single field of a single file.
14///
15/// Keys are extracted once before sorting begins, eliminating per-comparison allocations.
16#[derive(Debug, Clone, PartialEq)]
17enum SortKey {
18    /// Field value is missing — sorts AFTER all present values regardless of sort direction.
19    Missing,
20    /// Numeric value (parsed from string).
21    Numeric(f64),
22    /// String value (pre-lowercased for case-insensitive comparison).
23    Text(String),
24}
25
26impl SortKey {
27    /// Compares two sort keys for a single field.
28    ///
29    /// Missing values always sort after present values (regardless of sort order).
30    /// The `reverse` flag only applies to present-vs-present comparisons.
31    fn cmp_with_direction(&self, other: &SortKey, reverse: bool) -> Ordering {
32        match (self, other) {
33            (SortKey::Missing, SortKey::Missing) => Ordering::Equal,
34            (SortKey::Missing, _) => Ordering::Greater,
35            (_, SortKey::Missing) => Ordering::Less,
36            (SortKey::Numeric(a), SortKey::Numeric(b)) => {
37                let cmp = a.partial_cmp(b).unwrap_or(Ordering::Equal);
38                if reverse { cmp.reverse() } else { cmp }
39            }
40            (SortKey::Text(a), SortKey::Text(b)) => {
41                let cmp = a.cmp(b);
42                if reverse { cmp.reverse() } else { cmp }
43            }
44            // Should not happen in practice (mixed types for same field),
45            // but handle gracefully by treating as equal.
46            _ => Ordering::Equal,
47        }
48    }
49}
50
51/// Parsed sort direction to avoid repeated string comparisons.
52#[derive(Debug, Clone, Copy, PartialEq)]
53enum SortOrder {
54    Asc,
55    Desc,
56}
57
58/// Parsed comparison type to avoid repeated string comparisons.
59#[derive(Debug, Clone, Copy, PartialEq)]
60enum SortCompare {
61    String,
62    Numeric,
63}
64
65/// Sorts files in place according to the given sort configuration.
66///
67/// Uses a Schwartzian transform to minimize allocations:
68/// 1. **Decorate**: Extract all sort keys for all items (O(n * k) allocations, done once)
69/// 2. **Sort**: Sort an index array by comparing pre-computed keys (zero allocations)
70/// 3. **Undecorate**: Apply the permutation in-place
71///
72/// # Special field names
73/// - `"title"` - Uses frontmatter title, falls back to filename without extension
74/// - `"filename"` - Uses raw filename
75/// - `"created"` - Uses created timestamp
76/// - `"modified"` - Uses modified timestamp
77/// - Any other string - Looks up frontmatter field
78///
79/// # Missing value behavior
80/// Files missing a sort field sort AFTER files that have it.
81/// This enables patterns like "pinned" (files with pinned:true first) or
82/// "order" (files with explicit order first, then others).
83pub fn sort_files(files: &mut [Value], sort_config: &[SortField]) {
84    if sort_config.is_empty() || files.len() <= 1 {
85        return;
86    }
87
88    // Parse sort configuration once
89    let parsed_config: Vec<(SortOrder, SortCompare)> = sort_config
90        .iter()
91        .map(|sf| {
92            let order = if sf.order == "desc" {
93                SortOrder::Desc
94            } else {
95                SortOrder::Asc
96            };
97            let compare = if sf.compare == "numeric" {
98                SortCompare::Numeric
99            } else {
100                SortCompare::String
101            };
102            (order, compare)
103        })
104        .collect();
105
106    // Step 1: Decorate — extract all sort keys upfront
107    let keys: Vec<Vec<SortKey>> = files
108        .iter()
109        .map(|file| {
110            sort_config
111                .iter()
112                .zip(parsed_config.iter())
113                .map(|(sf, &(_, compare))| extract_sort_key(file, &sf.field, compare))
114                .collect()
115        })
116        .collect();
117
118    // Step 2: Sort — build index array and sort by pre-computed keys
119    let mut indices: Vec<usize> = (0..files.len()).collect();
120    indices.sort_by(|&a, &b| {
121        for (field_idx, &(order, _)) in parsed_config.iter().enumerate() {
122            let reverse = order == SortOrder::Desc;
123            let cmp = keys[a][field_idx].cmp_with_direction(&keys[b][field_idx], reverse);
124            if cmp != Ordering::Equal {
125                return cmp;
126            }
127        }
128        Ordering::Equal
129    });
130
131    // Step 3: Undecorate — apply permutation in-place using cycle-chase
132    apply_permutation(files, &mut indices);
133}
134
135/// Builds one subfolder entry for a section/home page listing.
136///
137/// `index_frontmatter` is the folder's own index note (`docs/index.md` for
138/// `docs/`), when it has one. Its `title` becomes the entry's `title` and its
139/// frontmatter rides along for custom sort fields — the same two things the
140/// sidebar's `buildFolderTree` attaches to a folder node, which is what lets
141/// [`sort_folders`] order a listing exactly as `sortFolders()` orders the
142/// sidebar. `title` is always present (the folder name when there is no index
143/// title), mirroring `getFolderFieldValue`'s `folder.title ?? folder.name`.
144///
145/// Contact details are filtered out of the attached frontmatter like they are
146/// out of `site.json`: a listing has no use for an index note's phone number.
147pub fn folder_entry(
148    name: &str,
149    url_path: String,
150    index_frontmatter: Option<&crate::markdown::SimpleMetadata>,
151) -> Value {
152    let title = index_frontmatter
153        .and_then(|fm| fm.get("title"))
154        .and_then(Value::as_str)
155        .unwrap_or(name);
156    let mut entry = serde_json::json!({
157        "name": name,
158        "url_path": url_path,
159        "title": title,
160    });
161    if let (Some(fm), Some(obj)) = (index_frontmatter, entry.as_object_mut()) {
162        let public: serde_json::Map<String, Value> = fm
163            .iter()
164            .filter(|(k, _)| crate::contact::is_public_frontmatter_key(k))
165            .map(|(k, v)| (k.clone(), v.clone()))
166            .collect();
167        obj.insert("frontmatter".to_string(), Value::Object(public));
168    }
169    entry
170}
171
172/// Sorts subfolder entries (from [`folder_entry`]) the way the sidebar's
173/// `sortFolders()` does, so a section page and the sidebar list folders in the
174/// same order.
175///
176/// Entries are first put in byte order of `name` so ties under the configured
177/// sort resolve the same way on every run — the callers' inputs come from a
178/// concurrent map or a `HashSet`, whose order changes per process.
179pub fn sort_folders(entries: &mut [Value], sort_config: &[SortField]) {
180    fn name(v: &Value) -> &str {
181        v.get("name").and_then(Value::as_str).unwrap_or("")
182    }
183    entries.sort_by(|a, b| name(a).cmp(name(b)));
184    sort_files(entries, sort_config);
185}
186
187/// Frontmatter of every folder's index note, keyed by the folder's URL
188/// (`/docs/`) — the lookup [`folder_entry`] needs, built in one pass.
189pub fn folder_index_frontmatter<'a>(
190    files: impl IntoIterator<Item = &'a crate::repo::MarkdownInfo>,
191    index_file: &str,
192) -> std::collections::HashMap<String, crate::markdown::SimpleMetadata> {
193    files
194        .into_iter()
195        .filter(|info| {
196            info.raw_path
197                .file_name()
198                .and_then(|f| f.to_str())
199                .is_some_and(|f| f == index_file)
200        })
201        .filter_map(|info| Some((info.url_path.clone(), info.frontmatter.clone()?)))
202        .collect()
203}
204
205/// Applies a permutation to a slice in-place using cycle-chase algorithm.
206///
207/// After this function, `data[i]` will contain the element that was originally
208/// at position `perm[i]`. The `perm` array is consumed (modified) during the process.
209fn apply_permutation(data: &mut [Value], perm: &mut [usize]) {
210    for i in 0..data.len() {
211        // Follow the cycle starting at position i
212        if perm[i] == i {
213            continue;
214        }
215        let mut current = i;
216        loop {
217            let target = perm[current];
218            perm[current] = current; // Mark as placed
219            if target == i {
220                break;
221            }
222            data.swap(current, target);
223            current = target;
224        }
225    }
226}
227
228/// Extracts a sort key from a file JSON object for a given field.
229fn extract_sort_key(file: &Value, field: &str, compare: SortCompare) -> SortKey {
230    match get_field_value(file, field) {
231        None => SortKey::Missing,
232        Some(s) => match compare {
233            SortCompare::Numeric => {
234                let num: f64 = s.parse().unwrap_or(0.0);
235                SortKey::Numeric(num)
236            }
237            SortCompare::String => SortKey::Text(s.to_lowercase()),
238        },
239    }
240}
241
242/// Extracts a field value from a file JSON object.
243///
244/// # Special fields
245/// - `"title"` - frontmatter.title, fallback to name (filename)
246/// - `"filename"` - raw name field
247/// - `"created"` - created timestamp as string
248/// - `"modified"` - modified timestamp as string
249/// - Any other field - frontmatter lookup
250///
251/// # Boolean handling
252/// Boolean values are converted to "1" (true) or "0" (false) for sorting.
253fn get_field_value(file: &Value, field: &str) -> Option<String> {
254    match field {
255        "title" => {
256            // Try frontmatter.title first, then fall back to name (filename without extension)
257            file.get("title")
258                .and_then(|v| v.as_str())
259                .map(|s| s.to_string())
260                .or_else(|| {
261                    file.get("frontmatter")
262                        .and_then(|fm| fm.get("title"))
263                        .and_then(|v| v.as_str())
264                        .map(|s| s.to_string())
265                })
266                .or_else(|| get_filename_without_ext(file))
267        }
268        "filename" => file
269            .get("name")
270            .and_then(|v| v.as_str())
271            .map(|s| s.to_string()),
272        "created" => file
273            .get("created")
274            .and_then(|v| v.as_u64())
275            .map(|n| n.to_string()),
276        "modified" => file
277            .get("modified")
278            .and_then(|v| v.as_u64())
279            .map(|n| n.to_string()),
280        // Look up any other field in frontmatter
281        _ => get_frontmatter_field(file, field),
282    }
283}
284
285/// Gets a field from the frontmatter object, handling various types.
286fn get_frontmatter_field(file: &Value, field: &str) -> Option<String> {
287    let fm = file.get("frontmatter")?;
288    let value = fm.get(field)?;
289
290    match value {
291        Value::String(s) => Some(s.clone()),
292        Value::Number(n) => Some(n.to_string()),
293        Value::Bool(b) => Some(if *b { "1".to_string() } else { "0".to_string() }),
294        _ => None,
295    }
296}
297
298/// Extracts filename without extension from the name field.
299fn get_filename_without_ext(file: &Value) -> Option<String> {
300    file.get("name").and_then(|v| v.as_str()).map(|name| {
301        // Remove .md extension if present
302        name.strip_suffix(".md").unwrap_or(name).to_string()
303    })
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use serde_json::json;
310
311    fn make_file(name: &str, title: Option<&str>, order: Option<i64>) -> Value {
312        let mut frontmatter = serde_json::Map::new();
313        if let Some(t) = title {
314            frontmatter.insert("title".to_string(), json!(t));
315        }
316        if let Some(o) = order {
317            frontmatter.insert("order".to_string(), json!(o));
318        }
319
320        json!({
321            "name": name,
322            "title": title,
323            "created": 1000,
324            "modified": 2000,
325            "frontmatter": frontmatter
326        })
327    }
328
329    fn make_file_with_pinned(name: &str, title: &str, pinned: Option<bool>) -> Value {
330        let mut frontmatter = serde_json::Map::new();
331        frontmatter.insert("title".to_string(), json!(title));
332        if let Some(p) = pinned {
333            frontmatter.insert("pinned".to_string(), json!(p));
334        }
335
336        json!({
337            "name": name,
338            "title": title,
339            "created": 1000,
340            "modified": 2000,
341            "frontmatter": frontmatter
342        })
343    }
344
345    fn folder_names(entries: &[Value]) -> Vec<&str> {
346        entries
347            .iter()
348            .map(|e| e["name"].as_str().unwrap())
349            .collect()
350    }
351
352    /// Mirrors `sortFolders()` in components/src/sorting.ts: index-note title
353    /// else folder name, case-insensitive, deterministic on ties.
354    #[test]
355    fn test_sort_folders_matches_sidebar_order() {
356        let titled: crate::markdown::SimpleMetadata =
357            [("title".to_string(), json!("Aardvark Studies"))].into();
358        let mut entries = vec![
359            folder_entry("zoo", "/zoo/".into(), Some(&titled)),
360            folder_entry("beta", "/beta/".into(), None),
361            folder_entry("Alpha", "/Alpha/".into(), None),
362            folder_entry("alpha", "/alpha/".into(), None),
363            folder_entry("Gamma", "/Gamma/".into(), None),
364        ];
365        sort_folders(&mut entries, &crate::config::default_sort_config());
366        // "zoo" sorts as its index title; Alpha/alpha tie case-insensitively
367        // and fall back to byte order.
368        assert_eq!(
369            folder_names(&entries),
370            ["zoo", "Alpha", "alpha", "beta", "Gamma"]
371        );
372        assert_eq!(entries[0]["title"], "Aardvark Studies");
373        assert_eq!(entries[1]["title"], "Alpha");
374    }
375
376    #[test]
377    fn test_sort_folders_is_input_order_independent() {
378        let make = |names: &[&str]| {
379            let mut v: Vec<Value> = names
380                .iter()
381                .map(|n| folder_entry(n, format!("/{n}/"), None))
382                .collect();
383            sort_folders(&mut v, &crate::config::default_sort_config());
384            folder_names(&v)
385                .into_iter()
386                .map(str::to_string)
387                .collect::<Vec<_>>()
388        };
389        assert_eq!(make(&["b", "B", "a", "A"]), make(&["A", "a", "B", "b"]));
390    }
391
392    #[test]
393    fn test_folder_entry_custom_field_and_private_details() {
394        let fm: crate::markdown::SimpleMetadata = [
395            ("order".to_string(), json!(2)),
396            ("phones.work".to_string(), json!("555")),
397        ]
398        .into();
399        let entry = folder_entry("x", "/x/".into(), Some(&fm));
400        assert_eq!(entry["frontmatter"]["order"], 2);
401        assert!(entry["frontmatter"].get("phones.work").is_none());
402
403        let mut entries = vec![folder_entry("a", "/a/".into(), None), entry];
404        let by_order = [SortField {
405            field: "order".into(),
406            order: "asc".into(),
407            compare: "numeric".into(),
408        }];
409        sort_folders(&mut entries, &by_order);
410        assert_eq!(folder_names(&entries), ["x", "a"], "missing sorts last");
411    }
412
413    #[test]
414    fn test_default_sort_by_title_ascending() {
415        let mut files = vec![
416            make_file("zebra.md", Some("Zebra"), None),
417            make_file("apple.md", Some("Apple"), None),
418            make_file("mango.md", Some("Mango"), None),
419        ];
420
421        let config = vec![SortField {
422            field: "title".to_string(),
423            order: "asc".to_string(),
424            compare: "string".to_string(),
425        }];
426
427        sort_files(&mut files, &config);
428
429        assert_eq!(files[0]["title"], "Apple");
430        assert_eq!(files[1]["title"], "Mango");
431        assert_eq!(files[2]["title"], "Zebra");
432    }
433
434    #[test]
435    fn test_sort_by_title_descending() {
436        let mut files = vec![
437            make_file("apple.md", Some("Apple"), None),
438            make_file("zebra.md", Some("Zebra"), None),
439            make_file("mango.md", Some("Mango"), None),
440        ];
441
442        let config = vec![SortField {
443            field: "title".to_string(),
444            order: "desc".to_string(),
445            compare: "string".to_string(),
446        }];
447
448        sort_files(&mut files, &config);
449
450        assert_eq!(files[0]["title"], "Zebra");
451        assert_eq!(files[1]["title"], "Mango");
452        assert_eq!(files[2]["title"], "Apple");
453    }
454
455    #[test]
456    fn test_sort_by_numeric_order() {
457        let mut files = vec![
458            make_file("third.md", Some("Third"), Some(3)),
459            make_file("first.md", Some("First"), Some(1)),
460            make_file("second.md", Some("Second"), Some(2)),
461        ];
462
463        let config = vec![SortField {
464            field: "order".to_string(),
465            order: "asc".to_string(),
466            compare: "numeric".to_string(),
467        }];
468
469        sort_files(&mut files, &config);
470
471        assert_eq!(files[0]["title"], "First");
472        assert_eq!(files[1]["title"], "Second");
473        assert_eq!(files[2]["title"], "Third");
474    }
475
476    #[test]
477    fn test_missing_field_sorts_after() {
478        let mut files = vec![
479            make_file("no_order.md", Some("No Order"), None), // No order field
480            make_file("first.md", Some("First"), Some(1)),
481            make_file("second.md", Some("Second"), Some(2)),
482        ];
483
484        let config = vec![SortField {
485            field: "order".to_string(),
486            order: "asc".to_string(),
487            compare: "numeric".to_string(),
488        }];
489
490        sort_files(&mut files, &config);
491
492        // Files with order come first (sorted), then files without
493        assert_eq!(files[0]["title"], "First");
494        assert_eq!(files[1]["title"], "Second");
495        assert_eq!(files[2]["title"], "No Order");
496    }
497
498    #[test]
499    fn test_multi_level_sort() {
500        let mut files = vec![
501            make_file("c.md", Some("C"), Some(1)),
502            make_file("a.md", Some("A"), Some(2)),
503            make_file("b.md", Some("B"), Some(1)),
504            make_file("d.md", Some("D"), Some(2)),
505        ];
506
507        // Sort by order first, then by title
508        let config = vec![
509            SortField {
510                field: "order".to_string(),
511                order: "asc".to_string(),
512                compare: "numeric".to_string(),
513            },
514            SortField {
515                field: "title".to_string(),
516                order: "asc".to_string(),
517                compare: "string".to_string(),
518            },
519        ];
520
521        sort_files(&mut files, &config);
522
523        // Order 1: B, C (sorted by title)
524        // Order 2: A, D (sorted by title)
525        assert_eq!(files[0]["title"], "B");
526        assert_eq!(files[1]["title"], "C");
527        assert_eq!(files[2]["title"], "A");
528        assert_eq!(files[3]["title"], "D");
529    }
530
531    #[test]
532    fn test_pinned_pattern() {
533        let mut files = vec![
534            make_file_with_pinned("normal1.md", "Normal 1", None),
535            make_file_with_pinned("pinned1.md", "Pinned 1", Some(true)),
536            make_file_with_pinned("normal2.md", "Normal 2", None),
537            make_file_with_pinned("unpinned.md", "Unpinned", Some(false)),
538        ];
539
540        // Sort by pinned descending (true=1 first), then by title
541        let config = vec![
542            SortField {
543                field: "pinned".to_string(),
544                order: "desc".to_string(),
545                compare: "numeric".to_string(),
546            },
547            SortField {
548                field: "title".to_string(),
549                order: "asc".to_string(),
550                compare: "string".to_string(),
551            },
552        ];
553
554        sort_files(&mut files, &config);
555
556        // Pinned:true first, then pinned:false, then files without pinned field
557        assert_eq!(files[0]["title"], "Pinned 1");
558        assert_eq!(files[1]["title"], "Unpinned"); // pinned: false = "0"
559        // Files without pinned come last, sorted by title
560        assert_eq!(files[2]["title"], "Normal 1");
561        assert_eq!(files[3]["title"], "Normal 2");
562    }
563
564    #[test]
565    fn test_title_falls_back_to_filename() {
566        let mut files = vec![
567            make_file("zebra.md", None, None), // No title, use filename
568            make_file("apple.md", Some("Apple"), None),
569        ];
570
571        let config = vec![SortField {
572            field: "title".to_string(),
573            order: "asc".to_string(),
574            compare: "string".to_string(),
575        }];
576
577        sort_files(&mut files, &config);
578
579        assert_eq!(files[0]["title"], "Apple");
580        // The second file has no title, fell back to "zebra"
581        assert!(files[1]["title"].is_null());
582        assert_eq!(files[1]["name"], "zebra.md");
583    }
584
585    #[test]
586    fn test_empty_config_preserves_order() {
587        let mut files = vec![
588            make_file("c.md", Some("C"), None),
589            make_file("a.md", Some("A"), None),
590            make_file("b.md", Some("B"), None),
591        ];
592
593        let config: Vec<SortField> = vec![];
594
595        sort_files(&mut files, &config);
596
597        // Order preserved
598        assert_eq!(files[0]["title"], "C");
599        assert_eq!(files[1]["title"], "A");
600        assert_eq!(files[2]["title"], "B");
601    }
602
603    #[test]
604    fn test_case_insensitive_string_sort() {
605        let mut files = vec![
606            make_file("b.md", Some("Banana"), None),
607            make_file("a.md", Some("apple"), None), // lowercase
608            make_file("c.md", Some("Cherry"), None),
609        ];
610
611        let config = vec![SortField {
612            field: "title".to_string(),
613            order: "asc".to_string(),
614            compare: "string".to_string(),
615        }];
616
617        sort_files(&mut files, &config);
618
619        assert_eq!(files[0]["title"], "apple");
620        assert_eq!(files[1]["title"], "Banana");
621        assert_eq!(files[2]["title"], "Cherry");
622    }
623
624    #[test]
625    fn test_sort_by_modified_descending() {
626        let mut files = vec![
627            json!({
628                "name": "old.md",
629                "title": "Old",
630                "modified": 1000,
631                "frontmatter": {}
632            }),
633            json!({
634                "name": "new.md",
635                "title": "New",
636                "modified": 3000,
637                "frontmatter": {}
638            }),
639            json!({
640                "name": "middle.md",
641                "title": "Middle",
642                "modified": 2000,
643                "frontmatter": {}
644            }),
645        ];
646
647        let config = vec![SortField {
648            field: "modified".to_string(),
649            order: "desc".to_string(),
650            compare: "numeric".to_string(),
651        }];
652
653        sort_files(&mut files, &config);
654
655        assert_eq!(files[0]["title"], "New");
656        assert_eq!(files[1]["title"], "Middle");
657        assert_eq!(files[2]["title"], "Old");
658    }
659
660    #[test]
661    fn test_single_element_preserves() {
662        let mut files = vec![make_file("a.md", Some("A"), None)];
663        let config = vec![SortField::default()];
664        sort_files(&mut files, &config);
665        assert_eq!(files[0]["title"], "A");
666    }
667
668    #[test]
669    fn test_apply_permutation_identity() {
670        let mut data = vec![json!("a"), json!("b"), json!("c")];
671        let mut perm = vec![0, 1, 2];
672        apply_permutation(&mut data, &mut perm);
673        assert_eq!(data, vec![json!("a"), json!("b"), json!("c")]);
674    }
675
676    #[test]
677    fn test_apply_permutation_reverse() {
678        let mut data = vec![json!("a"), json!("b"), json!("c")];
679        let mut perm = vec![2, 1, 0];
680        apply_permutation(&mut data, &mut perm);
681        assert_eq!(data, vec![json!("c"), json!("b"), json!("a")]);
682    }
683
684    #[test]
685    fn test_apply_permutation_cycle() {
686        let mut data = vec![json!("a"), json!("b"), json!("c"), json!("d")];
687        let mut perm = vec![1, 2, 3, 0]; // rotate left
688        apply_permutation(&mut data, &mut perm);
689        assert_eq!(data, vec![json!("b"), json!("c"), json!("d"), json!("a")]);
690    }
691}