Skip to main content

agd_core/agenda/
engine.rs

1//! `AgendaEngine` and what the agenda collects: the rows each heading gives on
2//! each day (scheduled, deadline and its warning, past-scheduled reminders,
3//! timestamps, date ranges, and closed entries in log mode), as
4//! org-agenda-get-scheduled and org-agenda-get-deadlines collect them, and the
5//! TODO, inbox and tags lists. Also the date helpers the agenda modules share.
6//!
7//! Ordering is `sort`'s, repeaters `repeat`'s, matching `matcher`'s, views
8//! `view`'s and org-ql `query`'s. The engine only reads the index it is given.
9
10use std::collections::BTreeSet;
11use std::path::Path;
12
13use chrono::{Duration, NaiveDate};
14
15use crate::date::parse_org_date;
16use crate::write::{CaptureTarget, find_olpath, heading_titled};
17use crate::{
18    AGENDA_ENTRY_SCHEMA, AgdError, AgendaEntry, CaptureTemplate, ErrorCode, HeadlineSpan,
19    IndexedDocument, Item, Occurrence, OccurrenceType, WorkspaceIndex,
20};
21
22use super::matcher::{TagTodoMatcher, matcher_now};
23use super::repeat::{planning_dates, repeated_dates};
24use super::sort::{compare_by_strategy, compare_items, urgency};
25
26#[derive(Clone, Debug)]
27pub struct AgendaQuery {
28    pub from: NaiveDate,
29    pub to: NaiveDate,
30    pub today: NaiveDate,
31    pub match_expression: Option<String>,
32    /// org-agenda log mode (`l`): also list CLOSED entries.
33    pub log_mode: bool,
34}
35
36#[derive(Clone, Debug)]
37pub struct AgendaEngine<'a> {
38    pub(super) index: &'a WorkspaceIndex,
39}
40
41impl<'a> AgendaEngine<'a> {
42    pub fn new(index: &'a WorkspaceIndex) -> Self {
43        Self { index }
44    }
45
46    pub fn agenda(&self, query: &AgendaQuery) -> Result<Vec<AgendaEntry>, AgdError> {
47        if query.to < query.from {
48            return Err(AgdError::new(
49                ErrorCode::InvalidRange,
50                "agenda end date is before its start date",
51            ));
52        }
53        let matcher = query
54            .match_expression
55            .as_deref()
56            .map(TagTodoMatcher::parse)
57            .transpose()?;
58        let now = matcher_now(query.today);
59        let settings = self.index.settings();
60        // org-agenda collects each file's deadlines, then scheduled entries,
61        // then blocks and timestamps, each in buffer order, and stable-sorts
62        // the day with its default strategy (time-up urgency-down
63        // category-keep, where category-keep keeps collection order).
64        let mut entries = Vec::new();
65        for document in self.index.documents() {
66            let mut file_entries = Vec::new();
67            for headline in document.headlines() {
68                if matcher
69                    .as_ref()
70                    .is_some_and(|matcher| !matcher.matches(headline, now, settings))
71                {
72                    continue;
73                }
74                let occurrences = self.occurrences(headline, query).map_err(|error| {
75                    AgdError::new(
76                        error.code(),
77                        format!("{}:{}: {error}", headline.item.file, headline.item.line),
78                    )
79                })?;
80                file_entries.extend(occurrences);
81            }
82            file_entries.sort_by_key(|entry| collection_rank(entry.occurrence.kind));
83            entries.extend(file_entries);
84        }
85        let mut keyed: Vec<(i64, AgendaEntry)> = entries
86            .into_iter()
87            .map(|entry| (urgency(&entry, query.today, self.index), entry))
88            .collect();
89        // The configured keywords in order, as a file without #+TODO reads them.
90        let todo_order: Vec<String> = crate::document::todo_keywords("", self.index.settings())
91            .into_iter()
92            .map(|keyword| keyword.name)
93            .collect();
94        keyed.sort_by(|left, right| {
95            left.1
96                .occurrence
97                .date
98                .cmp(&right.1.occurrence.date)
99                .then_with(|| compare_by_strategy(left, right, self.index, &todo_order))
100        });
101        Ok(keyed.into_iter().map(|(_, entry)| entry).collect())
102    }
103
104    pub fn todo(
105        &self,
106        match_expression: Option<&str>,
107        today: NaiveDate,
108    ) -> Result<Vec<Item>, AgdError> {
109        let matcher = match_expression.map(TagTodoMatcher::parse).transpose()?;
110        let now = matcher_now(today);
111        let settings = self.index.settings();
112        let mut items: Vec<Item> = self
113            .headlines()
114            .filter(|headline| headline.item.todo.is_some() && !headline.item.done)
115            .filter(|headline| {
116                matcher
117                    .as_ref()
118                    .is_none_or(|matcher| matcher.matches(headline, now, settings))
119            })
120            .map(|headline| headline.item.clone())
121            .collect();
122        items.sort_by(|left, right| compare_items(left, right, self.index));
123        Ok(items)
124    }
125
126    /// The inbox: unfinished entries where captures land, in file and
127    /// buffer order. For each `[[capture]]` template (`agent_inbox` is one
128    /// of them) that is the direct children of its target heading
129    /// (`heading`, `olpath` or `id`), or the top-level entries of a
130    /// file it targets as a whole. A `datetree` target files entries by
131    /// date, a log rather than an inbox, so it is left out.
132    pub fn inbox(&self) -> Vec<Item> {
133        let mut landed: BTreeSet<(&Path, usize)> = BTreeSet::new();
134        for template in &self.index.settings().captures {
135            if template.datetree {
136                continue;
137            }
138            if let Some((document, parent)) = self.capture_target(template) {
139                let level = parent.map_or(1, |parent| parent.level + 1);
140                let region = parent.map_or(0..document.source().len(), |parent| {
141                    parent.subtree_span.start..parent.subtree_span.end
142                });
143                for headline in document.headlines() {
144                    if headline.level == level && region.contains(&headline.line_span.start) {
145                        landed.insert((document.path(), headline.item.line));
146                    }
147                }
148            }
149        }
150        self.headlines()
151            .filter(|headline| {
152                !headline.item.done
153                    && landed.contains(&(Path::new(&headline.item.file), headline.item.line))
154            })
155            .map(|headline| headline.item.clone())
156            .collect()
157    }
158
159    /// The document a template captures into and the heading its entries go
160    /// under (None for a whole-file target), as the index reads them now;
161    /// None when the target is missing, as a capture would find it.
162    fn capture_target(
163        &self,
164        template: &CaptureTemplate,
165    ) -> Option<(&IndexedDocument, Option<&HeadlineSpan>)> {
166        if let CaptureTarget::Id(id) = CaptureTarget::of(template) {
167            let (document, headline) = self.index.resolve(&id).ok()?;
168            return Some((document, Some(headline)));
169        }
170        let file = shellexpand::tilde(template.file.as_deref()?).into_owned();
171        let path = self.index.roots().resolve_read(Path::new(&file)).ok()?;
172        let document = self.index.document(&path)?;
173        let parent = match CaptureTarget::of(template) {
174            CaptureTarget::File | CaptureTarget::Id(_) => None,
175            CaptureTarget::Heading(title) => Some(heading_titled(document, &title)?),
176            CaptureTarget::OutlinePath(outline) => {
177                let start = find_olpath(document, &outline).ok()?;
178                Some(
179                    document
180                        .headlines()
181                        .iter()
182                        .find(|headline| headline.line_span.start == start)?,
183                )
184            }
185        };
186        Some((document, parent))
187    }
188
189    /// Every heading matching an Emacs tags match, done or not, like an
190    /// org-agenda `tags` block.
191    pub fn tags(&self, match_expression: &str, today: NaiveDate) -> Result<Vec<Item>, AgdError> {
192        let matcher = TagTodoMatcher::parse(match_expression)?;
193        let now = matcher_now(today);
194        let settings = self.index.settings();
195        let mut items: Vec<Item> = self
196            .headlines()
197            .filter(|headline| matcher.matches(headline, now, settings))
198            .map(|headline| headline.item.clone())
199            .collect();
200        items.sort_by(|left, right| compare_items(left, right, self.index));
201        Ok(items)
202    }
203
204    pub(super) fn headlines(&self) -> impl Iterator<Item = &HeadlineSpan> {
205        self.index
206            .documents()
207            .flat_map(|document| document.headlines().iter())
208    }
209
210    fn occurrences(
211        &self,
212        headline: &HeadlineSpan,
213        query: &AgendaQuery,
214    ) -> Result<Vec<AgendaEntry>, AgdError> {
215        let (from, to, today) = (query.from, query.to, query.today);
216        let item = &headline.item;
217        let settings = self.index.settings();
218        let mut entries = Vec::new();
219
220        // Scheduled and deadline expansion mirrors org-agenda-get-scheduled
221        // and org-agenda-get-deadlines with Org's defaults: an entry shows on
222        // its base date and on future repeats, and today carries the
223        // past-scheduled or overdue reminder counted from the base date.
224        if !(item.done && settings.skip_scheduled_if_done)
225            && let Some(scheduled) = &item.planning.scheduled
226        {
227            let base = parse_org_date(&scheduled.date)?;
228            for date in planning_dates(scheduled, from, to, today, item.done)? {
229                entries.push(entry(
230                    item,
231                    OccurrenceType::Scheduled,
232                    date,
233                    (scheduled.time.clone(), scheduled.end_time.clone()),
234                    "Scheduled:".to_string(),
235                ));
236            }
237            if !item.done && base < today && today >= from && today <= to {
238                let late = (today - base).num_days();
239                entries.push(reminder(
240                    item,
241                    OccurrenceType::PastScheduled,
242                    today,
243                    format!("Sched.{late:>2}x:"),
244                    late,
245                ));
246            }
247        }
248
249        if !(item.done && settings.skip_deadline_if_done)
250            && let Some(deadline) = &item.planning.deadline
251        {
252            let base = parse_org_date(&deadline.date)?;
253            for date in planning_dates(deadline, from, to, today, item.done)? {
254                entries.push(entry(
255                    item,
256                    OccurrenceType::Deadline,
257                    date,
258                    (deadline.time.clone(), deadline.end_time.clone()),
259                    "Deadline:".to_string(),
260                ));
261            }
262            let warning_days = deadline
263                .warning
264                .as_deref()
265                .and_then(warning_days)
266                .unwrap_or(settings.deadline_warning_days)
267                .max(0);
268            if !item.done && today >= from && today <= to {
269                let late = (today - base).num_days();
270                if late > 0 {
271                    entries.push(reminder(
272                        item,
273                        OccurrenceType::Deadline,
274                        today,
275                        format!("{late} d. ago:"),
276                        late,
277                    ));
278                } else if late < 0 && -late <= warning_days {
279                    entries.push(reminder(
280                        item,
281                        OccurrenceType::UpcomingDeadline,
282                        today,
283                        format!("In {:>3} d.:", -late),
284                        late,
285                    ));
286                }
287            }
288        }
289
290        if query.log_mode
291            && let Some(closed) = &item.planning.closed
292        {
293            let date = parse_org_date(&closed.date)?;
294            if date >= from && date <= to {
295                entries.push(entry(
296                    item,
297                    OccurrenceType::Closed,
298                    date,
299                    (closed.time.clone(), None),
300                    "Closed:".to_string(),
301                ));
302            }
303        }
304
305        if !(item.done && settings.skip_timestamp_if_done) {
306            let mut index = 0;
307            while index < headline.timestamps.len() {
308                let timestamp = &headline.timestamps[index];
309                if !timestamp.active {
310                    index += 1;
311                    continue;
312                }
313                if let Some(end) = headline.timestamps.get(index + 1)
314                    && end.active
315                    && headline.timestamps[index].span.end <= end.span.start
316                {
317                    let document = self
318                        .index
319                        .document(std::path::Path::new(&item.file))
320                        .expect("headline document");
321                    if document.source()[timestamp.span.end..end.span.start].trim() == "--" {
322                        let start_date = parse_org_date(&timestamp.timestamp.date)?;
323                        let end_date = parse_org_date(&end.timestamp.date)?;
324                        let total = (end_date - start_date).num_days() + 1;
325                        for day in date_range(start_date.max(from), end_date.min(to)) {
326                            let ordinal = (day - start_date).num_days() + 1;
327                            let time = if day == start_date {
328                                timestamp.timestamp.time.clone()
329                            } else if day == end_date {
330                                end.timestamp.time.clone()
331                            } else {
332                                None
333                            };
334                            entries.push(entry(
335                                item,
336                                OccurrenceType::Block,
337                                day,
338                                (time, None),
339                                format!("({ordinal}/{total}):"),
340                            ));
341                        }
342                        index += 2;
343                        continue;
344                    }
345                }
346                for date in repeated_dates(&timestamp.timestamp, from, to)? {
347                    entries.push(entry(
348                        item,
349                        OccurrenceType::Timestamp,
350                        date,
351                        (
352                            timestamp.timestamp.time.clone(),
353                            timestamp.timestamp.end_time.clone(),
354                        ),
355                        String::new(),
356                    ));
357                }
358                index += 1;
359            }
360        }
361        Ok(entries)
362    }
363}
364
365/// A reminder row on today: a past-scheduled or missed deadline `days`
366/// late (a late row), or an upcoming deadline `-days` away.
367fn reminder(
368    item: &Item,
369    kind: OccurrenceType,
370    today: NaiveDate,
371    extra: String,
372    days: i64,
373) -> AgendaEntry {
374    let mut row = entry(item, kind, today, (None, None), extra);
375    row.occurrence.days = days;
376    row.occurrence.late = days > 0;
377    row
378}
379
380/// One agenda row on `date`; the time pair is the start time and, for a
381/// range such as `10:00-11:30`, the end time.
382fn entry(
383    item: &Item,
384    kind: OccurrenceType,
385    date: NaiveDate,
386    (time, end_time): (Option<String>, Option<String>),
387    extra: String,
388) -> AgendaEntry {
389    AgendaEntry {
390        schema: AGENDA_ENTRY_SCHEMA.to_string(),
391        item: item.clone(),
392        occurrence: Occurrence {
393            kind,
394            date: date.format("%Y-%m-%d").to_string(),
395            time,
396            end_time,
397            extra,
398            days: 0,
399            late: false,
400        },
401    }
402}
403
404/// A deadline's warning period in days, as org-get-wdays reads `-3d`,
405/// `-1w`, `-1m`, `-1y` or `-48h`: the count times the unit's days (a month
406/// is 30.4, a year 365.25, an hour 0.041667), rounded down.
407fn warning_days(warning: &str) -> Option<i64> {
408    let value = warning.trim_start_matches('-');
409    let unit = value.chars().last()?;
410    let factor = match unit {
411        'h' => 0.041667,
412        'd' => 1.0,
413        'w' => 7.0,
414        'm' => 30.4,
415        'y' => 365.25,
416        _ => return None,
417    };
418    let count: u32 = value[..value.len() - 1].parse().ok()?;
419    Some((f64::from(count) * factor).floor() as i64)
420}
421
422pub(super) fn date_range(start: NaiveDate, end: NaiveDate) -> impl Iterator<Item = NaiveDate> {
423    (0..=(end - start).num_days().max(-1)).map(move |days| start + Duration::days(days))
424}
425
426fn collection_rank(kind: OccurrenceType) -> u8 {
427    match kind {
428        OccurrenceType::Deadline | OccurrenceType::UpcomingDeadline => 0,
429        OccurrenceType::Scheduled | OccurrenceType::PastScheduled => 1,
430        OccurrenceType::Block => 2,
431        OccurrenceType::Timestamp => 3,
432        OccurrenceType::Closed | OccurrenceType::Todo => 4,
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::agenda::test_support::fixture;
440    use crate::{CaptureTemplate, Settings};
441
442    #[test]
443    fn the_inbox_is_where_capture_templates_land() {
444        let temp = tempfile::tempdir().unwrap();
445        let write = |name: &str, text: &str| std::fs::write(temp.path().join(name), text).unwrap();
446        write(
447            "refile.org",
448            "* Refile\n** TODO Renew passport\n*** Notes under it\n** DONE Filed already\n",
449        );
450        write("work.org", "* Inbox\n** TODO Not a capture target\n");
451        write(
452            "projects.org",
453            "* Agd\n** Ideas\n*** Faster index\n* Other\n** Ideas\n*** Elsewhere\n",
454        );
455        write("notes.org", "* Loose note\n** Its detail\n");
456        write(
457            "journal.org",
458            "* 2026\n** 2026-10 October\n*** 2026-10-16 Friday\n**** Wrote\n",
459        );
460        let template = |key: &str, file: &str| CaptureTemplate {
461            key: key.to_string(),
462            file: Some(file.to_string()),
463            heading: None,
464            ..CaptureTemplate::default()
465        };
466        let settings = Settings {
467            roots: vec![temp.path().to_string_lossy().into_owned()],
468            agenda_files: vec!["*.org".to_string()],
469            captures: vec![
470                CaptureTemplate {
471                    heading: Some("Refile".to_string()),
472                    ..template("t", "refile.org")
473                },
474                CaptureTemplate {
475                    olpath: Some(vec!["Agd".to_string(), "Ideas".to_string()]),
476                    ..template("i", "projects.org")
477                },
478                template("n", "notes.org"),
479                CaptureTemplate {
480                    datetree: true,
481                    ..template("j", "journal.org")
482                },
483            ],
484            ..Settings::default()
485        };
486        let index = WorkspaceIndex::load(settings).unwrap();
487        let titles: Vec<String> = AgendaEngine::new(&index)
488            .inbox()
489            .into_iter()
490            .map(|item| item.title)
491            .collect();
492        assert_eq!(titles, ["Loose note", "Faster index", "Renew passport"]);
493    }
494
495    #[test]
496    fn expands_warnings_repeaters_ranges_and_past_scheduled() {
497        let (_temp, index) = fixture(
498            "#+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",
499        );
500        let entries = AgendaEngine::new(&index)
501            .agenda(&AgendaQuery {
502                from: NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
503                to: NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(),
504                today: NaiveDate::from_ymd_opt(2026, 10, 14).unwrap(),
505                match_expression: Some("+work/TODO".to_string()),
506                log_mode: false,
507            })
508            .unwrap();
509        assert!(
510            entries
511                .iter()
512                .any(|entry| { entry.occurrence.kind == OccurrenceType::PastScheduled })
513        );
514        assert_eq!(
515            entries
516                .iter()
517                .filter(|entry| entry.occurrence.kind == OccurrenceType::UpcomingDeadline)
518                .count(),
519            1
520        );
521        assert_eq!(
522            entries
523                .iter()
524                .filter(|entry| entry.item.title == "Repeat")
525                .count(),
526            2
527        );
528        assert_eq!(
529            entries
530                .iter()
531                .filter(|entry| entry.occurrence.kind == OccurrenceType::Block)
532                .count(),
533            2
534        );
535    }
536}