Skip to main content

agd_core/agenda/
query.rs

1//! `AgendaEngine::query`: the org-ql subset agd supports (`and`, `or`, `not`,
2//! `todo`, `done`, `tags`, `priority`, `category`, `text`, `deadline :to` and
3//! `scheduled :to`), with org-ql's meanings.
4//!
5//! An unknown predicate or malformed query is an `invalid_query` error that
6//! says what was expected and lists every supported predicate; nothing is
7//! silently ignored.
8
9use chrono::{Duration, NaiveDate};
10
11use crate::{AgdError, ErrorCode, Item};
12
13use super::engine::AgendaEngine;
14use super::sort::compare_items;
15use crate::date::parse_org_date;
16
17impl<'a> AgendaEngine<'a> {
18    pub fn query(&self, expression: &str, today: NaiveDate) -> Result<Vec<Item>, AgdError> {
19        let expression = QlExpr::parse(expression)?;
20        let mut items: Vec<Item> = self
21            .index
22            .items()
23            .filter(|item| expression.matches(item, today))
24            .cloned()
25            .collect();
26        items.sort_by(|left, right| compare_items(left, right, self.index));
27        Ok(items)
28    }
29}
30
31/// The org-ql predicates `AgendaEngine::query` supports, as its errors list
32/// them.
33const QL_PREDICATES: &str = "(and ...), (or ...), (not ...), (todo), (todo \"KEYWORD\" ...), \
34(done), (tags \"TAG\"), (priority \"A\"), (category \"NAME\"), (text \"WORDS\"), \
35(deadline :to DAYS), (scheduled :to DAYS)";
36
37/// An org-ql expression, with org-ql's meanings: bare `(todo)` is any
38/// not-done TODO keyword, and `:to DAYS` is on or before today plus DAYS,
39/// past dates included.
40#[derive(Clone, Debug)]
41enum QlExpr {
42    And(Vec<QlExpr>),
43    Or(Vec<QlExpr>),
44    Not(Box<QlExpr>),
45    /// Empty: any not-done TODO keyword; else one of these keywords.
46    Todo(Vec<String>),
47    Tags(String),
48    Priority(String),
49    Category(String),
50    Text(String),
51    Deadline(i64),
52    Scheduled(i64),
53    Done,
54}
55
56impl QlExpr {
57    fn parse(input: &str) -> Result<Self, AgdError> {
58        let tokens = tokenize(input)?;
59        let mut cursor = 0;
60        let expression = parse_ql_expression(&tokens, &mut cursor)?;
61        if cursor != tokens.len() {
62            return Err(invalid_query(
63                "expected the query to end after its first predicate; combine predicates with (and ...) or (or ...)",
64            ));
65        }
66        Ok(expression)
67    }
68
69    fn matches(&self, item: &Item, today: NaiveDate) -> bool {
70        match self {
71            Self::And(expressions) => expressions
72                .iter()
73                .all(|expression| expression.matches(item, today)),
74            Self::Or(expressions) => expressions
75                .iter()
76                .any(|expression| expression.matches(item, today)),
77            Self::Not(expression) => !expression.matches(item, today),
78            Self::Todo(keywords) if keywords.is_empty() => item.todo.is_some() && !item.done,
79            Self::Todo(keywords) => item
80                .todo
81                .as_ref()
82                .is_some_and(|todo| keywords.contains(todo)),
83            Self::Tags(tag) => item
84                .tags
85                .iter()
86                .chain(&item.inherited_tags)
87                .any(|value| value == tag),
88            Self::Priority(priority) => item.priority.as_ref() == Some(priority),
89            Self::Category(category) => item.category == *category,
90            Self::Text(text) => item.title.to_lowercase().contains(&text.to_lowercase()),
91            Self::Deadline(days) => item
92                .planning
93                .deadline
94                .as_ref()
95                .and_then(|timestamp| parse_org_date(&timestamp.date).ok())
96                .is_some_and(|date| date <= today + Duration::days(*days)),
97            Self::Scheduled(days) => item
98                .planning
99                .scheduled
100                .as_ref()
101                .and_then(|timestamp| parse_org_date(&timestamp.date).ok())
102                .is_some_and(|date| date <= today + Duration::days(*days)),
103            Self::Done => item.done,
104        }
105    }
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
109enum Token {
110    Open,
111    Close,
112    Word(String),
113    String(String),
114}
115
116fn tokenize(input: &str) -> Result<Vec<Token>, AgdError> {
117    let mut tokens = Vec::new();
118    let mut chars = input.char_indices().peekable();
119    while let Some((_, character)) = chars.next() {
120        match character {
121            '(' => tokens.push(Token::Open),
122            ')' => tokens.push(Token::Close),
123            '"' => {
124                let mut value = String::new();
125                let mut closed = false;
126                while let Some((_, character)) = chars.next() {
127                    if character == '"' {
128                        closed = true;
129                        break;
130                    }
131                    if character == '\\' {
132                        let Some((_, escaped)) = chars.next() else {
133                            break;
134                        };
135                        value.push(escaped);
136                    } else {
137                        value.push(character);
138                    }
139                }
140                if !closed {
141                    return Err(invalid_query("expected a closing `\"` for a string"));
142                }
143                tokens.push(Token::String(value));
144            }
145            character if character.is_whitespace() => {}
146            _ => {
147                let mut value = String::from(character);
148                while let Some((_, next)) = chars.peek() {
149                    if next.is_whitespace() || matches!(next, '(' | ')') {
150                        break;
151                    }
152                    value.push(*next);
153                    chars.next();
154                }
155                tokens.push(Token::Word(value));
156            }
157        }
158    }
159    Ok(tokens)
160}
161
162fn parse_ql_expression(tokens: &[Token], cursor: &mut usize) -> Result<QlExpr, AgdError> {
163    expect(tokens, cursor, Token::Open, "`(` to start a predicate")?;
164    let operator = word(tokens, cursor, "a predicate name after `(`")?;
165    let expression = match operator.as_str() {
166        "and" | "or" => {
167            let mut children = Vec::new();
168            while !matches!(tokens.get(*cursor), Some(Token::Close) | None) {
169                children.push(parse_ql_expression(tokens, cursor)?);
170            }
171            if children.is_empty() {
172                return Err(invalid_query(format!(
173                    "expected one or more predicates inside ({operator} ...)"
174                )));
175            }
176            if operator == "and" {
177                QlExpr::And(children)
178            } else {
179                QlExpr::Or(children)
180            }
181        }
182        "not" => QlExpr::Not(Box::new(parse_ql_expression(tokens, cursor)?)),
183        "todo" => {
184            let mut keywords = Vec::new();
185            while let Some(Token::String(keyword)) = tokens.get(*cursor) {
186                keywords.push(keyword.clone());
187                *cursor += 1;
188            }
189            QlExpr::Todo(keywords)
190        }
191        "tags" => QlExpr::Tags(string_value(tokens, cursor, "(tags \"work\")")?),
192        "priority" => QlExpr::Priority(string_value(tokens, cursor, "(priority \"A\")")?),
193        "category" => QlExpr::Category(string_value(tokens, cursor, "(category \"home\")")?),
194        "text" => QlExpr::Text(string_value(tokens, cursor, "(text \"report\")")?),
195        "done" => QlExpr::Done,
196        "deadline" | "scheduled" => {
197            let usage = format!("`:to DAYS`, as in ({operator} :to 7)");
198            if word(tokens, cursor, &usage)? != ":to" {
199                return Err(invalid_query(format!("expected {usage}")));
200            }
201            let days = word(tokens, cursor, &usage)?
202                .parse::<i64>()
203                .map_err(|_| invalid_query(format!("expected {usage}")))?;
204            if operator == "deadline" {
205                QlExpr::Deadline(days)
206            } else {
207                QlExpr::Scheduled(days)
208            }
209        }
210        _ => {
211            return Err(invalid_query(format!("unknown predicate `{operator}`")));
212        }
213    };
214    expect(
215        tokens,
216        cursor,
217        Token::Close,
218        &format!("`)` to close ({operator} ...)"),
219    )?;
220    Ok(expression)
221}
222
223/// An `invalid_query` error: what was expected, then the supported
224/// predicates.
225fn invalid_query(problem: impl std::fmt::Display) -> AgdError {
226    AgdError::new(
227        ErrorCode::InvalidQuery,
228        format!("{problem}; supported predicates: {QL_PREDICATES}"),
229    )
230}
231
232fn expect(
233    tokens: &[Token],
234    cursor: &mut usize,
235    expected: Token,
236    what: &str,
237) -> Result<(), AgdError> {
238    if tokens.get(*cursor) == Some(&expected) {
239        *cursor += 1;
240        Ok(())
241    } else {
242        Err(invalid_query(format!("expected {what}")))
243    }
244}
245
246fn word(tokens: &[Token], cursor: &mut usize, what: &str) -> Result<String, AgdError> {
247    match tokens.get(*cursor) {
248        Some(Token::Word(value)) => {
249            *cursor += 1;
250            Ok(value.clone())
251        }
252        _ => Err(invalid_query(format!("expected {what}"))),
253    }
254}
255
256fn string_value(tokens: &[Token], cursor: &mut usize, example: &str) -> Result<String, AgdError> {
257    match tokens.get(*cursor) {
258        Some(Token::String(value)) => {
259            *cursor += 1;
260            Ok(value.clone())
261        }
262        _ => Err(invalid_query(format!(
263            "expected a quoted string, as in {example}"
264        ))),
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::agenda::test_support::fixture;
272
273    #[test]
274    fn evaluates_documented_org_ql_subset() {
275        let (_temp, index) = fixture(
276            "#+TODO: TODO NEXT | DONE\n* NEXT [#A] Report :work:\nDEADLINE: <2026-10-16 Fri>\n* TODO Other :personal:\n",
277        );
278        let items = AgendaEngine::new(&index)
279            .query(
280                "(and (todo \"NEXT\") (tags \"work\") (deadline :to 7))",
281                NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
282            )
283            .unwrap();
284        assert_eq!(items.len(), 1);
285        assert_eq!(items[0].title, "Report");
286    }
287
288    #[test]
289    fn org_ql_deadline_to_includes_past_dates_and_bare_todo_means_not_done() {
290        let (_temp, index) = fixture(
291            "#+TODO: TODO NEXT WAIT | DONE\n* TODO Overdue\nDEADLINE: <2026-10-11 Sun>\n* NEXT Soon\nDEADLINE: <2026-10-20 Tue>\n* WAIT Later\nDEADLINE: <2026-10-21 Wed>\n* DONE Finished\nDEADLINE: <2026-10-12 Mon>\n* Plain note\n",
292        );
293        let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
294        let titles = |expression: &str| -> Vec<String> {
295            AgendaEngine::new(&index)
296                .query(expression, today)
297                .unwrap()
298                .into_iter()
299                .map(|item| item.title)
300                .collect()
301        };
302        // Two days past is within `:to 7`, as in org-ql; so is a done item.
303        assert_eq!(titles("(deadline :to 7)"), ["Overdue", "Soon", "Finished"]);
304        assert_eq!(titles("(scheduled :to 7)"), Vec::<String>::new());
305        assert_eq!(titles("(todo)"), ["Overdue", "Soon", "Later"]);
306        assert_eq!(titles("(and (todo) (deadline :to 7))"), ["Overdue", "Soon"]);
307        assert_eq!(titles("(todo \"NEXT\" \"WAIT\")"), ["Soon", "Later"]);
308    }
309
310    #[test]
311    fn org_ql_errors_say_what_was_expected_and_list_the_predicates() {
312        let (_temp, index) = fixture("* TODO Task\n");
313        let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
314        let error = |expression: &str| {
315            let error = AgendaEngine::new(&index)
316                .query(expression, today)
317                .unwrap_err();
318            assert_eq!(error.code().as_str(), "invalid_query");
319            error.to_string()
320        };
321        let unknown = error("(heading \"x\")");
322        assert!(
323            unknown.starts_with("unknown predicate `heading`; supported predicates: (and ...)")
324        );
325        assert!(unknown.contains("(todo \"KEYWORD\" ...)"));
326        assert!(unknown.contains("(deadline :to DAYS)"));
327        assert!(
328            error("(tags work)").starts_with("expected a quoted string, as in (tags \"work\")")
329        );
330        assert!(error("(deadline 7)").starts_with("expected `:to DAYS`, as in (deadline :to 7)"));
331        assert!(error("todo").starts_with("expected `(` to start a predicate"));
332        assert!(error("(and (todo)").starts_with("expected `)` to close (and ...)"));
333        assert!(error("(todo) (done)").starts_with("expected the query to end"));
334    }
335}