1use crate::config::SortField;
10use serde_json::Value;
11use std::cmp::Ordering;
12
13#[derive(Debug, Clone, PartialEq)]
17enum SortKey {
18 Missing,
20 Numeric(f64),
22 Text(String),
24}
25
26impl SortKey {
27 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 _ => Ordering::Equal,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq)]
53enum SortOrder {
54 Asc,
55 Desc,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq)]
60enum SortCompare {
61 String,
62 Numeric,
63}
64
65pub fn sort_files(files: &mut [Value], sort_config: &[SortField]) {
84 if sort_config.is_empty() || files.len() <= 1 {
85 return;
86 }
87
88 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 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 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 apply_permutation(files, &mut indices);
133}
134
135pub 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
172pub 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
187pub 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
205fn apply_permutation(data: &mut [Value], perm: &mut [usize]) {
210 for i in 0..data.len() {
211 if perm[i] == i {
213 continue;
214 }
215 let mut current = i;
216 loop {
217 let target = perm[current];
218 perm[current] = current; if target == i {
220 break;
221 }
222 data.swap(current, target);
223 current = target;
224 }
225 }
226}
227
228fn 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
242fn get_field_value(file: &Value, field: &str) -> Option<String> {
254 match field {
255 "title" => {
256 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 _ => get_frontmatter_field(file, field),
282 }
283}
284
285fn 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
298fn get_filename_without_ext(file: &Value) -> Option<String> {
300 file.get("name").and_then(|v| v.as_str()).map(|name| {
301 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 #[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 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), 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 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 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 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 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 assert_eq!(files[0]["title"], "Pinned 1");
558 assert_eq!(files[1]["title"], "Unpinned"); 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), 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 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 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), 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]; apply_permutation(&mut data, &mut perm);
689 assert_eq!(data, vec![json!("b"), json!("c"), json!("d"), json!("a")]);
690 }
691}