Skip to main content

org_core/
agenda_engine.rs

1use std::cmp::Ordering;
2use std::collections::BTreeSet;
3use std::sync::LazyLock;
4
5use chrono::{Datelike, Duration, Months, NaiveDate};
6use regex::Regex;
7
8use serde::Serialize;
9
10use crate::{
11    AGENDA_ENTRY_SCHEMA, AgdError, AgendaEntry, HeadlineSpan, Item, Occurrence, OccurrenceType,
12    PlanningTimestamp, Settings, ViewBlock, ViewSettings, WorkspaceIndex,
13};
14
15/// Checks a view block's match the way `AgendaEngine::view` will use it.
16pub fn validate_view_block(block: &ViewBlock) -> Result<(), AgdError> {
17    let Some(expression) = block.match_expression.as_deref() else {
18        return Ok(());
19    };
20    let expression = if block.kind == "todo" {
21        format!("/{expression}")
22    } else {
23        expression.to_string()
24    };
25    TagTodoMatcher::parse(&expression).map(|_| ())
26}
27
28/// One block of a saved view: agenda rows for an agenda block, otherwise a
29/// list of items. `from`/`to` are set for agenda blocks only.
30#[derive(Clone, Debug, Serialize)]
31pub struct ViewSection {
32    pub header: String,
33    pub from: Option<NaiveDate>,
34    pub to: Option<NaiveDate>,
35    pub entries: Vec<AgendaEntry>,
36    pub items: Vec<Item>,
37}
38
39/// Agenda bounds for an org-agenda span: day, week, month, or a number of
40/// days starting at `start`.
41pub fn span_days(
42    span: &str,
43    start: NaiveDate,
44    week_start: &str,
45) -> Result<(NaiveDate, NaiveDate), AgdError> {
46    match span {
47        "day" => Ok(AgendaSpan::Day.bounds(start, week_start)),
48        "week" => Ok(AgendaSpan::Week.bounds(start, week_start)),
49        "month" => Ok(AgendaSpan::Month.bounds(start, week_start)),
50        days => {
51            let days = days
52                .parse::<i64>()
53                .ok()
54                .filter(|days| *days > 0)
55                .ok_or_else(|| {
56                    AgdError::new(
57                        "invalid_span",
58                        format!("agenda span must be day, week, month or a day count: {span}"),
59                    )
60                })?;
61            Ok((start, start + Duration::days(days - 1)))
62        }
63    }
64}
65
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum AgendaSpan {
68    Day,
69    Week,
70    Month,
71}
72
73impl AgendaSpan {
74    pub fn bounds(self, from: NaiveDate, week_start: &str) -> (NaiveDate, NaiveDate) {
75        match self {
76            Self::Day => (from, from),
77            Self::Week => {
78                let desired = if week_start.eq_ignore_ascii_case("sunday") {
79                    6
80                } else {
81                    0
82                };
83                let current = from.weekday().num_days_from_monday() as i64;
84                let start = from - Duration::days((current - desired).rem_euclid(7));
85                (start, start + Duration::days(6))
86            }
87            Self::Month => {
88                let start = NaiveDate::from_ymd_opt(from.year(), from.month(), 1)
89                    .expect("valid first of month");
90                let next = start
91                    .checked_add_months(Months::new(1))
92                    .expect("next month");
93                (start, next - Duration::days(1))
94            }
95        }
96    }
97}
98
99#[derive(Clone, Debug)]
100pub struct AgendaQuery {
101    pub from: NaiveDate,
102    pub to: NaiveDate,
103    pub today: NaiveDate,
104    pub match_expression: Option<String>,
105    /// org-agenda log mode (`l`): also list CLOSED entries.
106    pub log_mode: bool,
107}
108
109#[derive(Clone, Debug)]
110pub struct AgendaEngine<'a> {
111    index: &'a WorkspaceIndex,
112}
113
114impl<'a> AgendaEngine<'a> {
115    pub fn new(index: &'a WorkspaceIndex) -> Self {
116        Self { index }
117    }
118
119    pub fn agenda(&self, query: &AgendaQuery) -> Result<Vec<AgendaEntry>, AgdError> {
120        if query.to < query.from {
121            return Err(AgdError::new(
122                "invalid_range",
123                "agenda end date is before its start date",
124            ));
125        }
126        let matcher = query
127            .match_expression
128            .as_deref()
129            .map(TagTodoMatcher::parse)
130            .transpose()?;
131        // org-agenda collects each file's deadlines, then scheduled entries,
132        // then blocks and timestamps, each in buffer order, and stable-sorts
133        // the day with its default strategy (time-up urgency-down
134        // category-keep, where category-keep keeps collection order).
135        let mut entries = Vec::new();
136        for document in self.index.documents() {
137            let mut file_entries = Vec::new();
138            for headline in document.headlines() {
139                if matcher
140                    .as_ref()
141                    .is_some_and(|matcher| !matcher.matches(&headline.item))
142                {
143                    continue;
144                }
145                let occurrences = self.occurrences(headline, query).map_err(|error| {
146                    AgdError::new(
147                        error.code().to_string(),
148                        format!("{}:{}: {error}", headline.item.file, headline.item.line),
149                    )
150                })?;
151                file_entries.extend(occurrences);
152            }
153            file_entries.sort_by_key(|entry| collection_rank(entry.occurrence.kind));
154            entries.extend(file_entries);
155        }
156        let settings = self.index.settings();
157        let mut keyed: Vec<(i64, AgendaEntry)> = entries
158            .into_iter()
159            .map(|entry| (urgency(&entry, query.today, settings), entry))
160            .collect();
161        let todo_order: Vec<String> = self
162            .index
163            .settings()
164            .todo_keywords
165            .iter()
166            .flatten()
167            .filter(|keyword| keyword.as_str() != "|")
168            .map(|keyword| keyword_name(keyword).to_string())
169            .collect();
170        keyed.sort_by(|left, right| {
171            left.1
172                .occurrence
173                .date
174                .cmp(&right.1.occurrence.date)
175                .then_with(|| compare_by_strategy(left, right, settings, &todo_order))
176        });
177        Ok(keyed.into_iter().map(|(_, entry)| entry).collect())
178    }
179
180    pub fn todo(&self, match_expression: Option<&str>) -> Result<Vec<Item>, AgdError> {
181        let matcher = match_expression.map(TagTodoMatcher::parse).transpose()?;
182        let mut items: Vec<Item> = self
183            .index
184            .items()
185            .filter(|item| item.todo.is_some() && !item.done)
186            .filter(|item| matcher.as_ref().is_none_or(|matcher| matcher.matches(item)))
187            .cloned()
188            .collect();
189        items.sort_by(compare_items);
190        Ok(items)
191    }
192
193    /// Unfinished headings in an inbox: under a heading named Inbox, or in a
194    /// file or category named inbox. Containers without a TODO keyword that have
195    /// children are left out.
196    pub fn inbox(&self) -> Vec<Item> {
197        let mut items = Vec::new();
198        for document in self.index.documents() {
199            let in_inbox_file = document
200                .path()
201                .file_stem()
202                .is_some_and(|stem| stem.eq_ignore_ascii_case("inbox"));
203            for headline in document.headlines() {
204                let item = &headline.item;
205                let ancestors = &item.olpath[..item.olpath.len().saturating_sub(1)];
206                let under_inbox = ancestors
207                    .iter()
208                    .any(|heading| heading.eq_ignore_ascii_case("inbox"));
209                let leaf = headline.subtree_span.end == headline.section_span.end;
210                // Top-level headings without a keyword in an inbox file are
211                // its sections (Tasks, Notes, Bookmarks), not items.
212                let container = in_inbox_file && headline.level == 1 && item.todo.is_none();
213                if !item.done
214                    && !container
215                    && (under_inbox || in_inbox_file || item.category.eq_ignore_ascii_case("inbox"))
216                    && (item.todo.is_some() || leaf)
217                    && !item.title.eq_ignore_ascii_case("inbox")
218                {
219                    items.push(item.clone());
220                }
221            }
222        }
223        items
224    }
225
226    /// Every heading matching an Emacs tags match, done or not, like an
227    /// org-agenda `tags` block.
228    pub fn tags(&self, match_expression: &str) -> Result<Vec<Item>, AgdError> {
229        let matcher = TagTodoMatcher::parse(match_expression)?;
230        let mut items: Vec<Item> = self
231            .index
232            .items()
233            .filter(|item| matcher.matches(item))
234            .cloned()
235            .collect();
236        items.sort_by(compare_items);
237        Ok(items)
238    }
239
240    /// Runs a saved view block by block. `start` is the first agenda day
241    /// before span alignment, as for `agd agenda --from`.
242    pub fn view(
243        &self,
244        view: &ViewSettings,
245        start: NaiveDate,
246        today: NaiveDate,
247        log_mode: bool,
248    ) -> Result<Vec<ViewSection>, AgdError> {
249        let settings = self.index.settings();
250        let mut sections = Vec::new();
251        for block in &view.blocks {
252            let match_expression = block.match_expression.clone();
253            let section = match block.kind.as_str() {
254                "agenda" => {
255                    let span = block.span.as_deref().unwrap_or(&settings.agenda_span);
256                    let (from, to) = span_days(span, start, &settings.week_start)?;
257                    ViewSection {
258                        header: block.header.clone().unwrap_or_default(),
259                        from: Some(from),
260                        to: Some(to),
261                        entries: self.agenda(&AgendaQuery {
262                            from,
263                            to,
264                            today,
265                            match_expression,
266                            log_mode,
267                        })?,
268                        items: Vec::new(),
269                    }
270                }
271                kind => {
272                    let keywords = match_expression.as_deref().unwrap_or("");
273                    let (items, default_header) = match kind {
274                        "alltodo" => (
275                            self.todo(None)?,
276                            "Global list of TODO items of type: ALL".to_string(),
277                        ),
278                        // `(todo "DELEGATED")` lists that keyword even when it is
279                        // a done state; only alltodo and tags-todo skip done.
280                        "todo" if !keywords.is_empty() => (
281                            self.tags(&format!("/{keywords}"))?,
282                            format!("Global list of TODO items of type: {keywords}"),
283                        ),
284                        "todo" => (
285                            self.todo(None)?,
286                            "Global list of TODO items of type: ALL".to_string(),
287                        ),
288                        "tags" => (
289                            self.tags(keywords)?,
290                            format!("Headlines with TAGS match: {keywords}"),
291                        ),
292                        _ => (
293                            self.todo(Some(keywords))?,
294                            format!("Headlines with TAGS match: {keywords}"),
295                        ),
296                    };
297                    ViewSection {
298                        header: block.header.clone().unwrap_or(default_header),
299                        from: None,
300                        to: None,
301                        entries: Vec::new(),
302                        items,
303                    }
304                }
305            };
306            sections.push(section);
307        }
308        Ok(sections)
309    }
310
311    pub fn query(&self, expression: &str, today: NaiveDate) -> Result<Vec<Item>, AgdError> {
312        let expression = QlExpr::parse(expression)?;
313        let mut items: Vec<Item> = self
314            .index
315            .items()
316            .filter(|item| expression.matches(item, today))
317            .cloned()
318            .collect();
319        items.sort_by(compare_items);
320        Ok(items)
321    }
322
323    fn occurrences(
324        &self,
325        headline: &HeadlineSpan,
326        query: &AgendaQuery,
327    ) -> Result<Vec<AgendaEntry>, AgdError> {
328        let (from, to, today) = (query.from, query.to, query.today);
329        let item = &headline.item;
330        let settings = self.index.settings();
331        let mut entries = Vec::new();
332
333        // Scheduled and deadline expansion mirrors org-agenda-get-scheduled
334        // and org-agenda-get-deadlines with Org's defaults: an entry shows on
335        // its base date and on future repeats, and today carries the
336        // past-scheduled or overdue reminder counted from the base date.
337        if !(item.done && settings.skip_scheduled_if_done)
338            && let Some(scheduled) = &item.planning.scheduled
339        {
340            let base = parse_date(&scheduled.date)?;
341            for date in planning_dates(scheduled, from, to, today, item.done)? {
342                entries.push(entry(
343                    item,
344                    OccurrenceType::Scheduled,
345                    date,
346                    scheduled.time.clone(),
347                    "Scheduled:".to_string(),
348                ));
349            }
350            if !item.done && base < today && today >= from && today <= to {
351                entries.push(entry(
352                    item,
353                    OccurrenceType::PastScheduled,
354                    today,
355                    None,
356                    format!("Sched.{:>2}x:", (today - base).num_days()),
357                ));
358            }
359        }
360
361        if !(item.done && settings.skip_deadline_if_done)
362            && let Some(deadline) = &item.planning.deadline
363        {
364            let base = parse_date(&deadline.date)?;
365            for date in planning_dates(deadline, from, to, today, item.done)? {
366                entries.push(entry(
367                    item,
368                    OccurrenceType::Deadline,
369                    date,
370                    deadline.time.clone(),
371                    "Deadline:".to_string(),
372                ));
373            }
374            let warning_days = deadline
375                .warning
376                .as_deref()
377                .and_then(parse_day_interval)
378                .unwrap_or(settings.deadline_warning_days)
379                .max(0);
380            if !item.done && today >= from && today <= to {
381                if base < today {
382                    entries.push(entry(
383                        item,
384                        OccurrenceType::Deadline,
385                        today,
386                        None,
387                        format!("{} d. ago:", (today - base).num_days()),
388                    ));
389                } else if base > today && (base - today).num_days() <= warning_days {
390                    entries.push(entry(
391                        item,
392                        OccurrenceType::UpcomingDeadline,
393                        today,
394                        None,
395                        format!("In {:>3} d.:", (base - today).num_days()),
396                    ));
397                }
398            }
399        }
400
401        if query.log_mode
402            && let Some(closed) = &item.planning.closed
403        {
404            let date = parse_date(&closed.date)?;
405            if date >= from && date <= to {
406                entries.push(entry(
407                    item,
408                    OccurrenceType::Closed,
409                    date,
410                    closed.time.clone(),
411                    "Closed:".to_string(),
412                ));
413            }
414        }
415
416        if !(item.done && settings.skip_timestamp_if_done) {
417            let mut index = 0;
418            while index < headline.timestamps.len() {
419                let timestamp = &headline.timestamps[index];
420                if !timestamp.active {
421                    index += 1;
422                    continue;
423                }
424                if let Some(end) = headline.timestamps.get(index + 1)
425                    && end.active
426                    && headline.timestamps[index].span.end <= end.span.start
427                {
428                    let document = self
429                        .index
430                        .document(std::path::Path::new(&item.file))
431                        .expect("headline document");
432                    if document.source()[timestamp.span.end..end.span.start].trim() == "--" {
433                        let start_date = parse_date(&timestamp.timestamp.date)?;
434                        let end_date = parse_date(&end.timestamp.date)?;
435                        let total = (end_date - start_date).num_days() + 1;
436                        for day in date_range(start_date.max(from), end_date.min(to)) {
437                            let ordinal = (day - start_date).num_days() + 1;
438                            let time = if day == start_date {
439                                timestamp.timestamp.time.clone()
440                            } else if day == end_date {
441                                end.timestamp.time.clone()
442                            } else {
443                                None
444                            };
445                            entries.push(entry(
446                                item,
447                                OccurrenceType::Block,
448                                day,
449                                time,
450                                format!("({ordinal}/{total}):"),
451                            ));
452                        }
453                        index += 2;
454                        continue;
455                    }
456                }
457                for date in repeated_dates(&timestamp.timestamp, from, to, false)? {
458                    entries.push(entry(
459                        item,
460                        OccurrenceType::Timestamp,
461                        date,
462                        timestamp.timestamp.time.clone(),
463                        String::new(),
464                    ));
465                }
466                index += 1;
467            }
468        }
469        Ok(entries)
470    }
471}
472
473#[derive(Clone, Debug)]
474// Emacs tags/todo match syntax: `|`-separated alternatives of tag terms
475// (`work`, `+work`, `-someday`, joined by `&` or nothing), then an optional
476// `/` TODO part (`NEXT|WAIT`, `-DONE`, a leading `!` for not-done states).
477// Property comparisons such as `PRIORITY="A"` are rejected explicitly.
478struct TagTodoMatcher {
479    alternatives: Vec<TagTerms>,
480    todo_include: BTreeSet<String>,
481    todo_exclude: BTreeSet<String>,
482    todo_not_done: bool,
483}
484
485#[derive(Clone, Debug, Default)]
486struct TagTerms {
487    include: BTreeSet<String>,
488    exclude: BTreeSet<String>,
489}
490
491impl TagTodoMatcher {
492    fn parse(input: &str) -> Result<Self, AgdError> {
493        let (tags, todo) = input.split_once('/').unwrap_or((input, ""));
494        let mut alternatives = Vec::new();
495        if !tags.trim().is_empty() {
496            for alternative in tags.split('|') {
497                alternatives.push(parse_tag_terms(alternative, input)?);
498            }
499        }
500        let todo_not_done = todo.starts_with('!');
501        let mut todo_include = BTreeSet::new();
502        let mut todo_exclude = BTreeSet::new();
503        for term in todo.trim_start_matches('!').split('|') {
504            let term = term.trim();
505            if let Some(keyword) = term.strip_prefix('-') {
506                todo_exclude.insert(keyword.to_string());
507            } else if !term.is_empty() {
508                todo_include.insert(term.trim_start_matches('+').to_string());
509            }
510        }
511        Ok(Self {
512            alternatives,
513            todo_include,
514            todo_exclude,
515            todo_not_done,
516        })
517    }
518
519    fn matches(&self, item: &Item) -> bool {
520        let tags: BTreeSet<&str> = item
521            .tags
522            .iter()
523            .chain(&item.inherited_tags)
524            .map(String::as_str)
525            .collect();
526        let tags_match = self.alternatives.is_empty()
527            || self.alternatives.iter().any(|terms| {
528                terms.include.iter().all(|tag| tags.contains(tag.as_str()))
529                    && terms.exclude.iter().all(|tag| !tags.contains(tag.as_str()))
530            });
531        let todo = item.todo.as_deref();
532        tags_match
533            && (!self.todo_not_done || (todo.is_some() && !item.done))
534            && (self.todo_include.is_empty()
535                || todo.is_some_and(|todo| self.todo_include.contains(todo)))
536            && todo.is_none_or(|todo| !self.todo_exclude.contains(todo))
537    }
538}
539
540fn parse_tag_terms(alternative: &str, input: &str) -> Result<TagTerms, AgdError> {
541    let mut terms = TagTerms::default();
542    let mut chars = alternative.trim().chars().peekable();
543    while let Some(&next) = chars.peek() {
544        let include = match next {
545            '+' | '&' => {
546                chars.next();
547                if next == '&' {
548                    continue;
549                }
550                true
551            }
552            '-' => {
553                chars.next();
554                false
555            }
556            _ => true,
557        };
558        let mut tag = String::new();
559        while let Some(&character) = chars.peek() {
560            if character.is_alphanumeric() || matches!(character, '_' | '@' | '#' | '%') {
561                tag.push(character);
562                chars.next();
563            } else {
564                break;
565            }
566        }
567        if tag.is_empty() {
568            let found = chars.peek().map_or("end".to_string(), |c| format!("'{c}'"));
569            return Err(AgdError::new(
570                "invalid_match",
571                format!(
572                    "expected a tag before {found} in '{input}'; property and regexp matches are not supported"
573                ),
574            ));
575        }
576        if include {
577            terms.include.insert(tag);
578        } else {
579            terms.exclude.insert(tag);
580        }
581    }
582    Ok(terms)
583}
584
585#[derive(Clone, Debug)]
586enum QlExpr {
587    And(Vec<QlExpr>),
588    Or(Vec<QlExpr>),
589    Not(Box<QlExpr>),
590    Todo(String),
591    Tags(String),
592    Priority(String),
593    Category(String),
594    Text(String),
595    Deadline(i64),
596    Scheduled(i64),
597    Done,
598}
599
600impl QlExpr {
601    fn parse(input: &str) -> Result<Self, AgdError> {
602        let tokens = tokenize(input)?;
603        let mut cursor = 0;
604        let expression = parse_ql_expression(&tokens, &mut cursor)?;
605        if cursor != tokens.len() {
606            return Err(AgdError::new(
607                "invalid_query",
608                "unexpected tokens after query",
609            ));
610        }
611        Ok(expression)
612    }
613
614    fn matches(&self, item: &Item, today: NaiveDate) -> bool {
615        match self {
616            Self::And(expressions) => expressions
617                .iter()
618                .all(|expression| expression.matches(item, today)),
619            Self::Or(expressions) => expressions
620                .iter()
621                .any(|expression| expression.matches(item, today)),
622            Self::Not(expression) => !expression.matches(item, today),
623            Self::Todo(todo) => item.todo.as_ref() == Some(todo),
624            Self::Tags(tag) => item
625                .tags
626                .iter()
627                .chain(&item.inherited_tags)
628                .any(|value| value == tag),
629            Self::Priority(priority) => item.priority.as_ref() == Some(priority),
630            Self::Category(category) => item.category == *category,
631            Self::Text(text) => item.title.to_lowercase().contains(&text.to_lowercase()),
632            Self::Deadline(days) => item
633                .planning
634                .deadline
635                .as_ref()
636                .and_then(|timestamp| parse_date(&timestamp.date).ok())
637                .is_some_and(|date| date >= today && date <= today + Duration::days(*days)),
638            Self::Scheduled(days) => item
639                .planning
640                .scheduled
641                .as_ref()
642                .and_then(|timestamp| parse_date(&timestamp.date).ok())
643                .is_some_and(|date| date >= today && date <= today + Duration::days(*days)),
644            Self::Done => item.done,
645        }
646    }
647}
648
649#[derive(Clone, Debug, Eq, PartialEq)]
650enum Token {
651    Open,
652    Close,
653    Word(String),
654    String(String),
655}
656
657fn tokenize(input: &str) -> Result<Vec<Token>, AgdError> {
658    let mut tokens = Vec::new();
659    let mut chars = input.char_indices().peekable();
660    while let Some((_, character)) = chars.next() {
661        match character {
662            '(' => tokens.push(Token::Open),
663            ')' => tokens.push(Token::Close),
664            '"' => {
665                let mut value = String::new();
666                let mut closed = false;
667                while let Some((_, character)) = chars.next() {
668                    if character == '"' {
669                        closed = true;
670                        break;
671                    }
672                    if character == '\\' {
673                        let Some((_, escaped)) = chars.next() else {
674                            break;
675                        };
676                        value.push(escaped);
677                    } else {
678                        value.push(character);
679                    }
680                }
681                if !closed {
682                    return Err(AgdError::new("invalid_query", "unterminated string"));
683                }
684                tokens.push(Token::String(value));
685            }
686            character if character.is_whitespace() => {}
687            _ => {
688                let mut value = String::from(character);
689                while let Some((_, next)) = chars.peek() {
690                    if next.is_whitespace() || matches!(next, '(' | ')') {
691                        break;
692                    }
693                    value.push(*next);
694                    chars.next();
695                }
696                tokens.push(Token::Word(value));
697            }
698        }
699    }
700    Ok(tokens)
701}
702
703fn parse_ql_expression(tokens: &[Token], cursor: &mut usize) -> Result<QlExpr, AgdError> {
704    expect(tokens, cursor, Token::Open)?;
705    let operator = word(tokens, cursor)?;
706    let expression = match operator.as_str() {
707        "and" | "or" => {
708            let mut children = Vec::new();
709            while tokens.get(*cursor) != Some(&Token::Close) {
710                children.push(parse_ql_expression(tokens, cursor)?);
711            }
712            if children.is_empty() {
713                return Err(AgdError::new(
714                    "invalid_query",
715                    format!("{operator} needs operands"),
716                ));
717            }
718            if operator == "and" {
719                QlExpr::And(children)
720            } else {
721                QlExpr::Or(children)
722            }
723        }
724        "not" => QlExpr::Not(Box::new(parse_ql_expression(tokens, cursor)?)),
725        "todo" => QlExpr::Todo(string_value(tokens, cursor)?),
726        "tags" => QlExpr::Tags(string_value(tokens, cursor)?),
727        "priority" => QlExpr::Priority(string_value(tokens, cursor)?),
728        "category" => QlExpr::Category(string_value(tokens, cursor)?),
729        "text" => QlExpr::Text(string_value(tokens, cursor)?),
730        "done" => QlExpr::Done,
731        "deadline" | "scheduled" => {
732            let keyword = word(tokens, cursor)?;
733            if keyword != ":to" {
734                return Err(AgdError::new(
735                    "invalid_query",
736                    format!("{operator} supports only :to"),
737                ));
738            }
739            let days = word(tokens, cursor)?.parse::<i64>().map_err(|_| {
740                AgdError::new("invalid_query", format!("{operator} :to needs an integer"))
741            })?;
742            if operator == "deadline" {
743                QlExpr::Deadline(days)
744            } else {
745                QlExpr::Scheduled(days)
746            }
747        }
748        _ => {
749            return Err(AgdError::new(
750                "invalid_query",
751                format!("unsupported selector: {operator}"),
752            ));
753        }
754    };
755    expect(tokens, cursor, Token::Close)?;
756    Ok(expression)
757}
758
759fn expect(tokens: &[Token], cursor: &mut usize, expected: Token) -> Result<(), AgdError> {
760    if tokens.get(*cursor) == Some(&expected) {
761        *cursor += 1;
762        Ok(())
763    } else {
764        Err(AgdError::new(
765            "invalid_query",
766            format!("expected {expected:?}"),
767        ))
768    }
769}
770
771fn word(tokens: &[Token], cursor: &mut usize) -> Result<String, AgdError> {
772    match tokens.get(*cursor) {
773        Some(Token::Word(value)) => {
774            *cursor += 1;
775            Ok(value.clone())
776        }
777        _ => Err(AgdError::new("invalid_query", "expected a word")),
778    }
779}
780
781fn string_value(tokens: &[Token], cursor: &mut usize) -> Result<String, AgdError> {
782    match tokens.get(*cursor) {
783        Some(Token::String(value)) => {
784            *cursor += 1;
785            Ok(value.clone())
786        }
787        _ => Err(AgdError::new("invalid_query", "expected a quoted string")),
788    }
789}
790
791static ACTIVE_TIMESTAMP_RE: LazyLock<Regex> = LazyLock::new(|| {
792    Regex::new(r"[ \t]*<\d{4}-\d{2}-\d{2}[^>\n]*>").expect("active timestamp regex")
793});
794
795/// Headline text as org-agenda prints it: active timestamps in the title,
796/// such as `Birthday <1993-10-16 Sat +1y>`, are left out.
797pub fn agenda_title(item: &Item) -> String {
798    ACTIVE_TIMESTAMP_RE
799        .replace_all(&item.title, "")
800        .trim()
801        .to_string()
802}
803
804pub fn agenda_csv(entries: &[AgendaEntry]) -> String {
805    let mut output = String::new();
806    for entry in entries {
807        let mut seen_tags = BTreeSet::new();
808        let tags = entry
809            .item
810            .inherited_tags
811            .iter()
812            .chain(&entry.item.tags)
813            .filter(|tag| seen_tags.insert((*tag).clone()))
814            .cloned()
815            .collect::<Vec<_>>()
816            .join(":");
817        let fields = [
818            entry.item.category.clone(),
819            agenda_title(&entry.item),
820            occurrence_name(entry.occurrence.kind).to_string(),
821            entry.item.todo.clone().unwrap_or_default(),
822            tags,
823            entry.occurrence.date.clone(),
824            // Emacs CSV writes hours without a leading zero (9:00, 0:30).
825            entry
826                .occurrence
827                .time
828                .as_deref()
829                .map(|time| match time.strip_prefix('0') {
830                    Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => rest,
831                    _ => time,
832                })
833                .unwrap_or_default()
834                .to_string(),
835            entry.occurrence.extra.clone(),
836            entry.item.priority.clone().unwrap_or_default(),
837        ];
838        output.push_str(&fields.map(|field| csv_field(&field)).join(","));
839        output.push('\n');
840    }
841    output
842}
843
844fn entry(
845    item: &Item,
846    kind: OccurrenceType,
847    date: NaiveDate,
848    time: Option<String>,
849    extra: String,
850) -> AgendaEntry {
851    AgendaEntry {
852        schema: AGENDA_ENTRY_SCHEMA.to_string(),
853        item: item.clone(),
854        occurrence: Occurrence {
855            kind,
856            date: date.format("%Y-%m-%d").to_string(),
857            time,
858            extra,
859        },
860    }
861}
862
863fn repeated_dates(
864    timestamp: &PlanningTimestamp,
865    from: NaiveDate,
866    to: NaiveDate,
867    done: bool,
868) -> Result<Vec<NaiveDate>, AgdError> {
869    let mut date = parse_date(&timestamp.date)?;
870    if done {
871        return Ok((date >= from && date <= to)
872            .then_some(date)
873            .into_iter()
874            .collect());
875    }
876    let Some(repeater) = timestamp.repeater.as_deref() else {
877        return Ok((date >= from && date <= to)
878            .then_some(date)
879            .into_iter()
880            .collect());
881    };
882    let (_, amount, unit) = parse_repeater(repeater)?;
883    while date < from {
884        date = add_period(date, amount, unit)?;
885    }
886    let mut dates = Vec::new();
887    while date <= to {
888        dates.push(date);
889        date = add_period(date, amount, unit)?;
890    }
891    Ok(dates)
892}
893
894/// Days in [from, to] where a SCHEDULED or DEADLINE timestamp is listed
895/// itself: its base date, plus repeat dates after today while not done.
896fn planning_dates(
897    timestamp: &PlanningTimestamp,
898    from: NaiveDate,
899    to: NaiveDate,
900    today: NaiveDate,
901    done: bool,
902) -> Result<Vec<NaiveDate>, AgdError> {
903    let base = parse_date(&timestamp.date)?;
904    let mut dates = Vec::new();
905    if base >= from && base <= to {
906        dates.push(base);
907    }
908    if done {
909        return Ok(dates);
910    }
911    for date in repeated_dates(timestamp, from, to, false)? {
912        if date > today && date != base {
913            dates.push(date);
914        }
915    }
916    Ok(dates)
917}
918
919fn parse_repeater(input: &str) -> Result<(&str, u64, char), AgdError> {
920    // Habit repeaters carry a maximum interval, e.g. `.+2d/3d`; only the
921    // repeat interval matters for agenda dates.
922    let input = input.split('/').next().unwrap_or(input);
923    let (kind, value) = if let Some(value) = input.strip_prefix("++") {
924        ("++", value)
925    } else if let Some(value) = input.strip_prefix(".+") {
926        (".+", value)
927    } else if let Some(value) = input.strip_prefix('+') {
928        ("+", value)
929    } else {
930        return Err(AgdError::new(
931            "invalid_repeater",
932            format!("invalid repeater: {input}"),
933        ));
934    };
935    let unit = value
936        .chars()
937        .next_back()
938        .ok_or_else(|| AgdError::new("invalid_repeater", format!("invalid repeater: {input}")))?;
939    let amount = value[..value.len() - unit.len_utf8()]
940        .parse::<u64>()
941        .map_err(|_| AgdError::new("invalid_repeater", format!("invalid repeater: {input}")))?;
942    if amount == 0 {
943        return Err(AgdError::new(
944            "invalid_repeater",
945            "repeater interval must be positive",
946        ));
947    }
948    Ok((kind, amount, unit))
949}
950
951fn add_period(date: NaiveDate, amount: u64, unit: char) -> Result<NaiveDate, AgdError> {
952    let next = match unit {
953        'd' => date.checked_add_signed(Duration::days(amount as i64)),
954        'w' => date.checked_add_signed(Duration::weeks(amount as i64)),
955        'm' => date.checked_add_months(Months::new(amount as u32)),
956        'y' => date.checked_add_months(Months::new((amount * 12) as u32)),
957        _ => None,
958    };
959    next.ok_or_else(|| AgdError::new("invalid_repeater", "repeater date is out of range"))
960}
961
962fn parse_day_interval(input: &str) -> Option<i64> {
963    let value = input.strip_prefix('-')?;
964    let days = value.strip_suffix('d')?;
965    days.parse().ok()
966}
967
968fn parse_date(input: &str) -> Result<NaiveDate, AgdError> {
969    NaiveDate::parse_from_str(input, "%Y-%m-%d")
970        .map_err(|_| AgdError::new("invalid_date", format!("invalid Org date: {input}")))
971}
972
973fn date_range(start: NaiveDate, end: NaiveDate) -> impl Iterator<Item = NaiveDate> {
974    (0..=(end - start).num_days().max(-1)).map(move |days| start + Duration::days(days))
975}
976
977fn collection_rank(kind: OccurrenceType) -> u8 {
978    match kind {
979        OccurrenceType::Deadline | OccurrenceType::UpcomingDeadline => 0,
980        OccurrenceType::Scheduled | OccurrenceType::PastScheduled => 1,
981        OccurrenceType::Block => 2,
982        OccurrenceType::Timestamp => 3,
983        OccurrenceType::Closed | OccurrenceType::Todo => 4,
984    }
985}
986
987/// One comparison per org-agenda-sorting-strategy key, first difference
988/// wins; the sort is stable, so category-keep and habit-* keep collection
989/// order.
990fn compare_by_strategy(
991    (left_urgency, left): &(i64, AgendaEntry),
992    (right_urgency, right): &(i64, AgendaEntry),
993    settings: &Settings,
994    todo_order: &[String],
995) -> Ordering {
996    let todo_rank = |entry: &AgendaEntry| {
997        entry.item.todo.as_deref().map_or(-1, |todo| {
998            todo_order
999                .iter()
1000                .position(|keyword| keyword == todo)
1001                .map_or(-1, |index| index as i64)
1002        })
1003    };
1004    for key in &settings.sorting_strategy {
1005        let (name, direction) = key.rsplit_once('-').unwrap_or((key, "up"));
1006        let ordering = match name {
1007            "time" => compare_time_of_day(&left.occurrence.time, &right.occurrence.time),
1008            "urgency" => left_urgency.cmp(right_urgency),
1009            "priority" => priority_value(left.item.priority.as_deref(), settings)
1010                .cmp(&priority_value(right.item.priority.as_deref(), settings)),
1011            "category" if direction != "keep" => left.item.category.cmp(&right.item.category),
1012            "alpha" => agenda_title(&left.item).cmp(&agenda_title(&right.item)),
1013            "todo-state" => todo_rank(left).cmp(&todo_rank(right)),
1014            _ => Ordering::Equal,
1015        };
1016        let ordering = if direction == "down" {
1017            ordering.reverse()
1018        } else {
1019            ordering
1020        };
1021        if ordering != Ordering::Equal {
1022            return ordering;
1023        }
1024    }
1025    Ordering::Equal
1026}
1027
1028fn keyword_name(keyword: &str) -> &str {
1029    keyword.split(['(', '!', '@']).next().unwrap_or(keyword)
1030}
1031
1032/// org-agenda urgency: priority value (A=2000, B=1000, C=0 by default),
1033/// raised by days overdue for today's deadline reminders, lowered by days
1034/// left for upcoming ones, and raised by 99 plus days late for scheduled.
1035fn urgency(entry: &AgendaEntry, today: NaiveDate, settings: &Settings) -> i64 {
1036    let priority = priority_value(entry.item.priority.as_deref(), settings);
1037    let date = parse_date(&entry.occurrence.date).ok();
1038    match entry.occurrence.kind {
1039        OccurrenceType::Deadline | OccurrenceType::UpcomingDeadline => {
1040            let base = entry
1041                .item
1042                .planning
1043                .deadline
1044                .as_ref()
1045                .and_then(|deadline| parse_date(&deadline.date).ok());
1046            match (date, base) {
1047                (Some(date), Some(base)) if date == today => priority + (today - base).num_days(),
1048                _ => priority,
1049            }
1050        }
1051        OccurrenceType::Scheduled | OccurrenceType::PastScheduled => {
1052            let base = entry
1053                .item
1054                .planning
1055                .scheduled
1056                .as_ref()
1057                .and_then(|scheduled| parse_date(&scheduled.date).ok());
1058            match (date, base) {
1059                (Some(date), Some(base)) => priority + 99 + (date - base).num_days(),
1060                _ => priority + 99,
1061            }
1062        }
1063        _ => priority,
1064    }
1065}
1066
1067fn priority_value(priority: Option<&str>, settings: &Settings) -> i64 {
1068    let letter = |value: &str| value.chars().next().map_or(0, |c| c as i64);
1069    let lowest = letter(&settings.lowest_priority);
1070    let value = priority.map_or_else(|| letter(&settings.default_priority), letter);
1071    1000 * (lowest - value)
1072}
1073
1074// time-up with org-agenda-sort-notime-is-late: timed entries first.
1075fn compare_time_of_day(left: &Option<String>, right: &Option<String>) -> Ordering {
1076    match (left, right) {
1077        (Some(left), Some(right)) => time_minutes(left).cmp(&time_minutes(right)),
1078        (Some(_), None) => Ordering::Less,
1079        (None, Some(_)) => Ordering::Greater,
1080        (None, None) => Ordering::Equal,
1081    }
1082}
1083
1084fn time_minutes(time: &str) -> u32 {
1085    let (hours, minutes) = time.split_once(':').unwrap_or((time, "0"));
1086    hours.parse::<u32>().unwrap_or(0) * 60 + minutes.parse::<u32>().unwrap_or(0)
1087}
1088
1089// org-agenda TODO lists: urgency-down category-keep, i.e. priority first and
1090// otherwise file and buffer order.
1091fn compare_items(left: &Item, right: &Item) -> Ordering {
1092    compare_priority(&left.priority, &right.priority)
1093        .then_with(|| left.file.cmp(&right.file))
1094        .then_with(|| left.line.cmp(&right.line))
1095}
1096
1097fn compare_priority(left: &Option<String>, right: &Option<String>) -> Ordering {
1098    left.as_deref()
1099        .unwrap_or("B")
1100        .cmp(right.as_deref().unwrap_or("B"))
1101}
1102
1103fn occurrence_name(kind: OccurrenceType) -> &'static str {
1104    match kind {
1105        OccurrenceType::Scheduled => "scheduled",
1106        OccurrenceType::PastScheduled => "past-scheduled",
1107        OccurrenceType::Deadline => "deadline",
1108        OccurrenceType::UpcomingDeadline => "upcoming-deadline",
1109        OccurrenceType::Timestamp => "timestamp",
1110        OccurrenceType::Block => "block",
1111        OccurrenceType::Closed => "closed",
1112        OccurrenceType::Todo => "todo",
1113    }
1114}
1115
1116/// TODO-list rows in the org-batch-agenda-csv layout (type `todo`, no date).
1117pub fn items_csv(items: &[Item]) -> String {
1118    let mut output = String::new();
1119    for item in items {
1120        let mut seen_tags = BTreeSet::new();
1121        let tags = item
1122            .inherited_tags
1123            .iter()
1124            .chain(&item.tags)
1125            .filter(|tag| seen_tags.insert((*tag).clone()))
1126            .cloned()
1127            .collect::<Vec<_>>()
1128            .join(":");
1129        let fields = [
1130            item.category.clone(),
1131            agenda_title(item),
1132            "todo".to_string(),
1133            item.todo.clone().unwrap_or_default(),
1134            tags,
1135            String::new(),
1136            String::new(),
1137            String::new(),
1138            item.priority.clone().unwrap_or_default(),
1139        ];
1140        output.push_str(&fields.map(|field| csv_field(&field)).join(","));
1141        output.push('\n');
1142    }
1143    output
1144}
1145
1146fn csv_field(value: &str) -> String {
1147    if value.contains([',', '"', '\n', '\r']) {
1148        format!("\"{}\"", value.replace('"', "\"\""))
1149    } else {
1150        value.to_string()
1151    }
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157    use crate::Settings;
1158
1159    fn fixture(source: &str) -> (tempfile::TempDir, WorkspaceIndex) {
1160        let temp = tempfile::tempdir().unwrap();
1161        std::fs::write(temp.path().join("agenda.org"), source).unwrap();
1162        let settings = Settings {
1163            roots: vec![temp.path().to_string_lossy().into_owned()],
1164            agenda_files: vec!["**/*.org".to_string()],
1165            ..Settings::default()
1166        };
1167        let index = WorkspaceIndex::load(settings).unwrap();
1168        (temp, index)
1169    }
1170
1171    #[test]
1172    fn views_run_blocks_in_order_like_a_block_agenda() {
1173        let (_temp, index) = fixture(
1174            "#+TODO: TODO NEXT | DONE\n* TODO Today :work:\nSCHEDULED: <2026-10-13 Tue>\n* NEXT Later :home:\n* DONE Old :work:\n* Note :work:\n",
1175        );
1176        let view = ViewSettings {
1177            key: "m".to_string(),
1178            desc: "Mixed".to_string(),
1179            blocks: vec![
1180                ViewBlock {
1181                    kind: "agenda".to_string(),
1182                    match_expression: None,
1183                    span: Some("day".to_string()),
1184                    header: Some("Today".to_string()),
1185                },
1186                ViewBlock {
1187                    kind: "todo".to_string(),
1188                    match_expression: Some("NEXT|DONE".to_string()),
1189                    span: None,
1190                    header: None,
1191                },
1192                ViewBlock {
1193                    kind: "tags".to_string(),
1194                    match_expression: Some("work".to_string()),
1195                    span: None,
1196                    header: None,
1197                },
1198            ],
1199        };
1200        let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
1201        let sections = AgendaEngine::new(&index)
1202            .view(&view, today, today, false)
1203            .unwrap();
1204        assert_eq!(sections[0].header, "Today");
1205        assert_eq!(sections[0].entries.len(), 1);
1206        let titles = |section: &ViewSection| -> Vec<String> {
1207            section
1208                .items
1209                .iter()
1210                .map(|item| item.title.clone())
1211                .collect()
1212        };
1213        assert_eq!(
1214            sections[1].header,
1215            "Global list of TODO items of type: NEXT|DONE"
1216        );
1217        assert_eq!(titles(&sections[1]), ["Later", "Old"]);
1218        assert_eq!(titles(&sections[2]), ["Today", "Old", "Note"]);
1219    }
1220
1221    #[test]
1222    fn tags_todo_match_follows_emacs_syntax() {
1223        let (_temp, index) = fixture(
1224            "#+TODO: TODO NEXT WAIT | DONE\n* TODO A :work:\n* NEXT B :work:urgent:\n* WAIT C :home:\n* DONE D :home:\n* TODO E :work:someday:\n",
1225        );
1226        let titles = |expression: &str| -> Vec<String> {
1227            index
1228                .items()
1229                .filter(|item| TagTodoMatcher::parse(expression).unwrap().matches(item))
1230                .map(|item| item.title.clone())
1231                .collect()
1232        };
1233        assert_eq!(titles("work"), ["A", "B", "E"]);
1234        assert_eq!(titles("work-someday"), ["A", "B"]);
1235        assert_eq!(titles("work&urgent|home"), ["B", "C", "D"]);
1236        assert_eq!(titles("+work/NEXT|TODO"), ["A", "B", "E"]);
1237        assert_eq!(titles("home/!"), ["C"]);
1238        assert_eq!(titles("/-WAIT"), ["A", "B", "D", "E"]);
1239        assert_eq!(
1240            TagTodoMatcher::parse("PRIORITY=\"A\"")
1241                .err()
1242                .unwrap()
1243                .code(),
1244            "invalid_match"
1245        );
1246    }
1247
1248    #[test]
1249    fn expands_warnings_repeaters_ranges_and_past_scheduled() {
1250        let (_temp, index) = fixture(
1251            "#+TODO: TODO | DONE\n#+FILETAGS: :work:\n* TODO Late\nSCHEDULED: <2026-10-01 Thu>\n* TODO Due\nDEADLINE: <2026-10-16 Fri -2d>\n* TODO Repeat\nSCHEDULED: <2026-10-12 Mon +2d>\n* TODO Block\n<2026-10-14 Wed>--<2026-10-15 Thu>\n",
1252        );
1253        let entries = AgendaEngine::new(&index)
1254            .agenda(&AgendaQuery {
1255                from: NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
1256                to: NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(),
1257                today: NaiveDate::from_ymd_opt(2026, 10, 14).unwrap(),
1258                match_expression: Some("+work/TODO".to_string()),
1259                log_mode: false,
1260            })
1261            .unwrap();
1262        assert!(
1263            entries
1264                .iter()
1265                .any(|entry| { entry.occurrence.kind == OccurrenceType::PastScheduled })
1266        );
1267        assert_eq!(
1268            entries
1269                .iter()
1270                .filter(|entry| entry.occurrence.kind == OccurrenceType::UpcomingDeadline)
1271                .count(),
1272            1
1273        );
1274        assert_eq!(
1275            entries
1276                .iter()
1277                .filter(|entry| entry.item.title == "Repeat")
1278                .count(),
1279            2
1280        );
1281        assert_eq!(
1282            entries
1283                .iter()
1284                .filter(|entry| entry.occurrence.kind == OccurrenceType::Block)
1285                .count(),
1286            2
1287        );
1288    }
1289
1290    #[test]
1291    fn evaluates_documented_org_ql_subset() {
1292        let (_temp, index) = fixture(
1293            "#+TODO: TODO NEXT | DONE\n* NEXT [#A] Report :work:\nDEADLINE: <2026-10-16 Fri>\n* TODO Other :personal:\n",
1294        );
1295        let items = AgendaEngine::new(&index)
1296            .query(
1297                "(and (todo \"NEXT\") (tags \"work\") (deadline :to 7))",
1298                NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
1299            )
1300            .unwrap();
1301        assert_eq!(items.len(), 1);
1302        assert_eq!(items[0].title, "Report");
1303    }
1304
1305    #[test]
1306    fn csv_uses_emacs_occurrence_vocabulary() {
1307        let (_temp, index) = fixture("* TODO Call, bank\nSCHEDULED: <2026-10-13 Tue 10:00>\n");
1308        let entries = AgendaEngine::new(&index)
1309            .agenda(&AgendaQuery {
1310                from: NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
1311                to: NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
1312                today: NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
1313                match_expression: None,
1314                log_mode: false,
1315            })
1316            .unwrap();
1317        assert!(agenda_csv(&entries).contains("\"Call, bank\",scheduled,TODO"));
1318    }
1319}