use crate::config::SortField;
use serde_json::Value;
use std::cmp::Ordering;
#[derive(Debug, Clone, PartialEq)]
enum SortKey {
Missing,
Numeric(f64),
Text(String),
}
impl SortKey {
fn cmp_with_direction(&self, other: &SortKey, reverse: bool) -> Ordering {
match (self, other) {
(SortKey::Missing, SortKey::Missing) => Ordering::Equal,
(SortKey::Missing, _) => Ordering::Greater,
(_, SortKey::Missing) => Ordering::Less,
(SortKey::Numeric(a), SortKey::Numeric(b)) => {
let cmp = a.partial_cmp(b).unwrap_or(Ordering::Equal);
if reverse { cmp.reverse() } else { cmp }
}
(SortKey::Text(a), SortKey::Text(b)) => {
let cmp = a.cmp(b);
if reverse { cmp.reverse() } else { cmp }
}
_ => Ordering::Equal,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum SortOrder {
Asc,
Desc,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum SortCompare {
String,
Numeric,
}
pub fn sort_files(files: &mut [Value], sort_config: &[SortField]) {
if sort_config.is_empty() || files.len() <= 1 {
return;
}
let parsed_config: Vec<(SortOrder, SortCompare)> = sort_config
.iter()
.map(|sf| {
let order = if sf.order == "desc" {
SortOrder::Desc
} else {
SortOrder::Asc
};
let compare = if sf.compare == "numeric" {
SortCompare::Numeric
} else {
SortCompare::String
};
(order, compare)
})
.collect();
let keys: Vec<Vec<SortKey>> = files
.iter()
.map(|file| {
sort_config
.iter()
.zip(parsed_config.iter())
.map(|(sf, &(_, compare))| extract_sort_key(file, &sf.field, compare))
.collect()
})
.collect();
let mut indices: Vec<usize> = (0..files.len()).collect();
indices.sort_by(|&a, &b| {
for (field_idx, &(order, _)) in parsed_config.iter().enumerate() {
let reverse = order == SortOrder::Desc;
let cmp = keys[a][field_idx].cmp_with_direction(&keys[b][field_idx], reverse);
if cmp != Ordering::Equal {
return cmp;
}
}
Ordering::Equal
});
apply_permutation(files, &mut indices);
}
pub fn folder_entry(
name: &str,
url_path: String,
index_frontmatter: Option<&crate::markdown::SimpleMetadata>,
) -> Value {
let title = index_frontmatter
.and_then(|fm| fm.get("title"))
.and_then(Value::as_str)
.unwrap_or(name);
let mut entry = serde_json::json!({
"name": name,
"url_path": url_path,
"title": title,
});
if let (Some(fm), Some(obj)) = (index_frontmatter, entry.as_object_mut()) {
let public: serde_json::Map<String, Value> = fm
.iter()
.filter(|(k, _)| crate::contact::is_public_frontmatter_key(k))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
obj.insert("frontmatter".to_string(), Value::Object(public));
}
entry
}
pub fn sort_folders(entries: &mut [Value], sort_config: &[SortField]) {
fn name(v: &Value) -> &str {
v.get("name").and_then(Value::as_str).unwrap_or("")
}
entries.sort_by(|a, b| name(a).cmp(name(b)));
sort_files(entries, sort_config);
}
pub fn folder_index_frontmatter<'a>(
files: impl IntoIterator<Item = &'a crate::repo::MarkdownInfo>,
index_file: &str,
) -> std::collections::HashMap<String, crate::markdown::SimpleMetadata> {
files
.into_iter()
.filter(|info| {
info.raw_path
.file_name()
.and_then(|f| f.to_str())
.is_some_and(|f| f == index_file)
})
.filter_map(|info| Some((info.url_path.clone(), info.frontmatter.clone()?)))
.collect()
}
fn apply_permutation(data: &mut [Value], perm: &mut [usize]) {
for i in 0..data.len() {
if perm[i] == i {
continue;
}
let mut current = i;
loop {
let target = perm[current];
perm[current] = current; if target == i {
break;
}
data.swap(current, target);
current = target;
}
}
}
fn extract_sort_key(file: &Value, field: &str, compare: SortCompare) -> SortKey {
match get_field_value(file, field) {
None => SortKey::Missing,
Some(s) => match compare {
SortCompare::Numeric => {
let num: f64 = s.parse().unwrap_or(0.0);
SortKey::Numeric(num)
}
SortCompare::String => SortKey::Text(s.to_lowercase()),
},
}
}
fn get_field_value(file: &Value, field: &str) -> Option<String> {
match field {
"title" => {
file.get("title")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
file.get("frontmatter")
.and_then(|fm| fm.get("title"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.or_else(|| get_filename_without_ext(file))
}
"filename" => file
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
"created" => file
.get("created")
.and_then(|v| v.as_u64())
.map(|n| n.to_string()),
"modified" => file
.get("modified")
.and_then(|v| v.as_u64())
.map(|n| n.to_string()),
_ => get_frontmatter_field(file, field),
}
}
fn get_frontmatter_field(file: &Value, field: &str) -> Option<String> {
let fm = file.get("frontmatter")?;
let value = fm.get(field)?;
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(if *b { "1".to_string() } else { "0".to_string() }),
_ => None,
}
}
fn get_filename_without_ext(file: &Value) -> Option<String> {
file.get("name").and_then(|v| v.as_str()).map(|name| {
name.strip_suffix(".md").unwrap_or(name).to_string()
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_file(name: &str, title: Option<&str>, order: Option<i64>) -> Value {
let mut frontmatter = serde_json::Map::new();
if let Some(t) = title {
frontmatter.insert("title".to_string(), json!(t));
}
if let Some(o) = order {
frontmatter.insert("order".to_string(), json!(o));
}
json!({
"name": name,
"title": title,
"created": 1000,
"modified": 2000,
"frontmatter": frontmatter
})
}
fn make_file_with_pinned(name: &str, title: &str, pinned: Option<bool>) -> Value {
let mut frontmatter = serde_json::Map::new();
frontmatter.insert("title".to_string(), json!(title));
if let Some(p) = pinned {
frontmatter.insert("pinned".to_string(), json!(p));
}
json!({
"name": name,
"title": title,
"created": 1000,
"modified": 2000,
"frontmatter": frontmatter
})
}
fn folder_names(entries: &[Value]) -> Vec<&str> {
entries
.iter()
.map(|e| e["name"].as_str().unwrap())
.collect()
}
#[test]
fn test_sort_folders_matches_sidebar_order() {
let titled: crate::markdown::SimpleMetadata =
[("title".to_string(), json!("Aardvark Studies"))].into();
let mut entries = vec![
folder_entry("zoo", "/zoo/".into(), Some(&titled)),
folder_entry("beta", "/beta/".into(), None),
folder_entry("Alpha", "/Alpha/".into(), None),
folder_entry("alpha", "/alpha/".into(), None),
folder_entry("Gamma", "/Gamma/".into(), None),
];
sort_folders(&mut entries, &crate::config::default_sort_config());
assert_eq!(
folder_names(&entries),
["zoo", "Alpha", "alpha", "beta", "Gamma"]
);
assert_eq!(entries[0]["title"], "Aardvark Studies");
assert_eq!(entries[1]["title"], "Alpha");
}
#[test]
fn test_sort_folders_is_input_order_independent() {
let make = |names: &[&str]| {
let mut v: Vec<Value> = names
.iter()
.map(|n| folder_entry(n, format!("/{n}/"), None))
.collect();
sort_folders(&mut v, &crate::config::default_sort_config());
folder_names(&v)
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>()
};
assert_eq!(make(&["b", "B", "a", "A"]), make(&["A", "a", "B", "b"]));
}
#[test]
fn test_folder_entry_custom_field_and_private_details() {
let fm: crate::markdown::SimpleMetadata = [
("order".to_string(), json!(2)),
("phones.work".to_string(), json!("555")),
]
.into();
let entry = folder_entry("x", "/x/".into(), Some(&fm));
assert_eq!(entry["frontmatter"]["order"], 2);
assert!(entry["frontmatter"].get("phones.work").is_none());
let mut entries = vec![folder_entry("a", "/a/".into(), None), entry];
let by_order = [SortField {
field: "order".into(),
order: "asc".into(),
compare: "numeric".into(),
}];
sort_folders(&mut entries, &by_order);
assert_eq!(folder_names(&entries), ["x", "a"], "missing sorts last");
}
#[test]
fn test_default_sort_by_title_ascending() {
let mut files = vec![
make_file("zebra.md", Some("Zebra"), None),
make_file("apple.md", Some("Apple"), None),
make_file("mango.md", Some("Mango"), None),
];
let config = vec![SortField {
field: "title".to_string(),
order: "asc".to_string(),
compare: "string".to_string(),
}];
sort_files(&mut files, &config);
assert_eq!(files[0]["title"], "Apple");
assert_eq!(files[1]["title"], "Mango");
assert_eq!(files[2]["title"], "Zebra");
}
#[test]
fn test_sort_by_title_descending() {
let mut files = vec![
make_file("apple.md", Some("Apple"), None),
make_file("zebra.md", Some("Zebra"), None),
make_file("mango.md", Some("Mango"), None),
];
let config = vec![SortField {
field: "title".to_string(),
order: "desc".to_string(),
compare: "string".to_string(),
}];
sort_files(&mut files, &config);
assert_eq!(files[0]["title"], "Zebra");
assert_eq!(files[1]["title"], "Mango");
assert_eq!(files[2]["title"], "Apple");
}
#[test]
fn test_sort_by_numeric_order() {
let mut files = vec![
make_file("third.md", Some("Third"), Some(3)),
make_file("first.md", Some("First"), Some(1)),
make_file("second.md", Some("Second"), Some(2)),
];
let config = vec![SortField {
field: "order".to_string(),
order: "asc".to_string(),
compare: "numeric".to_string(),
}];
sort_files(&mut files, &config);
assert_eq!(files[0]["title"], "First");
assert_eq!(files[1]["title"], "Second");
assert_eq!(files[2]["title"], "Third");
}
#[test]
fn test_missing_field_sorts_after() {
let mut files = vec![
make_file("no_order.md", Some("No Order"), None), make_file("first.md", Some("First"), Some(1)),
make_file("second.md", Some("Second"), Some(2)),
];
let config = vec![SortField {
field: "order".to_string(),
order: "asc".to_string(),
compare: "numeric".to_string(),
}];
sort_files(&mut files, &config);
assert_eq!(files[0]["title"], "First");
assert_eq!(files[1]["title"], "Second");
assert_eq!(files[2]["title"], "No Order");
}
#[test]
fn test_multi_level_sort() {
let mut files = vec![
make_file("c.md", Some("C"), Some(1)),
make_file("a.md", Some("A"), Some(2)),
make_file("b.md", Some("B"), Some(1)),
make_file("d.md", Some("D"), Some(2)),
];
let config = vec![
SortField {
field: "order".to_string(),
order: "asc".to_string(),
compare: "numeric".to_string(),
},
SortField {
field: "title".to_string(),
order: "asc".to_string(),
compare: "string".to_string(),
},
];
sort_files(&mut files, &config);
assert_eq!(files[0]["title"], "B");
assert_eq!(files[1]["title"], "C");
assert_eq!(files[2]["title"], "A");
assert_eq!(files[3]["title"], "D");
}
#[test]
fn test_pinned_pattern() {
let mut files = vec![
make_file_with_pinned("normal1.md", "Normal 1", None),
make_file_with_pinned("pinned1.md", "Pinned 1", Some(true)),
make_file_with_pinned("normal2.md", "Normal 2", None),
make_file_with_pinned("unpinned.md", "Unpinned", Some(false)),
];
let config = vec![
SortField {
field: "pinned".to_string(),
order: "desc".to_string(),
compare: "numeric".to_string(),
},
SortField {
field: "title".to_string(),
order: "asc".to_string(),
compare: "string".to_string(),
},
];
sort_files(&mut files, &config);
assert_eq!(files[0]["title"], "Pinned 1");
assert_eq!(files[1]["title"], "Unpinned"); assert_eq!(files[2]["title"], "Normal 1");
assert_eq!(files[3]["title"], "Normal 2");
}
#[test]
fn test_title_falls_back_to_filename() {
let mut files = vec![
make_file("zebra.md", None, None), make_file("apple.md", Some("Apple"), None),
];
let config = vec![SortField {
field: "title".to_string(),
order: "asc".to_string(),
compare: "string".to_string(),
}];
sort_files(&mut files, &config);
assert_eq!(files[0]["title"], "Apple");
assert!(files[1]["title"].is_null());
assert_eq!(files[1]["name"], "zebra.md");
}
#[test]
fn test_empty_config_preserves_order() {
let mut files = vec![
make_file("c.md", Some("C"), None),
make_file("a.md", Some("A"), None),
make_file("b.md", Some("B"), None),
];
let config: Vec<SortField> = vec![];
sort_files(&mut files, &config);
assert_eq!(files[0]["title"], "C");
assert_eq!(files[1]["title"], "A");
assert_eq!(files[2]["title"], "B");
}
#[test]
fn test_case_insensitive_string_sort() {
let mut files = vec![
make_file("b.md", Some("Banana"), None),
make_file("a.md", Some("apple"), None), make_file("c.md", Some("Cherry"), None),
];
let config = vec![SortField {
field: "title".to_string(),
order: "asc".to_string(),
compare: "string".to_string(),
}];
sort_files(&mut files, &config);
assert_eq!(files[0]["title"], "apple");
assert_eq!(files[1]["title"], "Banana");
assert_eq!(files[2]["title"], "Cherry");
}
#[test]
fn test_sort_by_modified_descending() {
let mut files = vec![
json!({
"name": "old.md",
"title": "Old",
"modified": 1000,
"frontmatter": {}
}),
json!({
"name": "new.md",
"title": "New",
"modified": 3000,
"frontmatter": {}
}),
json!({
"name": "middle.md",
"title": "Middle",
"modified": 2000,
"frontmatter": {}
}),
];
let config = vec![SortField {
field: "modified".to_string(),
order: "desc".to_string(),
compare: "numeric".to_string(),
}];
sort_files(&mut files, &config);
assert_eq!(files[0]["title"], "New");
assert_eq!(files[1]["title"], "Middle");
assert_eq!(files[2]["title"], "Old");
}
#[test]
fn test_single_element_preserves() {
let mut files = vec![make_file("a.md", Some("A"), None)];
let config = vec![SortField::default()];
sort_files(&mut files, &config);
assert_eq!(files[0]["title"], "A");
}
#[test]
fn test_apply_permutation_identity() {
let mut data = vec![json!("a"), json!("b"), json!("c")];
let mut perm = vec![0, 1, 2];
apply_permutation(&mut data, &mut perm);
assert_eq!(data, vec![json!("a"), json!("b"), json!("c")]);
}
#[test]
fn test_apply_permutation_reverse() {
let mut data = vec![json!("a"), json!("b"), json!("c")];
let mut perm = vec![2, 1, 0];
apply_permutation(&mut data, &mut perm);
assert_eq!(data, vec![json!("c"), json!("b"), json!("a")]);
}
#[test]
fn test_apply_permutation_cycle() {
let mut data = vec![json!("a"), json!("b"), json!("c"), json!("d")];
let mut perm = vec![1, 2, 3, 0]; apply_permutation(&mut data, &mut perm);
assert_eq!(data, vec![json!("b"), json!("c"), json!("d"), json!("a")]);
}
}