use std::collections::BTreeMap;
use std::collections::HashMap;
use tera::{Map, Tera, Value};
pub fn register(env: &mut Tera) {
env.register_filter(
"dirtree",
|value: &Value, _args: &HashMap<String, Value>| -> tera::Result<Value> {
let docs = value.as_array().ok_or_else(|| {
tera::Error::msg("dirtree filter: input must be an array of docs")
})?;
build_tree(docs)
},
);
}
enum Node {
Dir(BTreeMap<String, Node>),
File(Value),
}
fn build_tree(docs: &[Value]) -> tera::Result<Value> {
let mut root: BTreeMap<String, Node> = BTreeMap::new();
'docs: for doc in docs {
let output_path = doc
.get("output_path")
.and_then(Value::as_str)
.ok_or_else(|| {
tera::Error::msg("dirtree filter: every doc must have a string `output_path`")
})?;
let segments: Vec<&str> = output_path.split('/').filter(|s| !s.is_empty()).collect();
let Some((file_name, dirs)) = segments.split_last() else {
continue;
};
let mut cursor = &mut root;
for dir in dirs {
let entry = cursor
.entry((*dir).to_string())
.or_insert_with(|| Node::Dir(BTreeMap::new()));
match entry {
Node::Dir(children) => cursor = children,
Node::File(_) => continue 'docs,
}
}
cursor
.entry((*file_name).to_string())
.or_insert_with(|| Node::File(doc.clone()));
}
Ok(to_value(&root, ""))
}
fn to_value(nodes: &BTreeMap<String, Node>, prefix: &str) -> Value {
let array = nodes
.iter()
.map(|(name, node)| {
let path = if prefix.is_empty() {
name.clone()
} else {
format!("{}/{}", prefix, name)
};
let mut obj = Map::new();
obj.insert("name".to_string(), Value::String(name.clone()));
obj.insert("path".to_string(), Value::String(path.clone()));
match node {
Node::Dir(children) => {
obj.insert("kind".to_string(), Value::String("dir".to_string()));
obj.insert("children".to_string(), to_value(children, &path));
}
Node::File(doc) => {
obj.insert("kind".to_string(), Value::String("file".to_string()));
obj.insert("doc".to_string(), doc.clone());
}
}
Value::Object(obj)
})
.collect();
Value::Array(array)
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(output_path: &str, title: &str) -> Value {
let map: BTreeMap<&str, &str> = [("output_path", output_path), ("title", title)]
.into_iter()
.collect();
tera::to_value(map).unwrap()
}
#[test]
fn flat_files_sorted_ascending() {
let tree = build_tree(&[doc("index.html", "Home"), doc("about.html", "About")]).unwrap();
let top = tree.as_array().unwrap();
assert_eq!(top.len(), 2);
assert_eq!(top[0]["kind"], "file");
assert_eq!(top[0]["name"], "about.html");
assert_eq!(top[0]["path"], "about.html");
assert_eq!(top[0]["doc"]["title"], "About");
assert_eq!(top[1]["name"], "index.html");
}
#[test]
fn nests_dirs_and_accumulates_path() {
let tree = build_tree(&[
doc("posts/b/index.html", "B"),
doc("posts/a/index.html", "A"),
doc("about.html", "About"),
])
.unwrap();
let top = tree.as_array().unwrap();
assert_eq!(top[0]["kind"], "file");
assert_eq!(top[0]["name"], "about.html");
assert_eq!(top[1]["kind"], "dir");
assert_eq!(top[1]["name"], "posts");
assert_eq!(top[1]["path"], "posts");
let posts = top[1]["children"].as_array().unwrap();
assert_eq!(posts.len(), 2);
assert_eq!(posts[0]["name"], "a");
assert_eq!(posts[0]["path"], "posts/a");
assert_eq!(posts[1]["name"], "b");
assert_eq!(posts[1]["path"], "posts/b");
let a_children = posts[0]["children"].as_array().unwrap();
assert_eq!(a_children[0]["kind"], "file");
assert_eq!(a_children[0]["path"], "posts/a/index.html");
assert_eq!(a_children[0]["doc"]["title"], "A");
}
#[test]
fn non_array_input_is_an_error() {
let mut env = Tera::default();
register(&mut env);
let mut ctx = tera::Context::new();
ctx.insert("x", "not an array");
assert!(env.render_str("{{ x | dirtree }}", &ctx).is_err());
}
#[test]
fn missing_output_path_is_an_error() {
let no_path = tera::to_value(BTreeMap::from([("title", "No path")])).unwrap();
assert!(build_tree(&[no_path]).is_err());
}
}