use crate::doc::Doc;
use anyhow::{Result, anyhow};
use globset::{Glob, GlobBuilder, GlobMatcher};
use serde_yaml_ng::Mapping;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrderKey {
Title,
Date,
Updated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDir {
Asc,
Desc,
}
#[derive(Debug, Clone)]
pub struct Query {
pub path: Option<Glob>,
pub order_by: OrderKey,
pub sort: SortDir,
pub omit: Vec<PathBuf>,
}
impl Default for Query {
fn default() -> Self {
Self {
path: None,
order_by: OrderKey::Date,
sort: SortDir::Desc,
omit: Vec::new(),
}
}
}
pub(crate) const KNOWN_KEYS: &[&str] = &["path", "order_by", "sort", "omit"];
impl Query {
pub fn from_yaml_mapping(m: &Mapping) -> Result<Self> {
for k in m.keys() {
let key = k
.as_str()
.ok_or_else(|| anyhow!("query: keys must be strings"))?;
if key == "limit" {
return Err(anyhow!(
"query: `limit` is no longer a collection key — use the \
`collection()` filter's `limit=` argument, or an archive's `limit:`"
));
}
if !KNOWN_KEYS.contains(&key) {
return Err(anyhow!(
"query: unknown key `{}` (allowed: {})",
key,
KNOWN_KEYS.join(", ")
));
}
}
let mut q = Self::default();
if let Some(v) = m.get("path") {
let s = v
.as_str()
.ok_or_else(|| anyhow!("query: `path` must be a string"))?;
let glob = GlobBuilder::new(s)
.literal_separator(true)
.build()
.map_err(|e| anyhow!("query: invalid glob `{}`: {}", s, e))?;
q.path = Some(glob);
}
if let Some(v) = m.get("order_by") {
let s = v
.as_str()
.ok_or_else(|| anyhow!("query: `order_by` must be a string"))?;
q.order_by = match s {
"title" => OrderKey::Title,
"date" => OrderKey::Date,
"updated" => OrderKey::Updated,
other => {
return Err(anyhow!(
"query: `order_by` must be one of title|date|updated (got `{}`)",
other
));
}
};
}
if let Some(v) = m.get("sort") {
let s = v
.as_str()
.ok_or_else(|| anyhow!("query: `sort` must be a string"))?;
q.sort = match s {
"asc" => SortDir::Asc,
"desc" => SortDir::Desc,
other => {
return Err(anyhow!(
"query: `sort` must be one of asc|desc (got `{}`)",
other
));
}
};
}
if let Some(v) = m.get("omit") {
let seq = v
.as_sequence()
.ok_or_else(|| anyhow!("query: `omit` must be an array of strings"))?;
q.omit = seq
.iter()
.map(|e| {
e.as_str()
.map(PathBuf::from)
.ok_or_else(|| anyhow!("query: `omit` entries must be strings"))
})
.collect::<Result<Vec<_>>>()?;
}
Ok(q)
}
}
impl Query {
pub fn evaluate<'a>(&self, docs: impl IntoIterator<Item = &'a Doc>) -> Vec<&'a Doc> {
let matcher: Option<GlobMatcher> = self.path.as_ref().map(Glob::compile_matcher);
let omit: HashSet<&Path> = self.omit.iter().map(PathBuf::as_path).collect();
let mut results: Vec<&Doc> = docs
.into_iter()
.filter(|d| {
if let Some(m) = &matcher
&& !m.is_match(&d.id_path)
{
return false;
}
if omit.contains(d.id_path.as_path()) {
return false;
}
true
})
.collect();
results.sort_by(|a, b| {
let cmp = match self.order_by {
OrderKey::Title => a.title.cmp(&b.title),
OrderKey::Date => a.date.cmp(&b.date),
OrderKey::Updated => a.updated.cmp(&b.updated),
};
let cmp = match self.sort {
SortDir::Asc => cmp,
SortDir::Desc => cmp.reverse(),
};
cmp.then_with(|| a.id_path.cmp(&b.id_path))
});
results
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, NaiveDate, Utc};
use std::path::PathBuf;
fn at(date: &str) -> DateTime<Utc> {
NaiveDate::parse_from_str(date, "%Y-%m-%d")
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
}
fn doc(id_path: &str, title: &str, date: &str) -> Doc {
Doc {
id_path: PathBuf::from(id_path),
title: title.to_string(),
date: at(date),
updated: at(date),
..Default::default()
}
}
fn yaml_mapping(s: &str) -> Mapping {
serde_yaml_ng::from_str(s).unwrap()
}
#[test]
fn from_yaml_mapping_empty_is_default() {
let q = Query::from_yaml_mapping(&Mapping::new()).unwrap();
assert!(q.path.is_none());
assert_eq!(q.order_by, OrderKey::Date);
assert_eq!(q.sort, SortDir::Desc);
}
#[test]
fn from_yaml_mapping_all_fields() {
let m = yaml_mapping("path: \"posts/*.md\"\norder_by: title\nsort: asc\n");
let q = Query::from_yaml_mapping(&m).unwrap();
assert!(q.path.is_some());
assert_eq!(q.order_by, OrderKey::Title);
assert_eq!(q.sort, SortDir::Asc);
}
#[test]
fn from_yaml_mapping_unknown_key_errors() {
let m = yaml_mapping("paht: x\n");
assert!(Query::from_yaml_mapping(&m).is_err());
}
#[test]
fn from_yaml_mapping_limit_key_errors_with_pointer() {
let m = yaml_mapping("limit: 5\n");
let err = Query::from_yaml_mapping(&m).unwrap_err().to_string();
assert!(err.contains("limit"), "error should mention limit: {err}");
assert!(
err.contains("collection()") || err.contains("archive"),
"error should point at the replacement: {err}"
);
}
#[test]
fn from_yaml_mapping_parses_omit() {
let m = yaml_mapping("omit:\n - posts/a.md\n - posts/b.md\n");
let q = Query::from_yaml_mapping(&m).unwrap();
assert_eq!(
q.omit,
vec![PathBuf::from("posts/a.md"), PathBuf::from("posts/b.md")]
);
}
#[test]
fn from_yaml_mapping_omit_not_sequence_errors() {
let m = yaml_mapping("omit: posts/a.md\n");
assert!(Query::from_yaml_mapping(&m).is_err());
}
#[test]
fn from_yaml_mapping_omit_non_string_entry_errors() {
let m = yaml_mapping("omit:\n - 42\n");
assert!(Query::from_yaml_mapping(&m).is_err());
}
#[test]
fn evaluate_filters_by_path_glob() {
let docs = vec![
doc("posts/a.md", "A", "2025-01-01"),
doc("posts/sub/b.md", "B", "2025-01-02"),
doc("pages/c.md", "C", "2025-01-03"),
];
let q = Query::from_yaml_mapping(&yaml_mapping("path: \"posts/*.md\"\n")).unwrap();
let results = q.evaluate(&docs);
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "A");
}
#[test]
fn evaluate_default_order_is_date_desc() {
let docs = vec![
doc("a.md", "A", "2025-01-01"),
doc("b.md", "B", "2025-02-01"),
doc("c.md", "C", "2025-03-01"),
];
let q = Query::default();
let results = q.evaluate(&docs);
let titles: Vec<&str> = results.iter().map(|d| d.title.as_str()).collect();
assert_eq!(titles, vec!["C", "B", "A"]);
}
#[test]
fn evaluate_order_by_title_asc() {
let docs = vec![
doc("a.md", "Charlie", "2025-01-01"),
doc("b.md", "Alpha", "2025-01-02"),
doc("c.md", "Bravo", "2025-01-03"),
];
let q = Query::from_yaml_mapping(&yaml_mapping("order_by: title\nsort: asc\n")).unwrap();
let results = q.evaluate(&docs);
let titles: Vec<&str> = results.iter().map(|d| d.title.as_str()).collect();
assert_eq!(titles, vec!["Alpha", "Bravo", "Charlie"]);
}
#[test]
fn evaluate_excludes_omitted_docs() {
let docs = vec![
doc("a.md", "A", "2025-01-01"),
doc("b.md", "B", "2025-02-01"),
doc("c.md", "C", "2025-03-01"),
];
let q = Query::from_yaml_mapping(&yaml_mapping("omit:\n - b.md\n")).unwrap();
let results = q.evaluate(&docs);
let titles: Vec<&str> = results.iter().map(|d| d.title.as_str()).collect();
assert_eq!(titles, vec!["C", "A"]);
}
#[test]
fn evaluate_omit_nonexistent_path_keeps_all() {
let docs = vec![
doc("a.md", "A", "2025-01-01"),
doc("b.md", "B", "2025-02-01"),
];
let q = Query::from_yaml_mapping(&yaml_mapping("omit:\n - nope.md\n")).unwrap();
let results = q.evaluate(&docs);
assert_eq!(results.len(), 2);
}
#[test]
fn evaluate_returns_whole_matching_set() {
let docs = vec![
doc("a.md", "A", "2025-01-01"),
doc("b.md", "B", "2025-02-01"),
doc("c.md", "C", "2025-03-01"),
];
let q = Query::default();
let results = q.evaluate(&docs);
assert_eq!(results.len(), 3);
}
}